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"}
|
|
|
|
|
|
|
preuves : trois gardes pour ce que ma lecture ne tiendra pas
Une revision de documentation vieillit comme le reste. Ce qui tient, c est ce
qu une machine verifie — et trois lacunes etaient nommees sans etre gardees.
P58 HABILITATIONS. autorisation.md posait la regle (un service nomme un
GROUPE, jamais une personne, D-66) et meta/acces.yml la portait ; rien ne
la verifiait. P29 gardait les POSITIONS d authentification, personne ne
gardait les DROITS.
Le controle qui porte la preuve est un croisement : une entree
porte_par: role-realm affirme que l habilitation voyage par un role de
realm projete depuis un groupe LDAP. P58 le confronte a serveur_keycloak.
Sans ca, un service annonce une habilitation que rien ne transporte, et l
ecran reste vide sans que personne sache pourquoi.
CE QU ELLE N EXIGE PAS, et c est le point le plus important : que les
groupes nommes existent dans l annuaire. Ce serait contredire le regime du
paragraphe 2 — le depot AMORCE un acces et se retire, les appartenances
appartiennent a une personne. dev et personnel n existent dans aucun code,
et ce n est pas un defaut.
P59 ENUMERATIONS ANNONCEES. Les deux ecarts trouves a la main pendant la
tournee — cinq portes annoncees devant une table de six, huit lignes
renvoyees vers une fiche qui en compte dix — etaient d une forme que P57
ne voit pas.
Ma premiere version a signale CINQ ecarts, et les cinq etaient du bruit :
dans « reprise dans les deux devis : », le nombre qualifie autre chose que
la liste. Cent pour cent de faux positifs — la preuve qui crie sur un cas
sain et qu on apprend a ignorer. Resserree aux deux formes ou le nombre ne
peut compter rien d autre. Etroite et vraie plutot que large et devineuse.
P60 WIKI PUBLIE. Le wiki est publie DEPUIS le depot ; rien ne mesurait l ecart,
et il s est creuse de VINGT-SEPT JOURS en silence. Deux unites jamais
publiees, vingt et une differentes : pour qui lit la forge plutot que le
depot, toute la revision n existait pas.
Le harnais est STATIQUE, zero appel reseau — cloner la forge romprait la
seule propriete qui fasse qu une preuve vaille hors de ce poste. La mesure
passe donc par un TEMOIN que make wiki-publier depose. Amorce avec la
valeur MESUREE : le wiki d eregion porte b6167f2, dont le message dit
source: ac85278.
Ce qu elle ne prouve pas : un temoin dit ce qui est PARTI, jamais ce qui
est ARRIVE.
LES TROIS SONT EPROUVEES DANS LES DEUX SENS
Douze essais negatifs, douze refus : groupe non projete, acces.yml disparu,
personne au lieu d un groupe, mecanisme invente, raison manquante, compte
revenu a cinq, septieme porte ajoutee sans toucher au compte, renvoi croise
fausse, temoin absent, temoin d un autre depot. Une garantie qu on n a jamais vu
dire non n est pas une garantie, c est une habitude.
ETAT : NON CONFORME, 58 OK, 1 echec, 1 saute.
P60 est rouge, et c est le comportement voulu : le registre a le droit de
perdre. Le retard qu elle signale est reel et anterieur a elle. Une commande le
ferme, et elle vient ensuite.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-08 12:47:18 -04:00
|
|
|
|
# LES MECANISMES QUI PORTENT REELLEMENT UNE HABILITATION (meta/acces.yml, champ
|
|
|
|
|
|
# `porte_par`). Le champ existe pour rendre visible, DANS LA DECLARATION, ce qui n'est
|
|
|
|
|
|
# pas cable : sans lui, « le groupe sysadmin donne Admin » se lit comme un fait alors que
|
|
|
|
|
|
# c'est parfois un souhait. Un mecanisme hors de ce jeu est donc refuse — inventer un nom
|
|
|
|
|
|
# de transport, c'est se donner l'air d'avoir cable quelque chose.
|
|
|
|
|
|
MECANISMES_ACCES = {
|
|
|
|
|
|
"defaut", # tout utilisateur authentifie l'obtient, sans rien porter
|
|
|
|
|
|
"claim-groupe", # le claim `groups` du jeton, lu par l'application
|
|
|
|
|
|
"role-realm", # un role de realm Keycloak, projete depuis un groupe LDAP
|
|
|
|
|
|
"role-client", # un role d'un client OIDC (ex. realm-management)
|
|
|
|
|
|
"groupe-annuaire", # l'application lit l'annuaire directement
|
|
|
|
|
|
"group-ldap-mapper", # le mapper Keycloak qui projette un groupe en role
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
NOMBRES_FR = {"deux": 2, "trois": 3, "quatre": 4, "cinq": 5, "six": 6, "sept": 7,
|
|
|
|
|
|
"huit": 8, "neuf": 9, "dix": 10, "onze": 11, "douze": 12, "treize": 13,
|
|
|
|
|
|
"quatorze": 14, "quinze": 15, "seize": 16}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _enumeration_apres(lignes: list[str], i: int) -> tuple[str | None, int]:
|
|
|
|
|
|
"""Le bloc qui suit la ligne `i` est-il une table ou une liste, et de combien ?"""
|
|
|
|
|
|
j = i + 1
|
|
|
|
|
|
while j < len(lignes) and not lignes[j].strip():
|
|
|
|
|
|
j += 1
|
|
|
|
|
|
if j >= len(lignes):
|
|
|
|
|
|
return None, 0
|
|
|
|
|
|
s = lignes[j].lstrip()
|
|
|
|
|
|
if s.startswith("|"):
|
|
|
|
|
|
k = j + 2 if j + 1 < len(lignes) and set(lignes[j + 1].strip()) <= set("|-: ") else j
|
|
|
|
|
|
n = 0
|
|
|
|
|
|
while k < len(lignes) and lignes[k].lstrip().startswith("|"):
|
|
|
|
|
|
n += 1
|
|
|
|
|
|
k += 1
|
|
|
|
|
|
return "table", n
|
|
|
|
|
|
if re.match(r"^([-*+]|\d+\.)\s", s):
|
|
|
|
|
|
n, k = 0, j
|
|
|
|
|
|
while k < len(lignes):
|
|
|
|
|
|
u = lignes[k].lstrip()
|
|
|
|
|
|
if re.match(r"^([-*+]|\d+\.)\s", u):
|
|
|
|
|
|
n += 1
|
|
|
|
|
|
elif not lignes[k].strip() or lignes[k][:1] in " \t":
|
|
|
|
|
|
pass
|
|
|
|
|
|
else:
|
|
|
|
|
|
break
|
|
|
|
|
|
k += 1
|
|
|
|
|
|
return "liste", n
|
|
|
|
|
|
return None, 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _lignes_premiere_table(texte: str) -> int:
|
|
|
|
|
|
"""Lignes de DONNEES de la PREMIERE table markdown d'un texte.
|
|
|
|
|
|
|
|
|
|
|
|
« Premiere », et c'est une correction de l'instrument, pas de la cible : le §7 de
|
|
|
|
|
|
`preparer-un-site-hebergeur.md` porte DEUX tables. En les additionnant, la garde
|
|
|
|
|
|
annoncait un ecart la ou il n'y en avait pas — elle accusait le document de ce
|
|
|
|
|
|
qu'elle mesurait mal.
|
|
|
|
|
|
"""
|
|
|
|
|
|
n, dans = 0, False
|
|
|
|
|
|
for l in texte.splitlines():
|
|
|
|
|
|
s = l.lstrip()
|
|
|
|
|
|
if s.startswith("|"):
|
|
|
|
|
|
if not dans:
|
|
|
|
|
|
dans = True # l'en-tete
|
|
|
|
|
|
continue
|
|
|
|
|
|
if set(s) <= set("|-: "):
|
|
|
|
|
|
continue # le separateur
|
|
|
|
|
|
n += 1
|
|
|
|
|
|
elif dans:
|
|
|
|
|
|
break
|
|
|
|
|
|
return n
|
|
|
|
|
|
|
|
|
|
|
|
|
schema du plan : la forme des registres devient derivee, et gardee
Etape 2 du chantier « l UI reflete fidelement la structure ». Le GUI porte
CHAMPS_ECRITS_PAR_GUI, une liste tenue A LA MAIN de ce qu il sait ecrire, que
P19 confronte au reel. C est une copie — gardee, donc honnete, mais une copie :
quelqu un doit penser a l allonger.
`make schema` produit docs/audit/schema-plan.json : six registres, 42 champs,
leurs types, leurs enumerations et ce qui est requis.
CE QUE LE SCHEMA EST, ET CE QU IL N EST PAS
schema -> la FORME -> generera les champs du formulaire
validateurs -> la COHERENCE -> refusent une saisie incoherente
Un JSON Schema ne sait pas dire qu un `consommateur` designe une application
inexistante, ni qu une integration universelle recopiee au plan est un defaut.
Vouloir l y mettre creerait la seconde source de verite que tout ce depot
refuse. Les valider_* restent l autorite.
LES ENUMERATIONS SONT IMPORTEES, JAMAIS RECOPIEES
ETATS_SERVEUR, PORTEES_BD et AUTORITES_DNS viennent des constantes que les
validateurs appliquent. Une enumeration recopiee diverge — c est la lecon des
neuf resolutions d instance que P41 garde depuis.
CE QUE L ETAPE 1 AVAIT TROUVE, ET QUE P61 A CONFIRME
Le recensement montrait `categorie` et `service` presents dans TOUS les plans et
absents de CHAMPS_ECRITS_PAR_GUI, dont la ligne `nomenclature` est vide : le GUI
ne sait pas les editer, l operateur doit ouvrir le YAML. P61 a refuse le premier
schema pour cette raison exacte. Les trois tables imbriquees de la nomenclature
sont donc DECRITES et non resumees en « object ».
31 champs observes dans l instance courante, 42 decrits par le schema. La
difference n est pas du bavardage : observer une instance n est pas un schema.
`noeud`, `stockage` et `coeurs` sont legitimes et simplement inutilises ici — un
schema derive de l observation les INTERDIRAIT.
P61, EPROUVEE DANS LES DEUX SENS
fichier genere perime -> REFUSE
champ du plan absent du schema -> REFUSE
champ decrit mais inutilise au plan -> COMPTE, pas refuse
Le troisieme point est delibere : refuser obligerait a retirer du schema un
champ valide des que plus personne ne s en sert. Meme mesure que les lacunes
nommees de P29.
UN DEFAUT DE MON INSTRUMENT, PAYE EN ROUTE
P61 comparait des noms a plat quand couverture_gui aplatit les tables
imbriquees : elle criait sur un schema correct. L instrument mesurait autre
chose que la cible. On aplatit desormais des deux cotes.
make prouver : CONFORME, 60 OK, 0 echec, 1 saute.
Prochaine etape : generer les formulaires depuis ce schema, et retirer
CHAMPS_ECRITS_PAR_GUI.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-08 15:19:20 -04:00
|
|
|
|
def preuve_schema_du_plan() -> tuple[bool, str]:
|
|
|
|
|
|
"""Le schema des registres decrit tout ce que les plans reels contiennent.
|
|
|
|
|
|
|
|
|
|
|
|
CE QU'IL REMPLACE (2026-09-08). Le GUI portait `CHAMPS_ECRITS_PAR_GUI`, une liste
|
|
|
|
|
|
tenue A LA MAIN de ce qu'il savait ecrire, et P19 la confrontait au reel. C'etait une
|
|
|
|
|
|
copie — gardee, donc honnete, mais une copie : quelqu'un devait penser a l'allonger.
|
|
|
|
|
|
Le schema la remplace comme SOURCE : les formulaires en derivent, et cette preuve
|
|
|
|
|
|
verifie que le schema, lui, n'a rien oublie.
|
|
|
|
|
|
|
|
|
|
|
|
DEUX SENS, ET LE SECOND EST LE MOINS EVIDENT :
|
|
|
|
|
|
|
|
|
|
|
|
1. tout champ present dans un plan reel (instance courante ET tous les modeles) est
|
|
|
|
|
|
DECRIT par le schema — sinon l'operateur devra editer le YAML a la main, et le
|
|
|
|
|
|
principe 10 se dement en silence ;
|
|
|
|
|
|
2. tout champ decrit est REELLEMENT admis quelque part — un champ inventé dans le
|
|
|
|
|
|
schema ferait apparaitre a l'ecran une case que rien ne consomme.
|
|
|
|
|
|
|
|
|
|
|
|
Le second sens n'est PAS une erreur en soi : `mail`, `coeurs`, `noeud` et `stockage`
|
|
|
|
|
|
sont legitimes et simplement inutilises dans les plans d'aujourd'hui. On les COMPTE
|
|
|
|
|
|
donc, sans refuser — la meme mesure que les lacunes nommees de P29. Refuser
|
|
|
|
|
|
obligerait a retirer du schema un champ valide des que plus personne ne s'en sert.
|
|
|
|
|
|
|
|
|
|
|
|
Et le fichier genere doit etre a jour : un schema perime ferait generer des
|
|
|
|
|
|
formulaires d'hier.
|
|
|
|
|
|
"""
|
|
|
|
|
|
import subprocess as _sp
|
|
|
|
|
|
r = _sp.run([sys.executable, "scripts/schema_plan.py", "--verifier"],
|
|
|
|
|
|
capture_output=True, text=True, cwd=str(RACINE))
|
|
|
|
|
|
if r.returncode != 0:
|
|
|
|
|
|
return False, (r.stderr or r.stdout).strip()
|
|
|
|
|
|
|
|
|
|
|
|
import json as _json
|
|
|
|
|
|
schema = _json.loads((RACINE / "docs" / "audit" / "schema-plan.json").read_text(encoding="utf-8"))
|
|
|
|
|
|
def _noms(props: dict) -> set:
|
|
|
|
|
|
"""Tous les champs decrits, TABLES IMBRIQUEES COMPRISES.
|
|
|
|
|
|
|
|
|
|
|
|
`couverture_gui.champs_utilises()` aplatit : il descend dans
|
|
|
|
|
|
`nomenclature.fonctions.*` et rend `categorie` et `service` comme s'ils etaient
|
|
|
|
|
|
des champs de la nomenclature. Comparer a plat d'un cote et en arbre de l'autre
|
|
|
|
|
|
faisait crier la preuve sur un schema correct — l'instrument mesurait autre chose
|
|
|
|
|
|
que la cible. On aplatit donc des deux cotes.
|
|
|
|
|
|
"""
|
|
|
|
|
|
vus = set()
|
|
|
|
|
|
for nom, p in (props or {}).items():
|
|
|
|
|
|
vus.add(nom)
|
plan : sauvegarder n emportait plus quarante lignes de commentaire
En voulant generer deux formulaires de plus, j ai trouve pire que ce que
je cherchais.
CE QUI ETAIT DEJA LA. Les quatre ecrivains de registre ecrasaient le
fichier au safe_dump. Mesure sur les fichiers reels : domaines.yml 6->3,
applications.yml 27->5, serveurs.yml 18->3. Quarante lignes, detruites
par n importe quel clic sur Sauvegarder dans les vues Serveurs,
Applications ou Domaines. Parmi elles, celle qui explique pourquoi
backup-01 a ete retire, et celle qui dit dans quel ordre les deux roles
du runner s appliquent. C etait l incident du 2026-08-18, jamais corrige
pour les registres du plan. Les quatre passent par _ecrire_registre :
aller-retour a vide identique a l octet, sur les quatre fichiers.
TROIS ECARTS DE SCHEMA, trouves en confrontant le schema aux VALIDATEURS
et non aux seuls plans :
- edge designe un GROUPE, pas un hote. Le schema disait serveurs : un
formulaire genere aurait offert une valeur qu aucun hote ne reconnait,
donc aucun SAN, donc la panne du 2026-08-25 reintroduite ;
- exposition, entierement valide par le moteur, manquait au schema ;
- liens etait items: {type: object} — une liste d objets sans forme.
Et mail, offert par la vue Domaines depuis sa creation, decrit ici comme
un booleen, saisi la-bas comme du texte, lu par rien : retire.
P62 garde tout ca. Elle separe l entite du reste mecaniquement : un
validateur lit son entite par des variables LOCALES, les autres registres
par ses PARAMETRES. Controle negatif rejoue.
LES FORMULAIRES. Serveurs de BD et Domaines sont generes, chargement et
sauvegarde compris. Quatre registres sur six. Le generateur a appris la
liste d objets.
LIMITE : restent serveurs et applications, les deux plus gros ; et je n ai
toujours pas ouvert ces pages dans un navigateur.
make prouver : CONFORME, 61 OK, 0 echec, 1 saute (62 preuves).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-08 17:54:49 -04:00
|
|
|
|
# Trois imbrications possibles : table a clefs libres (`additionalProperties`),
|
|
|
|
|
|
# bloc a clefs fixes (`properties`), liste d'objets (`items.properties`).
|
|
|
|
|
|
for sous in (p.get("additionalProperties"), p.get("items")):
|
|
|
|
|
|
if isinstance(sous, dict):
|
|
|
|
|
|
vus |= _noms(sous.get("properties"))
|
|
|
|
|
|
if isinstance(p.get("properties"), dict):
|
|
|
|
|
|
vus |= _noms(p["properties"])
|
schema du plan : la forme des registres devient derivee, et gardee
Etape 2 du chantier « l UI reflete fidelement la structure ». Le GUI porte
CHAMPS_ECRITS_PAR_GUI, une liste tenue A LA MAIN de ce qu il sait ecrire, que
P19 confronte au reel. C est une copie — gardee, donc honnete, mais une copie :
quelqu un doit penser a l allonger.
`make schema` produit docs/audit/schema-plan.json : six registres, 42 champs,
leurs types, leurs enumerations et ce qui est requis.
CE QUE LE SCHEMA EST, ET CE QU IL N EST PAS
schema -> la FORME -> generera les champs du formulaire
validateurs -> la COHERENCE -> refusent une saisie incoherente
Un JSON Schema ne sait pas dire qu un `consommateur` designe une application
inexistante, ni qu une integration universelle recopiee au plan est un defaut.
Vouloir l y mettre creerait la seconde source de verite que tout ce depot
refuse. Les valider_* restent l autorite.
LES ENUMERATIONS SONT IMPORTEES, JAMAIS RECOPIEES
ETATS_SERVEUR, PORTEES_BD et AUTORITES_DNS viennent des constantes que les
validateurs appliquent. Une enumeration recopiee diverge — c est la lecon des
neuf resolutions d instance que P41 garde depuis.
CE QUE L ETAPE 1 AVAIT TROUVE, ET QUE P61 A CONFIRME
Le recensement montrait `categorie` et `service` presents dans TOUS les plans et
absents de CHAMPS_ECRITS_PAR_GUI, dont la ligne `nomenclature` est vide : le GUI
ne sait pas les editer, l operateur doit ouvrir le YAML. P61 a refuse le premier
schema pour cette raison exacte. Les trois tables imbriquees de la nomenclature
sont donc DECRITES et non resumees en « object ».
31 champs observes dans l instance courante, 42 decrits par le schema. La
difference n est pas du bavardage : observer une instance n est pas un schema.
`noeud`, `stockage` et `coeurs` sont legitimes et simplement inutilises ici — un
schema derive de l observation les INTERDIRAIT.
P61, EPROUVEE DANS LES DEUX SENS
fichier genere perime -> REFUSE
champ du plan absent du schema -> REFUSE
champ decrit mais inutilise au plan -> COMPTE, pas refuse
Le troisieme point est delibere : refuser obligerait a retirer du schema un
champ valide des que plus personne ne s en sert. Meme mesure que les lacunes
nommees de P29.
UN DEFAUT DE MON INSTRUMENT, PAYE EN ROUTE
P61 comparait des noms a plat quand couverture_gui aplatit les tables
imbriquees : elle criait sur un schema correct. L instrument mesurait autre
chose que la cible. On aplatit desormais des deux cotes.
make prouver : CONFORME, 60 OK, 0 echec, 1 saute.
Prochaine etape : generer les formulaires depuis ce schema, et retirer
CHAMPS_ECRITS_PAR_GUI.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-08 15:19:20 -04:00
|
|
|
|
return vus
|
|
|
|
|
|
|
|
|
|
|
|
decrit = {nom: _noms(reg["entite"]["properties"])
|
|
|
|
|
|
for nom, reg in schema["registres"].items()}
|
|
|
|
|
|
|
|
|
|
|
|
sys.path.insert(0, str(RACINE / "scripts"))
|
|
|
|
|
|
import couverture_gui as _cg
|
|
|
|
|
|
observes = {nom: set(champs) for nom, champs in _cg.champs_utilises().items()}
|
|
|
|
|
|
|
GUI : la vue Nomenclature, et deux fautes que mes bancs ne voyaient pas
LA VUE. La nomenclature etait le seul registre que le GUI ne savait pas
ecrire du tout : ajouter une fonction exigeait d ouvrir le YAML. Elle a
sa vue, et son formulaire est GENERE depuis le schema. Deuxieme registre
sur six. couverture_gui verifier passe : les 28 champs des plans reels
sont editables.
Elle n est pas un registre comme les autres : elle decrit la REGLE dont
VMID, VLAN, adresse et passerelle se derivent. Chaque fonction montre ce
qu elle derive et les VM qui la portent ; l index est montre mais pas
editable, parce qu il est alloue par le site ; valider_nomenclature
refuse de retirer une fonction encore portee, ou de designer une zone
non declaree.
DEUX FAUTES, ET POURQUOI MES BANCS NE LES VOYAIENT PAS.
Le formulaire des bases, livre la veille, etait casse dans un navigateur.
Il lisait data.schema, or il n existe aucun data global : c est une const
locale de charger(). ReferenceError a l ouverture, et zone morte dans
sauvegarderBases. Je l avais eprouve sous node EN LUI PASSANT data : le
banc reproduisait la fonction, pas sa portee. D ou test_rendu_gui.py, qui
charge le JS entier dans un DOM simule et dessine les douze vues, avec son
controle negatif.
Le schema decrivait reservations comme une table de zones ; le fichier
reel est un bloc plat. P61 comparait des NOMS aplatis, donc ne voyait
rien. Elle compare desormais aussi la FORME.
ECRIRE SANS DEPLACER UN COMMENTAIRE. _fusion_chirurgicale remplace le
bloc entier des qu une valeur change : quinze entrees compactes devenaient
42 lignes, et le commentaire du poste d exploitation se retrouvait en tete
du bloc, ou il affirmait que collab etait le poste d exploitation. Un
commentaire deplace n est pas laid, il est faux. _fusion_table edite les
tables ligne a ligne ; le diff fait trois lignes.
Au passage : sort_keys triait le schema, donc l ordre des cases a l ecran
(reserve_max avant reserve_min) ; et _ecrire_index_nomenclature ecrivait
encore par write_text, oubliee au passage des ecritures atomiques.
LIMITE : deux registres sur six sont generes, et je n ai toujours pas
ouvert cette page dans un navigateur.
make prouver : CONFORME, 60 OK, 0 echec, 1 saute.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-08 17:08:45 -04:00
|
|
|
|
# TROISIEME SENS, ajoute le 2026-09-08 : la FORME, pas seulement le nom.
|
|
|
|
|
|
#
|
|
|
|
|
|
# `reservations` etait decrit comme une TABLE de zones, chacune portant `passerelle`.
|
|
|
|
|
|
# Le fichier reel est un bloc PLAT, et `underlay.py` lit `reservations.passerelle`
|
|
|
|
|
|
# a plat. Les deux sens ci-dessus n'y voyaient rien : ils aplatissent les noms des
|
|
|
|
|
|
# deux cotes, et `passerelle` existe bien de part et d'autre — a des profondeurs
|
|
|
|
|
|
# differentes. Un formulaire genere depuis cette description aurait offert un bouton
|
|
|
|
|
|
# « ajouter une zone » et ecrit dans le plan une forme que le moteur ne lit pas.
|
|
|
|
|
|
#
|
|
|
|
|
|
# Un nom commun ne dit donc rien de la structure. On compare desormais trois formes :
|
|
|
|
|
|
# scalaire, objet a clefs fixes, table a clefs libres. La lecture des plans se derive
|
|
|
|
|
|
# de `x-fichier` / `x-racine` du schema lui-meme — pas d'une seconde liste a tenir.
|
|
|
|
|
|
def _forme_declaree(spec: dict) -> str:
|
|
|
|
|
|
if isinstance(spec.get("additionalProperties"), dict):
|
|
|
|
|
|
return "table"
|
|
|
|
|
|
if spec.get("type") == "object":
|
|
|
|
|
|
return "objet"
|
|
|
|
|
|
return "scalaire"
|
|
|
|
|
|
|
|
|
|
|
|
def _forme_observee(valeur):
|
|
|
|
|
|
if isinstance(valeur, dict):
|
|
|
|
|
|
if not valeur:
|
|
|
|
|
|
return None # un dictionnaire vide ne tranche pas entre objet et table
|
|
|
|
|
|
return "table" if all(isinstance(x, dict) for x in valeur.values()) else "objet"
|
|
|
|
|
|
return "scalaire"
|
|
|
|
|
|
|
|
|
|
|
|
# `plan_de()` et non `RACINE / "instance" / "plan"` : P41 refuse une dixieme copie de
|
|
|
|
|
|
# la resolution d'instance, et elle a refuse celle-ci en premier.
|
|
|
|
|
|
plans = [plan_de()] + sorted((RACINE / "exemples" / "modeles").glob("*/plan"))
|
|
|
|
|
|
formes = []
|
|
|
|
|
|
for plan in plans:
|
|
|
|
|
|
for nom, reg in schema["registres"].items():
|
|
|
|
|
|
fichier = plan / Path(reg["x-fichier"]).name
|
|
|
|
|
|
if not fichier.is_file():
|
|
|
|
|
|
continue
|
|
|
|
|
|
try:
|
|
|
|
|
|
data = yaml.safe_load(fichier.read_text(encoding="utf-8")) or {}
|
|
|
|
|
|
except yaml.YAMLError:
|
|
|
|
|
|
continue
|
|
|
|
|
|
racine = reg["x-racine"]
|
|
|
|
|
|
# `x-racine: null` = le document EST l'entite (la nomenclature).
|
|
|
|
|
|
entites = list((data.get(racine) or {}).values()) if racine else [data]
|
|
|
|
|
|
props = reg["entite"]["properties"]
|
|
|
|
|
|
for entite in entites:
|
|
|
|
|
|
if not isinstance(entite, dict):
|
|
|
|
|
|
continue
|
|
|
|
|
|
for champ, valeur in entite.items():
|
|
|
|
|
|
if champ not in props:
|
|
|
|
|
|
continue
|
|
|
|
|
|
vue = _forme_observee(valeur)
|
|
|
|
|
|
dite = _forme_declaree(props[champ])
|
|
|
|
|
|
if vue and vue != dite:
|
|
|
|
|
|
formes.append(f"{nom}.{champ} : le plan en fait un {vue}, "
|
|
|
|
|
|
f"le schema un {dite} ({plan.parent.name})")
|
|
|
|
|
|
|
schema du plan : la forme des registres devient derivee, et gardee
Etape 2 du chantier « l UI reflete fidelement la structure ». Le GUI porte
CHAMPS_ECRITS_PAR_GUI, une liste tenue A LA MAIN de ce qu il sait ecrire, que
P19 confronte au reel. C est une copie — gardee, donc honnete, mais une copie :
quelqu un doit penser a l allonger.
`make schema` produit docs/audit/schema-plan.json : six registres, 42 champs,
leurs types, leurs enumerations et ce qui est requis.
CE QUE LE SCHEMA EST, ET CE QU IL N EST PAS
schema -> la FORME -> generera les champs du formulaire
validateurs -> la COHERENCE -> refusent une saisie incoherente
Un JSON Schema ne sait pas dire qu un `consommateur` designe une application
inexistante, ni qu une integration universelle recopiee au plan est un defaut.
Vouloir l y mettre creerait la seconde source de verite que tout ce depot
refuse. Les valider_* restent l autorite.
LES ENUMERATIONS SONT IMPORTEES, JAMAIS RECOPIEES
ETATS_SERVEUR, PORTEES_BD et AUTORITES_DNS viennent des constantes que les
validateurs appliquent. Une enumeration recopiee diverge — c est la lecon des
neuf resolutions d instance que P41 garde depuis.
CE QUE L ETAPE 1 AVAIT TROUVE, ET QUE P61 A CONFIRME
Le recensement montrait `categorie` et `service` presents dans TOUS les plans et
absents de CHAMPS_ECRITS_PAR_GUI, dont la ligne `nomenclature` est vide : le GUI
ne sait pas les editer, l operateur doit ouvrir le YAML. P61 a refuse le premier
schema pour cette raison exacte. Les trois tables imbriquees de la nomenclature
sont donc DECRITES et non resumees en « object ».
31 champs observes dans l instance courante, 42 decrits par le schema. La
difference n est pas du bavardage : observer une instance n est pas un schema.
`noeud`, `stockage` et `coeurs` sont legitimes et simplement inutilises ici — un
schema derive de l observation les INTERDIRAIT.
P61, EPROUVEE DANS LES DEUX SENS
fichier genere perime -> REFUSE
champ du plan absent du schema -> REFUSE
champ decrit mais inutilise au plan -> COMPTE, pas refuse
Le troisieme point est delibere : refuser obligerait a retirer du schema un
champ valide des que plus personne ne s en sert. Meme mesure que les lacunes
nommees de P29.
UN DEFAUT DE MON INSTRUMENT, PAYE EN ROUTE
P61 comparait des noms a plat quand couverture_gui aplatit les tables
imbriquees : elle criait sur un schema correct. L instrument mesurait autre
chose que la cible. On aplatit desormais des deux cotes.
make prouver : CONFORME, 60 OK, 0 echec, 1 saute.
Prochaine etape : generer les formulaires depuis ce schema, et retirer
CHAMPS_ECRITS_PAR_GUI.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-08 15:19:20 -04:00
|
|
|
|
trous, inutilises = [], []
|
|
|
|
|
|
for nom, champs in observes.items():
|
|
|
|
|
|
manquants = sorted(champs - decrit.get(nom, set()))
|
|
|
|
|
|
if manquants:
|
|
|
|
|
|
trous.append(f"{nom} : {', '.join(manquants)} present(s) au plan, absent(s) du schema")
|
|
|
|
|
|
for nom, champs in decrit.items():
|
|
|
|
|
|
jamais = sorted(champs - observes.get(nom, set()))
|
|
|
|
|
|
if jamais:
|
|
|
|
|
|
inutilises.append(f"{nom}:{len(jamais)}")
|
|
|
|
|
|
|
GUI : la vue Nomenclature, et deux fautes que mes bancs ne voyaient pas
LA VUE. La nomenclature etait le seul registre que le GUI ne savait pas
ecrire du tout : ajouter une fonction exigeait d ouvrir le YAML. Elle a
sa vue, et son formulaire est GENERE depuis le schema. Deuxieme registre
sur six. couverture_gui verifier passe : les 28 champs des plans reels
sont editables.
Elle n est pas un registre comme les autres : elle decrit la REGLE dont
VMID, VLAN, adresse et passerelle se derivent. Chaque fonction montre ce
qu elle derive et les VM qui la portent ; l index est montre mais pas
editable, parce qu il est alloue par le site ; valider_nomenclature
refuse de retirer une fonction encore portee, ou de designer une zone
non declaree.
DEUX FAUTES, ET POURQUOI MES BANCS NE LES VOYAIENT PAS.
Le formulaire des bases, livre la veille, etait casse dans un navigateur.
Il lisait data.schema, or il n existe aucun data global : c est une const
locale de charger(). ReferenceError a l ouverture, et zone morte dans
sauvegarderBases. Je l avais eprouve sous node EN LUI PASSANT data : le
banc reproduisait la fonction, pas sa portee. D ou test_rendu_gui.py, qui
charge le JS entier dans un DOM simule et dessine les douze vues, avec son
controle negatif.
Le schema decrivait reservations comme une table de zones ; le fichier
reel est un bloc plat. P61 comparait des NOMS aplatis, donc ne voyait
rien. Elle compare desormais aussi la FORME.
ECRIRE SANS DEPLACER UN COMMENTAIRE. _fusion_chirurgicale remplace le
bloc entier des qu une valeur change : quinze entrees compactes devenaient
42 lignes, et le commentaire du poste d exploitation se retrouvait en tete
du bloc, ou il affirmait que collab etait le poste d exploitation. Un
commentaire deplace n est pas laid, il est faux. _fusion_table edite les
tables ligne a ligne ; le diff fait trois lignes.
Au passage : sort_keys triait le schema, donc l ordre des cases a l ecran
(reserve_max avant reserve_min) ; et _ecrire_index_nomenclature ecrivait
encore par write_text, oubliee au passage des ecritures atomiques.
LIMITE : deux registres sur six sont generes, et je n ai toujours pas
ouvert cette page dans un navigateur.
make prouver : CONFORME, 60 OK, 0 echec, 1 saute.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-08 17:08:45 -04:00
|
|
|
|
if formes:
|
|
|
|
|
|
return False, ("Le schema decrit une FORME que les plans n'ont pas :\n - "
|
|
|
|
|
|
+ "\n - ".join(sorted(set(formes)))
|
|
|
|
|
|
+ "\n (corriger REGISTRES dans scripts/schema_plan.py — `entree` "
|
|
|
|
|
|
"pour une table a clefs libres, `sous_champs` pour un bloc fixe — "
|
|
|
|
|
|
"puis `make schema`)")
|
schema du plan : la forme des registres devient derivee, et gardee
Etape 2 du chantier « l UI reflete fidelement la structure ». Le GUI porte
CHAMPS_ECRITS_PAR_GUI, une liste tenue A LA MAIN de ce qu il sait ecrire, que
P19 confronte au reel. C est une copie — gardee, donc honnete, mais une copie :
quelqu un doit penser a l allonger.
`make schema` produit docs/audit/schema-plan.json : six registres, 42 champs,
leurs types, leurs enumerations et ce qui est requis.
CE QUE LE SCHEMA EST, ET CE QU IL N EST PAS
schema -> la FORME -> generera les champs du formulaire
validateurs -> la COHERENCE -> refusent une saisie incoherente
Un JSON Schema ne sait pas dire qu un `consommateur` designe une application
inexistante, ni qu une integration universelle recopiee au plan est un defaut.
Vouloir l y mettre creerait la seconde source de verite que tout ce depot
refuse. Les valider_* restent l autorite.
LES ENUMERATIONS SONT IMPORTEES, JAMAIS RECOPIEES
ETATS_SERVEUR, PORTEES_BD et AUTORITES_DNS viennent des constantes que les
validateurs appliquent. Une enumeration recopiee diverge — c est la lecon des
neuf resolutions d instance que P41 garde depuis.
CE QUE L ETAPE 1 AVAIT TROUVE, ET QUE P61 A CONFIRME
Le recensement montrait `categorie` et `service` presents dans TOUS les plans et
absents de CHAMPS_ECRITS_PAR_GUI, dont la ligne `nomenclature` est vide : le GUI
ne sait pas les editer, l operateur doit ouvrir le YAML. P61 a refuse le premier
schema pour cette raison exacte. Les trois tables imbriquees de la nomenclature
sont donc DECRITES et non resumees en « object ».
31 champs observes dans l instance courante, 42 decrits par le schema. La
difference n est pas du bavardage : observer une instance n est pas un schema.
`noeud`, `stockage` et `coeurs` sont legitimes et simplement inutilises ici — un
schema derive de l observation les INTERDIRAIT.
P61, EPROUVEE DANS LES DEUX SENS
fichier genere perime -> REFUSE
champ du plan absent du schema -> REFUSE
champ decrit mais inutilise au plan -> COMPTE, pas refuse
Le troisieme point est delibere : refuser obligerait a retirer du schema un
champ valide des que plus personne ne s en sert. Meme mesure que les lacunes
nommees de P29.
UN DEFAUT DE MON INSTRUMENT, PAYE EN ROUTE
P61 comparait des noms a plat quand couverture_gui aplatit les tables
imbriquees : elle criait sur un schema correct. L instrument mesurait autre
chose que la cible. On aplatit desormais des deux cotes.
make prouver : CONFORME, 60 OK, 0 echec, 1 saute.
Prochaine etape : generer les formulaires depuis ce schema, et retirer
CHAMPS_ECRITS_PAR_GUI.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-08 15:19:20 -04:00
|
|
|
|
if trous:
|
|
|
|
|
|
return False, ("Le schema ne decrit pas tout ce que les plans contiennent :\n - "
|
|
|
|
|
|
+ "\n - ".join(trous)
|
|
|
|
|
|
+ "\n (ajouter le champ a REGISTRES dans scripts/schema_plan.py, "
|
|
|
|
|
|
"puis `make schema`)")
|
|
|
|
|
|
n = sum(len(c) for c in decrit.values())
|
|
|
|
|
|
suffixe = (f" ; {', '.join(inutilises)} champ(s) decrits et inutilises dans les plans "
|
|
|
|
|
|
f"d'aujourd'hui (legitime)") if inutilises else ""
|
GUI : la vue Nomenclature, et deux fautes que mes bancs ne voyaient pas
LA VUE. La nomenclature etait le seul registre que le GUI ne savait pas
ecrire du tout : ajouter une fonction exigeait d ouvrir le YAML. Elle a
sa vue, et son formulaire est GENERE depuis le schema. Deuxieme registre
sur six. couverture_gui verifier passe : les 28 champs des plans reels
sont editables.
Elle n est pas un registre comme les autres : elle decrit la REGLE dont
VMID, VLAN, adresse et passerelle se derivent. Chaque fonction montre ce
qu elle derive et les VM qui la portent ; l index est montre mais pas
editable, parce qu il est alloue par le site ; valider_nomenclature
refuse de retirer une fonction encore portee, ou de designer une zone
non declaree.
DEUX FAUTES, ET POURQUOI MES BANCS NE LES VOYAIENT PAS.
Le formulaire des bases, livre la veille, etait casse dans un navigateur.
Il lisait data.schema, or il n existe aucun data global : c est une const
locale de charger(). ReferenceError a l ouverture, et zone morte dans
sauvegarderBases. Je l avais eprouve sous node EN LUI PASSANT data : le
banc reproduisait la fonction, pas sa portee. D ou test_rendu_gui.py, qui
charge le JS entier dans un DOM simule et dessine les douze vues, avec son
controle negatif.
Le schema decrivait reservations comme une table de zones ; le fichier
reel est un bloc plat. P61 comparait des NOMS aplatis, donc ne voyait
rien. Elle compare desormais aussi la FORME.
ECRIRE SANS DEPLACER UN COMMENTAIRE. _fusion_chirurgicale remplace le
bloc entier des qu une valeur change : quinze entrees compactes devenaient
42 lignes, et le commentaire du poste d exploitation se retrouvait en tete
du bloc, ou il affirmait que collab etait le poste d exploitation. Un
commentaire deplace n est pas laid, il est faux. _fusion_table edite les
tables ligne a ligne ; le diff fait trois lignes.
Au passage : sort_keys triait le schema, donc l ordre des cases a l ecran
(reserve_max avant reserve_min) ; et _ecrire_index_nomenclature ecrivait
encore par write_text, oubliee au passage des ecritures atomiques.
LIMITE : deux registres sur six sont generes, et je n ai toujours pas
ouvert cette page dans un navigateur.
make prouver : CONFORME, 60 OK, 0 echec, 1 saute.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-08 17:08:45 -04:00
|
|
|
|
return True, (f"Le schema decrit {n} champ(s) sur {len(decrit)} registres ; il couvre "
|
|
|
|
|
|
f"tout ce que les plans reels contiennent, et la FORME de chaque champ "
|
|
|
|
|
|
f"(scalaire / objet / table) correspond dans {len(plans)} plan(s){suffixe}.")
|
schema du plan : la forme des registres devient derivee, et gardee
Etape 2 du chantier « l UI reflete fidelement la structure ». Le GUI porte
CHAMPS_ECRITS_PAR_GUI, une liste tenue A LA MAIN de ce qu il sait ecrire, que
P19 confronte au reel. C est une copie — gardee, donc honnete, mais une copie :
quelqu un doit penser a l allonger.
`make schema` produit docs/audit/schema-plan.json : six registres, 42 champs,
leurs types, leurs enumerations et ce qui est requis.
CE QUE LE SCHEMA EST, ET CE QU IL N EST PAS
schema -> la FORME -> generera les champs du formulaire
validateurs -> la COHERENCE -> refusent une saisie incoherente
Un JSON Schema ne sait pas dire qu un `consommateur` designe une application
inexistante, ni qu une integration universelle recopiee au plan est un defaut.
Vouloir l y mettre creerait la seconde source de verite que tout ce depot
refuse. Les valider_* restent l autorite.
LES ENUMERATIONS SONT IMPORTEES, JAMAIS RECOPIEES
ETATS_SERVEUR, PORTEES_BD et AUTORITES_DNS viennent des constantes que les
validateurs appliquent. Une enumeration recopiee diverge — c est la lecon des
neuf resolutions d instance que P41 garde depuis.
CE QUE L ETAPE 1 AVAIT TROUVE, ET QUE P61 A CONFIRME
Le recensement montrait `categorie` et `service` presents dans TOUS les plans et
absents de CHAMPS_ECRITS_PAR_GUI, dont la ligne `nomenclature` est vide : le GUI
ne sait pas les editer, l operateur doit ouvrir le YAML. P61 a refuse le premier
schema pour cette raison exacte. Les trois tables imbriquees de la nomenclature
sont donc DECRITES et non resumees en « object ».
31 champs observes dans l instance courante, 42 decrits par le schema. La
difference n est pas du bavardage : observer une instance n est pas un schema.
`noeud`, `stockage` et `coeurs` sont legitimes et simplement inutilises ici — un
schema derive de l observation les INTERDIRAIT.
P61, EPROUVEE DANS LES DEUX SENS
fichier genere perime -> REFUSE
champ du plan absent du schema -> REFUSE
champ decrit mais inutilise au plan -> COMPTE, pas refuse
Le troisieme point est delibere : refuser obligerait a retirer du schema un
champ valide des que plus personne ne s en sert. Meme mesure que les lacunes
nommees de P29.
UN DEFAUT DE MON INSTRUMENT, PAYE EN ROUTE
P61 comparait des noms a plat quand couverture_gui aplatit les tables
imbriquees : elle criait sur un schema correct. L instrument mesurait autre
chose que la cible. On aplatit desormais des deux cotes.
make prouver : CONFORME, 60 OK, 0 echec, 1 saute.
Prochaine etape : generer les formulaires depuis ce schema, et retirer
CHAMPS_ECRITS_PAR_GUI.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-08 15:19:20 -04:00
|
|
|
|
|
|
|
|
|
|
|
plan : sauvegarder n emportait plus quarante lignes de commentaire
En voulant generer deux formulaires de plus, j ai trouve pire que ce que
je cherchais.
CE QUI ETAIT DEJA LA. Les quatre ecrivains de registre ecrasaient le
fichier au safe_dump. Mesure sur les fichiers reels : domaines.yml 6->3,
applications.yml 27->5, serveurs.yml 18->3. Quarante lignes, detruites
par n importe quel clic sur Sauvegarder dans les vues Serveurs,
Applications ou Domaines. Parmi elles, celle qui explique pourquoi
backup-01 a ete retire, et celle qui dit dans quel ordre les deux roles
du runner s appliquent. C etait l incident du 2026-08-18, jamais corrige
pour les registres du plan. Les quatre passent par _ecrire_registre :
aller-retour a vide identique a l octet, sur les quatre fichiers.
TROIS ECARTS DE SCHEMA, trouves en confrontant le schema aux VALIDATEURS
et non aux seuls plans :
- edge designe un GROUPE, pas un hote. Le schema disait serveurs : un
formulaire genere aurait offert une valeur qu aucun hote ne reconnait,
donc aucun SAN, donc la panne du 2026-08-25 reintroduite ;
- exposition, entierement valide par le moteur, manquait au schema ;
- liens etait items: {type: object} — une liste d objets sans forme.
Et mail, offert par la vue Domaines depuis sa creation, decrit ici comme
un booleen, saisi la-bas comme du texte, lu par rien : retire.
P62 garde tout ca. Elle separe l entite du reste mecaniquement : un
validateur lit son entite par des variables LOCALES, les autres registres
par ses PARAMETRES. Controle negatif rejoue.
LES FORMULAIRES. Serveurs de BD et Domaines sont generes, chargement et
sauvegarde compris. Quatre registres sur six. Le generateur a appris la
liste d objets.
LIMITE : restent serveurs et applications, les deux plus gros ; et je n ai
toujours pas ouvert ces pages dans un navigateur.
make prouver : CONFORME, 61 OK, 0 echec, 1 saute (62 preuves).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-08 17:54:49 -04:00
|
|
|
|
def preuve_schema_couvre_les_validateurs() -> tuple[bool, str]:
|
|
|
|
|
|
"""Le schema decrit-il tout ce que le MOTEUR accepte — pas seulement ce que les plans ont ?
|
|
|
|
|
|
|
|
|
|
|
|
LA LACUNE QUE CETTE PREUVE COMBLE (2026-09-08). P61 confronte le schema aux plans
|
|
|
|
|
|
REELS : tout champ present quelque part doit etre decrit. C'est necessaire, et ca ne
|
|
|
|
|
|
suffit pas. Un champ que les validateurs acceptent mais qu'aucun plan n'emploie
|
|
|
|
|
|
ENCORE passe entre les mailles — et le formulaire genere depuis le schema ne saura
|
|
|
|
|
|
jamais l'offrir. L'operateur devra rouvrir le YAML pour une fonctionnalite que le
|
|
|
|
|
|
moteur possede deja.
|
|
|
|
|
|
|
|
|
|
|
|
DEUX CAS MESURES, LE JOUR OU CETTE PREUVE A ETE ECRITE :
|
|
|
|
|
|
|
|
|
|
|
|
- `valider_domaines` valide entierement `exposition` (une liste de `{nom, cible,
|
|
|
|
|
|
type}`) et le schema l'ignorait. Aucun plan ne s'en sert : P61 etait au vert.
|
|
|
|
|
|
- `applications.liens` etait decrit `items: {type: object}` — une liste d'objets
|
|
|
|
|
|
sans forme. Le moteur, lui, exige `vers` et `role`. Un formulaire genere aurait
|
|
|
|
|
|
offert « ajouter un lien » sans savoir quelles cases y mettre.
|
|
|
|
|
|
|
|
|
|
|
|
COMMENT ON SEPARE L'ENTITE DU RESTE. Un validateur lit deux choses : l'entite qu'il
|
|
|
|
|
|
valide, et d'AUTRES registres passes en parametres pour la coherence croisee
|
|
|
|
|
|
(`valider_serveurs` lit `nomenclature.get("fonctions")`). Les champs de l'entite sont
|
|
|
|
|
|
donc les acces dont le destinataire est une variable LOCALE — jamais un parametre.
|
|
|
|
|
|
C'est ce qui distingue `srv.get("fonction")` de `nomenclature.get("fonctions")`, et
|
|
|
|
|
|
c'est mecanique : aucune liste a tenir.
|
|
|
|
|
|
|
|
|
|
|
|
CE QU'ELLE NE FAIT PAS. Elle ne verifie pas l'inverse (un champ decrit qu'aucun
|
|
|
|
|
|
validateur ne lit) : `usage`, `websocket` ou `dnssec` sont consommes par les roles et
|
|
|
|
|
|
les gabarits, pas par les validateurs. Exiger la reciproque ferait crier la preuve
|
|
|
|
|
|
sur des champs parfaitement legitimes.
|
|
|
|
|
|
"""
|
|
|
|
|
|
import json as _json
|
|
|
|
|
|
schema = _json.loads((RACINE / "docs" / "audit" / "schema-plan.json").read_text(encoding="utf-8"))
|
|
|
|
|
|
|
|
|
|
|
|
def _noms(props: dict) -> set:
|
|
|
|
|
|
vus = set()
|
|
|
|
|
|
for nom, p in (props or {}).items():
|
|
|
|
|
|
vus.add(nom)
|
|
|
|
|
|
for sous in (p.get("additionalProperties"), p.get("items")):
|
|
|
|
|
|
if isinstance(sous, dict):
|
|
|
|
|
|
vus |= _noms(sous.get("properties"))
|
|
|
|
|
|
if isinstance(p.get("properties"), dict):
|
|
|
|
|
|
vus |= _noms(p["properties"])
|
|
|
|
|
|
return vus
|
|
|
|
|
|
|
|
|
|
|
|
decrit = {nom: _noms(reg["entite"]["properties"]) for nom, reg in schema["registres"].items()}
|
|
|
|
|
|
racines = {reg["x-racine"] for reg in schema["registres"].values()} | set(schema["registres"])
|
|
|
|
|
|
|
|
|
|
|
|
VALIDATEURS = {
|
|
|
|
|
|
"domaines_publics": "valider_domaines",
|
|
|
|
|
|
"serveurs": "valider_serveurs",
|
|
|
|
|
|
"bases_donnees": "valider_bases",
|
|
|
|
|
|
"applications": "valider_applications",
|
|
|
|
|
|
}
|
|
|
|
|
|
source = (RACINE / "scripts" / "inventory_rules.py").read_text(encoding="utf-8")
|
|
|
|
|
|
arbre = ast.parse(source)
|
|
|
|
|
|
fonctions = {n.name: n for n in ast.walk(arbre) if isinstance(n, ast.FunctionDef)}
|
|
|
|
|
|
|
|
|
|
|
|
trous, mesures = [], []
|
|
|
|
|
|
for registre, nom_fn in sorted(VALIDATEURS.items()):
|
|
|
|
|
|
fn = fonctions.get(nom_fn)
|
|
|
|
|
|
if fn is None:
|
|
|
|
|
|
trous.append(f"{registre} : validateur `{nom_fn}` introuvable — la preuve ne mesure plus rien")
|
|
|
|
|
|
continue
|
|
|
|
|
|
parametres = {a.arg for a in fn.args.args} | {a.arg for a in fn.args.kwonlyargs}
|
|
|
|
|
|
champs = set()
|
|
|
|
|
|
for n in ast.walk(fn):
|
|
|
|
|
|
cible = None
|
|
|
|
|
|
if (isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute) and n.func.attr == "get"
|
|
|
|
|
|
and n.args and isinstance(n.args[0], ast.Constant)
|
|
|
|
|
|
and isinstance(n.args[0].value, str)):
|
|
|
|
|
|
cible, champ = n.func.value, n.args[0].value
|
|
|
|
|
|
elif (isinstance(n, ast.Subscript) and isinstance(n.slice, ast.Constant)
|
|
|
|
|
|
and isinstance(n.slice.value, str)):
|
|
|
|
|
|
cible, champ = n.value, n.slice.value
|
|
|
|
|
|
if cible is None:
|
|
|
|
|
|
continue
|
|
|
|
|
|
# Seuls les acces sur une variable LOCALE decrivent l'entite validee.
|
|
|
|
|
|
if not isinstance(cible, ast.Name) or cible.id in parametres:
|
|
|
|
|
|
continue
|
|
|
|
|
|
champs.add(champ)
|
|
|
|
|
|
manquants = sorted(champs - decrit.get(registre, set()) - racines)
|
|
|
|
|
|
mesures.append(f"{registre}:{len(champs)}")
|
|
|
|
|
|
if manquants:
|
|
|
|
|
|
trous.append(f"{registre} : `{nom_fn}` accepte {', '.join(manquants)}, "
|
|
|
|
|
|
f"que le schema ne decrit pas")
|
|
|
|
|
|
|
|
|
|
|
|
if trous:
|
|
|
|
|
|
return False, ("Le moteur accepte des champs que le schema ignore — le formulaire "
|
|
|
|
|
|
"genere ne pourra pas les offrir :\n - " + "\n - ".join(trous)
|
|
|
|
|
|
+ "\n (ajouter le champ a REGISTRES dans scripts/schema_plan.py, "
|
|
|
|
|
|
"puis `make schema`)")
|
|
|
|
|
|
return True, (f"Les {len(VALIDATEURS)} validateurs n'acceptent aucun champ que le schema "
|
|
|
|
|
|
f"ignore ({', '.join(mesures)} champ(s) lus par validateur).")
|
|
|
|
|
|
|
|
|
|
|
|
|
preuves : trois gardes pour ce que ma lecture ne tiendra pas
Une revision de documentation vieillit comme le reste. Ce qui tient, c est ce
qu une machine verifie — et trois lacunes etaient nommees sans etre gardees.
P58 HABILITATIONS. autorisation.md posait la regle (un service nomme un
GROUPE, jamais une personne, D-66) et meta/acces.yml la portait ; rien ne
la verifiait. P29 gardait les POSITIONS d authentification, personne ne
gardait les DROITS.
Le controle qui porte la preuve est un croisement : une entree
porte_par: role-realm affirme que l habilitation voyage par un role de
realm projete depuis un groupe LDAP. P58 le confronte a serveur_keycloak.
Sans ca, un service annonce une habilitation que rien ne transporte, et l
ecran reste vide sans que personne sache pourquoi.
CE QU ELLE N EXIGE PAS, et c est le point le plus important : que les
groupes nommes existent dans l annuaire. Ce serait contredire le regime du
paragraphe 2 — le depot AMORCE un acces et se retire, les appartenances
appartiennent a une personne. dev et personnel n existent dans aucun code,
et ce n est pas un defaut.
P59 ENUMERATIONS ANNONCEES. Les deux ecarts trouves a la main pendant la
tournee — cinq portes annoncees devant une table de six, huit lignes
renvoyees vers une fiche qui en compte dix — etaient d une forme que P57
ne voit pas.
Ma premiere version a signale CINQ ecarts, et les cinq etaient du bruit :
dans « reprise dans les deux devis : », le nombre qualifie autre chose que
la liste. Cent pour cent de faux positifs — la preuve qui crie sur un cas
sain et qu on apprend a ignorer. Resserree aux deux formes ou le nombre ne
peut compter rien d autre. Etroite et vraie plutot que large et devineuse.
P60 WIKI PUBLIE. Le wiki est publie DEPUIS le depot ; rien ne mesurait l ecart,
et il s est creuse de VINGT-SEPT JOURS en silence. Deux unites jamais
publiees, vingt et une differentes : pour qui lit la forge plutot que le
depot, toute la revision n existait pas.
Le harnais est STATIQUE, zero appel reseau — cloner la forge romprait la
seule propriete qui fasse qu une preuve vaille hors de ce poste. La mesure
passe donc par un TEMOIN que make wiki-publier depose. Amorce avec la
valeur MESUREE : le wiki d eregion porte b6167f2, dont le message dit
source: ac85278.
Ce qu elle ne prouve pas : un temoin dit ce qui est PARTI, jamais ce qui
est ARRIVE.
LES TROIS SONT EPROUVEES DANS LES DEUX SENS
Douze essais negatifs, douze refus : groupe non projete, acces.yml disparu,
personne au lieu d un groupe, mecanisme invente, raison manquante, compte
revenu a cinq, septieme porte ajoutee sans toucher au compte, renvoi croise
fausse, temoin absent, temoin d un autre depot. Une garantie qu on n a jamais vu
dire non n est pas une garantie, c est une habitude.
ETAT : NON CONFORME, 58 OK, 1 echec, 1 saute.
P60 est rouge, et c est le comportement voulu : le registre a le droit de
perdre. Le retard qu elle signale est reel et anterieur a elle. Une commande le
ferme, et elle vient ensuite.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-08 12:47:18 -04:00
|
|
|
|
def preuve_wiki_publie_a_jour() -> tuple[bool, str]:
|
|
|
|
|
|
"""Le wiki publie sur la forge correspond a `wiki/` dans le depot.
|
|
|
|
|
|
|
|
|
|
|
|
LA LACUNE QUE CETTE PREUVE COMBLE (2026-09-07). Le wiki est publie DEPUIS le depot :
|
|
|
|
|
|
« on lit la-bas, on ecrit ici ». Rien ne mesurait l'ecart entre les deux, et il s'est
|
|
|
|
|
|
creuse de VINGT-SEPT JOURS sans que personne ne le voie — deux unites n'avaient jamais
|
|
|
|
|
|
ete publiees, vingt et une differaient. Pour qui lit le wiki plutot que le depot,
|
|
|
|
|
|
toute une revision de documentation n'existait pas.
|
|
|
|
|
|
|
|
|
|
|
|
POURQUOI UN TEMOIN PLUTOT QUE LA MESURE DIRECTE. Le harnais est STATIQUE : il lit le
|
|
|
|
|
|
depot, zero appel reseau. C'est ce qui le rend rejouable partout, par n'importe qui,
|
|
|
|
|
|
et presentable comme piece justificative. Cloner le wiki de la forge pour comparer
|
|
|
|
|
|
romprait cette propriete — la seule qui fasse qu'une preuve vaille hors de ce poste.
|
|
|
|
|
|
|
|
|
|
|
|
`make wiki-publier` depose donc `docs/audit/wiki-publie.yml` : le commit du depot qui
|
|
|
|
|
|
a ete publie. Cette preuve compare ce commit a l'etat de `wiki/`.
|
|
|
|
|
|
|
|
|
|
|
|
CE QU'ELLE NE PROUVE PAS, ET IL FAUT LE SAVOIR AVANT DE S'Y FIER. Elle prouve que le
|
|
|
|
|
|
depot n'a pas bouge depuis la derniere publication REUSSIE — pas que la forge porte
|
|
|
|
|
|
bien ce contenu. Si quelqu'un edite une page dans l'interface de la forge, le temoin
|
|
|
|
|
|
ne le voit pas ; c'est la publication suivante qui l'ecrase, et c'est la regle du
|
|
|
|
|
|
depot. Un temoin dit ce qui est PARTI, jamais ce qui est ARRIVE.
|
|
|
|
|
|
"""
|
|
|
|
|
|
temoin = RACINE / "docs" / "audit" / "wiki-publie.yml"
|
|
|
|
|
|
if not temoin.is_file():
|
|
|
|
|
|
return False, ("Aucun temoin de publication (docs/audit/wiki-publie.yml) : on ne "
|
|
|
|
|
|
"peut pas savoir si le wiki en ligne correspond au depot. "
|
|
|
|
|
|
"Publier — `make wiki-publier WIKI_REMOTE=<url>.wiki.git` — depose "
|
|
|
|
|
|
"le temoin.")
|
|
|
|
|
|
import yaml as _yaml
|
|
|
|
|
|
d = _yaml.safe_load(temoin.read_text(encoding="utf-8")) or {}
|
|
|
|
|
|
publie = str(d.get("source", "")).strip()
|
|
|
|
|
|
if not publie:
|
|
|
|
|
|
return False, "docs/audit/wiki-publie.yml sans clef `source` : temoin inexploitable."
|
|
|
|
|
|
|
|
|
|
|
|
def _git(*a: str) -> str:
|
|
|
|
|
|
return subprocess.run(["git", "-C", str(RACINE), *a],
|
|
|
|
|
|
capture_output=True, text=True).stdout.strip()
|
|
|
|
|
|
|
|
|
|
|
|
if _git("cat-file", "-t", publie) != "commit":
|
|
|
|
|
|
return False, (f"Le temoin designe le commit `{publie}`, que ce depot ne connait "
|
|
|
|
|
|
f"pas — une histoire reecrite, ou un temoin d'un autre depot.")
|
|
|
|
|
|
|
|
|
|
|
|
# Les commits qui ont touche `wiki/` APRES celui qui a ete publie.
|
|
|
|
|
|
depuis = [l for l in _git("log", "--oneline", f"{publie}..HEAD", "--", "wiki/").splitlines() if l]
|
|
|
|
|
|
if not depuis:
|
|
|
|
|
|
return True, (f"Le wiki publie correspond au depot : `wiki/` n'a pas bouge depuis "
|
|
|
|
|
|
f"`{publie}` (publie le {d.get('date', '?')}).")
|
|
|
|
|
|
dernier = _git("log", "-1", "--format=%h %ad", "--date=short", "--", "wiki/")
|
|
|
|
|
|
return False, (f"Le wiki en ligne est EN RETARD : {len(depuis)} commit(s) ont touche "
|
|
|
|
|
|
f"`wiki/` depuis la publication de `{publie}` ({d.get('date', '?')}), "
|
|
|
|
|
|
f"le dernier etant {dernier}. Ce que la forge sert n'est plus ce que le "
|
|
|
|
|
|
f"depot dit. Republier : `make wiki-publier WIKI_REMOTE=<url>.wiki.git`.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def preuve_enumerations_annoncees() -> tuple[bool, str]:
|
|
|
|
|
|
"""Un nombre qui annonce une enumeration correspond a ce qui suit.
|
|
|
|
|
|
|
|
|
|
|
|
POURQUOI, ALORS QUE P57 GARDE DEJA DES COMPTES (2026-09-07). P57 tient trois comptes
|
|
|
|
|
|
NOMMES — preuves, roles, groupes — parce qu'ils se mesurent contre le depot. Les deux
|
|
|
|
|
|
ecarts trouves a la main pendant la tournee de documentation etaient d'une autre
|
|
|
|
|
|
forme : un nombre qui compte ce que le document lui-meme enumere.
|
|
|
|
|
|
|
|
|
|
|
|
README.md « Il y en a cinq : » au-dessus d'une table de SIX portes
|
|
|
|
|
|
implanter-... « les huit lignes du §7 de … » pour une fiche qui en compte DIX
|
|
|
|
|
|
|
|
|
|
|
|
CE QUE CETTE PREUVE NE FAIT PAS, ET POURQUOI ELLE SERAIT NUISIBLE AUTREMENT. Une
|
|
|
|
|
|
premiere version acceptait toute ligne finissant par « : » et contenant un nombre en
|
|
|
|
|
|
toutes lettres. Elle a signale CINQ ecarts sur le corpus, et les cinq etaient du
|
|
|
|
|
|
bruit : dans « reprise dans les deux devis : » ou « de quatorze machines : », le
|
|
|
|
|
|
nombre qualifie autre chose que l'enumeration qui suit. Cent pour cent de faux
|
|
|
|
|
|
positifs — exactement la preuve qui crie sur un cas sain et qu'on apprend a ignorer.
|
|
|
|
|
|
|
|
|
|
|
|
Elle ne retient donc que les DEUX formes ou le nombre ne peut compter rien d'autre :
|
|
|
|
|
|
|
|
|
|
|
|
A. « Il y en a <N> : » / « Il y a <N> … : » suivi d'une table ou d'une liste ;
|
|
|
|
|
|
B. « les <N> lignes du §<S> de <autre document.md> » — un renvoi chiffre vers la
|
|
|
|
|
|
premiere table de cette section-la.
|
|
|
|
|
|
|
|
|
|
|
|
Une annonce enfouie au milieu d'une phrase reste hors de portee, et c'est assume :
|
|
|
|
|
|
mieux vaut une garde etroite qui dit vrai qu'une large qui devine.
|
|
|
|
|
|
"""
|
|
|
|
|
|
docs = (sorted((RACINE / "docs").glob("*.md")) + sorted((RACINE / "wiki").glob("*.md"))
|
|
|
|
|
|
+ [RACINE / "AGENTS.md", RACINE / "README.md", RACINE / "QUICKSTART.md"])
|
|
|
|
|
|
mot = "|".join(NOMBRES_FR)
|
|
|
|
|
|
forme_a = rf"\bil y (?:en )?a\s+(?:\*\*)?({mot})(?:\*\*)?\b[^:]{{0,20}}:\s*$"
|
|
|
|
|
|
forme_b = rf"\bles\s+(?:\*\*)?({mot})(?:\*\*)?\s+lignes\s+du\s+§\s*(\d+)"
|
|
|
|
|
|
ecarts: list[str] = []
|
|
|
|
|
|
vus = 0
|
|
|
|
|
|
|
|
|
|
|
|
for d in docs:
|
|
|
|
|
|
if not d.is_file() or "/audit/" in str(d):
|
|
|
|
|
|
continue
|
|
|
|
|
|
texte = d.read_text(encoding="utf-8")
|
|
|
|
|
|
lignes = texte.splitlines()
|
|
|
|
|
|
|
|
|
|
|
|
for i, l in enumerate(lignes):
|
|
|
|
|
|
m = re.search(forme_a, l, re.I)
|
|
|
|
|
|
if not m:
|
|
|
|
|
|
continue
|
|
|
|
|
|
genre, n = _enumeration_apres(lignes, i)
|
|
|
|
|
|
if not genre:
|
|
|
|
|
|
continue
|
|
|
|
|
|
vus += 1
|
|
|
|
|
|
annonce = NOMBRES_FR[m.group(1).lower()]
|
|
|
|
|
|
if n != annonce:
|
|
|
|
|
|
ecarts.append(f"{d.relative_to(RACINE)}:{i + 1} annonce {annonce}, "
|
|
|
|
|
|
f"la {genre} qui suit en compte {n}")
|
|
|
|
|
|
|
|
|
|
|
|
for m in re.finditer(forme_b, texte, re.I):
|
|
|
|
|
|
cible = re.search(r"\(([^)]+\.md)\)", texte[m.end():m.end() + 220])
|
|
|
|
|
|
if not cible:
|
|
|
|
|
|
continue
|
|
|
|
|
|
f = d.parent / cible.group(1)
|
|
|
|
|
|
if not f.is_file():
|
|
|
|
|
|
continue
|
|
|
|
|
|
sec = re.search(rf"^##\s*{m.group(2)}\..*?(?=^##\s|\Z)",
|
|
|
|
|
|
f.read_text(encoding="utf-8"), re.M | re.S)
|
|
|
|
|
|
if not sec:
|
|
|
|
|
|
continue
|
|
|
|
|
|
vus += 1
|
|
|
|
|
|
annonce = NOMBRES_FR[m.group(1).lower()]
|
|
|
|
|
|
n = _lignes_premiere_table(sec.group(0))
|
|
|
|
|
|
if n != annonce:
|
|
|
|
|
|
ecarts.append(f"{d.relative_to(RACINE)} renvoie aux {annonce} lignes du "
|
|
|
|
|
|
f"§{m.group(2)} de {cible.group(1)}, qui en compte {n}")
|
|
|
|
|
|
|
|
|
|
|
|
if ecarts:
|
|
|
|
|
|
return False, ("Des enumerations annoncees ne disent plus vrai :\n - "
|
|
|
|
|
|
+ "\n - ".join(ecarts))
|
|
|
|
|
|
return True, (f"{vus} enumeration(s) annoncee(s) correspondent a ce qu'elles annoncent "
|
|
|
|
|
|
f"(formes non ambigues seulement).")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def preuve_habilitations_declarees() -> tuple[bool, str]:
|
|
|
|
|
|
"""Chaque service qui accorde quelque chose declare A QUI, et par quel mecanisme.
|
|
|
|
|
|
|
|
|
|
|
|
LA LACUNE QUE CETTE PREUVE COMBLE (2026-09-07). `docs/autorisation.md` posait la
|
|
|
|
|
|
regle — un service nomme un GROUPE, jamais une personne (D-66) — et `meta/acces.yml`
|
|
|
|
|
|
la portait. Rien ne la verifiait : ni qu'un service `web-sso` en porte un, ni que le
|
|
|
|
|
|
mecanisme annonce existe. Le §5 d'`authentification.md` dit pourtant l'inverse en
|
|
|
|
|
|
toutes lettres : *une directive qu'aucune garde ne verifie finit par ne plus etre
|
|
|
|
|
|
vraie*. P29 gardait les POSITIONS d'authentification ; personne ne gardait les
|
|
|
|
|
|
HABILITATIONS.
|
|
|
|
|
|
|
|
|
|
|
|
CE QU'ELLE N'EXIGE PAS, ET C'EST LE POINT LE PLUS IMPORTANT. Elle ne demande PAS que
|
|
|
|
|
|
les groupes nommes existent dans l'annuaire. Ce serait contredire la decision
|
|
|
|
|
|
centrale du document (§2) : le depot AMORCE un acces et se retire ; les appartenances
|
|
|
|
|
|
appartiennent a une personne, et le depot n'a pas a les reconcilier. `amorcage_acces`
|
|
|
|
|
|
ne cree qu'un seul groupe ; `dev` et `personnel` sont crees par l'exploitant, et leur
|
|
|
|
|
|
absence du code n'est pas un defaut — c'est le regime.
|
|
|
|
|
|
|
|
|
|
|
|
CE QU'ELLE EXIGE :
|
|
|
|
|
|
|
|
|
|
|
|
1. tout role `web-sso` porte un `meta/acces.yml` — SAUF `formulaire_local: aucun`,
|
|
|
|
|
|
qui designe une passerelle : elle authentifie devant, elle n'accorde rien. Le cas
|
|
|
|
|
|
est DERIVE de la declaration, jamais un nom en dur ;
|
|
|
|
|
|
2. chaque entree nomme `groupe`, `accorde`, `porte_par` et `raison` ;
|
|
|
|
|
|
3. `porte_par` vient de `MECANISMES_ACCES` ;
|
|
|
|
|
|
4. `groupe` ressemble a un groupe et non a un compte — ni `@`, ni `uid=`, ni le nom
|
|
|
|
|
|
du compte d'amorcage lui-meme ;
|
|
|
|
|
|
5. LE CROISEMENT QUI PORTE LA PREUVE : une entree `porte_par: role-realm` dit que
|
|
|
|
|
|
l'habilitation voyage par un role de realm PROJETE depuis un groupe LDAP. Ce
|
|
|
|
|
|
n'est vrai que si `serveur_keycloak` projette effectivement ce groupe
|
|
|
|
|
|
(`acces_projection`). Sans ce controle, un service peut annoncer une habilitation
|
|
|
|
|
|
que rien ne transporte, et l'ecran restera vide sans que personne ne sache
|
|
|
|
|
|
pourquoi — un chèque vert sur un perimetre vide, exactement.
|
|
|
|
|
|
"""
|
|
|
|
|
|
import yaml as _yaml
|
|
|
|
|
|
roles = RACINE / "roles"
|
|
|
|
|
|
fautes: list[str] = []
|
|
|
|
|
|
declarees: list[str] = []
|
|
|
|
|
|
|
|
|
|
|
|
def _acces(nom_role: str) -> dict:
|
|
|
|
|
|
f = roles / nom_role / "meta" / "acces.yml"
|
|
|
|
|
|
if not f.is_file():
|
|
|
|
|
|
return {}
|
|
|
|
|
|
return _yaml.safe_load(f.read_text(encoding="utf-8")) or {}
|
|
|
|
|
|
|
|
|
|
|
|
# Le compte d'amorcage : un `groupe` qui vaudrait ce nom-la designerait une PERSONNE.
|
|
|
|
|
|
amorc = _yaml.safe_load(
|
|
|
|
|
|
(roles / "amorcage_acces" / "defaults" / "main.yml").read_text(encoding="utf-8")) or {}
|
|
|
|
|
|
compte_amorcage = str(amorc.get("amorcage_acces_uid", "")).strip()
|
|
|
|
|
|
|
|
|
|
|
|
# Ce que Keycloak projette reellement : groupe LDAP -> role(s) de realm.
|
|
|
|
|
|
projetes = {str(e.get("groupe", "")).strip()
|
|
|
|
|
|
for e in (_acces("serveur_keycloak").get("acces_projection") or [])}
|
|
|
|
|
|
|
|
|
|
|
|
for d in sorted(x for x in roles.iterdir() if x.is_dir() and x.name.startswith("serveur_")):
|
|
|
|
|
|
meta = d / "meta" / "authentification.yml"
|
|
|
|
|
|
if not meta.is_file():
|
|
|
|
|
|
continue
|
|
|
|
|
|
decl = ((_yaml.safe_load(meta.read_text(encoding="utf-8")) or {})
|
|
|
|
|
|
.get("authentification") or {})
|
|
|
|
|
|
portee = str(decl.get("portee", "")).strip()
|
|
|
|
|
|
passerelle = decl.get("formulaire_local") == "aucun"
|
|
|
|
|
|
entrees = _acces(d.name).get("acces") or []
|
|
|
|
|
|
|
|
|
|
|
|
if portee == "web-sso" and not passerelle and not entrees:
|
|
|
|
|
|
fautes.append(f"{d.name}: web-sso sans meta/acces.yml — il accorde quelque "
|
|
|
|
|
|
f"chose a quelqu'un, et ne dit pas a qui")
|
|
|
|
|
|
continue
|
|
|
|
|
|
if portee == "web-sso" and passerelle and entrees:
|
|
|
|
|
|
fautes.append(f"{d.name}: se declare passerelle (formulaire_local: aucun) mais "
|
|
|
|
|
|
f"porte des habilitations — l'un des deux est faux")
|
|
|
|
|
|
|
|
|
|
|
|
for e in entrees:
|
|
|
|
|
|
groupe = str(e.get("groupe", "")).strip()
|
|
|
|
|
|
ref = f"{d.name}[{groupe or '?'}]"
|
|
|
|
|
|
for champ in ("groupe", "accorde", "porte_par", "raison"):
|
|
|
|
|
|
if not str(e.get(champ, "")).strip():
|
|
|
|
|
|
fautes.append(f"{ref}: champ `{champ}` vide ou absent")
|
|
|
|
|
|
mecanisme = str(e.get("porte_par", "")).strip()
|
|
|
|
|
|
if mecanisme and mecanisme not in MECANISMES_ACCES:
|
|
|
|
|
|
fautes.append(f"{ref}: mecanisme `{mecanisme}` inconnu "
|
|
|
|
|
|
f"(attendus : {', '.join(sorted(MECANISMES_ACCES))})")
|
|
|
|
|
|
if groupe and ("@" in groupe or "uid=" in groupe
|
|
|
|
|
|
or (compte_amorcage and groupe == compte_amorcage
|
|
|
|
|
|
and groupe != str(amorc.get("amorcage_acces_groupe", "")).strip())):
|
|
|
|
|
|
fautes.append(f"{ref}: `{groupe}` designe une PERSONNE, pas un groupe (D-66)")
|
|
|
|
|
|
if mecanisme == "role-realm" and groupe and groupe not in projetes:
|
|
|
|
|
|
fautes.append(f"{ref}: annonce `role-realm`, mais serveur_keycloak ne "
|
|
|
|
|
|
f"projette pas le groupe `{groupe}` (acces_projection) — "
|
|
|
|
|
|
f"l'habilitation n'est portee par rien")
|
|
|
|
|
|
if groupe:
|
|
|
|
|
|
declarees.append(ref)
|
|
|
|
|
|
|
|
|
|
|
|
if fautes:
|
|
|
|
|
|
return False, ("Des habilitations ne disent pas vrai :\n - " + "\n - ".join(fautes[:6]))
|
|
|
|
|
|
return True, (f"{len(declarees)} habilitation(s) declarees, toutes nommant un groupe, "
|
|
|
|
|
|
f"un mecanisme connu et une raison ; les `role-realm` sont projetees.")
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
documentation : la tournee des 74 documents, parce qu un balayage ne lit pas
La revision a commence par un balayage par motifs — chemins morts, cibles make
absentes, comptes derives. Il a trouve une trentaine d ecarts et rate presque
tout le reste : un motif ne voit que ce qui s exprime en motif.
make hote-planifier en est l exemple. La cible EXISTE, donc le controle passait
au vert. C est une cible depreciee qui refuse et sort en 2, recommandee par
AGENTS.md, et qui contredit la REGLE D OR du meme fichier trois ecrans plus
haut. Il fallait lire pour la voir.
74 documents lus un par un. 66 corriges, 8 exacts.
CE QUI ETAIT FRANCHEMENT FAUX
AGENTS.md, la source d autorite, annoncait la flotte pas encore executee contre
des VM reelles. Elle a ete rasee et remontee depuis zero trois fois.
ecosysteme-chezlepro.md, le document montre a un client, portait la meme
phrase : il se sous-vendait gravement.
courriel-conception.md s ouvrait sur aucun role n est encore ecrit, au-dessus de
son propre paragraphe 1 qui les nomme. autorisation.md se terminait sur rien n
est construit alors qu il rapporte des mesures datees du role en fonctionnement.
hebergeur-exploitation.md disait rien n est fait d un depot qui existe.
filiation-emancipation.md se contredisait a deux ecrans de distance.
DES MODELES DECRITS D APRES UN MONDE ANTERIEUR
Le resolveur : cinq documents decrivaient un Unbound par VM en opt-in, trois le
donnaient en exemple d integration FACULTATIVE — il est universel depuis le
2026-08-24. L adressage de nomenclature-vm.md : reseau unique, VLAN 11-15, VMID
a cinq chiffres. Le nommage SDN de sdn-evpn.md contre le code : c est le wiki
qui avait raison.
CE QUI CASSE AU PREMIER ESSAI
Le nom du gabarit dore etait faux a quatre endroits, dont la procedure qui le
FABRIQUE et le critere R2 de l epreuve d operateur independant.
preparer-un-site-hebergeur.md avertissait qu une VM faite a la main serait
detruite : raser derive du plan, il ne la detruira jamais — le risque est l
inverse. Un mot de passe d essai en clair dans un depot public.
DEUX PREUVES ETENDUES, ET UNE QUI SE TROMPAIT ELLE-MEME
P57 couvre les groupes : elle a signale aussitot 29 groupes annonces au-dessus d
un tableau qui en cite 40. P29 confronte le tableau de authentification.md aux
declarations reelles : 12 annonces, 21 reels.
Et P57 imposait un chiffre faux — 56 preuves alors que le depot en porte 57, la
conditionnelle vivant hors de tout comptage. Un garde-fou qui fait respecter une
erreur ajoute l assurance a l erreur.
CE QUI RESTE, ET QU AUCUNE PREUVE NE TIENT
Deux comptes trouves a la main. Et une lacune reelle : rien ne garde les
meta/acces.yml — ni qu un service web-sso en porte un, ni que le groupe qu il
nomme existe. P29 tient les positions d authentification, personne ne tient les
habilitations.
make prouver : CONFORME, 56 OK, 0 echec, 1 saute. 0 lien mort.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-06 16:18:23 -04:00
|
|
|
|
# LE TABLEAU DU DOCUMENT DOIT DIRE LA MEME CHOSE QUE LES DECLARATIONS (2026-09-06).
|
|
|
|
|
|
#
|
|
|
|
|
|
# `docs/authentification.md` §5 recopie ces portees avec, en troisieme colonne, soit un
|
|
|
|
|
|
# COMPTE, soit la LISTE des roles. Les deux derivent de ce qu'on vient de lire — et les
|
|
|
|
|
|
# deux avaient cesse d'etre vrais : la table annoncait 12 roles `sans-auth-humaine`, il
|
|
|
|
|
|
# y en a 21. Personne ne l'avait vu parce que rien ne les confrontait ; la preuve lisait
|
|
|
|
|
|
# deja les declarations sans jamais regarder ce que le document en disait.
|
|
|
|
|
|
#
|
|
|
|
|
|
# On accepte les DEUX formes, parce que les deux sont legitimes : un compte quand la
|
|
|
|
|
|
# liste serait illisible, la liste quand elle tient et qu'elle informe davantage.
|
|
|
|
|
|
doc = RACINE / "docs" / "authentification.md"
|
|
|
|
|
|
if doc.is_file():
|
|
|
|
|
|
for ligne in doc.read_text(encoding="utf-8").splitlines():
|
|
|
|
|
|
m = re.match(r"\|\s*`([a-z-]+)`\s*\|.*\|\s*([^|]+?)\s*\|\s*$", ligne)
|
|
|
|
|
|
if not m or m.group(1) not in PORTEES_AUTH:
|
|
|
|
|
|
continue
|
|
|
|
|
|
portee, cellule = m.group(1), m.group(2)
|
|
|
|
|
|
reels = sorted(n.replace("serveur_", "") for n in par_portee.get(portee, []))
|
|
|
|
|
|
if cellule.isdigit():
|
|
|
|
|
|
if int(cellule) != len(reels):
|
|
|
|
|
|
fautes.append(f"docs/authentification.md : la portee '{portee}' est "
|
|
|
|
|
|
f"annoncee pour {cellule} role(s), il y en a {len(reels)}")
|
|
|
|
|
|
else:
|
|
|
|
|
|
cites = sorted(x.strip().replace("-", "_") for x in cellule.split(",")
|
|
|
|
|
|
if x.strip())
|
|
|
|
|
|
if cites != reels:
|
|
|
|
|
|
fautes.append(f"docs/authentification.md : la portee '{portee}' cite "
|
|
|
|
|
|
f"{cites}, les declarations disent {reels}")
|
|
|
|
|
|
|
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
|
|
|
|
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 ""
|
documentation : la tournee des 74 documents, parce qu un balayage ne lit pas
La revision a commence par un balayage par motifs — chemins morts, cibles make
absentes, comptes derives. Il a trouve une trentaine d ecarts et rate presque
tout le reste : un motif ne voit que ce qui s exprime en motif.
make hote-planifier en est l exemple. La cible EXISTE, donc le controle passait
au vert. C est une cible depreciee qui refuse et sort en 2, recommandee par
AGENTS.md, et qui contredit la REGLE D OR du meme fichier trois ecrans plus
haut. Il fallait lire pour la voir.
74 documents lus un par un. 66 corriges, 8 exacts.
CE QUI ETAIT FRANCHEMENT FAUX
AGENTS.md, la source d autorite, annoncait la flotte pas encore executee contre
des VM reelles. Elle a ete rasee et remontee depuis zero trois fois.
ecosysteme-chezlepro.md, le document montre a un client, portait la meme
phrase : il se sous-vendait gravement.
courriel-conception.md s ouvrait sur aucun role n est encore ecrit, au-dessus de
son propre paragraphe 1 qui les nomme. autorisation.md se terminait sur rien n
est construit alors qu il rapporte des mesures datees du role en fonctionnement.
hebergeur-exploitation.md disait rien n est fait d un depot qui existe.
filiation-emancipation.md se contredisait a deux ecrans de distance.
DES MODELES DECRITS D APRES UN MONDE ANTERIEUR
Le resolveur : cinq documents decrivaient un Unbound par VM en opt-in, trois le
donnaient en exemple d integration FACULTATIVE — il est universel depuis le
2026-08-24. L adressage de nomenclature-vm.md : reseau unique, VLAN 11-15, VMID
a cinq chiffres. Le nommage SDN de sdn-evpn.md contre le code : c est le wiki
qui avait raison.
CE QUI CASSE AU PREMIER ESSAI
Le nom du gabarit dore etait faux a quatre endroits, dont la procedure qui le
FABRIQUE et le critere R2 de l epreuve d operateur independant.
preparer-un-site-hebergeur.md avertissait qu une VM faite a la main serait
detruite : raser derive du plan, il ne la detruira jamais — le risque est l
inverse. Un mot de passe d essai en clair dans un depot public.
DEUX PREUVES ETENDUES, ET UNE QUI SE TROMPAIT ELLE-MEME
P57 couvre les groupes : elle a signale aussitot 29 groupes annonces au-dessus d
un tableau qui en cite 40. P29 confronte le tableau de authentification.md aux
declarations reelles : 12 annonces, 21 reels.
Et P57 imposait un chiffre faux — 56 preuves alors que le depot en porte 57, la
conditionnelle vivant hors de tout comptage. Un garde-fou qui fait respecter une
erreur ajoute l assurance a l erreur.
CE QUI RESTE, ET QU AUCUNE PREUVE NE TIENT
Deux comptes trouves a la main. Et une lacune reelle : rien ne garde les
meta/acces.yml — ni qu un service web-sso en porte un, ni que le groupe qu il
nomme existe. P29 tient les positions d authentification, personne ne tient les
habilitations.
make prouver : CONFORME, 56 OK, 0 echec, 1 saute. 0 lien mort.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-06 16:18:23 -04:00
|
|
|
|
return True, (f"{total} role(s) serveur declares ({resume}){suffixe} ; "
|
|
|
|
|
|
f"le tableau de docs/authentification.md dit la meme chose.")
|
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
|
|
|
|
|
|
|
|
|
|
|
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")
|
adressage : le site est une flotte, et P20 le regarde enfin
L'exploitant a demande si l'adressage derive ne valait pas pour toute flotte, site compris.
Oui — decision du 12 septembre, index 37 pour le site. J'avais affirme le contraire a
l'entree (11) du CHANGELOG, en recopiant un commentaire perime de huit jours au lieu de
mesurer. Un commentaire perime se propage : celui-la s'etait aussi loge dans la docstring de
ConsoleSite.
P20 s'appelle « Adressage 100% derive du seed (aucun stocke) » et sa portee etait
« l'instance liee + les modeles » : elle ne regardait jamais le site, qui stockait
sous_reseau et passerelle pour ses sept zones. Elle juge desormais les deux natures d'un
underlay et refuse une entree qui ne declare pas la sienne — on ne peut pas juger ce qu'on
ne sait pas lire. Controle negatif joue : une zone remise a 10.36.31.0/24 est refusee, en
nommant l'index dont elle aurait du descendre.
Le loader derive sous_reseau et passerelle a la lecture, pour que rien ne change chez les
consommateurs. Mesure : 14 valeurs retirees du fichier, zero ecart sur ce qu'ils lisent, et
make underlay, devis-sdn et devis-reseau passent.
Valide : make test a 0 echec, P20 verte sur 2 nomenclatures et 7 zones de site. P02 et P60
restent, pour les raisons deja consignees.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 22:46:43 -04:00
|
|
|
|
# ── ET LE SITE, qui est une flotte comme une autre (2026-09-20) ────────────────
|
|
|
|
|
|
#
|
|
|
|
|
|
# CETTE PREUVE NE REGARDAIT QUE « l'instance liee + les modeles ». Le site a recu son
|
|
|
|
|
|
# propre index le 2026-09-12 — decision ecrite dans son underlay : « sites et
|
|
|
|
|
|
# locataires se partagent la classe A, chacun avec SON index » — et son adressage a
|
|
|
|
|
|
# continue d'etre ECRIT : `sous_reseau` et `passerelle` pour chacune de ses sept
|
|
|
|
|
|
# zones, exactement ce que les lignes ci-dessus interdisent a un locataire.
|
|
|
|
|
|
#
|
|
|
|
|
|
# Les valeurs concordaient parce qu'un humain les avait tapees juste. Rien ne le
|
|
|
|
|
|
# garantissait : une zone ecrite `10.36.x` serait passee sans un mot. Une decision
|
|
|
|
|
|
# appliquee a moitie est plus dangereuse qu'une decision non prise — on la croit tenue.
|
|
|
|
|
|
#
|
|
|
|
|
|
# LES DEUX NATURES NE SE JUGENT PAS PAREIL. Un lien de fabric PHYSIQUE (grappe, iSCSI,
|
|
|
|
|
|
# Ceph, transit, VXLAN) tient son adressage des participants du lien, pas d'un seed :
|
|
|
|
|
|
# D-02 et D-04 le disent, et il reste ecrit. Seules les zones du site derivent.
|
|
|
|
|
|
import underlay as _u
|
|
|
|
|
|
carte = _u.chemin()
|
|
|
|
|
|
zones_site = 0
|
|
|
|
|
|
if carte:
|
|
|
|
|
|
brut = (yaml.safe_load(carte.read_text(encoding="utf-8")) or {}).get("underlay") or {}
|
|
|
|
|
|
index = brut.get("index")
|
|
|
|
|
|
for reseau in brut.get("reseaux") or []:
|
|
|
|
|
|
nature = reseau.get("nature")
|
|
|
|
|
|
nom = reseau.get("nom", "?")
|
|
|
|
|
|
if nature not in ("site", "fabric"):
|
|
|
|
|
|
fautes.append(f"{carte.name}: {nom} ne declare pas sa nature "
|
|
|
|
|
|
f"(site|fabric) — on ne peut pas juger ce qu'on ne sait pas lire")
|
|
|
|
|
|
continue
|
|
|
|
|
|
if nature != "site":
|
|
|
|
|
|
continue
|
|
|
|
|
|
zones_site += 1
|
|
|
|
|
|
for champ in ("sous_reseau", "passerelle"):
|
|
|
|
|
|
if champ in reseau:
|
|
|
|
|
|
fautes.append(f"{carte.name}: zone {nom}.{champ} stocke alors "
|
|
|
|
|
|
f"qu'elle derive de l'index {index}")
|
|
|
|
|
|
if reseau.get("vlan") is None:
|
|
|
|
|
|
fautes.append(f"{carte.name}: zone {nom} sans vlan — rien dont deriver")
|
|
|
|
|
|
|
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
|
|
|
|
if fautes:
|
|
|
|
|
|
return False, f"{len(fautes)} champ(s) d'adressage stockes : " + " | ".join(fautes[:4])
|
adressage : le site est une flotte, et P20 le regarde enfin
L'exploitant a demande si l'adressage derive ne valait pas pour toute flotte, site compris.
Oui — decision du 12 septembre, index 37 pour le site. J'avais affirme le contraire a
l'entree (11) du CHANGELOG, en recopiant un commentaire perime de huit jours au lieu de
mesurer. Un commentaire perime se propage : celui-la s'etait aussi loge dans la docstring de
ConsoleSite.
P20 s'appelle « Adressage 100% derive du seed (aucun stocke) » et sa portee etait
« l'instance liee + les modeles » : elle ne regardait jamais le site, qui stockait
sous_reseau et passerelle pour ses sept zones. Elle juge desormais les deux natures d'un
underlay et refuse une entree qui ne declare pas la sienne — on ne peut pas juger ce qu'on
ne sait pas lire. Controle negatif joue : une zone remise a 10.36.31.0/24 est refusee, en
nommant l'index dont elle aurait du descendre.
Le loader derive sous_reseau et passerelle a la lecture, pour que rien ne change chez les
consommateurs. Mesure : 14 valeurs retirees du fichier, zero ecart sur ce qu'ils lisent, et
make underlay, devis-sdn et devis-reseau passent.
Valide : make test a 0 echec, P20 verte sur 2 nomenclatures et 7 zones de site. P02 et P60
restent, pour les raisons deja consignees.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 22:46:43 -04:00
|
|
|
|
return True, (f"{len(cibles)} nomenclature(s) et {zones_site} zone(s) de site : "
|
|
|
|
|
|
f"adressage 100% derive du seed index.")
|
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
|
|
|
|
|
|
|
|
|
|
|
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
|
2026-09-27 21:53:36 -04:00
|
|
|
|
# un autre ecosysteme — elle a rendu un verdict juste sur le mauvais ecosysteme).
|
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
|
|
|
|
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 {}
|
|
|
|
|
|
|
2026-09-02 11:21:52 -04:00
|
|
|
|
def _manques(apps: dict, serveurs: dict, ou: str) -> tuple[list[str], int]:
|
|
|
|
|
|
detenu: dict[str, set[str]] = {}
|
|
|
|
|
|
for app in (apps or {}).values():
|
|
|
|
|
|
groupe = str((app or {}).get("groupe") or "")
|
|
|
|
|
|
if groupe in catalogue:
|
|
|
|
|
|
detenu.setdefault(str((app or {}).get("hote") or ""), set()).add(groupe)
|
|
|
|
|
|
return ([f"{ou} : {hote} detient {sorted(groupes)}"
|
|
|
|
|
|
for hote, groupes in sorted(detenu.items())
|
|
|
|
|
|
if "client_backup" not in ((serveurs.get(hote) or {}).get("integrations") or [])],
|
|
|
|
|
|
len(detenu))
|
|
|
|
|
|
|
|
|
|
|
|
manques, n_tenant = _manques(apps, serveurs, "ecosysteme")
|
|
|
|
|
|
|
|
|
|
|
|
# LE SITE DETIENT DE L'ETAT LUI AUSSI, ET LE PLUS CONSEQUENT (2026-09-02).
|
|
|
|
|
|
#
|
|
|
|
|
|
# Cette preuve ne lisait que le plan de l'instance MONTEE. Or l'hebergeur porte la
|
|
|
|
|
|
# racine de son autorite de certification et la forge du genome — perdre l'une oblige
|
|
|
|
|
|
# a redistribuer la confiance a chaque machine de chaque ecosysteme, perdre l'autre
|
|
|
|
|
|
# coupe la source dont tout descend. Rien ne l'exigeait, et rien ne l'aurait dit.
|
|
|
|
|
|
#
|
|
|
|
|
|
# Meme catalogue, meme regle : ce qui se lit statiquement se prouve statiquement,
|
|
|
|
|
|
# sinon on l'apprend le jour de la restauration (D-75). Le site n'a simplement pas
|
|
|
|
|
|
# d'`instance/` — son plan vit a cote de sa carte.
|
|
|
|
|
|
n_site = 0
|
|
|
|
|
|
try:
|
|
|
|
|
|
import underlay as underlay_mod
|
|
|
|
|
|
if underlay_mod.chemin():
|
|
|
|
|
|
m_site, n_site = _manques(
|
|
|
|
|
|
(underlay_mod.lire_plan_site("applications.yml") or {}).get("applications") or {},
|
|
|
|
|
|
(underlay_mod.lire_plan_site("serveurs.yml") or {}).get("serveurs") or {},
|
|
|
|
|
|
"site")
|
|
|
|
|
|
manques += m_site
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
# Aucune carte d'hebergeur montee : on prouve ce qu'on peut voir, et on ne
|
|
|
|
|
|
# transforme pas une absence en echec.
|
|
|
|
|
|
pass
|
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
|
|
|
|
|
|
|
|
|
|
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`.")
|
2026-09-02 11:21:52 -04:00
|
|
|
|
return True, (f"{n_tenant} hote(s) de l'ecosysteme et {n_site} du site detiennent de "
|
|
|
|
|
|
f"l'etat, tous porteurs de `client_backup` "
|
|
|
|
|
|
f"({len(catalogue)} groupe(s) au catalogue).")
|
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
|
|
|
|
|
|
|
|
|
|
|
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.
|
|
|
|
|
|
|
2026-09-27 21:53:36 -04:00
|
|
|
|
POURQUOI CETTE PREUVE EXISTE (2026-08-23). L'edge d'un ecosysteme publiait sa forge
|
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
|
|
|
|
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 : le plancher survit au redemarrage, et la zone dit les vraies adresses
Le decoupage du site en quatre zones a deplace cinq machines. Ni le plancher
/etc/hosts ni la zone DNS n'avaient suivi. Quatre defauts, tous dans le moteur.
LE PLANCHER ETAIT EFFACE A CHAQUE DEMARRAGE — ET LE PREMIER CORRECTIF N'EN
ETAIT PAS UN.
`hosts_statiques` posait `99-setops-hosts.cfg` avec `manage_etc_hosts: false`,
pendant que `cloud_init` posait `99_setops.cfg` avec `true`. Dans `cloud.cfg.d`
l'ordre est LEXICAL et le dernier gagne : `-` vaut 0x2D, `_` vaut 0x5F. On a
donc retire la cle de `cloud_init` — le role qui POSSEDE le fichier decide —
puis renomme notre fragment `zz-` pour passer apres le `99_chezlepro.cfg` du
gabarit dore.
Et ca ne suffisait toujours pas. Redemarrage d'epreuve : plancher encore efface.
La cause reelle est ailleurs — Proxmox inscrit `manage_etc_hosts: true` dans la
USER-DATA de son lecteur cloud-init, et la user-data prime sur `cloud.cfg.d`
tout entier. Aucun fragment ne pouvait gagner ; renommer pour parler en dernier
ne servait a rien, le dernier mot n'appartenant pas a ce repertoire.
Ce que cloud-init regenere, il le regenere depuis `hosts.debian.tmpl` — le
gabarit le documente lui-meme. `hosts_statiques` le pose desormais avec le MEME
contenu que /etc/hosts, et une garde compare les deux a chaque passage.
Redemarrage d'epreuve : les neuf entrees sont la.
La garde precedente affirmait « conforme » en mesurant l'ordre lexical — vrai,
et sans rapport avec ce qui se passait. Une garde qui mesure la mauvaise chose
est pire qu'aucune.
LA ZONE DNS NE PUBLIAIT PAS LES NOMS DE SERVICE.
`forge.genese.internal` et `pki.genese.internal` — des noms que les certificats
portent et que les clients appellent — n'avaient aucun enregistrement. Seul le
plancher savait les resoudre. Trois causes empilees :
- le plan du site coupait `serveur_powerdns_publier_expositions`, au motif que
« le site n'a pas d'edge » : ca confondait PUBLIC et EXPOSE ;
- `expositions_des_applications` rendait `domaine: None` faute de
`domaines.yml`, et le modele de zone ecarte les expositions dont le domaine
n'est pas la zone. Repli ajoute, symetrique de celui deja ecrit pour `edge` :
sans domaine public declare, le domaine est celui que porte le FQDN ;
- `serveur_powerdns` exigeait les deux registres et echouait si `domaines.yml`
manquait — le meme tout-ou-rien que `hosts_statiques` a corrige le meme jour.
Puis `named-checkzone` a refuse la zone : `dns.genese.internal` heritait d'un
CNAME par defaut du role ET d'un A par exposition. La garde a bien joue son role
— elle a arrete une zone cassee avant qu'elle soit servie. Le plan l'emporte
desormais sur le defaut du role.
Un service ne doit pas dependre d'un plancher pour etre joignable : le plancher
est un filet, pas le sol.
VERIFICATION. Les huit noms — cinq machines, trois services — resolvent vers les
bonnes adresses depuis les cinq hotes, par le plancher ET par le DNS, et le
plancher survit au redemarrage.
P46 refuse desormais deux choses : plus d'un role ecrivant `manage_etc_hosts`,
et l'absence du gabarit maitre. Controle negatif verifie.
RESTE NOMME, PAS CORRIGE : la zone INVERSE. `serveur_powerdns_zone_inverse`
derive d'un supernet /16 — la forme d'un tenant. Un site declare plusieurs /24
et n'a pas de supernet unique : aucune zone inverse n'est generee.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 20:31:01 -04:00
|
|
|
|
def preuve_un_seul_maitre_du_plancher() -> tuple[bool, str]:
|
|
|
|
|
|
"""Un seul role decide si cloud-init a le droit de reecrire /etc/hosts.
|
|
|
|
|
|
|
|
|
|
|
|
POURQUOI (2026-08-25). Deux roles ecrivaient un fragment dans `/etc/cloud/cloud.cfg.d`
|
|
|
|
|
|
et se contredisaient : `hosts_statiques` posait `manage_etc_hosts: false`,
|
|
|
|
|
|
`cloud_init` posait `true`. Or l'ordre y est LEXICAL — `99-setops-hosts.cfg` (`-`,
|
|
|
|
|
|
0x2D) est lu AVANT `99_setops.cfg` (`_`, 0x5F). C'est donc le `true` qui gagnait, et
|
|
|
|
|
|
cloud-init reecrivait /etc/hosts a chaque demarrage.
|
|
|
|
|
|
|
|
|
|
|
|
Le plancher de resolution disparaissait ainsi en silence, et le defaut ne se
|
|
|
|
|
|
manifestait qu'au redemarrage suivant, ailleurs : un `apt update` qui ne resout plus
|
|
|
|
|
|
le cache, un certificat dont le nom ne pointe nulle part. Mesure : les cinq machines
|
|
|
|
|
|
du site redemarrees le 2026-08-25 n'avaient plus que les entrees Debian par defaut.
|
|
|
|
|
|
|
|
|
|
|
|
Le role qui POSSEDE le fichier decide. Cette preuve refuse qu'un second s'en mele :
|
|
|
|
|
|
deux ecritures, meme d'accord aujourd'hui, sont un desaccord qui attend son heure.
|
|
|
|
|
|
"""
|
|
|
|
|
|
ecrivains: list[str] = []
|
|
|
|
|
|
for chemin in sorted((RACINE / "roles").rglob("*")):
|
|
|
|
|
|
if not chemin.is_file() or chemin.suffix not in (".yml", ".yaml", ".j2"):
|
|
|
|
|
|
continue
|
|
|
|
|
|
for ligne in chemin.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
|
|
|
|
nu = ligne.strip()
|
|
|
|
|
|
if nu.startswith("#"):
|
|
|
|
|
|
continue # un commentaire explique, il n'ecrit pas
|
|
|
|
|
|
if re.match(r"^manage_etc_hosts\s*:", nu):
|
|
|
|
|
|
ecrivains.append(f"{chemin.relative_to(RACINE)} : {nu}")
|
|
|
|
|
|
if len(ecrivains) != 1:
|
|
|
|
|
|
return False, (
|
|
|
|
|
|
f"{len(ecrivains)} ecriture(s) de `manage_etc_hosts` dans les roles — il en "
|
|
|
|
|
|
f"faut exactement une, sinon l'ordre lexical de `cloud.cfg.d` tranche a notre "
|
|
|
|
|
|
f"place :\n - " + "\n - ".join(ecrivains or ["aucune : plus personne "
|
|
|
|
|
|
"n'empeche cloud-init d'effacer le plancher au demarrage"]))
|
|
|
|
|
|
|
|
|
|
|
|
# ET LE FRAGMENT NE SUFFIT PAS. Proxmox inscrit `manage_etc_hosts: true` dans la
|
|
|
|
|
|
# USER-DATA de son lecteur cloud-init, et la user-data prime sur tout `cloud.cfg.d`.
|
|
|
|
|
|
# Le plancher a donc ete efface a chaque demarrage malgre le fragment — d'abord sous
|
|
|
|
|
|
# le nom `99-`, puis sous `zz-` : renommer pour parler en dernier ne servait a rien,
|
|
|
|
|
|
# le dernier mot n'appartenant pas a ce repertoire.
|
|
|
|
|
|
#
|
|
|
|
|
|
# Ce que cloud-init regenere, il le regenere depuis `hosts.<distro>.tmpl`. Un role
|
|
|
|
|
|
# doit donc poser CE gabarit, sans quoi la correction n'en est pas une.
|
|
|
|
|
|
gabarit = [str(p.relative_to(RACINE)) for p in (RACINE / "roles").rglob("*")
|
|
|
|
|
|
if p.is_file() and "hosts.debian.tmpl" in p.name]
|
|
|
|
|
|
if not gabarit:
|
|
|
|
|
|
return False, (
|
|
|
|
|
|
"Aucun role ne pose `/etc/cloud/templates/hosts.debian.tmpl`. Le fragment "
|
|
|
|
|
|
"`cloud.cfg.d` seul ne protege rien : la user-data du lecteur cloud-init "
|
|
|
|
|
|
"(Proxmox y met `manage_etc_hosts: true`) prime sur ce repertoire. Le "
|
|
|
|
|
|
"plancher serait efface au prochain demarrage, sans erreur ni message.")
|
|
|
|
|
|
return True, (f"Un seul maitre du plancher — {ecrivains[0]} ; "
|
|
|
|
|
|
f"et le gabarit maitre est pose ({gabarit[0]}).")
|
|
|
|
|
|
|
|
|
|
|
|
|
dns : quatre zones inverses pour le site, et rien de plus
Le site ne servait aucun PTR. `serveur_powerdns_zone_inverse` derivait d'un
supernet /16 — la forme d'un TENANT, qui tire tout de son index. Un site ne
derive pas : il declare plusieurs /24 et n'a pas de supernet unique, si bien que
la derivation rendait une chaine vide et qu'aucune zone n'etait generee.
Le site revendique desormais exactement ce qu'il occupe :
31.0.10.in-addr.arpa 32.0.10.in-addr.arpa
33.0.10.in-addr.arpa 34.0.10.in-addr.arpa
Revendiquer `0.10.in-addr.arpa` d'un seul geste aurait ete plus simple et faux :
cette zone couvre aussi la frontiere, le transit et les hyperviseurs, qui ne sont
pas a lui. Une autorite qu'on s'attribue sans l'exercer est une panne differee —
le resolveur repondrait NXDOMAIN pour des adresses qu'un autre sait nommer.
LA DERIVATION VIT DANS UN FILTRE (`zones_inverses`) parce qu'elle a DEUX
appelants : `serveur_powerdns` ecrit ces zones, `serveur_resolveur` les delegue a
l'autoritatif. Deux calculs separes finiraient par diverger, et la divergence ne
se verrait qu'au premier PTR interroge. Le nom d'une zone dit sa profondeur —
trois etiquettes numeriques valent un /24, deux valent un /16 — et le modele en
deduit seul la forme du PTR.
DEUX CHEMINS MORTS TROUVES EN ROUTE.
Les zones etaient servies, et personne ne les demandait. `serveur_resolveur`
deleguait la zone directe par une `stub-zone` mais pas les inverses : `dig -x`
rendait vide depuis les cinq machines alors que la meme requete posee directement
a l'autoritatif repondait juste. Un service correct derriere un chemin que rien
n'emprunte.
Puis, les stubs poses, Unbound repondait toujours NXDOMAIN avec le drapeau `aa` —
une reponse AUTORITAIRE, sans jamais consulter le stub. Il embarque des
`local-zone` pour tout l'espace RFC1918 inverse. `nodefault` n'y change rien : ce
mode n'agit que si le nom correspond EXACTEMENT a une zone par defaut, et la
sienne est `10.in-addr.arpa`, le /8 entier. C'est `transparent` qui laisse la
requete suivre son cours — pour nos quatre zones seulement, la ou
`unblock-lan-zones` aurait ouvert tout l'espace prive.
Rien ne distinguait ce blocage d'une absence : le meme NXDOMAIN qu'un nom qui
n'existe pas.
AUSSI : les zones inverses sont desormais validees par `named-checkzone` comme la
directe (un fichier mal forme etait refuse en silence par PowerDNS), et une zone
qu'un ecosysteme cesse de revendiquer est retiree du repertoire.
VERIFICATION. Les cinq PTR resolvent depuis les cinq hotes par le resolveur, les
huit noms directs par le plancher ET par le DNS, et les deux roles sont
idempotents (changed=0).
P47 evalue la derivation sur cinq cas — un site a quatre zones, un tenant a une,
deux machines d'un meme /24 qui n'en font qu'une, aucune adresse, une adresse
illisible. Controle negatif verifie.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 20:58:50 -04:00
|
|
|
|
def preuve_zones_inverses_couvrent_l_occupe() -> tuple[bool, str]:
|
|
|
|
|
|
"""Les zones `in-addr.arpa` couvrent ce que l'ecosysteme occupe, et rien de plus.
|
|
|
|
|
|
|
|
|
|
|
|
POURQUOI (2026-08-25). La derivation supposait un supernet /16 — la forme d'un TENANT,
|
|
|
|
|
|
qui tire tout de son index. Un SITE ne derive pas : il declare plusieurs /24 et n'a pas
|
|
|
|
|
|
de supernet unique. La derivation rendait donc une chaine vide, aucune zone inverse
|
|
|
|
|
|
n'etait generee, et les cinq machines du site restaient anonymes a l'envers — ce que le
|
|
|
|
|
|
courriel et la journalisation reprochent en silence.
|
|
|
|
|
|
|
|
|
|
|
|
LE PIEGE INVERSE COMPTE AUTANT. Revendiquer `0.10.in-addr.arpa` aurait couvert le site
|
|
|
|
|
|
d'un seul geste — et aussi la frontiere, le transit et les hyperviseurs, qui ne sont
|
|
|
|
|
|
pas a lui. Une autorite qu'on s'attribue sans l'exercer est une panne differee : le
|
|
|
|
|
|
resolveur repondrait NXDOMAIN pour des adresses qu'un autre sait nommer.
|
|
|
|
|
|
|
|
|
|
|
|
Cette preuve evalue la derivation elle-meme, sur les deux formes d'ecosysteme plus les
|
|
|
|
|
|
cas degenerees. Le cas tenant DOIT rendre une seule zone /16, et le cas site DOIT en
|
|
|
|
|
|
rendre quatre : sans ces deux-la, une derivation qui rend toujours une liste vide
|
|
|
|
|
|
passerait la preuve sans rien garantir.
|
|
|
|
|
|
"""
|
|
|
|
|
|
from inventory_rules import zones_inverses as _zi
|
|
|
|
|
|
|
|
|
|
|
|
site = ["10.0.31.11", "10.0.32.11", "10.0.33.11", "10.0.33.21", "10.0.34.11"]
|
|
|
|
|
|
cas = [
|
|
|
|
|
|
("site — quatre reseaux declares, un /24 chacun", site, "10.0.34.0/24",
|
|
|
|
|
|
["31.0.10.in-addr.arpa", "32.0.10.in-addr.arpa",
|
|
|
|
|
|
"33.0.10.in-addr.arpa", "34.0.10.in-addr.arpa"]),
|
|
|
|
|
|
("tenant — un supernet /16 couvre tout", ["10.27.15.11", "10.27.16.11"],
|
|
|
|
|
|
"10.27.0.0/16", ["27.10.in-addr.arpa"]),
|
|
|
|
|
|
("deux machines d'un meme /24 ne font qu'une zone",
|
|
|
|
|
|
["10.0.33.11", "10.0.33.21"], "", ["33.0.10.in-addr.arpa"]),
|
|
|
|
|
|
("aucune adresse : aucune zone, plutot qu'une zone fausse", [], "", []),
|
|
|
|
|
|
("une adresse illisible est ignoree, pas devinee",
|
|
|
|
|
|
["pas-une-adresse", "10.0.31.11"], "", ["31.0.10.in-addr.arpa"]),
|
|
|
|
|
|
]
|
|
|
|
|
|
manques: list[str] = []
|
|
|
|
|
|
for titre, adresses, supernet, attendu in cas:
|
|
|
|
|
|
obtenu = _zi(adresses, supernet)
|
|
|
|
|
|
if obtenu != attendu:
|
|
|
|
|
|
manques.append(f"{titre} : rend {obtenu}, attendu {attendu}")
|
|
|
|
|
|
if manques:
|
|
|
|
|
|
return False, ("Les zones inverses ne couvrent pas ce que l'ecosysteme occupe :"
|
|
|
|
|
|
"\n - " + "\n - ".join(manques))
|
|
|
|
|
|
return True, (f"Les zones inverses couvrent l'occupe et rien de plus "
|
|
|
|
|
|
f"({len(cas)} cas evalues, dont un site a quatre zones et un tenant "
|
|
|
|
|
|
f"a une).")
|
|
|
|
|
|
|
|
|
|
|
|
|
carte : P48 — l'index du mainteneur ne peut plus mentir
`docs/carte-set-ops.md` est l'index du MAINTENEUR : l'ordre de lecture du corpus,
et surtout le catalogue des MECANISMES TRANSVERSES avec, pour chacun, OU IL VIT
DANS LE CODE. Son but est ecrit en toutes lettres : « ne plus re-deterrer ce qui
existe ».
Elle n'avait aucune garde, alors que `catalogue-services.md` a la sienne depuis
P38. Ses sept chiffres etaient faux — 54 roles annonces contre 60, 34 documents
contre 38, 15 pieces d'audit contre 27, 70 decisions contre 78.
Le defaut couteux n'est pourtant pas la. C'est le POINTEUR MORT : la carte dit ou
vit un mecanisme, quelqu'un ne l'y trouve pas, et le reimplemente a cote —
exactement la panne qu'elle existe pour prevenir. Aucun de ces nombres ne fait
travailler personne ; mais un document dont les faits verifiables sont faux cesse
d'etre consulte, et c'est alors ses pointeurs qu'on perd.
P48 verifie les deux : 84 chemins cites existent, et 7 chiffres correspondent a
la mesure. Controle negatif verifie sur les DEUX moities — un chiffre fausse, un
pointeur casse, la preuve echoue dans les deux cas.
QUATRE DISTINCTIONS ont du etre ecrites pour qu'elle ne soit pas fausse dans
l'autre sens : un gabarit de nom (`preuve-<date>.md`) decrit une forme, pas un
fichier ; un chemin hors depot (`~/.config/setops-vault-pass`) vit sur le poste de
l'exploitant, et c'est tout l'interet de la doctrine des voutes ; un fragment
(`meta/acces.yml`) vaut comme SUFFIXE, parce qu'un index se lit ainsi ; et un
artefact GENERE (`hosts.yml`) n'a pas a exister dans le moteur. Les quatre sont
nommees dans le code plutot que sautees en silence.
Le tableau « Le depot en chiffres » remplace les comptes en prose : ce qu'on
n'entretient pas, on ne l'affirme pas — ou bien on le fait recompter.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 13:56:58 -04:00
|
|
|
|
# Ce que la carte nomme a juste titre et que ce depot ne contient pas : le produit de
|
remise au client : deux temps, un outil, une garde
Livrer se terminait par une phrase — tes cles te seront remises separement — et
rien n ecrivait la suite. Temps 1 l identite (sa cle de voute, sa voute, sa racine
d AC), temps 2 la machine a echeance (sa cle entre, la notre sort, voute re-cletee,
secrets tournes). scripts/remise.py refuse une destination interne, un paquet sans
racine d AC, et tout ce qui n est pas l ecosysteme monte. Le registre remise.yml
declare enfin le responsable designe (D-18). P80 refuse un registre incomplet, un
second temps echu, un second temps declare fait sans revocation au plan, et un
secret dans un fichier versionne.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 11:16:27 -04:00
|
|
|
|
# `make instancier`, qui vit dans le depot d'un ecosysteme. `remise.yml` (2026-09-16) est
|
|
|
|
|
|
# de la meme famille — ecrit par `make remise-inscrire` chez le LOCATAIRE, a cote de
|
|
|
|
|
|
# `parente.yml` : de qui il descend d'un cote, a qui il appartient de l'autre.
|
|
|
|
|
|
ARTEFACTS_GENERES = {"hosts.yml", "remise.yml"}
|
carte : P48 — l'index du mainteneur ne peut plus mentir
`docs/carte-set-ops.md` est l'index du MAINTENEUR : l'ordre de lecture du corpus,
et surtout le catalogue des MECANISMES TRANSVERSES avec, pour chacun, OU IL VIT
DANS LE CODE. Son but est ecrit en toutes lettres : « ne plus re-deterrer ce qui
existe ».
Elle n'avait aucune garde, alors que `catalogue-services.md` a la sienne depuis
P38. Ses sept chiffres etaient faux — 54 roles annonces contre 60, 34 documents
contre 38, 15 pieces d'audit contre 27, 70 decisions contre 78.
Le defaut couteux n'est pourtant pas la. C'est le POINTEUR MORT : la carte dit ou
vit un mecanisme, quelqu'un ne l'y trouve pas, et le reimplemente a cote —
exactement la panne qu'elle existe pour prevenir. Aucun de ces nombres ne fait
travailler personne ; mais un document dont les faits verifiables sont faux cesse
d'etre consulte, et c'est alors ses pointeurs qu'on perd.
P48 verifie les deux : 84 chemins cites existent, et 7 chiffres correspondent a
la mesure. Controle negatif verifie sur les DEUX moities — un chiffre fausse, un
pointeur casse, la preuve echoue dans les deux cas.
QUATRE DISTINCTIONS ont du etre ecrites pour qu'elle ne soit pas fausse dans
l'autre sens : un gabarit de nom (`preuve-<date>.md`) decrit une forme, pas un
fichier ; un chemin hors depot (`~/.config/setops-vault-pass`) vit sur le poste de
l'exploitant, et c'est tout l'interet de la doctrine des voutes ; un fragment
(`meta/acces.yml`) vaut comme SUFFIXE, parce qu'un index se lit ainsi ; et un
artefact GENERE (`hosts.yml`) n'a pas a exister dans le moteur. Les quatre sont
nommees dans le code plutot que sautees en silence.
Le tableau « Le depot en chiffres » remplace les comptes en prose : ce qu'on
n'entretient pas, on ne l'affirme pas — ou bien on le fait recompter.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 13:56:58 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _sans_accents(texte: str) -> str:
|
|
|
|
|
|
return "".join(c for c in unicodedata.normalize("NFD", texte)
|
|
|
|
|
|
if unicodedata.category(c) != "Mn")
|
|
|
|
|
|
|
|
|
|
|
|
_ECARTES = {".git", "__pycache__", ".venv", "node_modules", "collections"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _propre(chemin: Path) -> bool:
|
|
|
|
|
|
return not any(part in _ECARTES for part in chemin.relative_to(RACINE).parts)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _existe_en_suffixe(fragment: str) -> bool:
|
|
|
|
|
|
"""Un fichier du depot se termine-t-il par ce fragment de chemin ?"""
|
|
|
|
|
|
nom = fragment.rsplit("/", 1)[-1]
|
|
|
|
|
|
return any(_propre(p) and str(p).endswith("/" + fragment)
|
|
|
|
|
|
for p in RACINE.rglob(nom))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _existe_quelque_part(nom: str) -> bool:
|
|
|
|
|
|
"""Un fichier ou dossier de ce nom existe-t-il dans le depot ?
|
|
|
|
|
|
|
|
|
|
|
|
La carte nomme volontiers un fichier sans son dossier — c'est ce qui la rend lisible.
|
|
|
|
|
|
On cherche donc par NOM, en ecartant ce qui n'appartient pas au depot (`.git`, les
|
|
|
|
|
|
caches Python, les dependances installees).
|
|
|
|
|
|
"""
|
|
|
|
|
|
return any(_propre(chemin) for chemin in RACINE.rglob(nom))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _carte_chiffres_mesures() -> dict[str, int]:
|
|
|
|
|
|
"""Ce que la carte affirme, recompte a la source."""
|
|
|
|
|
|
dec = (RACINE / "docs" / "decisions-architecture.md")
|
|
|
|
|
|
texte = dec.read_text(encoding="utf-8") if dec.is_file() else ""
|
|
|
|
|
|
return {
|
|
|
|
|
|
"roles": len([p for p in (RACINE / "roles").glob("*") if p.is_dir()]),
|
|
|
|
|
|
"README de roles": len(list((RACINE / "roles").glob("*/README.md"))),
|
|
|
|
|
|
"documents": len(list((RACINE / "docs").glob("*.md"))),
|
|
|
|
|
|
"pieces d'audit": len([p for p in (RACINE / "docs" / "audit").glob("*")
|
|
|
|
|
|
if p.is_file()]),
|
2026-09-14 14:09:13 -04:00
|
|
|
|
# UNE FICHE GENEREE N'EST PAS UNE UNITE D'APPRENTISSAGE (2026-09-14). Les
|
|
|
|
|
|
# unites suivent le moule en quatre temps — le concept, comment Set-OPS le fait,
|
|
|
|
|
|
# pourquoi c'est transferable, a toi de jouer. Les fiches de role sont de la
|
|
|
|
|
|
# REFERENCE : generees, consultees pour agir, jamais lues pour apprendre.
|
|
|
|
|
|
#
|
|
|
|
|
|
# Les compter ensemble ferait annoncer « 96 unites » a un wiki qui en enseigne
|
|
|
|
|
|
# vingt-sept. Le nombre serait exact et l'affirmation fausse.
|
|
|
|
|
|
"unites de wiki": len([p for p in (RACINE / "wiki").glob("*.md")
|
|
|
|
|
|
# On ne retire QUE ce que la generation a ajoute. Que
|
|
|
|
|
|
# `_Sidebar` soit compte ou non est un autre debat, et le
|
|
|
|
|
|
# melanger a celui-ci rendrait le chiffre incomparable
|
|
|
|
|
|
# avec celui d'hier.
|
|
|
|
|
|
if not p.name.startswith("Rôle")]),
|
carte : P48 — l'index du mainteneur ne peut plus mentir
`docs/carte-set-ops.md` est l'index du MAINTENEUR : l'ordre de lecture du corpus,
et surtout le catalogue des MECANISMES TRANSVERSES avec, pour chacun, OU IL VIT
DANS LE CODE. Son but est ecrit en toutes lettres : « ne plus re-deterrer ce qui
existe ».
Elle n'avait aucune garde, alors que `catalogue-services.md` a la sienne depuis
P38. Ses sept chiffres etaient faux — 54 roles annonces contre 60, 34 documents
contre 38, 15 pieces d'audit contre 27, 70 decisions contre 78.
Le defaut couteux n'est pourtant pas la. C'est le POINTEUR MORT : la carte dit ou
vit un mecanisme, quelqu'un ne l'y trouve pas, et le reimplemente a cote —
exactement la panne qu'elle existe pour prevenir. Aucun de ces nombres ne fait
travailler personne ; mais un document dont les faits verifiables sont faux cesse
d'etre consulte, et c'est alors ses pointeurs qu'on perd.
P48 verifie les deux : 84 chemins cites existent, et 7 chiffres correspondent a
la mesure. Controle negatif verifie sur les DEUX moities — un chiffre fausse, un
pointeur casse, la preuve echoue dans les deux cas.
QUATRE DISTINCTIONS ont du etre ecrites pour qu'elle ne soit pas fausse dans
l'autre sens : un gabarit de nom (`preuve-<date>.md`) decrit une forme, pas un
fichier ; un chemin hors depot (`~/.config/setops-vault-pass`) vit sur le poste de
l'exploitant, et c'est tout l'interet de la doctrine des voutes ; un fragment
(`meta/acces.yml`) vaut comme SUFFIXE, parce qu'un index se lit ainsi ; et un
artefact GENERE (`hosts.yml`) n'a pas a exister dans le moteur. Les quatre sont
nommees dans le code plutot que sautees en silence.
Le tableau « Le depot en chiffres » remplace les comptes en prose : ce qu'on
n'entretient pas, on ne l'affirme pas — ou bien on le fait recompter.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 13:56:58 -04:00
|
|
|
|
"decisions en vigueur": len(re.findall(r"^\| \*\*D-\d+\*\* \|", texte, re.M)),
|
|
|
|
|
|
"decisions renversees": len(re.findall(r"^\| \*\*D-\d+\*\* —", texte, re.M)),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
documentation : la tournee des 74 documents, parce qu un balayage ne lit pas
La revision a commence par un balayage par motifs — chemins morts, cibles make
absentes, comptes derives. Il a trouve une trentaine d ecarts et rate presque
tout le reste : un motif ne voit que ce qui s exprime en motif.
make hote-planifier en est l exemple. La cible EXISTE, donc le controle passait
au vert. C est une cible depreciee qui refuse et sort en 2, recommandee par
AGENTS.md, et qui contredit la REGLE D OR du meme fichier trois ecrans plus
haut. Il fallait lire pour la voir.
74 documents lus un par un. 66 corriges, 8 exacts.
CE QUI ETAIT FRANCHEMENT FAUX
AGENTS.md, la source d autorite, annoncait la flotte pas encore executee contre
des VM reelles. Elle a ete rasee et remontee depuis zero trois fois.
ecosysteme-chezlepro.md, le document montre a un client, portait la meme
phrase : il se sous-vendait gravement.
courriel-conception.md s ouvrait sur aucun role n est encore ecrit, au-dessus de
son propre paragraphe 1 qui les nomme. autorisation.md se terminait sur rien n
est construit alors qu il rapporte des mesures datees du role en fonctionnement.
hebergeur-exploitation.md disait rien n est fait d un depot qui existe.
filiation-emancipation.md se contredisait a deux ecrans de distance.
DES MODELES DECRITS D APRES UN MONDE ANTERIEUR
Le resolveur : cinq documents decrivaient un Unbound par VM en opt-in, trois le
donnaient en exemple d integration FACULTATIVE — il est universel depuis le
2026-08-24. L adressage de nomenclature-vm.md : reseau unique, VLAN 11-15, VMID
a cinq chiffres. Le nommage SDN de sdn-evpn.md contre le code : c est le wiki
qui avait raison.
CE QUI CASSE AU PREMIER ESSAI
Le nom du gabarit dore etait faux a quatre endroits, dont la procedure qui le
FABRIQUE et le critere R2 de l epreuve d operateur independant.
preparer-un-site-hebergeur.md avertissait qu une VM faite a la main serait
detruite : raser derive du plan, il ne la detruira jamais — le risque est l
inverse. Un mot de passe d essai en clair dans un depot public.
DEUX PREUVES ETENDUES, ET UNE QUI SE TROMPAIT ELLE-MEME
P57 couvre les groupes : elle a signale aussitot 29 groupes annonces au-dessus d
un tableau qui en cite 40. P29 confronte le tableau de authentification.md aux
declarations reelles : 12 annonces, 21 reels.
Et P57 imposait un chiffre faux — 56 preuves alors que le depot en porte 57, la
conditionnelle vivant hors de tout comptage. Un garde-fou qui fait respecter une
erreur ajoute l assurance a l erreur.
CE QUI RESTE, ET QU AUCUNE PREUVE NE TIENT
Deux comptes trouves a la main. Et une lacune reelle : rien ne garde les
meta/acces.yml — ni qu un service web-sso en porte un, ni que le groupe qu il
nomme existe. P29 tient les positions d authentification, personne ne tient les
habilitations.
make prouver : CONFORME, 56 OK, 0 echec, 1 saute. 0 lien mort.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-06 16:18:23 -04:00
|
|
|
|
def preuve_comptes_de_la_prose() -> tuple[bool, str]:
|
|
|
|
|
|
"""Un nombre de roles ou de preuves ecrit en prose correspond a la mesure.
|
|
|
|
|
|
|
|
|
|
|
|
POURQUOI, ALORS QUE P48 GARDE DEJA LA CARTE (mesure du 2026-09-05). P48 ne regarde
|
|
|
|
|
|
que le tableau « Le depot en chiffres ». Les MEMES nombres vivent ailleurs, en pleine
|
|
|
|
|
|
phrase, et ils avaient tous derive :
|
|
|
|
|
|
|
|
|
|
|
|
docs/devis-services.md : « porte 35 preuves » -> 56
|
|
|
|
|
|
AGENTS.md : « les 35 preuves » -> 56
|
|
|
|
|
|
docs/pouvoirs-set-ops : « ≈50 roles » -> 65
|
|
|
|
|
|
docs/catalogue-services : « les 29 groupes » -> 40
|
|
|
|
|
|
|
|
|
|
|
|
Le quatrieme a ete trouve APRES coup (2026-09-06), par la revision de documentation et
|
|
|
|
|
|
non par cette preuve : elle ne regardait que `preuves` et `roles`. Un catalogue qui
|
|
|
|
|
|
annonce 29 groupes au-dessus d'un tableau qui en cite 40 se contredit A UNE LIGNE DE
|
|
|
|
|
|
DISTANCE. D'ou le troisieme compte.
|
|
|
|
|
|
|
|
|
|
|
|
Aucun de ces nombres ne fait travailler personne. Mais AGENTS.md est la source
|
|
|
|
|
|
d'autorite du depot : un document qui se trompe sur ce qu'il decrit cesse d'etre cru,
|
|
|
|
|
|
et c'est alors ses DIRECTIVES qu'on perd — pas seulement ses chiffres.
|
|
|
|
|
|
|
|
|
|
|
|
CE QU'ELLE NE FLAGUE PAS, ET POURQUOI C'EST ESSENTIEL. Une mesure DATEE est un fait
|
|
|
|
|
|
historique : « Set-OPS a longtemps eu 31 preuves » raconte pourquoi `make valider`
|
|
|
|
|
|
existe, et reecrire ce 31 detruirait le recit. On ignore donc toute ligne portant une
|
|
|
|
|
|
annee — le depot date ses mesures, c'est deja sa discipline.
|
|
|
|
|
|
|
|
|
|
|
|
Une preuve qui crie sur un cas sain finit par etre ignoree, ce qui est pire que de ne
|
|
|
|
|
|
pas l'avoir. Celle-ci a ete ecrite, puis passee sur tout le corpus jusqu'a etre
|
|
|
|
|
|
SILENCIEUSE sur les formulations legitimes avant d'etre retenue.
|
|
|
|
|
|
"""
|
|
|
|
|
|
mesures = {
|
|
|
|
|
|
"preuves": len(TOUTES_LES_PREUVES),
|
|
|
|
|
|
"roles": len([d for d in (RACINE / "roles").iterdir() if d.is_dir()]),
|
|
|
|
|
|
# UN GROUPE OPERATIONNEL EST UN PLAYBOOK, PAS UN ROLE. P04 tient deja la regle
|
|
|
|
|
|
# « groupe -> playbooks/groupes/<groupe>.yml » : c'est donc ce repertoire qui
|
|
|
|
|
|
# compte, et non `roles/`, qui contient aussi les rôles composés par un groupe
|
|
|
|
|
|
# (les onze du durcissement n'ont pas de groupe a eux).
|
|
|
|
|
|
"groupes": len(list((RACINE / "playbooks" / "groupes").glob("*.yml"))),
|
|
|
|
|
|
}
|
|
|
|
|
|
fichiers = sorted(
|
|
|
|
|
|
list((RACINE / "docs").glob("*.md")) + list((RACINE / "wiki").glob("*.md"))
|
|
|
|
|
|
+ [RACINE / "AGENTS.md", RACINE / "README.md", RACINE / "CLAUDE.md"])
|
|
|
|
|
|
|
|
|
|
|
|
ecarts: list[str] = []
|
|
|
|
|
|
for f in fichiers:
|
|
|
|
|
|
if not f.is_file():
|
|
|
|
|
|
continue
|
|
|
|
|
|
lignes = f.read_text(encoding="utf-8").splitlines()
|
|
|
|
|
|
# LA DATATION PORTE SUR LE PARAGRAPHE, PAS SUR LA LIGNE (mesure du 2026-09-05).
|
|
|
|
|
|
#
|
|
|
|
|
|
# Premiere version : on ignorait une ligne portant une annee. Elle a crie sur les
|
|
|
|
|
|
# deux seuls cas legitimes du corpus — `autorisation.md` datait son constat trois
|
|
|
|
|
|
# lignes plus haut, et `Verifier-le-deploye.md` ecrit « a longtemps eu » sans
|
|
|
|
|
|
# millesime. Une garde qui crie sur des cas sains finit par etre ignoree ; on
|
|
|
|
|
|
# regarde donc le PARAGRAPHE, qui est l'unite ou une mesure se date reellement.
|
|
|
|
|
|
bornes: list[tuple[int, int]] = []
|
|
|
|
|
|
debut = 0
|
|
|
|
|
|
for i, l in enumerate(lignes):
|
|
|
|
|
|
if not l.strip():
|
|
|
|
|
|
if i > debut:
|
|
|
|
|
|
bornes.append((debut, i))
|
|
|
|
|
|
debut = i + 1
|
|
|
|
|
|
if debut < len(lignes):
|
|
|
|
|
|
bornes.append((debut, len(lignes)))
|
|
|
|
|
|
|
|
|
|
|
|
historiques: set[int] = set()
|
|
|
|
|
|
for a, b in bornes:
|
|
|
|
|
|
para = "\n".join(lignes[a:b])
|
|
|
|
|
|
if re.search(r"\b20\d\d\b|longtemps|autrefois|jusqu'(a|au)\b", para, re.I):
|
|
|
|
|
|
historiques.update(range(a, b))
|
|
|
|
|
|
|
|
|
|
|
|
for n, ligne in enumerate(lignes, 1):
|
|
|
|
|
|
if (n - 1) in historiques:
|
|
|
|
|
|
continue
|
|
|
|
|
|
for m in re.finditer(
|
|
|
|
|
|
r"(?<![\d.])(\d{2,3})\s+(preuves|rôles|roles|groupes)\b", ligne):
|
|
|
|
|
|
valeur = int(m.group(1))
|
|
|
|
|
|
mot = m.group(2)
|
|
|
|
|
|
quoi = ("preuves" if mot == "preuves"
|
|
|
|
|
|
else "groupes" if mot == "groupes" else "roles")
|
|
|
|
|
|
if valeur != mesures[quoi]:
|
|
|
|
|
|
ecarts.append(
|
|
|
|
|
|
f"{f.relative_to(RACINE)}:{n} annonce {valeur} {quoi}, "
|
|
|
|
|
|
f"le depot en compte {mesures[quoi]}")
|
|
|
|
|
|
if ecarts:
|
|
|
|
|
|
return False, ("Des comptes ecrits en prose ne disent plus vrai :\n - "
|
|
|
|
|
|
+ "\n - ".join(ecarts)
|
|
|
|
|
|
+ "\n (dater la ligne la rend historique et l'exempte)")
|
|
|
|
|
|
return True, (f"Les comptes ecrits en prose correspondent a la mesure "
|
|
|
|
|
|
f"({mesures['preuves']} preuves, {mesures['roles']} roles, "
|
|
|
|
|
|
f"{mesures['groupes']} groupes).")
|
|
|
|
|
|
|
|
|
|
|
|
|
carte : P48 — l'index du mainteneur ne peut plus mentir
`docs/carte-set-ops.md` est l'index du MAINTENEUR : l'ordre de lecture du corpus,
et surtout le catalogue des MECANISMES TRANSVERSES avec, pour chacun, OU IL VIT
DANS LE CODE. Son but est ecrit en toutes lettres : « ne plus re-deterrer ce qui
existe ».
Elle n'avait aucune garde, alors que `catalogue-services.md` a la sienne depuis
P38. Ses sept chiffres etaient faux — 54 roles annonces contre 60, 34 documents
contre 38, 15 pieces d'audit contre 27, 70 decisions contre 78.
Le defaut couteux n'est pourtant pas la. C'est le POINTEUR MORT : la carte dit ou
vit un mecanisme, quelqu'un ne l'y trouve pas, et le reimplemente a cote —
exactement la panne qu'elle existe pour prevenir. Aucun de ces nombres ne fait
travailler personne ; mais un document dont les faits verifiables sont faux cesse
d'etre consulte, et c'est alors ses pointeurs qu'on perd.
P48 verifie les deux : 84 chemins cites existent, et 7 chiffres correspondent a
la mesure. Controle negatif verifie sur les DEUX moities — un chiffre fausse, un
pointeur casse, la preuve echoue dans les deux cas.
QUATRE DISTINCTIONS ont du etre ecrites pour qu'elle ne soit pas fausse dans
l'autre sens : un gabarit de nom (`preuve-<date>.md`) decrit une forme, pas un
fichier ; un chemin hors depot (`~/.config/setops-vault-pass`) vit sur le poste de
l'exploitant, et c'est tout l'interet de la doctrine des voutes ; un fragment
(`meta/acces.yml`) vaut comme SUFFIXE, parce qu'un index se lit ainsi ; et un
artefact GENERE (`hosts.yml`) n'a pas a exister dans le moteur. Les quatre sont
nommees dans le code plutot que sautees en silence.
Le tableau « Le depot en chiffres » remplace les comptes en prose : ce qu'on
n'entretient pas, on ne l'affirme pas — ou bien on le fait recompter.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 13:56:58 -04:00
|
|
|
|
def preuve_carte_dit_vrai() -> tuple[bool, str]:
|
|
|
|
|
|
"""La carte d'orientation designe des choses qui existent, et compte juste.
|
|
|
|
|
|
|
|
|
|
|
|
CE QU'EST LA CARTE. `docs/carte-set-ops.md` est l'index du MAINTENEUR : l'ordre de
|
|
|
|
|
|
lecture du corpus, et surtout le catalogue des MECANISMES TRANSVERSES avec, pour
|
|
|
|
|
|
chacun, OU IL VIT DANS LE CODE. Son but est ecrit en toutes lettres : « ne plus
|
|
|
|
|
|
re-deterrer ce qui existe ».
|
|
|
|
|
|
|
|
|
|
|
|
POURQUOI UNE GARDE (mesure du 2026-08-26). Un POINTEUR MORT est le defaut couteux :
|
|
|
|
|
|
la carte dit ou vit un mecanisme, quelqu'un ne l'y trouve pas, et le reimplemente a
|
|
|
|
|
|
cote. C'est exactement la panne que la carte existe pour prevenir.
|
|
|
|
|
|
|
|
|
|
|
|
LES CHIFFRES SONT L'AUTRE MOITIE, et ils etaient TOUS faux : 54 roles annonces contre
|
|
|
|
|
|
60, 34 documents contre 38, 15 pieces d'audit contre 27, 70 decisions contre 78.
|
|
|
|
|
|
Aucun de ces nombres ne fait travailler personne. Mais un document dont les faits
|
|
|
|
|
|
verifiables sont faux cesse d'etre consulte — et c'est alors ses pointeurs qu'on perd.
|
|
|
|
|
|
|
|
|
|
|
|
`catalogue-services.md` avait deja sa garde (P38) ; la carte n'en avait aucune.
|
|
|
|
|
|
"""
|
|
|
|
|
|
carte = RACINE / "docs" / "carte-set-ops.md"
|
|
|
|
|
|
if not carte.is_file():
|
|
|
|
|
|
return False, "docs/carte-set-ops.md est introuvable."
|
|
|
|
|
|
texte = carte.read_text(encoding="utf-8")
|
|
|
|
|
|
manques: list[str] = []
|
|
|
|
|
|
|
|
|
|
|
|
# --- Les pointeurs ---------------------------------------------------------
|
|
|
|
|
|
#
|
|
|
|
|
|
# On ne retient que ce qui RESSEMBLE a un chemin du depot : un segment avec un `/`
|
|
|
|
|
|
# ou une extension connue. Les `make cible`, les noms de variables et les fragments
|
|
|
|
|
|
# de code entre accents graves ne sont pas des chemins, et les exiger rendrait la
|
|
|
|
|
|
# preuve fausse dans l'autre sens.
|
|
|
|
|
|
chemins = set()
|
|
|
|
|
|
for brut in re.findall(r"`([^`\n]+)`", texte):
|
|
|
|
|
|
c = brut.strip()
|
|
|
|
|
|
# Un GABARIT de nom n'est pas un chemin : `docs/audit/preuve-<date>.md` decrit
|
|
|
|
|
|
# une forme de fichier, pas un fichier. L'exiger rendrait la preuve fausse.
|
|
|
|
|
|
# Ni un gabarit de nom (`preuve-<date>.md` decrit une FORME), ni un chemin HORS
|
|
|
|
|
|
# depot (`~/.config/setops-vault-pass` vit sur le poste de l'exploitant, et c'est
|
|
|
|
|
|
# tout l'interet de la doctrine des voutes). Les exiger rendrait la preuve fausse.
|
|
|
|
|
|
if " " in c or c.startswith(("$", "-", "~", "/")) or "<" in c or ">" in c:
|
|
|
|
|
|
continue
|
|
|
|
|
|
if not ("/" in c or c.endswith((".md", ".yml", ".py", ".j2"))):
|
|
|
|
|
|
continue
|
|
|
|
|
|
chemins.add(c.rstrip("/"))
|
|
|
|
|
|
|
|
|
|
|
|
for c in sorted(chemins):
|
|
|
|
|
|
# DEUX SEMANTIQUES, ET C'EST VOULU.
|
|
|
|
|
|
#
|
|
|
|
|
|
# Un chemin AVEC dossier (`roles/serveur_nginx/tasks/main.yml`) affirme un
|
|
|
|
|
|
# emplacement : on l'exige tel quel. Un nom SANS dossier (`devis_reseau.py`,
|
|
|
|
|
|
# `expositions.conf.j2`) affirme seulement qu'une chose de ce nom existe — c'est
|
|
|
|
|
|
# ainsi qu'un index se lit, et exiger le chemin complet le rendrait illisible.
|
|
|
|
|
|
# UN ARTEFACT GENERE N'A PAS A EXISTER ICI. La carte nomme `hosts.yml` en disant
|
|
|
|
|
|
# elle-meme « genere » : c'est le produit de `make instancier`, il vit dans le
|
|
|
|
|
|
# depot d'un ECOSYSTEME, pas dans le moteur. L'exiger reprocherait a la carte
|
|
|
|
|
|
# d'etre juste. On les nomme plutot que de les sauter en silence.
|
|
|
|
|
|
if c in ARTEFACTS_GENERES:
|
|
|
|
|
|
continue
|
|
|
|
|
|
if "/" in c:
|
|
|
|
|
|
# Un fragment (`meta/acces.yml`) designe un emplacement RELATIF a un role ou
|
|
|
|
|
|
# a une instance. Il vaut donc comme SUFFIXE : `roles/serveur_forgejo/meta/
|
|
|
|
|
|
# acces.yml` le satisfait. Exiger la racine rendrait la carte illisible.
|
|
|
|
|
|
if any((RACINE / base / c).exists() for base in ("", "docs")):
|
|
|
|
|
|
continue
|
|
|
|
|
|
if _existe_en_suffixe(c):
|
|
|
|
|
|
continue
|
|
|
|
|
|
elif _existe_quelque_part(c):
|
|
|
|
|
|
continue
|
|
|
|
|
|
# Les globs et les chemins d'une INSTANCE (`plan/*.yml`, `roles/*/meta/...`)
|
|
|
|
|
|
# se verifient par expansion : ils designent une forme, pas un fichier.
|
|
|
|
|
|
# UN GLOB DESIGNE UNE FORME, ou qu'elle se trouve. `plan/*.yml` est le plan d'un
|
|
|
|
|
|
# ECOSYSTEME : il n'existe pas a la racine du moteur, mais bien sous `instance/`
|
|
|
|
|
|
# ou dans un depot voisin. On cherche donc la forme partout.
|
|
|
|
|
|
if "*" in c and (list(RACINE.glob(c)) or list(RACINE.glob("**/" + c))):
|
|
|
|
|
|
continue
|
|
|
|
|
|
manques.append(f"`{c}` : cite par la carte, introuvable dans le depot")
|
|
|
|
|
|
|
|
|
|
|
|
# --- Les chiffres ----------------------------------------------------------
|
|
|
|
|
|
#
|
|
|
|
|
|
# L'appariement se fait SANS ACCENTS : la carte ecrit « rôles » et « pièces d'audit »,
|
|
|
|
|
|
# ce code ecrit sans diacritiques comme tout le reste du depot. Comparer les deux
|
|
|
|
|
|
# tels quels ferait echouer la preuve sur son propre alphabet.
|
|
|
|
|
|
annonces = {}
|
|
|
|
|
|
for etiq, valeur in re.findall(r"^\| *([^|]+?) *\| *(\d+) *\|", texte, re.M):
|
|
|
|
|
|
annonces[_sans_accents(etiq).lower()] = int(valeur)
|
|
|
|
|
|
|
|
|
|
|
|
for etiquette, attendu in _carte_chiffres_mesures().items():
|
|
|
|
|
|
cle = _sans_accents(etiquette).lower()
|
|
|
|
|
|
if cle not in annonces:
|
|
|
|
|
|
manques.append(f"« {etiquette} » : absent du tableau « Le depot en chiffres » "
|
|
|
|
|
|
f"(mesure : {attendu})")
|
|
|
|
|
|
continue
|
|
|
|
|
|
if annonces[cle] != attendu:
|
|
|
|
|
|
manques.append(f"« {etiquette} » : la carte annonce {annonces[cle]}, "
|
|
|
|
|
|
f"le depot en compte {attendu}")
|
|
|
|
|
|
|
|
|
|
|
|
if manques:
|
|
|
|
|
|
return False, ("La carte d'orientation ne dit plus vrai :\n - "
|
|
|
|
|
|
+ "\n - ".join(manques))
|
|
|
|
|
|
return True, (f"La carte designe {len(chemins)} chemin(s) qui existent, et ses "
|
|
|
|
|
|
f"{len(_carte_chiffres_mesures())} chiffres correspondent a la mesure.")
|
|
|
|
|
|
|
|
|
|
|
|
|
grafana : les panneaux declares deviennent des tableaux
La moitie qui manquait. Les roles declaraient leurs panneaux, Prometheus en
derivait ses cibles, les expressions repondaient, et rien ne les assemblait.
serveur_grafana lit les memes meta/metriques.yml que serveur_prometheus et en
assemble un tableau par role — la raison du panneau devient sa description dans
Grafana. Moisson des tableaux orphelins, prefixe setops-role- pour ne jamais
toucher un tableau ecrit a la main, JSON valide avant d etre pose (Grafana ne
tombe pas sur un tableau illisible : il le saute en silence).
client_metrique declare quatre panneaux sans exportateur — le cas symetrique.
Celui qui compte : la memoire disponible, depuis que le ballon est actif.
Eprouve sur le site : les deux tableaux charges dans le stockage unifie, un
temoin orphelin retire, les quatre panneaux de flotte a 10 series chacun.
Un panneau etait faux — /var/lib/lxcfs rapporte toujours zero octet libre et un
min() en meurt. Corrige en liste blanche : un montage inconnu manque au graphe,
ce qui se voit, au lieu de l ecraser.
P77 garde la table des unites : une unite inventee retombe sur short, lisible et
fausse. 76 OK.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-14 16:32:30 -04:00
|
|
|
|
def preuve_panneaux_assembles() -> tuple[bool, str]:
|
|
|
|
|
|
"""Chaque panneau declare porte de quoi etre assemble, et son unite est traduisible.
|
|
|
|
|
|
|
|
|
|
|
|
LES DEUX MOITIES DE `meta/metriques.yml` ONT CHACUNE LEUR CONSOMMATEUR : l'
|
|
|
|
|
|
`exportateur` devient une cible de scrutation chez `serveur_prometheus`, les
|
|
|
|
|
|
`panneaux` deviennent un tableau chez `serveur_grafana`. P64 fait ce travail pour les
|
|
|
|
|
|
sondes ; celle-ci le fait pour les series.
|
|
|
|
|
|
|
|
|
|
|
|
CE QU'ELLE EXIGE DE CHAQUE PANNEAU. Un `titre`, une `expr`, une `unite`, et une
|
|
|
|
|
|
`raison`. La `raison` est le seul champ qui ne produit aucun pixel, et c'est le plus
|
|
|
|
|
|
important : elle devient la description du panneau dans Grafana. Sans elle, celui qui
|
|
|
|
|
|
regarde a six mois de distance voit une courbe sans savoir ce qu'elle annonce.
|
|
|
|
|
|
|
|
|
|
|
|
ET SURTOUT : L'UNITE DOIT ETRE TRADUISIBLE. `serveur_grafana` tient une table qui
|
|
|
|
|
|
traduit la langue des roles — « octets », « connexions » — vers celle de Grafana.
|
|
|
|
|
|
C'est UNE LISTE QUI EN SUIT UNE AUTRE, et une liste qui suit une autre prend du
|
|
|
|
|
|
retard : un role qui invente une unite ne casse rien, son panneau retombe simplement
|
|
|
|
|
|
sur `short`. Un graphe d'octets gradue en unites brutes reste parfaitement lisible et
|
|
|
|
|
|
parfaitement faux.
|
|
|
|
|
|
|
|
|
|
|
|
CE QU'ELLE NE FAIT PAS. Elle ne dit pas si l'expression REPOND — aucune lecture
|
|
|
|
|
|
statique ne le dira, il faut un Prometheus qui a des donnees. C'est au controle de
|
|
|
|
|
|
chaque panneau, trace au CHANGELOG, comme le controle negatif d'une sonde.
|
|
|
|
|
|
"""
|
|
|
|
|
|
roles = RACINE / "roles"
|
|
|
|
|
|
table = roles / "serveur_grafana" / "defaults" / "main.yml"
|
|
|
|
|
|
try:
|
|
|
|
|
|
unites = set((yaml.safe_load(table.read_text(encoding="utf-8")) or {})
|
|
|
|
|
|
.get("serveur_grafana_unites") or {})
|
|
|
|
|
|
except (OSError, yaml.YAMLError) as e:
|
|
|
|
|
|
return False, f"Table des unites de serveur_grafana illisible ({e})."
|
|
|
|
|
|
if not unites:
|
|
|
|
|
|
return False, ("`serveur_grafana_unites` est vide : aucun panneau ne pourrait "
|
|
|
|
|
|
"etre gradue.")
|
|
|
|
|
|
|
|
|
|
|
|
fautes, mesures = [], []
|
|
|
|
|
|
for meta in sorted(roles.glob("*/meta/metriques.yml")):
|
|
|
|
|
|
role = meta.parent.parent.name
|
|
|
|
|
|
try:
|
|
|
|
|
|
data = yaml.safe_load(meta.read_text(encoding="utf-8")) or {}
|
|
|
|
|
|
except yaml.YAMLError as e:
|
|
|
|
|
|
fautes.append(f"{role} : `meta/metriques.yml` illisible ({e})")
|
|
|
|
|
|
continue
|
|
|
|
|
|
panneaux = data.get("panneaux")
|
|
|
|
|
|
expo = data.get("exportateur")
|
|
|
|
|
|
if not panneaux and not expo:
|
|
|
|
|
|
fautes.append(f"{role} : `meta/metriques.yml` ne declare ni exportateur "
|
|
|
|
|
|
f"ni panneau — personne ne le lira")
|
|
|
|
|
|
continue
|
|
|
|
|
|
for p in (panneaux or []):
|
|
|
|
|
|
titre = p.get("titre") or "(sans titre)"
|
|
|
|
|
|
for champ in ("titre", "expr", "unite", "raison"):
|
|
|
|
|
|
if not str(p.get(champ) or "").strip():
|
|
|
|
|
|
fautes.append(f"{role}/{titre} : `{champ}` manquant"
|
|
|
|
|
|
+ (" — un panneau dont personne ne sait ce qu'il "
|
|
|
|
|
|
"annonce n'est pas une observabilite"
|
|
|
|
|
|
if champ == "raison" else ""))
|
|
|
|
|
|
unite = str(p.get("unite") or "").strip()
|
|
|
|
|
|
if unite and unite not in unites:
|
|
|
|
|
|
fautes.append(f"{role}/{titre} : unite « {unite} » absente de "
|
|
|
|
|
|
f"`serveur_grafana_unites` — le panneau retomberait sur "
|
|
|
|
|
|
f"`short`, lisible et faux")
|
|
|
|
|
|
mesures.append(f"{role}/{titre}")
|
|
|
|
|
|
|
|
|
|
|
|
if fautes:
|
|
|
|
|
|
return False, "Panneaux declares :\n - " + "\n - ".join(fautes)
|
|
|
|
|
|
if not mesures:
|
|
|
|
|
|
return True, ("Aucun panneau declare — rien a assembler "
|
|
|
|
|
|
"(cf. docs/supervision-conception.md).")
|
|
|
|
|
|
return True, (f"{len(mesures)} panneau(x) declare(s) dans "
|
|
|
|
|
|
f"{len({m.split('/')[0] for m in mesures})} role(s), tous avec titre, "
|
|
|
|
|
|
f"expression, raison et une unite que la table sait traduire.")
|
|
|
|
|
|
|
|
|
|
|
|
|
TLS des bases : le consommateur suit son serveur, il ne le devine plus
La supervision du SITE etait morte depuis 11:10 et rien ne le disait. icingadb
refuse par pg_hba — hostssl impose cote serveur, connexion en clair cote client.
icinga2 tournait, redis tournait, les sondes poussaient, et rien n atteignait la
base : les verdicts se calculaient dans le vide.
La cause est une seconde liste tenue a la main. tls_force allume hostssl ; chaque
consommateur avait SON interrupteur a allumer dans les group_vars. Chezlepro
avait les trois, le site avait le premier. serveur_forgejo disait pire que rien :
sslmode disable ecrit en dur, le contraire de ce que le serveur imposait.
resoudre_base expose resoudre_base_db_tls_force, lu dans les hostvars de la
machine qui PORTE la base. Les trois interrupteurs en derivent. Un serveur qui
ne declare rien ne force rien : on ne casse pas un ecosysteme qui n a pas
bascule.
P78 refuse une valeur ecrite chez un consommateur, et nomme les deux roles sans
reglage TLS plutot que de rendre un vert muet sur eux.
Apres : icingadb active, TLSv1.3 vu par PostgreSQL, 16 hotes et 88 services en
base. Les cinq sondes des marqueurs du site, INCONNU faute de deploiement,
rapportent leur phrase.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-14 18:42:21 -04:00
|
|
|
|
def preuve_consommateurs_suivent_le_verrou_tls() -> tuple[bool, str]:
|
|
|
|
|
|
"""Un consommateur de base ne decide pas seul s'il chiffre : il suit son serveur.
|
|
|
|
|
|
|
|
|
|
|
|
LE DEFAUT, MESURE LE 2026-09-14 A 11:10. `serveur_postgresql_tls_force` pose `hostssl`
|
|
|
|
|
|
dans `pg_hba` : toute connexion NON chiffree est refusee. Chaque consommateur avait
|
|
|
|
|
|
alors SON interrupteur — `serveur_icinga_db_tls`, `serveur_keycloak_db_sslmode`,
|
|
|
|
|
|
`serveur_forgejo_db_sslmode` — a allumer A LA MAIN, dans les `group_vars`, en meme
|
|
|
|
|
|
temps que celui du serveur.
|
|
|
|
|
|
|
|
|
|
|
|
Chezlepro les avait tous les trois. Le SITE n'avait que celui du serveur :
|
|
|
|
|
|
|
|
|
|
|
|
aucune entree dans pg_hba.conf pour l'hote « 10.37.36.11 »,
|
|
|
|
|
|
utilisateur « icingadb », base « icingadb », aucun chiffrement
|
|
|
|
|
|
|
|
|
|
|
|
`icingadb` est tombe et n'est jamais remonte. Le moteur de supervision tournait,
|
|
|
|
|
|
Redis tournait, et plus rien n'atteignait la base : les verdicts se calculaient dans
|
|
|
|
|
|
le vide. Une panne qui ne fait echouer aucun deploiement.
|
|
|
|
|
|
|
|
|
|
|
|
ET `serveur_forgejo` DISAIT PIRE QUE RIEN : `sslmode: "disable"` en dur. Le
|
|
|
|
|
|
consommateur affirmait le contraire de ce que le serveur imposait.
|
|
|
|
|
|
|
|
|
|
|
|
CE QUE CETTE PREUVE EXIGE : que l'interrupteur de chaque consommateur DERIVE de
|
|
|
|
|
|
`resoudre_base_db_tls_force` — la posture lue chez le serveur — au lieu de porter une
|
|
|
|
|
|
valeur ecrite. Une valeur ecrite est une seconde liste, et une liste qui suit une
|
|
|
|
|
|
autre prend du retard.
|
|
|
|
|
|
|
|
|
|
|
|
CE QU'ELLE NE FAIT PAS. Elle ne verifie pas qu'une connexion CHIFFREE aboutit : ca
|
|
|
|
|
|
demande un serveur, un certificat et une AC. C'est au deploiement de le dire, et il
|
|
|
|
|
|
le dit — un consommateur qui n'y arrive pas ne demarre pas.
|
|
|
|
|
|
"""
|
|
|
|
|
|
roles = RACINE / "roles"
|
|
|
|
|
|
consommateurs: list[str] = []
|
|
|
|
|
|
for taches in sorted(roles.glob("*/tasks/main.yml")):
|
|
|
|
|
|
role = taches.parent.parent.name
|
|
|
|
|
|
if role == "resoudre_base":
|
|
|
|
|
|
continue
|
|
|
|
|
|
corps = taches.read_text(encoding="utf-8")
|
|
|
|
|
|
meta = roles / role / "meta" / "main.yml"
|
|
|
|
|
|
if meta.is_file():
|
|
|
|
|
|
corps += meta.read_text(encoding="utf-8")
|
|
|
|
|
|
if "resoudre_base" in corps:
|
|
|
|
|
|
consommateurs.append(role)
|
|
|
|
|
|
|
|
|
|
|
|
if not consommateurs:
|
|
|
|
|
|
return True, "Aucun role ne consomme de base : rien a verifier."
|
|
|
|
|
|
|
|
|
|
|
|
fautes, suivis, sans_reglage = [], [], []
|
|
|
|
|
|
for role in consommateurs:
|
|
|
|
|
|
f = roles / role / "defaults" / "main.yml"
|
|
|
|
|
|
brut = f.read_text(encoding="utf-8") if f.is_file() else ""
|
|
|
|
|
|
# Les lignes de reglage TLS, quelle que soit la forme du nom.
|
|
|
|
|
|
lignes = [l for l in brut.splitlines()
|
|
|
|
|
|
if re.match(r"\s*[a-z_]*_db_(tls|sslmode|ssl_mode)\s*:", l)]
|
|
|
|
|
|
if not lignes:
|
|
|
|
|
|
# PAS UNE FAUTE : un consommateur peut n'avoir aucun reglage parce que son
|
|
|
|
|
|
# client chiffre par defaut, ou parce qu'il parle a SQLite. On le DIT, ce qui
|
|
|
|
|
|
# vaut mieux qu'un vert muet — c'est la carte de ce qui reste a regarder.
|
|
|
|
|
|
sans_reglage.append(role)
|
|
|
|
|
|
continue
|
|
|
|
|
|
# La valeur derivee peut etre sur la ligne ou sur la suivante (`>-`).
|
|
|
|
|
|
bloc = brut
|
|
|
|
|
|
if "resoudre_base_db_tls_force" in bloc:
|
|
|
|
|
|
suivis.append(role)
|
|
|
|
|
|
else:
|
|
|
|
|
|
fautes.append(f"{role} : {lignes[0].strip()} — valeur ecrite, elle ne suit "
|
|
|
|
|
|
f"pas `resoudre_base_db_tls_force`")
|
|
|
|
|
|
|
|
|
|
|
|
if fautes:
|
|
|
|
|
|
return False, ("Un consommateur decide seul s'il chiffre, au lieu de suivre son "
|
|
|
|
|
|
"serveur :\n - " + "\n - ".join(fautes)
|
|
|
|
|
|
+ "\n Un `hostssl` allume cote serveur le fera tomber, sans "
|
|
|
|
|
|
"qu'aucun deploiement n'echoue.")
|
|
|
|
|
|
detail = f"{len(suivis)} consommateur(s) suivent la posture de leur serveur"
|
|
|
|
|
|
if sans_reglage:
|
|
|
|
|
|
detail += f" ; {len(sans_reglage)} sans reglage TLS ({', '.join(sans_reglage)})"
|
|
|
|
|
|
return True, detail + "."
|
|
|
|
|
|
|
|
|
|
|
|
|
supervision : les huit derniers roles, et deux defauts que l epreuve a trouves
Les 33 roles serveur_* declarent maintenant une sonde. Les huit qui manquaient
sont ceux dont la verite ne ressemble pas a « ce service repond-il ».
Quatre marqueurs du site : ce que tasks/main.yml verifie UNE FOIS au deploiement
cesse d etre vrai sans que rien ne tombe. La racine du cache se retrouve chainee,
la forge du genome repond en n ayant plus rien dedans, un locataire n est plus
admis a resoudre, l isolation d un depot glisse.
serveur_ops_site ne sert rien : il detient un pouvoir. La sonde verifie que la
carte est la, que la voute du site est chiffree et que sa cle est en 0600 — sans
lire le contenu d aucun des trois.
serveur_icingaweb2 surveille la vitrine de la supervision elle-meme : si la
console meurt, tout reste vert et l exploitant est aveugle.
DEFAUT 1 — quatre gabarits qu Ansible aurait refuse de rendre. Jinja lit le
{# de ${#tableau[@]} comme un debut de commentaire. Le depot connaissait le
remede et l appliquait la ou quelqu un s etait fait prendre, nulle part ailleurs.
P76 rend desormais chaque gabarit de role, avec les delimiteurs qu Ansible en
tirerait — pas une recherche de motif.
DEFAUT 2 — la doctrine promettait 54 greffons et citait check_pgsql. La flotte a
monitoring-plugins-basic : 53, sans check_pgsql ni check_dns ni check_ldap. Le
paquet qui les porte traine samba et snmp sur chaque machine. Un greffon absent
sort en 127, qui n est pas un code Nagios.
21 controles negatifs sur les machines reelles du site. ansible-lint production
0/91, harnais 75 OK.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-14 15:43:52 -04:00
|
|
|
|
def preuve_gabarits_shell_rendent() -> tuple[bool, str]:
|
|
|
|
|
|
"""Aucun gabarit shell ne contient de sequence que Jinja lirait autrement.
|
|
|
|
|
|
|
|
|
|
|
|
LE DEFAUT EST INVISIBLE JUSQU'AU DEPLOIEMENT, ET IL EST FACILE A REFAIRE.
|
|
|
|
|
|
|
|
|
|
|
|
`${#tableau[@]}` est la SEULE facon d'obtenir la longueur d'un tableau en bash. Elle
|
|
|
|
|
|
contient `{#`, que Jinja lit comme un debut de commentaire — et le rendu s'arrete sur
|
|
|
|
|
|
|
|
|
|
|
|
TemplateSyntaxError: Missing end of comment tag
|
|
|
|
|
|
|
|
|
|
|
|
Rien ne le signale avant : le fichier est valide en shell, valide a la lecture, et
|
|
|
|
|
|
`--syntax-check` ne rend pas les gabarits. La panne arrive sur la machine, pendant un
|
|
|
|
|
|
deploiement, sur une tache `template` qui marchait la veille.
|
|
|
|
|
|
|
|
|
|
|
|
LE DEPOT CONNAIT DEJA LE REMEDE, et l'applique trois fois : une en-tete
|
|
|
|
|
|
`#jinja2: comment_start_string:'{=#', comment_end_string:'#=}'` en toute premiere
|
|
|
|
|
|
ligne. Le shell garde `${#...}`, c'est Jinja qui cede.
|
|
|
|
|
|
|
|
|
|
|
|
CE QUI MANQUAIT, C'EST LA GARDE. Le remede etait pose la ou quelqu'un s'etait fait
|
|
|
|
|
|
prendre, et nulle part ailleurs. Le 2026-09-14, quatre sondes neuves l'ont refait
|
|
|
|
|
|
d'un coup — quatre gabarits que le harnais declarait conformes et qu'Ansible aurait
|
|
|
|
|
|
refuse de rendre.
|
|
|
|
|
|
|
|
|
|
|
|
ELLE MESURE LE RENDU, PAS LE MOTIF. Chercher `${#` suffirait a attraper ce cas-la,
|
|
|
|
|
|
mais pas le suivant : Jinja a d'autres sequences (`{%`, `{{`) qu'un script peut
|
|
|
|
|
|
produire par accident. On rend donc chaque gabarit pour de vrai, avec les memes
|
|
|
|
|
|
delimiteurs qu'Ansible en tirerait — et on ne juge pas les VARIABLES manquantes, qui
|
|
|
|
|
|
sont normales hors d'un play.
|
|
|
|
|
|
"""
|
|
|
|
|
|
import re as _re
|
|
|
|
|
|
try:
|
|
|
|
|
|
import jinja2
|
|
|
|
|
|
except ImportError:
|
|
|
|
|
|
return True, "jinja2 absent du controleur — rendu des gabarits non mesure."
|
|
|
|
|
|
|
|
|
|
|
|
fautes, mesures = [], 0
|
|
|
|
|
|
for gabarit in sorted((RACINE / "roles").glob("*/templates/*.j2")):
|
|
|
|
|
|
brut = gabarit.read_text(encoding="utf-8", errors="replace")
|
|
|
|
|
|
# ANSIBLE LIT L'EN-TETE `#jinja2:` ET L'APPLIQUE. On fait pareil, sinon la garde
|
|
|
|
|
|
# refuserait precisement les gabarits qui portent deja le remede.
|
|
|
|
|
|
options: dict = {}
|
|
|
|
|
|
entete = _re.match(r"#jinja2:\s*(.*)\n", brut)
|
|
|
|
|
|
if entete:
|
|
|
|
|
|
for cle, val in _re.findall(r"(\w+):\s*'([^']*)'", entete.group(1)):
|
|
|
|
|
|
options[cle] = val
|
|
|
|
|
|
brut = brut[entete.end():]
|
|
|
|
|
|
try:
|
|
|
|
|
|
jinja2.Environment(**options).parse(brut)
|
|
|
|
|
|
except jinja2.TemplateSyntaxError as e:
|
|
|
|
|
|
role = gabarit.parent.parent.name
|
|
|
|
|
|
fautes.append(f"{role}/{gabarit.name} l.{e.lineno} : {e.message}")
|
|
|
|
|
|
mesures += 1
|
|
|
|
|
|
|
|
|
|
|
|
if fautes:
|
|
|
|
|
|
return False, ("Gabarits qu'Ansible refuserait de rendre :\n - "
|
|
|
|
|
|
+ "\n - ".join(fautes)
|
|
|
|
|
|
+ "\n Remede : poser en PREMIERE ligne du gabarit"
|
|
|
|
|
|
"\n #jinja2: comment_start_string:'{=#', comment_end_string:'#=}'")
|
|
|
|
|
|
return True, f"{mesures} gabarits de role : tous se rendent."
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-13 20:10:24 -04:00
|
|
|
|
def preuve_parametres_clone_traversent() -> tuple[bool, str]:
|
|
|
|
|
|
"""Tout `SETOPS_*` que le Makefile transmet au clonage a bien une SOURCE.
|
|
|
|
|
|
|
|
|
|
|
|
LE PLANCHER DE MEMOIRE A TRAVERSE TROIS MODULES ET S'EST PERDU AU TROISIEME
|
|
|
|
|
|
(2026-09-13). `instancier` derivait `proxmox_memoire_min` dans l'inventaire ; le
|
|
|
|
|
|
playbook de clonage savait poser `balloon:` ; entre les deux, `inventory_host.py` ne
|
|
|
|
|
|
transmettait rien. Quatorze machines allaient naitre avec le ballooning DESACTIVE.
|
|
|
|
|
|
|
|
|
|
|
|
ET RIEN N'AURAIT ECHOUE. `$$SETOPS_MEMOIRE_MIN` non defini vaut la chaine vide ; le
|
|
|
|
|
|
Makefile ne passe alors pas `-e proxmox_clone_memoire_min` ; la garde `when:` de la
|
|
|
|
|
|
tache saute proprement. Trois silences qui s'enchainent et une flotte muette.
|
|
|
|
|
|
|
|
|
|
|
|
CE QUE CETTE GARDE LIT, ET C'EST DELIBEREMENT MECANIQUE : chaque `$$SETOPS_X` que la
|
|
|
|
|
|
cible `creer-vm` consomme doit figurer dans `CHAMPS_PROXMOX`, la table qui les EMET.
|
|
|
|
|
|
Elle ne juge pas la valeur — elle refuse qu'un maillon manque.
|
|
|
|
|
|
"""
|
|
|
|
|
|
mk = (RACINE / "Makefile").read_text(encoding="utf-8")
|
|
|
|
|
|
ih = (RACINE / "scripts" / "inventory_host.py").read_text(encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
|
|
# Le bloc de la cible `creer-vm` : de son entete a la ligne vide qui la ferme.
|
|
|
|
|
|
m = re.search(r"^creer-vm:.*?(?=^\S|\Z)", mk, re.S | re.M)
|
|
|
|
|
|
if not m:
|
|
|
|
|
|
return False, "cible `creer-vm` introuvable dans le Makefile — la garde ne lit plus rien."
|
|
|
|
|
|
consommes = set(re.findall(r"\$\$\{?(SETOPS_[A-Z_]+)", m.group(0)))
|
|
|
|
|
|
if not consommes:
|
|
|
|
|
|
return False, "aucun `$SETOPS_*` lu dans `creer-vm` — la garde ne lit plus rien."
|
|
|
|
|
|
|
|
|
|
|
|
# La table qui les emet, plus les variables posees a la main autour d'elle.
|
|
|
|
|
|
emis = set(re.findall(r'\("(SETOPS_[A-Z_]+)"', ih))
|
|
|
|
|
|
emis |= set(re.findall(r"^\s*(SETOPS_[A-Z_]+)\s*=", ih, re.M))
|
|
|
|
|
|
emis |= set(re.findall(r'"(SETOPS_[A-Z_]+)"\s*:', ih))
|
|
|
|
|
|
|
|
|
|
|
|
manquants = sorted(consommes - emis)
|
|
|
|
|
|
if manquants:
|
|
|
|
|
|
return False, ("transmis au clonage sans source dans `CHAMPS_PROXMOX` : "
|
|
|
|
|
|
+ ", ".join(manquants)
|
|
|
|
|
|
+ " — la VM naitra sans, et aucune etape n'echouera.")
|
|
|
|
|
|
return True, f"{len(consommes)} parametre(s) de clonage, tous emis par l'inventaire."
|
|
|
|
|
|
|
|
|
|
|
|
|
gabarit : minimal — il etait un cache du socle, et il perimait sans le dire
Il portait DIX-SEPT roles : exactement ceux du socle et du durcissement, que le
deploiement rejoue a l identique. C etait donc un CACHE — et comme tout cache, il
perimait sans le dire.
MESURE : derniere recapture le 2026-08-09, et SIX de ses roles avaient change depuis
— common_packages, cloud_init, ssh_baseline, ssh_hardening, auditd,
nftables_baseline. Rien ne le signalait : le deploiement masquait la derive en
reappliquant tout, donc personne ne pouvait la voir. Aucune preuve du harnais ne
regardait sa fraicheur.
IL NE GARDE QUE CE QUI DOIT EXISTER AVANT QU ANSIBLE PUISSE AGIR :
qemu_guest_agent l agent repond AVANT SSH — P52 s en sert
cloud_init le seul chemin vers la premiere seconde
sudo_ansible la porte par ou tout entre
ssh_baseline le serveur SSH
Ce ne sont pas des choix d efficacite, ce sont des conditions d existence.
CE QUE CA COUTE, ET QUI EST COUVERT : une VM neuve n est plus durcie a la naissance.
Elle nait cependant DERRIERE LE PARE-FEU DE L HYPERVISEUR, policy_in=REJECT arme au
clonage — verifie sur obs-01. La fenetre d exposition est fermee par la fabric, pas
par le gabarit. Mon objection initiale tombait devant la mesure.
P56 GARDE LES DEUX MOITIES. Qu il ne REGROSSISSE pas : un role ajoute recree le
cache, donc la peremption invisible. Et que rien de retire ne soit PERDU : un role
absent du gabarit ET du socle disparaitrait de toutes les machines neuves, sans
erreur ni trace, et la panne arriverait des mois plus tard sur une machine qu on
croyait durcie. Verifie : 14 retires, 14 repris, zero orphelin. Deux controles
negatifs.
make verifier : vert. make prouver : CONFORME, 56 OK, 0 echec.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-09-01 10:35:20 -04:00
|
|
|
|
def preuve_gabarit_minimal_et_repris() -> tuple[bool, str]:
|
|
|
|
|
|
"""Le gabarit ne porte que l'indispensable, et rien de retire n'est perdu.
|
|
|
|
|
|
|
|
|
|
|
|
LE GABARIT ETAIT UN CACHE DU SOCLE, ET IL PERIMAIT SANS LE DIRE (2026-09-01). Il
|
|
|
|
|
|
portait dix-sept roles — exactement ceux que le deploiement rejoue. Derniere recapture
|
|
|
|
|
|
le 2026-08-09, et six d'entre eux avaient change depuis. Le deploiement masquait la
|
|
|
|
|
|
derive en reappliquant tout : personne ne pouvait la voir.
|
|
|
|
|
|
|
|
|
|
|
|
Il est desormais MINIMAL — seulement ce qui doit exister AVANT qu'Ansible puisse agir.
|
|
|
|
|
|
Cette preuve garde les deux moities de cette decision :
|
|
|
|
|
|
|
|
|
|
|
|
1. il ne REGROSSIT pas. Un role ajoute ici recree le cache, donc la peremption
|
|
|
|
|
|
invisible. La liste est courte et chaque entree se justifie par une condition
|
|
|
|
|
|
d'existence, jamais par la vitesse.
|
|
|
|
|
|
|
|
|
|
|
|
2. rien de ce qu'il ne porte plus n'est PERDU. Un role retire du gabarit et absent
|
|
|
|
|
|
du socle disparaitrait de toutes les machines neuves — sans erreur, sans trace,
|
|
|
|
|
|
et la panne arriverait des mois plus tard sur une machine qu'on croyait durcie.
|
|
|
|
|
|
C'est le mode de defaillance le plus couteux de cette famille.
|
|
|
|
|
|
"""
|
|
|
|
|
|
def roles_de(chemin: str) -> list[str]:
|
|
|
|
|
|
data = yaml.safe_load((RACINE / chemin).read_text(encoding="utf-8")) or []
|
|
|
|
|
|
out: list[str] = []
|
|
|
|
|
|
for play in data:
|
|
|
|
|
|
for r in (play.get("roles") or []):
|
|
|
|
|
|
nom = r if isinstance(r, str) else (r or {}).get("role")
|
|
|
|
|
|
if nom:
|
|
|
|
|
|
out.append(str(nom))
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
gabarit = roles_de("playbooks/modeles_vm/debian13_proxmox_preparer.yml")
|
|
|
|
|
|
couverture = set(roles_de("playbooks/groupes/serveur_debian.yml")) | set(
|
|
|
|
|
|
roles_de("playbooks/groupes/serveur_durci.yml"))
|
|
|
|
|
|
|
|
|
|
|
|
# CE QUI DOIT EXISTER AVANT ANSIBLE, et qui ne peut donc pas venir de lui.
|
|
|
|
|
|
# Elargir cette liste est une DECISION, pas un ajustement : elle doit passer par
|
|
|
|
|
|
# cette preuve, et par la question « pourquoi Ansible ne peut-il pas le faire ? ».
|
|
|
|
|
|
indispensables = {
|
|
|
|
|
|
"qemu_guest_agent": "l'agent invite repond avant SSH — P52 s'en sert pour "
|
|
|
|
|
|
"confirmer la materialisation sans entrer chez le tenant",
|
|
|
|
|
|
"cloud_init": "le seul chemin vers la premiere seconde : adresse, nom, cles",
|
|
|
|
|
|
"sudo_ansible": "le compte technique et son sudo — la porte par ou tout entre",
|
|
|
|
|
|
"ssh_baseline": "le serveur SSH, meme raison un cran plus bas",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fautes: list[str] = []
|
|
|
|
|
|
for r in gabarit:
|
|
|
|
|
|
if r not in indispensables:
|
|
|
|
|
|
fautes.append(f"`{r}` est dans le gabarit sans etre indispensable au premier "
|
|
|
|
|
|
f"demarrage — le gabarit redevient un cache du socle, qui "
|
|
|
|
|
|
f"perime sans le dire")
|
|
|
|
|
|
manquants = [r for r in indispensables if r not in gabarit]
|
|
|
|
|
|
if manquants:
|
|
|
|
|
|
fautes.append("le gabarit ne porte plus " + ", ".join(f"`{m}`" for m in manquants)
|
|
|
|
|
|
+ " — une VM neuve serait injoignable ou muette")
|
|
|
|
|
|
|
|
|
|
|
|
# LE CONTRAT AVEC LE SOCLE : ce que le gabarit ne fait plus, quelqu'un le fait.
|
|
|
|
|
|
ancien_gabarit = {
|
|
|
|
|
|
"common_packages", "chrony", "systemd_ssh_auto", "motd", "hardening_packages",
|
|
|
|
|
|
"sysctl_hardening", "core_dumps", "unattended_upgrades", "apparmor", "auditd",
|
|
|
|
|
|
"fail2ban_ssh", "journald", "ssh_hardening", "nftables_baseline",
|
|
|
|
|
|
}
|
|
|
|
|
|
perdus = sorted(r for r in ancien_gabarit if r not in couverture and r not in gabarit)
|
|
|
|
|
|
if perdus:
|
|
|
|
|
|
fautes.append("retire(s) du gabarit et repris par PERSONNE : "
|
|
|
|
|
|
+ ", ".join(f"`{r}`" for r in perdus)
|
|
|
|
|
|
+ " — les machines neuves ne l'auraient plus, sans erreur ni trace")
|
|
|
|
|
|
|
|
|
|
|
|
if fautes:
|
|
|
|
|
|
return False, "Gabarit :\n - " + "\n - ".join(fautes)
|
|
|
|
|
|
return True, (f"Gabarit minimal : {len(gabarit)} role(s), tous indispensables au "
|
|
|
|
|
|
f"premier demarrage ; {len(ancien_gabarit)} role(s) retire(s), tous "
|
|
|
|
|
|
f"repris par le socle ou le durcissement.")
|
|
|
|
|
|
|
|
|
|
|
|
|
supervision : la sonde se declare dans le role, comme le flux
LE CONSTAT. 39 roles declarent leurs flux, 32 leur empreinte, 32 leur
authentification — tous derives. Et 19 groupes sur 19 declaraient une
surveillance en prose que RIEN n executait ; Icinga en surveillait deux.
La carte disait ce qui etait surveille, et personne ne surveillait.
LE MECANISME. Un role declare ses sondes dans meta/supervision.yml et
depose lui-meme son script dans /usr/local/lib/setops/sondes/. Le porteur
client_sante les fait toutes tourner et pousse un resultat passif par
sonde, sans savoir ce qu elles mesurent. serveur_icinga derive les objets
Service ET le filtre de permission d API des memes declarations. Ajouter
une sonde ne demande de toucher ni au porteur ni a Icinga.
PREMIERE SONDE : client_pki/certificat. Heures restantes sur le certificat
reellement pose, chaine verifiee, et empreinte SERVIE comparee au disque
quand un service le consomme. 14/14 au tenant, 7/7 au site.
QUATRE OBSTACLES, ET TROIS SONT LA MEME LECON.
La sonde a rendu 14/14 en CRITIQUE sur une PKI saine : openssl verify
-CAfile racine ne trouve pas l intermediaire qui signe nos certificats.
step certificate verify, lui, repond VALIDE.
Deployee au site, elle a rendu 5/7 : le seuil d avertissement (12 h)
etait AU-DESSUS du point de renouvellement (8 h, le tiers restant). Elle
criait avant que le mecanisme ne soit cense agir. Seuils ramenes a 6 h et
3 h. Un seuil se DERIVE du moment ou le mecanisme surveille agit.
Une alarme toujours allumee ne vaut pas mieux qu une alarme jamais
allumee : elle apprend a ne plus regarder. Une sonde se prouve DEUX FOIS,
verte sur le sain et rouge sur le casse.
Le filtre d API etait ecrit avant la lecture des declarations : les
services auraient existe et Icinga aurait refuse leurs resultats.
Et mon controle negatif a casse un service reel : substituer le certificat
d hote a fait propager un cert sans sa clef vers node_exporter. Un controle
negatif se fait sur une COPIE.
P64 tient les deux bouts : declaree sans etre deposee, ou deposee sans
etre declaree. Trois controles negatifs rejoues.
make prouver : CONFORME, 64 OK, 0 echec, 0 saute.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-09 21:45:49 -04:00
|
|
|
|
def preuve_sondes_declarees_et_deposees() -> tuple[bool, str]:
|
|
|
|
|
|
"""Une sonde declaree existe-t-elle vraiment, et un role qui en depose une la declare-t-il ?
|
|
|
|
|
|
|
|
|
|
|
|
CE QU'ELLE FERME (2026-09-09). `docs/dependances-groupes.yml` porte une ligne
|
|
|
|
|
|
`surveillance:` pour DIX-NEUF groupes sur dix-neuf. Icinga en surveillait DEUX. Des
|
|
|
|
|
|
intentions ecrites, versionnees, relues — et qu'aucun mecanisme n'executait. C'est la
|
|
|
|
|
|
classe d'echec de ce depot, en version documentaire : la carte dit ce qui est
|
|
|
|
|
|
surveille, et personne ne surveille.
|
|
|
|
|
|
|
|
|
|
|
|
La supervision se declare desormais dans `roles/<role>/meta/supervision.yml`, et le
|
|
|
|
|
|
moteur en derive les objets Icinga ET la permission d'API. Cette preuve tient les deux
|
|
|
|
|
|
bouts de la declaration, parce que chacun peut se rompre seul :
|
|
|
|
|
|
|
|
|
|
|
|
1. une sonde DECLAREE dont le script n'est pas depose : le service existerait dans
|
|
|
|
|
|
Icinga et n'aurait jamais de resultat. Le `ttl` le perimerait — un service
|
|
|
|
|
|
« expire » qui ne l'a jamais ete, que personne ne saurait interpreter ;
|
|
|
|
|
|
2. un script DEPOSE que rien ne declare : la sonde tournerait, pousserait, et le
|
|
|
|
|
|
compte d'API la refuserait (le filtre derive des declarations). Symptome :
|
|
|
|
|
|
« 404 No objects found » sur un objet inexistant — une heure perdue le
|
|
|
|
|
|
2026-09-02 sur exactement ce message.
|
|
|
|
|
|
|
|
|
|
|
|
CE QU'ELLE NE FAIT PAS. Elle ne juge pas si la sonde MESURE quelque chose d'utile, ni
|
|
|
|
|
|
si elle peut echouer. Ca, aucune lecture statique ne le dira : c'est au controle
|
|
|
|
|
|
negatif de chaque sonde, exige par `docs/supervision-conception.md` et trace au
|
|
|
|
|
|
CHANGELOG.
|
|
|
|
|
|
"""
|
|
|
|
|
|
roles = RACINE / "roles"
|
|
|
|
|
|
fautes, mesures = [], []
|
|
|
|
|
|
for meta in sorted(roles.glob("*/meta/supervision.yml")):
|
|
|
|
|
|
role = meta.parent.parent.name
|
|
|
|
|
|
try:
|
|
|
|
|
|
data = yaml.safe_load(meta.read_text(encoding="utf-8")) or {}
|
|
|
|
|
|
except yaml.YAMLError as e:
|
|
|
|
|
|
fautes.append(f"{role} : `meta/supervision.yml` illisible ({e})")
|
|
|
|
|
|
continue
|
|
|
|
|
|
sondes = data.get("sondes") or []
|
|
|
|
|
|
if not sondes:
|
|
|
|
|
|
fautes.append(f"{role} : `meta/supervision.yml` ne declare aucune sonde")
|
|
|
|
|
|
continue
|
|
|
|
|
|
taches = (roles / role / "tasks" / "main.yml")
|
|
|
|
|
|
corps = taches.read_text(encoding="utf-8") if taches.is_file() else ""
|
un fichier vide existe, et une sonde pour les correctifs
LA GARDE FICHIER ENTIER, en deux corrections. Un telechargement
interrompu laisse un fichier de zero octet QUI EXISTE, et toutes les gardes
demandaient seulement s il etait la. infra-mail-01 a garde une cle
smallstep de 0 octet apres l epreuve hors ligne.
La premiere correction n a pas suffi. Ajouter le controle de taille faisait
bien s executer la tache - et le fichier faisait toujours 0 octet au
passage suivant. get_url sur une destination existante emet une requete
CONDITIONNELLE : l amont repond non modifie, le module rend ok, la ruine
reste. Le play etait vert et ne reparait rien. Il faut effacer avant de
redemander.
Controle negatif : 0 -> 1022 octets, 0 erreur apt. Cinq roles.
LA SONDE CORRECTIFS, 23e. Set-OPS desarme unattended-upgrades et applique
les correctifs au deploiement - choix defendable, le verrou dpkg a fait
decrocher une machine d une reconstruction entiere le matin meme. Mais rien
ne disait QUAND le geste etait du : une flotte pouvait deriver des mois en
restant verte.
Elle mesure les paquets de securite en attente ET depuis quand. Elle ne
lance pas apt-get update - une sonde qui rafraichit l index toutes les
quinze minutes deviendrait la cause de la panne qu elle surveille. Et le
seuil de 72 h est un choix d exploitation, pas une derivation : le
mecanisme qui applique les correctifs est un geste humain.
P64 REFUSAIT UNE DECLARATION CORRECTE. serveur_debian et serveur_durci sont
des roles de declaration pure, sans une tache ; le travail est fait par les
roles que leur playbook applique. La preuve exigeait declaration et depot
dans le meme role - vrai des vingt-deux premieres sondes, faux des qu une
sonde appartient au socle. Une garde qui force a contourner ce qu elle
protege est un defaut. Elle suit desormais le playbook du groupe.
Mesure : 21/21 machines vertes, 65 preuves, 23 sondes, 0 echec.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-10 17:26:35 -04:00
|
|
|
|
# UN ROLE DE GROUPE N'A PAS DE TACHES, ET C'EST LA STRUCTURE DU DEPOT (2026-09-10).
|
|
|
|
|
|
#
|
|
|
|
|
|
# `serveur_debian` et `serveur_durci` sont des roles de DECLARATION : ils portent
|
|
|
|
|
|
# `flux.yml`, `authentification.yml`, `supervision.yml` — et pas une seule tache.
|
|
|
|
|
|
# Le travail est fait par les roles que leur playbook de groupe APPLIQUE
|
|
|
|
|
|
# (`common_packages`, `hardening_packages`, `auditd`…).
|
|
|
|
|
|
#
|
|
|
|
|
|
# Cette preuve exigeait que la declaration et le depot vivent dans le MEME role.
|
|
|
|
|
|
# C'etait vrai des vingt-deux premieres sondes — toutes portees par des roles qui
|
|
|
|
|
|
# sont a la fois groupe et travail — et faux des qu'une sonde appartient au SOCLE.
|
|
|
|
|
|
# Elle refusait alors une declaration correcte, ce qui est le pire defaut d'une
|
|
|
|
|
|
# garde : forcer a contourner ce qu'elle protege.
|
|
|
|
|
|
#
|
|
|
|
|
|
# On suit donc le playbook du groupe : ce qu'il applique compte comme depose. La
|
|
|
|
|
|
# garde ne s'affaiblit pas — elle apprend ou le depot a le droit de vivre.
|
|
|
|
|
|
if not corps.strip():
|
|
|
|
|
|
playbook = RACINE / "playbooks" / "groupes" / f"{role}.yml"
|
|
|
|
|
|
if playbook.is_file():
|
|
|
|
|
|
for applique in re.findall(r"^\s+- ([a-z][a-z0-9_]*)\s*$",
|
|
|
|
|
|
playbook.read_text(encoding="utf-8"), re.M):
|
|
|
|
|
|
t = roles / applique / "tasks" / "main.yml"
|
|
|
|
|
|
if t.is_file():
|
|
|
|
|
|
corps += t.read_text(encoding="utf-8")
|
supervision : la sonde se declare dans le role, comme le flux
LE CONSTAT. 39 roles declarent leurs flux, 32 leur empreinte, 32 leur
authentification — tous derives. Et 19 groupes sur 19 declaraient une
surveillance en prose que RIEN n executait ; Icinga en surveillait deux.
La carte disait ce qui etait surveille, et personne ne surveillait.
LE MECANISME. Un role declare ses sondes dans meta/supervision.yml et
depose lui-meme son script dans /usr/local/lib/setops/sondes/. Le porteur
client_sante les fait toutes tourner et pousse un resultat passif par
sonde, sans savoir ce qu elles mesurent. serveur_icinga derive les objets
Service ET le filtre de permission d API des memes declarations. Ajouter
une sonde ne demande de toucher ni au porteur ni a Icinga.
PREMIERE SONDE : client_pki/certificat. Heures restantes sur le certificat
reellement pose, chaine verifiee, et empreinte SERVIE comparee au disque
quand un service le consomme. 14/14 au tenant, 7/7 au site.
QUATRE OBSTACLES, ET TROIS SONT LA MEME LECON.
La sonde a rendu 14/14 en CRITIQUE sur une PKI saine : openssl verify
-CAfile racine ne trouve pas l intermediaire qui signe nos certificats.
step certificate verify, lui, repond VALIDE.
Deployee au site, elle a rendu 5/7 : le seuil d avertissement (12 h)
etait AU-DESSUS du point de renouvellement (8 h, le tiers restant). Elle
criait avant que le mecanisme ne soit cense agir. Seuils ramenes a 6 h et
3 h. Un seuil se DERIVE du moment ou le mecanisme surveille agit.
Une alarme toujours allumee ne vaut pas mieux qu une alarme jamais
allumee : elle apprend a ne plus regarder. Une sonde se prouve DEUX FOIS,
verte sur le sain et rouge sur le casse.
Le filtre d API etait ecrit avant la lecture des declarations : les
services auraient existe et Icinga aurait refuse leurs resultats.
Et mon controle negatif a casse un service reel : substituer le certificat
d hote a fait propager un cert sans sa clef vers node_exporter. Un controle
negatif se fait sur une COPIE.
P64 tient les deux bouts : declaree sans etre deposee, ou deposee sans
etre declaree. Trois controles negatifs rejoues.
make prouver : CONFORME, 64 OK, 0 echec, 0 saute.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-09 21:45:49 -04:00
|
|
|
|
for s in sondes:
|
|
|
|
|
|
nom = s.get("nom")
|
|
|
|
|
|
if not nom:
|
|
|
|
|
|
fautes.append(f"{role} : une sonde sans `nom`")
|
|
|
|
|
|
continue
|
|
|
|
|
|
if not s.get("raison"):
|
|
|
|
|
|
fautes.append(f"{role}/{nom} : aucune `raison` — un service dont personne "
|
|
|
|
|
|
f"ne sait ce qu'il mesure n'est pas une supervision")
|
|
|
|
|
|
if not isinstance(s.get("ttl"), int):
|
|
|
|
|
|
fautes.append(f"{role}/{nom} : `ttl` manquant — sans lui, le SILENCE "
|
|
|
|
|
|
f"n'alerte pas, et c'est la moitie de l'interet")
|
|
|
|
|
|
# Le role doit deposer le script au chemin du contrat.
|
2026-09-30 22:46:49 -04:00
|
|
|
|
# `sondes-minute/` : les sondes que le porteur joue chaque minute (2026-09-30).
|
|
|
|
|
|
if (f"/usr/local/lib/setops/sondes/{nom}.sh" not in corps
|
|
|
|
|
|
and f"/usr/local/lib/setops/sondes-minute/{nom}.sh" not in corps):
|
supervision : la sonde se declare dans le role, comme le flux
LE CONSTAT. 39 roles declarent leurs flux, 32 leur empreinte, 32 leur
authentification — tous derives. Et 19 groupes sur 19 declaraient une
surveillance en prose que RIEN n executait ; Icinga en surveillait deux.
La carte disait ce qui etait surveille, et personne ne surveillait.
LE MECANISME. Un role declare ses sondes dans meta/supervision.yml et
depose lui-meme son script dans /usr/local/lib/setops/sondes/. Le porteur
client_sante les fait toutes tourner et pousse un resultat passif par
sonde, sans savoir ce qu elles mesurent. serveur_icinga derive les objets
Service ET le filtre de permission d API des memes declarations. Ajouter
une sonde ne demande de toucher ni au porteur ni a Icinga.
PREMIERE SONDE : client_pki/certificat. Heures restantes sur le certificat
reellement pose, chaine verifiee, et empreinte SERVIE comparee au disque
quand un service le consomme. 14/14 au tenant, 7/7 au site.
QUATRE OBSTACLES, ET TROIS SONT LA MEME LECON.
La sonde a rendu 14/14 en CRITIQUE sur une PKI saine : openssl verify
-CAfile racine ne trouve pas l intermediaire qui signe nos certificats.
step certificate verify, lui, repond VALIDE.
Deployee au site, elle a rendu 5/7 : le seuil d avertissement (12 h)
etait AU-DESSUS du point de renouvellement (8 h, le tiers restant). Elle
criait avant que le mecanisme ne soit cense agir. Seuils ramenes a 6 h et
3 h. Un seuil se DERIVE du moment ou le mecanisme surveille agit.
Une alarme toujours allumee ne vaut pas mieux qu une alarme jamais
allumee : elle apprend a ne plus regarder. Une sonde se prouve DEUX FOIS,
verte sur le sain et rouge sur le casse.
Le filtre d API etait ecrit avant la lecture des declarations : les
services auraient existe et Icinga aurait refuse leurs resultats.
Et mon controle negatif a casse un service reel : substituer le certificat
d hote a fait propager un cert sans sa clef vers node_exporter. Un controle
negatif se fait sur une COPIE.
P64 tient les deux bouts : declaree sans etre deposee, ou deposee sans
etre declaree. Trois controles negatifs rejoues.
make prouver : CONFORME, 64 OK, 0 echec, 0 saute.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-09 21:45:49 -04:00
|
|
|
|
fautes.append(f"{role}/{nom} : declaree mais jamais deposee dans "
|
|
|
|
|
|
f"`tasks/main.yml` — le service existerait sans jamais avoir "
|
|
|
|
|
|
f"de resultat")
|
|
|
|
|
|
mesures.append(f"{role}/{nom}")
|
|
|
|
|
|
|
|
|
|
|
|
# L'AUTRE SENS : un script depose que rien ne declare.
|
un fichier vide existe, et une sonde pour les correctifs
LA GARDE FICHIER ENTIER, en deux corrections. Un telechargement
interrompu laisse un fichier de zero octet QUI EXISTE, et toutes les gardes
demandaient seulement s il etait la. infra-mail-01 a garde une cle
smallstep de 0 octet apres l epreuve hors ligne.
La premiere correction n a pas suffi. Ajouter le controle de taille faisait
bien s executer la tache - et le fichier faisait toujours 0 octet au
passage suivant. get_url sur une destination existante emet une requete
CONDITIONNELLE : l amont repond non modifie, le module rend ok, la ruine
reste. Le play etait vert et ne reparait rien. Il faut effacer avant de
redemander.
Controle negatif : 0 -> 1022 octets, 0 erreur apt. Cinq roles.
LA SONDE CORRECTIFS, 23e. Set-OPS desarme unattended-upgrades et applique
les correctifs au deploiement - choix defendable, le verrou dpkg a fait
decrocher une machine d une reconstruction entiere le matin meme. Mais rien
ne disait QUAND le geste etait du : une flotte pouvait deriver des mois en
restant verte.
Elle mesure les paquets de securite en attente ET depuis quand. Elle ne
lance pas apt-get update - une sonde qui rafraichit l index toutes les
quinze minutes deviendrait la cause de la panne qu elle surveille. Et le
seuil de 72 h est un choix d exploitation, pas une derivation : le
mecanisme qui applique les correctifs est un geste humain.
P64 REFUSAIT UNE DECLARATION CORRECTE. serveur_debian et serveur_durci sont
des roles de declaration pure, sans une tache ; le travail est fait par les
roles que leur playbook applique. La preuve exigeait declaration et depot
dans le meme role - vrai des vingt-deux premieres sondes, faux des qu une
sonde appartient au socle. Une garde qui force a contourner ce qu elle
protege est un defaut. Elle suit desormais le playbook du groupe.
Mesure : 21/21 machines vertes, 65 preuves, 23 sondes, 0 echec.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-10 17:26:35 -04:00
|
|
|
|
# Les sondes declarees PARTOUT, y compris par un role de groupe qui delegue son depot.
|
|
|
|
|
|
_declarees_globalement = {m.split("/", 1)[1] for m in mesures}
|
supervision : la sonde se declare dans le role, comme le flux
LE CONSTAT. 39 roles declarent leurs flux, 32 leur empreinte, 32 leur
authentification — tous derives. Et 19 groupes sur 19 declaraient une
surveillance en prose que RIEN n executait ; Icinga en surveillait deux.
La carte disait ce qui etait surveille, et personne ne surveillait.
LE MECANISME. Un role declare ses sondes dans meta/supervision.yml et
depose lui-meme son script dans /usr/local/lib/setops/sondes/. Le porteur
client_sante les fait toutes tourner et pousse un resultat passif par
sonde, sans savoir ce qu elles mesurent. serveur_icinga derive les objets
Service ET le filtre de permission d API des memes declarations. Ajouter
une sonde ne demande de toucher ni au porteur ni a Icinga.
PREMIERE SONDE : client_pki/certificat. Heures restantes sur le certificat
reellement pose, chaine verifiee, et empreinte SERVIE comparee au disque
quand un service le consomme. 14/14 au tenant, 7/7 au site.
QUATRE OBSTACLES, ET TROIS SONT LA MEME LECON.
La sonde a rendu 14/14 en CRITIQUE sur une PKI saine : openssl verify
-CAfile racine ne trouve pas l intermediaire qui signe nos certificats.
step certificate verify, lui, repond VALIDE.
Deployee au site, elle a rendu 5/7 : le seuil d avertissement (12 h)
etait AU-DESSUS du point de renouvellement (8 h, le tiers restant). Elle
criait avant que le mecanisme ne soit cense agir. Seuils ramenes a 6 h et
3 h. Un seuil se DERIVE du moment ou le mecanisme surveille agit.
Une alarme toujours allumee ne vaut pas mieux qu une alarme jamais
allumee : elle apprend a ne plus regarder. Une sonde se prouve DEUX FOIS,
verte sur le sain et rouge sur le casse.
Le filtre d API etait ecrit avant la lecture des declarations : les
services auraient existe et Icinga aurait refuse leurs resultats.
Et mon controle negatif a casse un service reel : substituer le certificat
d hote a fait propager un cert sans sa clef vers node_exporter. Un controle
negatif se fait sur une COPIE.
P64 tient les deux bouts : declaree sans etre deposee, ou deposee sans
etre declaree. Trois controles negatifs rejoues.
make prouver : CONFORME, 64 OK, 0 echec, 0 saute.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-09 21:45:49 -04:00
|
|
|
|
for taches in sorted(roles.glob("*/tasks/main.yml")):
|
|
|
|
|
|
role = taches.parent.parent.name
|
|
|
|
|
|
corps = taches.read_text(encoding="utf-8")
|
2026-09-30 22:46:49 -04:00
|
|
|
|
deposees = set(re.findall(r"/usr/local/lib/setops/sondes(?:-minute)?/([A-Za-z0-9_.-]+)\.sh", corps))
|
supervision : la sonde se declare dans le role, comme le flux
LE CONSTAT. 39 roles declarent leurs flux, 32 leur empreinte, 32 leur
authentification — tous derives. Et 19 groupes sur 19 declaraient une
surveillance en prose que RIEN n executait ; Icinga en surveillait deux.
La carte disait ce qui etait surveille, et personne ne surveillait.
LE MECANISME. Un role declare ses sondes dans meta/supervision.yml et
depose lui-meme son script dans /usr/local/lib/setops/sondes/. Le porteur
client_sante les fait toutes tourner et pousse un resultat passif par
sonde, sans savoir ce qu elles mesurent. serveur_icinga derive les objets
Service ET le filtre de permission d API des memes declarations. Ajouter
une sonde ne demande de toucher ni au porteur ni a Icinga.
PREMIERE SONDE : client_pki/certificat. Heures restantes sur le certificat
reellement pose, chaine verifiee, et empreinte SERVIE comparee au disque
quand un service le consomme. 14/14 au tenant, 7/7 au site.
QUATRE OBSTACLES, ET TROIS SONT LA MEME LECON.
La sonde a rendu 14/14 en CRITIQUE sur une PKI saine : openssl verify
-CAfile racine ne trouve pas l intermediaire qui signe nos certificats.
step certificate verify, lui, repond VALIDE.
Deployee au site, elle a rendu 5/7 : le seuil d avertissement (12 h)
etait AU-DESSUS du point de renouvellement (8 h, le tiers restant). Elle
criait avant que le mecanisme ne soit cense agir. Seuils ramenes a 6 h et
3 h. Un seuil se DERIVE du moment ou le mecanisme surveille agit.
Une alarme toujours allumee ne vaut pas mieux qu une alarme jamais
allumee : elle apprend a ne plus regarder. Une sonde se prouve DEUX FOIS,
verte sur le sain et rouge sur le casse.
Le filtre d API etait ecrit avant la lecture des declarations : les
services auraient existe et Icinga aurait refuse leurs resultats.
Et mon controle negatif a casse un service reel : substituer le certificat
d hote a fait propager un cert sans sa clef vers node_exporter. Un controle
negatif se fait sur une COPIE.
P64 tient les deux bouts : declaree sans etre deposee, ou deposee sans
etre declaree. Trois controles negatifs rejoues.
make prouver : CONFORME, 64 OK, 0 echec, 0 saute.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-09 21:45:49 -04:00
|
|
|
|
meta = roles / role / "meta" / "supervision.yml"
|
|
|
|
|
|
declarees = set()
|
|
|
|
|
|
if meta.is_file():
|
|
|
|
|
|
data = yaml.safe_load(meta.read_text(encoding="utf-8")) or {}
|
|
|
|
|
|
declarees = {s.get("nom") for s in (data.get("sondes") or [])}
|
un fichier vide existe, et une sonde pour les correctifs
LA GARDE FICHIER ENTIER, en deux corrections. Un telechargement
interrompu laisse un fichier de zero octet QUI EXISTE, et toutes les gardes
demandaient seulement s il etait la. infra-mail-01 a garde une cle
smallstep de 0 octet apres l epreuve hors ligne.
La premiere correction n a pas suffi. Ajouter le controle de taille faisait
bien s executer la tache - et le fichier faisait toujours 0 octet au
passage suivant. get_url sur une destination existante emet une requete
CONDITIONNELLE : l amont repond non modifie, le module rend ok, la ruine
reste. Le play etait vert et ne reparait rien. Il faut effacer avant de
redemander.
Controle negatif : 0 -> 1022 octets, 0 erreur apt. Cinq roles.
LA SONDE CORRECTIFS, 23e. Set-OPS desarme unattended-upgrades et applique
les correctifs au deploiement - choix defendable, le verrou dpkg a fait
decrocher une machine d une reconstruction entiere le matin meme. Mais rien
ne disait QUAND le geste etait du : une flotte pouvait deriver des mois en
restant verte.
Elle mesure les paquets de securite en attente ET depuis quand. Elle ne
lance pas apt-get update - une sonde qui rafraichit l index toutes les
quinze minutes deviendrait la cause de la panne qu elle surveille. Et le
seuil de 72 h est un choix d exploitation, pas une derivation : le
mecanisme qui applique les correctifs est un geste humain.
P64 REFUSAIT UNE DECLARATION CORRECTE. serveur_debian et serveur_durci sont
des roles de declaration pure, sans une tache ; le travail est fait par les
roles que leur playbook applique. La preuve exigeait declaration et depot
dans le meme role - vrai des vingt-deux premieres sondes, faux des qu une
sonde appartient au socle. Une garde qui force a contourner ce qu elle
protege est un defaut. Elle suit desormais le playbook du groupe.
Mesure : 21/21 machines vertes, 65 preuves, 23 sondes, 0 echec.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-10 17:26:35 -04:00
|
|
|
|
for orpheline in sorted(deposees - declarees - _declarees_globalement):
|
supervision : la sonde se declare dans le role, comme le flux
LE CONSTAT. 39 roles declarent leurs flux, 32 leur empreinte, 32 leur
authentification — tous derives. Et 19 groupes sur 19 declaraient une
surveillance en prose que RIEN n executait ; Icinga en surveillait deux.
La carte disait ce qui etait surveille, et personne ne surveillait.
LE MECANISME. Un role declare ses sondes dans meta/supervision.yml et
depose lui-meme son script dans /usr/local/lib/setops/sondes/. Le porteur
client_sante les fait toutes tourner et pousse un resultat passif par
sonde, sans savoir ce qu elles mesurent. serveur_icinga derive les objets
Service ET le filtre de permission d API des memes declarations. Ajouter
une sonde ne demande de toucher ni au porteur ni a Icinga.
PREMIERE SONDE : client_pki/certificat. Heures restantes sur le certificat
reellement pose, chaine verifiee, et empreinte SERVIE comparee au disque
quand un service le consomme. 14/14 au tenant, 7/7 au site.
QUATRE OBSTACLES, ET TROIS SONT LA MEME LECON.
La sonde a rendu 14/14 en CRITIQUE sur une PKI saine : openssl verify
-CAfile racine ne trouve pas l intermediaire qui signe nos certificats.
step certificate verify, lui, repond VALIDE.
Deployee au site, elle a rendu 5/7 : le seuil d avertissement (12 h)
etait AU-DESSUS du point de renouvellement (8 h, le tiers restant). Elle
criait avant que le mecanisme ne soit cense agir. Seuils ramenes a 6 h et
3 h. Un seuil se DERIVE du moment ou le mecanisme surveille agit.
Une alarme toujours allumee ne vaut pas mieux qu une alarme jamais
allumee : elle apprend a ne plus regarder. Une sonde se prouve DEUX FOIS,
verte sur le sain et rouge sur le casse.
Le filtre d API etait ecrit avant la lecture des declarations : les
services auraient existe et Icinga aurait refuse leurs resultats.
Et mon controle negatif a casse un service reel : substituer le certificat
d hote a fait propager un cert sans sa clef vers node_exporter. Un controle
negatif se fait sur une COPIE.
P64 tient les deux bouts : declaree sans etre deposee, ou deposee sans
etre declaree. Trois controles negatifs rejoues.
make prouver : CONFORME, 64 OK, 0 echec, 0 saute.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-09 21:45:49 -04:00
|
|
|
|
fautes.append(f"{role}/{orpheline} : deposee mais absente de "
|
|
|
|
|
|
f"`meta/supervision.yml` — le compte d'API la refuserait")
|
|
|
|
|
|
|
|
|
|
|
|
if fautes:
|
|
|
|
|
|
return False, "Sondes de supervision :\n - " + "\n - ".join(fautes)
|
|
|
|
|
|
if not mesures:
|
|
|
|
|
|
return True, ("Aucune sonde declaree — la supervision se limite a `sante` et "
|
|
|
|
|
|
"`sauvegarde` (cf. docs/supervision-conception.md).")
|
|
|
|
|
|
return True, (f"{len(mesures)} sonde(s) declaree(s) ET deposee(s), chacune avec sa "
|
|
|
|
|
|
f"raison et son `ttl` : {', '.join(mesures)}.")
|
|
|
|
|
|
|
|
|
|
|
|
|
durcissement : cloud-init nait avec la VM et ne lui survit pas
cloud-init n est pas un logiciel d installation : c est une SOURCE DE
VERITE EXTERNE. Il se reveille a chaque demarrage et relit le lecteur
attache par l hyperviseur, qui peut redefinir comptes, cles SSH, mots de
passe et reseau. Sur une machine que le plan possede, c est un second
maitre — que le plan ne decrit pas, que make valider ne mesure pas, et
qui parle en premier.
Sa tache est finie a la premiere seconde : c est parce qu il a REUSSI a
poser l adresse et les cles qu Ansible a pu entrer.
TROIS MOITIES, ET ELLES SE DEFONT SEPAREMENT.
- le GABARIT le garde : sans lui un clone n a ni adresse ni nom ;
- le SOCLE ne l installe plus : le garder produisait un va-et-vient a
chaque deploiement, deux changed par passage, idempotence perdue ;
- le DURCISSEMENT le retire (roles/cloud_init_retrait, en dernier).
P63 garde les trois, plus le CONTENU du role : une coquille vide passerait
les trois premiers controles sans rien fermer. Quatre controles negatifs
rejoues.
CE QUI REND LE RETRAIT SUR EST MESURE, PAS SUPPOSE (obs-01, 2026-09-09) :
/etc/network/interfaces.d/50-cloud-init n appartient a aucun paquet — dpkg
-S ne le trouve pas — et le postrm ne le nomme jamais, meme en purge.
L adresse survit. Le role le verifie quand meme, avant et apres, et n
accuse que si le retrait l a emporte : une VM qui perd ce fichier ne se
plaint pas, elle repart sans adresse et plus personne ne peut entrer.
DEUX CHOIX DITS FRANCHEMENT. cloud-guest-utils reste (growpart : ni
service, ni port, ni source de donnees). Les ~29 paquets orphelins ne sont
pas retires par defaut : autoremove deciderait a partir des drapeaux dpkg,
et un durcissement ne doit pas pouvoir surprendre.
NON DEPLOYE : le code est ecrit, valide et prouve ; il n a pas ete
applique a la flotte. Essai a blanc sur obs-01 : cloud-init a retirer,
configuration reseau intacte.
make prouver : CONFORME, 62 OK, 0 echec, 1 saute (63 preuves).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-09 09:25:42 -04:00
|
|
|
|
def preuve_cloud_init_rendu_puis_retire() -> tuple[bool, str]:
|
|
|
|
|
|
"""cloud-init nait avec la VM, et ne lui survit pas.
|
|
|
|
|
|
|
|
|
|
|
|
LE POUVOIR QU'IL GARDE (decision du 2026-09-09). cloud-init n'est pas un logiciel
|
|
|
|
|
|
d'installation : c'est une SOURCE DE VERITE EXTERNE. A chaque demarrage il relit le
|
|
|
|
|
|
lecteur attache par l'hyperviseur, qui peut redefinir comptes, cles SSH autorisees,
|
|
|
|
|
|
mots de passe et reseau. Sur une machine que le plan possede, c'est un second maitre
|
|
|
|
|
|
— que le plan ne decrit pas, que `make valider` ne mesure pas, et qui gagne parce
|
|
|
|
|
|
qu'il parle en premier.
|
|
|
|
|
|
|
|
|
|
|
|
Sa tache est pourtant finie a la premiere seconde : c'est parce qu'il a REUSSI a poser
|
|
|
|
|
|
l'adresse et les cles qu'Ansible a pu entrer.
|
|
|
|
|
|
|
|
|
|
|
|
TROIS MOITIES, ET ELLES SE DEFONT SEPAREMENT :
|
|
|
|
|
|
|
|
|
|
|
|
1. le GABARIT le garde. Sans lui, un clone n'a ni adresse ni nom : il ne nait pas.
|
|
|
|
|
|
(P56 le tient deja comme indispensable ; on le redit ici parce que c'est la
|
|
|
|
|
|
moitie qu'on serait tente de retirer « pour faire propre ».)
|
|
|
|
|
|
2. le SOCLE ne l'installe plus. Le garder produisait un va-et-vient a chaque
|
|
|
|
|
|
deploiement — le socle installe, le durcissement retire, deux `changed` par
|
|
|
|
|
|
passage, l'idempotence perdue et `make valider` bruyant pour rien.
|
|
|
|
|
|
3. le DURCISSEMENT le retire. C'est la moitie qui porte l'intention ; sans elle,
|
|
|
|
|
|
les deux autres ne font que deplacer le probleme.
|
|
|
|
|
|
|
|
|
|
|
|
Une seule des trois qui bouge, et la decision devient son contraire en silence : un
|
|
|
|
|
|
gabarit sans cloud-init donne des VM mortes ; un socle qui le reinstalle rend le
|
|
|
|
|
|
pouvoir a chaque passage ; un durcissement qui ne le retire plus laisse le second
|
|
|
|
|
|
maitre en place sans que rien ne le dise.
|
|
|
|
|
|
"""
|
|
|
|
|
|
def roles_de(chemin: str) -> list[str]:
|
|
|
|
|
|
data = yaml.safe_load((RACINE / chemin).read_text(encoding="utf-8")) or []
|
|
|
|
|
|
out: list[str] = []
|
|
|
|
|
|
for play in data:
|
|
|
|
|
|
for r in (play.get("roles") or []):
|
|
|
|
|
|
nom = r if isinstance(r, str) else (r or {}).get("role")
|
|
|
|
|
|
if nom:
|
|
|
|
|
|
out.append(str(nom))
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
gabarit = roles_de("playbooks/modeles_vm/debian13_proxmox_preparer.yml")
|
|
|
|
|
|
socle = roles_de("playbooks/groupes/serveur_debian.yml")
|
|
|
|
|
|
durci = roles_de("playbooks/groupes/serveur_durci.yml")
|
|
|
|
|
|
|
|
|
|
|
|
fautes = []
|
|
|
|
|
|
if "cloud_init" not in gabarit:
|
|
|
|
|
|
fautes.append("le GABARIT ne porte plus `cloud_init` — un clone naitrait sans "
|
|
|
|
|
|
"adresse ni nom d'hote, donc injoignable")
|
|
|
|
|
|
if "cloud_init" in socle:
|
|
|
|
|
|
fautes.append("le SOCLE applique encore `cloud_init` alors que le durcissement le "
|
|
|
|
|
|
"retire — va-et-vient a chaque deploiement, idempotence perdue")
|
|
|
|
|
|
if "cloud_init_retrait" not in durci:
|
|
|
|
|
|
fautes.append("le DURCISSEMENT n'applique plus `cloud_init_retrait` — la source de "
|
|
|
|
|
|
"verite externe reste en place, et rien ne le dit")
|
|
|
|
|
|
|
|
|
|
|
|
# Le role doit REELLEMENT retirer le paquet : une coquille vide passerait les trois
|
|
|
|
|
|
# controles ci-dessus tout en ne fermant rien.
|
|
|
|
|
|
defauts = RACINE / "roles" / "cloud_init_retrait" / "defaults" / "main.yml"
|
|
|
|
|
|
taches = RACINE / "roles" / "cloud_init_retrait" / "tasks" / "main.yml"
|
|
|
|
|
|
if not taches.is_file():
|
|
|
|
|
|
fautes.append("`roles/cloud_init_retrait/tasks/main.yml` est absent")
|
|
|
|
|
|
else:
|
|
|
|
|
|
corps = yaml.safe_load(taches.read_text(encoding="utf-8")) or []
|
|
|
|
|
|
retire = any((t.get("ansible.builtin.apt") or {}).get("state") == "absent"
|
|
|
|
|
|
for t in corps if isinstance(t, dict))
|
|
|
|
|
|
if not retire:
|
|
|
|
|
|
fautes.append("`cloud_init_retrait` ne retire aucun paquet (`state: absent` "
|
|
|
|
|
|
"absent) — le role existe mais ne ferme rien")
|
|
|
|
|
|
garde = any("assert" in cle for t in corps if isinstance(t, dict) for cle in t)
|
|
|
|
|
|
if not garde:
|
|
|
|
|
|
fautes.append("`cloud_init_retrait` ne verifie plus que la configuration "
|
|
|
|
|
|
"reseau survit au retrait — une VM sans adresse ne se plaint "
|
|
|
|
|
|
"pas, elle disparait")
|
|
|
|
|
|
if defauts.is_file():
|
|
|
|
|
|
d = yaml.safe_load(defauts.read_text(encoding="utf-8")) or {}
|
|
|
|
|
|
if "cloud-init" not in (d.get("cloud_init_retrait_paquets") or []):
|
|
|
|
|
|
fautes.append("`cloud_init_retrait_paquets` ne nomme plus `cloud-init`")
|
|
|
|
|
|
|
|
|
|
|
|
if fautes:
|
|
|
|
|
|
return False, "cloud-init :\n - " + "\n - ".join(fautes)
|
|
|
|
|
|
return True, ("cloud-init est au gabarit (la premiere seconde), absent du socle (pas "
|
|
|
|
|
|
"de va-et-vient), et retire par le durcissement — avec la garde qui "
|
|
|
|
|
|
"verifie que le reseau survit au retrait.")
|
|
|
|
|
|
|
|
|
|
|
|
|
cles : le terrain etait inoccupable — la cle du tenant nait avec ses machines
Le deploiement lance depuis ops-01 s est arrete au premier geste, sur les quinze
machines a la fois : Permission denied (publickey). Les VM neuves n acceptaient que
la cle de l exploitant. Celle du runner EST declaree au plan, mais c est le SOCLE
qui la depose — et le socle doit etre applique par quelqu un qui peut deja entrer.
Boucle fermee : le tenant recevait un terrain qu il ne pouvait pas occuper.
DEUX CLES, DEUX PORTEES :
la cle du SITE -> sur le SEUL runner du tenant l insemination
la cle du TENANT -> sur TOUTES ses machines il va les configurer
POSER UNE CLE AU CLONAGE N EST PAS ENTRER CHEZ LE TENANT. C est un parametre de
creation, au meme titre que l adresse ou le disque : le site ecrit les conditions
de NAISSANCE, il n ouvre aucune session. Le site n obtient aucun acces sur ces
machines ; seul le runner du tenant en obtient un.
Forme tranchee par l exploitant : le site renseigne le seul runner, qui se charge
de toute sa flotte. Le plancher /etc/hosts suit le meme chemin — ops-01 a le sien
depuis son insemination et resout ses quinze voisines par leur nom.
LA REVOCATION EST HONOREE A LA NAISSANCE : une entree a etat absent n est pas
reposee. Sans cette lecture, une cle retiree de la flotte serait ressuscitee sur
chaque VM creee ensuite — panne lente, silencieuse, invisible au plan.
P55 garde les deux moities : la cle du site ne nait que sur un porteur de
serveur_ops_tenant, et aucune machine ne reste sans celle de son tenant. Une
frontiere tenue a une seule couche n est pas tenue. Deux controles negatifs.
make verifier : vert. make prouver : CONFORME, 55 OK, 0 echec, 0 saute.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-08-30 08:54:17 -04:00
|
|
|
|
def preuve_cle_du_site_bornee_au_runner() -> tuple[bool, str]:
|
|
|
|
|
|
"""La cle du SITE ne nait que sur le runner d'un tenant. Celle du tenant, partout chez lui.
|
|
|
|
|
|
|
|
|
|
|
|
LE POUVOIR SE LIT DANS LES CLES, PAS DANS LES INTENTIONS. Une VM recoit ses cles a la
|
|
|
|
|
|
NAISSANCE, par cloud-init — et personne ne relit un `authorized_keys` pose il y a six
|
|
|
|
|
|
mois. Si la cle du site se mettait a partir sur toutes les machines d'un locataire,
|
|
|
|
|
|
l'hebergeur obtiendrait un acces SSH a sa flotte entiere, en silence, et rien dans le
|
|
|
|
|
|
plan ne le dirait.
|
|
|
|
|
|
|
|
|
|
|
|
C'est le pendant exact du flux d'insemination : celui-ci vise le groupe
|
|
|
|
|
|
`serveur_ops_tenant`, cette preuve verifie que la CLE suit le meme critere. Une
|
|
|
|
|
|
frontiere tenue a une seule couche n'est pas tenue.
|
|
|
|
|
|
|
|
|
|
|
|
L'AUTRE MOITIE COMPTE AUSSI : sans la cle du TENANT sur ses machines, son runner recoit
|
|
|
|
|
|
un terrain qu'il ne peut pas occuper — mesure du 2026-08-30, quinze VM et quinze
|
|
|
|
|
|
« Permission denied ».
|
|
|
|
|
|
"""
|
|
|
|
|
|
inv = RACINE / INV
|
|
|
|
|
|
if not inv.is_file():
|
|
|
|
|
|
return True, "Aucun inventaire monte : rien a verifier."
|
|
|
|
|
|
sys.path.insert(0, str(RACINE / "scripts"))
|
|
|
|
|
|
import inventory_host as ih
|
|
|
|
|
|
data = yaml.safe_load(inv.read_text(encoding="utf-8")) or {}
|
|
|
|
|
|
actifs = sorted(ih._hotes_du_groupe(data, "hotes_actifs")
|
|
|
|
|
|
if hasattr(ih, "_hotes_du_groupe")
|
|
|
|
|
|
else (data.get("all", {}).get("children", {})
|
|
|
|
|
|
.get("hotes_actifs", {}).get("hosts", {}) or {}))
|
|
|
|
|
|
if not actifs:
|
|
|
|
|
|
return True, "Aucun hote actif : rien a verifier."
|
|
|
|
|
|
|
|
|
|
|
|
cle_site = ""
|
|
|
|
|
|
try:
|
|
|
|
|
|
import underlay as underlay_mod
|
|
|
|
|
|
cle_site = str((underlay_mod.lire_plan_site("10-intrants.yml") or {})
|
|
|
|
|
|
.get("runner_cle_publique") or "").strip()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
runners = set(ih.groupes_hote(data, h) and h for h in actifs
|
|
|
|
|
|
if ih.GROUPE_RUNNER_TENANT in ih.groupes_hote(data, h))
|
|
|
|
|
|
fautes: list[str] = []
|
|
|
|
|
|
porteurs_site: list[str] = []
|
|
|
|
|
|
sans_cle_tenant: list[str] = []
|
|
|
|
|
|
for h in actifs:
|
|
|
|
|
|
recues = [c for c in ih._cle_amorcage(data, h, inv).split("\n") if c.strip()]
|
|
|
|
|
|
if cle_site and cle_site in recues:
|
|
|
|
|
|
porteurs_site.append(h)
|
|
|
|
|
|
if h not in runners:
|
|
|
|
|
|
fautes.append(f"{h} recoit la cle du SITE sans porter "
|
|
|
|
|
|
f"`{ih.GROUPE_RUNNER_TENANT}` — l'hebergeur y gagnerait "
|
|
|
|
|
|
f"un acces qu'aucun flux ne declare")
|
|
|
|
|
|
if not [c for c in recues if c != cle_site]:
|
|
|
|
|
|
sans_cle_tenant.append(h)
|
|
|
|
|
|
|
|
|
|
|
|
# Sans cle du tenant, le runner ne peut pas occuper le terrain qu'on lui prepare.
|
|
|
|
|
|
# On ne l'exige que si l'instance en declare une : un plan qui n'en nomme aucune
|
|
|
|
|
|
# se materialise comme avant, avec la seule cle de l'exploitant.
|
|
|
|
|
|
if ih._cles_admin_du_plan(inv) and sans_cle_tenant:
|
|
|
|
|
|
fautes.append(f"{len(sans_cle_tenant)} hote(s) ne recevraient AUCUNE cle de "
|
|
|
|
|
|
f"leur tenant : son runner ne pourrait pas les configurer "
|
|
|
|
|
|
f"({', '.join(sans_cle_tenant[:3])})")
|
|
|
|
|
|
if fautes:
|
|
|
|
|
|
return False, "Les cles de naissance debordent :\n - " + "\n - ".join(fautes)
|
|
|
|
|
|
return True, (f"{len(actifs)} hote(s) : la cle du SITE ne nait que sur "
|
|
|
|
|
|
f"{len(porteurs_site)} runner(s) de tenant, celle du tenant sur "
|
|
|
|
|
|
f"{len(actifs) - len(sans_cle_tenant)}.")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-28 17:14:37 -04:00
|
|
|
|
def preuve_insemination_sans_secret() -> tuple[bool, str]:
|
|
|
|
|
|
"""Ce que le SITE pose chez un tenant ne demande AUCUN secret de ce tenant.
|
|
|
|
|
|
|
|
|
|
|
|
POURQUOI. Un ecosysteme neuf ne s'amorce pas lui-meme : quelqu'un doit poser sa
|
|
|
|
|
|
premiere machine. Le runner du SITE le fait — socle, moteur, plan, plancher — puis
|
|
|
|
|
|
s'arrete. Il s'arrete parce qu'il N'A PAS LA CLE, pas parce qu'une regle le lui
|
|
|
|
|
|
interdit : la voute d'un tenant ne vit dans aucun depot et son mot de passe sur aucune
|
|
|
|
|
|
de ses machines.
|
|
|
|
|
|
|
|
|
|
|
|
Cette preuve garde la ligne a l'endroit ou elle pourrait glisser SANS BRUIT : le jour
|
|
|
|
|
|
ou l'on ajouterait une couche a `COUCHES_INSEMINATION` pour « aller un peu plus loin »,
|
|
|
|
|
|
et ou cette couche reclamerait un `vault_*`. Le deploiement echouerait alors chez le
|
|
|
|
|
|
tenant, sur une valeur vide — un message qui ne dit pas qu'un POUVOIR a ete franchi.
|
|
|
|
|
|
|
|
|
|
|
|
Elle mesure la propriete, pas l'intention : les roles reellement appliques, lus dans
|
|
|
|
|
|
leurs playbooks, ne doivent citer aucune variable de voute.
|
|
|
|
|
|
"""
|
|
|
|
|
|
mk = RACINE / "Makefile"
|
|
|
|
|
|
txt = mk.read_text(encoding="utf-8")
|
|
|
|
|
|
m = re.search(r"^COUCHES_INSEMINATION \?=(.*)$", txt, re.M)
|
|
|
|
|
|
if not m:
|
|
|
|
|
|
return False, "`COUCHES_INSEMINATION` n'est pas declare dans le Makefile."
|
|
|
|
|
|
groupes = m.group(1).split()
|
|
|
|
|
|
if not groupes:
|
|
|
|
|
|
return False, "`COUCHES_INSEMINATION` est vide : le site ne poserait rien."
|
|
|
|
|
|
|
|
|
|
|
|
fautes: list[str] = []
|
|
|
|
|
|
roles_vus: list[str] = []
|
|
|
|
|
|
for g in groupes:
|
|
|
|
|
|
pb = RACINE / "playbooks" / "groupes" / f"{g}.yml"
|
|
|
|
|
|
if not pb.is_file():
|
|
|
|
|
|
fautes.append(f"{g} : aucun playbook `playbooks/groupes/{g}.yml`")
|
|
|
|
|
|
continue
|
|
|
|
|
|
joue = yaml.safe_load(pb.read_text(encoding="utf-8")) or []
|
|
|
|
|
|
roles = [r if isinstance(r, str) else (r or {}).get("role")
|
|
|
|
|
|
for r in ((joue[0] if joue else {}) or {}).get("roles") or []]
|
|
|
|
|
|
for role in [r for r in roles if r]:
|
|
|
|
|
|
roles_vus.append(role)
|
|
|
|
|
|
d = RACINE / "roles" / role
|
|
|
|
|
|
if not d.is_dir():
|
|
|
|
|
|
fautes.append(f"{g} : role introuvable `{role}`")
|
|
|
|
|
|
continue
|
|
|
|
|
|
for f in sorted(list(d.rglob("*.yml")) + list(d.rglob("*.j2"))):
|
|
|
|
|
|
texte = f.read_text(encoding="utf-8", errors="ignore")
|
|
|
|
|
|
trouves = sorted(set(re.findall(r"\bvault_[a-z0-9_]+", texte)))
|
|
|
|
|
|
if trouves:
|
|
|
|
|
|
fautes.append(
|
|
|
|
|
|
f"{g} -> {role} cite {', '.join(trouves[:3])} dans "
|
|
|
|
|
|
f"{f.relative_to(RACINE)} : le SITE ne detient pas cette voute")
|
|
|
|
|
|
if fautes:
|
|
|
|
|
|
return False, ("L'insemination reclamerait un secret du tenant :\n - "
|
|
|
|
|
|
+ "\n - ".join(fautes))
|
|
|
|
|
|
return True, (f"{len(groupes)} couche(s) d'insemination ({', '.join(groupes)}), "
|
|
|
|
|
|
f"{len(set(roles_vus))} role(s) applique(s), aucun secret de tenant reclame.")
|
|
|
|
|
|
|
|
|
|
|
|
|
flux : l interne refuse a voix haute, la bordure se tait (P53)
Decision de l exploitant : block vers l Internet, reject a l interieur, parce que
c est prudent. Ce n est pas le refus qui informe, c est CE QU IL FAIT AU SILENCE :
sous drop partout, un timeout voulait dire aucune machine, aucune route, ou une
politique. Quand la politique parle, il n en reste qu une.
nftables par hote policy drop + reject with icmpx type admin-prohibited
pare-feu Proxmox policy_in = REJECT (POLITIQUE_VM, source unique)
frontiere OPNsense block — INCHANGE, et c est la condition
admin-prohibited ET NON tcp reset : un RST est indiscernable d un port ferme sans
service. La chaine forward reste muette : elle porte le trafic qui TRAVERSE l hote,
et y repondre ferait parler cette machine au nom d une destination qui n est pas
elle.
POURQUOI C EST PRUDENT : l obscurite etait deja nulle a l interieur (chaque machine
porte un /etc/hosts qui liste ses voisines), et la bordure protege le reject —
rien d indeclare ne franchit le perimetre, donc il ne repond jamais a l Internet.
982 000 entrees par jour a la frontiere, dont 82 % un balayage VNC.
LA MESURE A CORRIGE LA MESURE, DEUX FOIS.
Ma preuve interdisait le litteral DROP et a fait echouer un code JUSTE : la
detection d une politique posee AU DATACENTER, qui est un garde-fou. Une preuve qui
interdit un mot au lieu de mesurer une propriete finit par accuser ce qu elle
devrait proteger.
Et l absence parlait deja : EHOSTUNREACH en 3,05 s pour une machine inexistante,
timeout a 6 s pour un refus de la frontiere. Mes deux erreurs de diagnostic ne
venaient pas du drop mais de ma SONDE — curl et bash /dev/tcp ecrasent les deux
dans un meme echec.
Applique : 6 VM en REJECT, 0 creee, 0 retiree. Rien ne se ferme.
Trois controles negatifs verifies.
make verifier : vert. make prouver : CONFORME, 53 OK, 0 echec, 0 saute.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-08-28 16:43:22 -04:00
|
|
|
|
def preuve_interne_parle_bordure_se_tait() -> tuple[bool, str]:
|
2026-10-01 15:39:46 -04:00
|
|
|
|
"""L'interne REFUSE A VOIX HAUTE, la bordure reste MUETTE sur l'Internet.
|
flux : l interne refuse a voix haute, la bordure se tait (P53)
Decision de l exploitant : block vers l Internet, reject a l interieur, parce que
c est prudent. Ce n est pas le refus qui informe, c est CE QU IL FAIT AU SILENCE :
sous drop partout, un timeout voulait dire aucune machine, aucune route, ou une
politique. Quand la politique parle, il n en reste qu une.
nftables par hote policy drop + reject with icmpx type admin-prohibited
pare-feu Proxmox policy_in = REJECT (POLITIQUE_VM, source unique)
frontiere OPNsense block — INCHANGE, et c est la condition
admin-prohibited ET NON tcp reset : un RST est indiscernable d un port ferme sans
service. La chaine forward reste muette : elle porte le trafic qui TRAVERSE l hote,
et y repondre ferait parler cette machine au nom d une destination qui n est pas
elle.
POURQUOI C EST PRUDENT : l obscurite etait deja nulle a l interieur (chaque machine
porte un /etc/hosts qui liste ses voisines), et la bordure protege le reject —
rien d indeclare ne franchit le perimetre, donc il ne repond jamais a l Internet.
982 000 entrees par jour a la frontiere, dont 82 % un balayage VNC.
LA MESURE A CORRIGE LA MESURE, DEUX FOIS.
Ma preuve interdisait le litteral DROP et a fait echouer un code JUSTE : la
detection d une politique posee AU DATACENTER, qui est un garde-fou. Une preuve qui
interdit un mot au lieu de mesurer une propriete finit par accuser ce qu elle
devrait proteger.
Et l absence parlait deja : EHOSTUNREACH en 3,05 s pour une machine inexistante,
timeout a 6 s pour un refus de la frontiere. Mes deux erreurs de diagnostic ne
venaient pas du drop mais de ma SONDE — curl et bash /dev/tcp ecrasent les deux
dans un meme echec.
Applique : 6 VM en REJECT, 0 creee, 0 retiree. Rien ne se ferme.
Trois controles negatifs verifies.
make verifier : vert. make prouver : CONFORME, 53 OK, 0 echec, 0 saute.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-08-28 16:43:22 -04:00
|
|
|
|
|
|
|
|
|
|
POURQUOI (2026-08-28). Sous `drop` partout, un timeout voulait dire trois choses
|
|
|
|
|
|
incompatibles : aucune machine, aucune route, ou une politique. Deux erreurs de
|
|
|
|
|
|
diagnostic en une journee, dont une qui a fait appliquer un pare-feu pour reparer une
|
|
|
|
|
|
ABSENCE. Ce qui informe n'est pas le refus — c'est ce qu'il fait au SILENCE : quand la
|
|
|
|
|
|
politique parle, un timeout ne veut plus dire qu'une chose.
|
|
|
|
|
|
|
|
|
|
|
|
L'OBSCURITE ETAIT DEJA NULLE A L'INTERIEUR : chaque machine porte un `/etc/hosts`
|
|
|
|
|
|
genere qui liste toutes ses voisines avec leurs adresses. Se cacher de pairs qui ont
|
|
|
|
|
|
deja notre adresse ne protege de rien, et coute le diagnostic.
|
|
|
|
|
|
|
|
|
|
|
|
LA BORDURE, ELLE, DOIT SE TAIRE, et c'est ce qui rend le reject interne prudent :
|
|
|
|
|
|
rien d'indeclare ne franchit le perimetre, donc ce reject ne repond jamais a
|
|
|
|
|
|
l'Internet. Mesure du 2026-08-27 a la frontiere : 982 000 entrees par jour, dont 82 %
|
|
|
|
|
|
un balayage contre le port VNC. Y repondre serait un vecteur d'amplification, source
|
|
|
|
|
|
usurpee comprise.
|
|
|
|
|
|
|
|
|
|
|
|
Cette preuve garde les deux moities ENSEMBLE, parce que l'une sans l'autre est fausse :
|
|
|
|
|
|
une bordure bavarde s'expose, un interne muet ment.
|
|
|
|
|
|
"""
|
|
|
|
|
|
fautes: list[str] = []
|
|
|
|
|
|
mesures: list[str] = []
|
|
|
|
|
|
|
|
|
|
|
|
# 1. nftables d'hote : chaque ruleset genere doit REFUSER A VOIX HAUTE.
|
|
|
|
|
|
src = (RACINE / "scripts" / "resoudre_flux.py").read_text(encoding="utf-8")
|
|
|
|
|
|
if "reject with icmpx type admin-prohibited" not in src:
|
|
|
|
|
|
fautes.append("le gabarit nftables n'emet pas de `reject with icmpx type "
|
|
|
|
|
|
"admin-prohibited` : l'interne resterait muet")
|
|
|
|
|
|
# `tcp reset` serait indiscernable d'un port ferme sans service.
|
|
|
|
|
|
if "reject with tcp reset" in src:
|
|
|
|
|
|
fautes.append("le gabarit nftables emet un `tcp reset`, indiscernable d'un port "
|
|
|
|
|
|
"ferme sans service — `admin-prohibited` dit qu'une POLITIQUE refuse")
|
|
|
|
|
|
|
|
|
|
|
|
rulesets = sorted((INSTANCE / "flux-genere").glob("*.nft"))
|
|
|
|
|
|
muets = [f.name for f in rulesets
|
|
|
|
|
|
if "reject with icmpx type admin-prohibited" not in f.read_text(encoding="utf-8")]
|
|
|
|
|
|
if muets:
|
|
|
|
|
|
fautes.append(f"{len(muets)} ruleset(s) genere(s) sans refus audible : "
|
|
|
|
|
|
+ ", ".join(muets[:4]))
|
|
|
|
|
|
mesures.append(f"{len(rulesets)} ruleset(s) nftables refusent a voix haute")
|
|
|
|
|
|
|
|
|
|
|
|
# 2. Pare-feu est-ouest Proxmox : politique REJECT, et UNE SEULE source.
|
|
|
|
|
|
ew = (RACINE / "scripts" / "appliquer_proxmox_fw.py").read_text(encoding="utf-8")
|
|
|
|
|
|
if 'POLITIQUE_VM = "REJECT"' not in ew:
|
|
|
|
|
|
fautes.append("le pare-feu est-ouest ne pose pas `POLITIQUE_VM = \"REJECT\"`")
|
|
|
|
|
|
# DEUX LITTERAUX POUR LA MEME DECISION, c'est une divergence differee : l'ecriture de
|
|
|
|
|
|
# la politique et la detection de son ecart doivent lire la MEME source.
|
|
|
|
|
|
#
|
|
|
|
|
|
# ON NE CHERCHE PAS LE MOT « DROP », ON CHERCHE UNE ASSIGNATION EN DUR. Ma premiere
|
|
|
|
|
|
# version refusait toute occurrence du litteral, et elle a fait echouer un code
|
|
|
|
|
|
# JUSTE : `dc["politique"].upper() in ("DROP", "REJECT")` detecte une politique posee
|
|
|
|
|
|
# AU DATACENTER, ou elle vaudrait pour tout le parc — c'est un garde-fou, pas un
|
|
|
|
|
|
# reglage. Une preuve qui interdit un mot au lieu de mesurer une propriete finit par
|
|
|
|
|
|
# accuser ce qu'elle devrait proteger.
|
|
|
|
|
|
if re.search(r'"policy_in":\s*"', ew):
|
|
|
|
|
|
fautes.append("la politique de la VM est ecrite en dur : elle doit venir de "
|
|
|
|
|
|
"`POLITIQUE_VM`, seule source")
|
|
|
|
|
|
if re.search(r'\["politique"\]\s*!=\s*"', ew):
|
|
|
|
|
|
fautes.append("la detection d'ecart compare a un litteral : elle doit lire "
|
|
|
|
|
|
"`POLITIQUE_VM`, sinon les deux divergeront")
|
|
|
|
|
|
mesures.append("pare-feu est-ouest en REJECT, source unique")
|
|
|
|
|
|
|
2026-10-01 15:39:46 -04:00
|
|
|
|
# 3. La bordure se tait SUR LE WAN — c'est la condition de prudence de tout reject
|
|
|
|
|
|
# interne. Mais ses interfaces INTERNES refusent a voix haute depuis le 2026-10-01 :
|
|
|
|
|
|
# elles ne voient que nos zones, et un flux mal declare entre deux zones expirait au
|
|
|
|
|
|
# lieu d'echouer. Le devis emet donc des `reject`, et JAMAIS sur l'interface WAN.
|
flux : l interne refuse a voix haute, la bordure se tait (P53)
Decision de l exploitant : block vers l Internet, reject a l interieur, parce que
c est prudent. Ce n est pas le refus qui informe, c est CE QU IL FAIT AU SILENCE :
sous drop partout, un timeout voulait dire aucune machine, aucune route, ou une
politique. Quand la politique parle, il n en reste qu une.
nftables par hote policy drop + reject with icmpx type admin-prohibited
pare-feu Proxmox policy_in = REJECT (POLITIQUE_VM, source unique)
frontiere OPNsense block — INCHANGE, et c est la condition
admin-prohibited ET NON tcp reset : un RST est indiscernable d un port ferme sans
service. La chaine forward reste muette : elle porte le trafic qui TRAVERSE l hote,
et y repondre ferait parler cette machine au nom d une destination qui n est pas
elle.
POURQUOI C EST PRUDENT : l obscurite etait deja nulle a l interieur (chaque machine
porte un /etc/hosts qui liste ses voisines), et la bordure protege le reject —
rien d indeclare ne franchit le perimetre, donc il ne repond jamais a l Internet.
982 000 entrees par jour a la frontiere, dont 82 % un balayage VNC.
LA MESURE A CORRIGE LA MESURE, DEUX FOIS.
Ma preuve interdisait le litteral DROP et a fait echouer un code JUSTE : la
detection d une politique posee AU DATACENTER, qui est un garde-fou. Une preuve qui
interdit un mot au lieu de mesurer une propriete finit par accuser ce qu elle
devrait proteger.
Et l absence parlait deja : EHOSTUNREACH en 3,05 s pour une machine inexistante,
timeout a 6 s pour un refus de la frontiere. Mes deux erreurs de diagnostic ne
venaient pas du drop mais de ma SONDE — curl et bash /dev/tcp ecrasent les deux
dans un meme echec.
Applique : 6 VM en REJECT, 0 creee, 0 retiree. Rien ne se ferme.
Trois controles negatifs verifies.
make verifier : vert. make prouver : CONFORME, 53 OK, 0 echec, 0 saute.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-08-28 16:43:22 -04:00
|
|
|
|
front = (RACINE / "scripts" / "devis_opnsense.py").read_text(encoding="utf-8")
|
2026-10-01 15:39:46 -04:00
|
|
|
|
if '"action": "reject"' not in front:
|
|
|
|
|
|
fautes.append("le devis de la frontiere n'emet aucun refus audible interne : un "
|
|
|
|
|
|
"flux mal declare entre deux zones expirerait au lieu d'echouer")
|
|
|
|
|
|
if "i != if_wan" not in front:
|
|
|
|
|
|
fautes.append("le refus audible de la frontiere n'exclut pas le WAN : la bordure "
|
|
|
|
|
|
"repondrait aux balayages d'Internet")
|
flux : l interne refuse a voix haute, la bordure se tait (P53)
Decision de l exploitant : block vers l Internet, reject a l interieur, parce que
c est prudent. Ce n est pas le refus qui informe, c est CE QU IL FAIT AU SILENCE :
sous drop partout, un timeout voulait dire aucune machine, aucune route, ou une
politique. Quand la politique parle, il n en reste qu une.
nftables par hote policy drop + reject with icmpx type admin-prohibited
pare-feu Proxmox policy_in = REJECT (POLITIQUE_VM, source unique)
frontiere OPNsense block — INCHANGE, et c est la condition
admin-prohibited ET NON tcp reset : un RST est indiscernable d un port ferme sans
service. La chaine forward reste muette : elle porte le trafic qui TRAVERSE l hote,
et y repondre ferait parler cette machine au nom d une destination qui n est pas
elle.
POURQUOI C EST PRUDENT : l obscurite etait deja nulle a l interieur (chaque machine
porte un /etc/hosts qui liste ses voisines), et la bordure protege le reject —
rien d indeclare ne franchit le perimetre, donc il ne repond jamais a l Internet.
982 000 entrees par jour a la frontiere, dont 82 % un balayage VNC.
LA MESURE A CORRIGE LA MESURE, DEUX FOIS.
Ma preuve interdisait le litteral DROP et a fait echouer un code JUSTE : la
detection d une politique posee AU DATACENTER, qui est un garde-fou. Une preuve qui
interdit un mot au lieu de mesurer une propriete finit par accuser ce qu elle
devrait proteger.
Et l absence parlait deja : EHOSTUNREACH en 3,05 s pour une machine inexistante,
timeout a 6 s pour un refus de la frontiere. Mes deux erreurs de diagnostic ne
venaient pas du drop mais de ma SONDE — curl et bash /dev/tcp ecrasent les deux
dans un meme echec.
Applique : 6 VM en REJECT, 0 creee, 0 retiree. Rien ne se ferme.
Trois controles negatifs verifies.
make verifier : vert. make prouver : CONFORME, 53 OK, 0 echec, 0 saute.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-08-28 16:43:22 -04:00
|
|
|
|
actions = sorted(set(re.findall(r'"action":\s*"(\w+)"', front))
|
|
|
|
|
|
| set(re.findall(r'action.*?or\s+"(\w+)"', front)))
|
2026-10-01 15:39:46 -04:00
|
|
|
|
mesures.append("frontiere : WAN muet, interfaces internes audibles (actions : "
|
|
|
|
|
|
+ ", ".join(actions or ["pass"]) + ")")
|
flux : l interne refuse a voix haute, la bordure se tait (P53)
Decision de l exploitant : block vers l Internet, reject a l interieur, parce que
c est prudent. Ce n est pas le refus qui informe, c est CE QU IL FAIT AU SILENCE :
sous drop partout, un timeout voulait dire aucune machine, aucune route, ou une
politique. Quand la politique parle, il n en reste qu une.
nftables par hote policy drop + reject with icmpx type admin-prohibited
pare-feu Proxmox policy_in = REJECT (POLITIQUE_VM, source unique)
frontiere OPNsense block — INCHANGE, et c est la condition
admin-prohibited ET NON tcp reset : un RST est indiscernable d un port ferme sans
service. La chaine forward reste muette : elle porte le trafic qui TRAVERSE l hote,
et y repondre ferait parler cette machine au nom d une destination qui n est pas
elle.
POURQUOI C EST PRUDENT : l obscurite etait deja nulle a l interieur (chaque machine
porte un /etc/hosts qui liste ses voisines), et la bordure protege le reject —
rien d indeclare ne franchit le perimetre, donc il ne repond jamais a l Internet.
982 000 entrees par jour a la frontiere, dont 82 % un balayage VNC.
LA MESURE A CORRIGE LA MESURE, DEUX FOIS.
Ma preuve interdisait le litteral DROP et a fait echouer un code JUSTE : la
detection d une politique posee AU DATACENTER, qui est un garde-fou. Une preuve qui
interdit un mot au lieu de mesurer une propriete finit par accuser ce qu elle
devrait proteger.
Et l absence parlait deja : EHOSTUNREACH en 3,05 s pour une machine inexistante,
timeout a 6 s pour un refus de la frontiere. Mes deux erreurs de diagnostic ne
venaient pas du drop mais de ma SONDE — curl et bash /dev/tcp ecrasent les deux
dans un meme echec.
Applique : 6 VM en REJECT, 0 creee, 0 retiree. Rien ne se ferme.
Trois controles negatifs verifies.
make verifier : vert. make prouver : CONFORME, 53 OK, 0 echec, 0 saute.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-08-28 16:43:22 -04:00
|
|
|
|
|
|
|
|
|
|
if fautes:
|
|
|
|
|
|
return False, "La ligne n'est plus tenue :\n - " + "\n - ".join(fautes)
|
|
|
|
|
|
return True, "L'interne parle, la bordure se tait — " + " ; ".join(mesures) + "."
|
|
|
|
|
|
|
|
|
|
|
|
|
creer-vm : prouver la materialisation sans entrer chez le tenant
`creer-vm` confirmait son succes en attendant une reponse SSH. Le runner du SITE
materialise le terrain de TOUS les tenants, mais la frontiere lui refuse d'entrer
chez eux — c'est le sens meme de leur isolation. La premiere VM qu'il a creee a
donc ete declaree en echec apres 600 secondes alors qu'elle tournait, avec
l'adresse exacte que le plan lui destinait :
Attente de SSH sur ops-01 ............ ECHEC: injoignable apres 600s.
LA TENTATION ETAIT D'OUVRIR LE SSH du runner vers tous les tenants. Ca aurait
repare la mesure en detruisant ce qu'elle protege : une machine capable d'entrer
chez chaque locataire est precisement ce que cette architecture refuse d'avoir.
L'agent invite repond sans rien ouvrir — l'API des hyperviseurs est deja le flux
par lequel la VM vient d'etre creee, donc qui peut la creer peut la voir naitre —
et il PROUVE DAVANTAGE. « Quelque chose ecoute sur le port 22 » ne dit ni quel
systeme a demarre, ni si cloud-init a pose la bonne adresse. Sur ops-01 :
10.17.19.41 portee sur asgard — Debian GNU/Linux 13 (trixie) 6.12.101
Ses echecs distinguent deux causes tres differentes : « la machine demarre mais
rapporte une AUTRE adresse — cloud-init, ou le pont sur lequel elle est posee »,
et « introuvable sur la fabric ».
ATTENDRE LA DISPONIBILITE A CHANGE DE MAIN. Guetter cloud-init et la liberation de
dpkg appartient a qui va CONFIGURER : `deployer` le fait desormais, la ou il se
contentait d'un `ping` unique. Il y gagne ce qu'il n'avait pas — attendre le verrou
APT, faute de quoi la premiere couche echouait dessus. Contrepartie assumee : un
nom d'hote errone patiente au lieu d'echouer vite ; echouer vite interdirait de
chainer creation et deploiement, le geste central d'une reconstruction.
P52 garde le couplage ferme. Deux controles negatifs verifies.
make prouver : CONFORME, 52 OK, 0 echec.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 22:48:49 -04:00
|
|
|
|
def preuve_materialiser_n_exige_pas_d_entrer() -> tuple[bool, str]:
|
|
|
|
|
|
"""Creer une VM ne demande pas d'entrer dans le tenant qui l'heberge.
|
|
|
|
|
|
|
|
|
|
|
|
POURQUOI (mesure du 2026-08-27). `creer-vm` confirmait son succes en attendant une
|
|
|
|
|
|
reponse SSH. Le runner du SITE materialise le terrain de TOUS les tenants, mais la
|
|
|
|
|
|
frontiere lui refuse d'entrer chez eux — c'est le sens meme de leur isolation. La
|
|
|
|
|
|
premiere VM qu'il a creee a donc ete declaree en echec apres 600 secondes alors
|
|
|
|
|
|
qu'elle tournait, avec l'adresse exacte que le plan lui destinait.
|
|
|
|
|
|
|
|
|
|
|
|
La tentation etait d'ouvrir le SSH du runner vers tous les tenants. Ca aurait
|
|
|
|
|
|
« repare » la mesure en detruisant ce qu'elle protege : une machine capable d'entrer
|
|
|
|
|
|
chez chaque locataire est precisement ce que cette architecture refuse d'avoir.
|
|
|
|
|
|
|
|
|
|
|
|
L'agent invite repond sans rien ouvrir, et prouve DAVANTAGE — le systeme, le noyau et
|
|
|
|
|
|
l'adresse effectivement portee, la ou SSH ne disait que « quelque chose ecoute sur le
|
|
|
|
|
|
port 22 ». Cette preuve garde le couplage ferme : materialiser et configurer sont deux
|
|
|
|
|
|
metiers, et seul le second exige d'entrer.
|
|
|
|
|
|
"""
|
|
|
|
|
|
mk = RACINE / "Makefile"
|
|
|
|
|
|
if not mk.is_file():
|
|
|
|
|
|
return False, "Makefile introuvable."
|
|
|
|
|
|
txt = mk.read_text(encoding="utf-8")
|
|
|
|
|
|
debut = txt.find("\ncreer-vm:")
|
|
|
|
|
|
if debut < 0:
|
|
|
|
|
|
return False, "La cible `creer-vm` a disparu du Makefile."
|
|
|
|
|
|
# La recette court jusqu'a la prochaine cible en debut de ligne.
|
|
|
|
|
|
suite = txt[debut + 1:]
|
|
|
|
|
|
fin = re.search(r"\n[A-Za-z_][A-Za-z0-9_.-]*:", suite)
|
|
|
|
|
|
recette = suite[:fin.start()] if fin else suite
|
|
|
|
|
|
|
|
|
|
|
|
outil = RACINE / "scripts" / "attendre_materialisation.py"
|
|
|
|
|
|
fautes = []
|
|
|
|
|
|
if "_attendre-hote" in recette:
|
|
|
|
|
|
fautes.append("`creer-vm` appelle `_attendre-hote`, qui exige SSH : un runner de "
|
|
|
|
|
|
"SITE ne peut pas entrer chez un tenant, et ne doit pas pouvoir.")
|
|
|
|
|
|
if not outil.is_file():
|
|
|
|
|
|
fautes.append("scripts/attendre_materialisation.py est absent.")
|
|
|
|
|
|
elif "attendre_materialisation.py" not in recette:
|
|
|
|
|
|
fautes.append("`creer-vm` ne confirme plus la materialisation : ni SSH ni agent "
|
|
|
|
|
|
"invite. Une creation qui ne verifie rien n'est pas une creation.")
|
|
|
|
|
|
if fautes:
|
|
|
|
|
|
return False, " | ".join(fautes)
|
|
|
|
|
|
return True, ("`creer-vm` confirme par l'agent invite (API des hyperviseurs, deja "
|
|
|
|
|
|
"utilisee pour creer), sans exiger d'entrer dans le tenant.")
|
|
|
|
|
|
|
|
|
|
|
|
|
collections : epingler les versions — le runner echouait la ou le poste reussissait
Premiere materialisation de VM depuis le runner du SITE :
ERROR! couldn't resolve module/action 'community.general.proxmox_pool'
Meme depot, meme playbook, meme plan que chez le mainteneur. La difference tenait
a une seule chose que le moteur ne disait pas : la VERSION de ses collections.
Le poste porte `community.general` 10.3.0. Le runner, monte un jour plus tard, a
recu la 13.3.0 — et la version 11 a RETIRE les modules Proxmox de cette collection
(ils vivent desormais dans `community.proxmox`). `requirements.yml` nommait ses
collections sans dire lesquelles : chaque machine installait donc ce qui etait
courant le jour de son montage. Une dependance non epinglee n'est pas une
dependance, c'est un pari sur l'etat d'Internet a la date du deploiement.
TROIS CORRECTIONS.
`requirements.yml` epingle les trois collections aux versions eprouvees.
`serveur_ops` installe avec `--force`. Sans lui, ansible-galaxy laisse en place une
version SUPERIEURE a celle demandee : il ne retrograde pas. Un poste peut etre en
avance, pas seulement en retard, et le depot doit faire autorite dans les deux sens.
P51 garde les deux faiblesses de ce fichier — celle du 2026-08-24, une collection
utilisee sans etre declaree (`ansible.posix`), et celle d'aujourd'hui, declaree sans
version. La preuve ne lit que les fichiers de TACHES : un `defaults/main.yml` porte
`net.ipv4.ip_forward`, qu'un motif trop large prend pour un module.
MIGRATION CONNUE, PAS FAITE : passer les quatre modules Proxmox a
`community.proxmox` permettra de suivre `community.general` au-dela de la 11.
make prouver : CONFORME, 51 OK, 0 echec.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 18:58:23 -04:00
|
|
|
|
def preuve_collections_declarees_et_epinglees() -> tuple[bool, str]:
|
|
|
|
|
|
"""Toute collection appelee par le moteur est DECLAREE, et a une version EPINGLEE.
|
|
|
|
|
|
|
|
|
|
|
|
DEUX PANNES, LA MEME CAUSE — le moteur ne disait pas de quoi il depend.
|
|
|
|
|
|
|
|
|
|
|
|
Le 2026-08-24, `ansible.posix` etait utilisee depuis longtemps sans figurer dans
|
|
|
|
|
|
`requirements.yml` : elle trainait sur le poste du mainteneur, et manquait partout
|
|
|
|
|
|
ailleurs. Le runner n'aurait pas pu deployer les sauvegardes.
|
|
|
|
|
|
|
|
|
|
|
|
Le 2026-08-27, la meme faiblesse a frappe d'un autre cote. Les collections etaient
|
|
|
|
|
|
nommees mais SANS VERSION : le runner du site, monte un jour plus tard, a recu
|
|
|
|
|
|
`community.general` 13.3.0 quand le poste porte la 10.3.0. La version 11 ayant sorti
|
|
|
|
|
|
les modules Proxmox de cette collection, la premiere materialisation de VM depuis le
|
|
|
|
|
|
runner a echoue sur `couldn't resolve module/action community.general.proxmox_pool`
|
|
|
|
|
|
— avec le meme depot, le meme playbook et le meme plan que chez le mainteneur.
|
|
|
|
|
|
|
|
|
|
|
|
Une dependance non epinglee n'est pas une dependance : c'est un pari sur l'etat
|
|
|
|
|
|
d'Internet a la date du deploiement. Un moteur qui se veut portable, et exploitable
|
|
|
|
|
|
par un sysadmin sans IA, doit pouvoir se reconstruire a l'identique dans six mois.
|
|
|
|
|
|
|
|
|
|
|
|
On lit les MODULES REELLEMENT APPELES dans les fichiers YAML — la clef d'une tache,
|
|
|
|
|
|
pas une chaine dans du Python : `ansible.parsing.vault` est un import, pas une
|
|
|
|
|
|
collection, et l'y confondre ferait crier la preuve pour rien.
|
|
|
|
|
|
"""
|
|
|
|
|
|
req = RACINE / "requirements.yml"
|
|
|
|
|
|
if not req.is_file():
|
|
|
|
|
|
return False, "requirements.yml est introuvable — le moteur ne declare aucune dependance."
|
|
|
|
|
|
try:
|
|
|
|
|
|
import yaml as _y
|
|
|
|
|
|
decl = (_y.safe_load(req.read_text(encoding="utf-8")) or {}).get("collections") or []
|
|
|
|
|
|
except Exception as e: # noqa: BLE001
|
|
|
|
|
|
return False, f"requirements.yml est illisible : {e}"
|
|
|
|
|
|
|
|
|
|
|
|
declarees = {}
|
|
|
|
|
|
for c in decl:
|
|
|
|
|
|
if isinstance(c, dict) and c.get("name"):
|
|
|
|
|
|
declarees[str(c["name"])] = str(c.get("version") or "").strip()
|
|
|
|
|
|
elif isinstance(c, str):
|
|
|
|
|
|
declarees[c] = ""
|
|
|
|
|
|
|
|
|
|
|
|
# Une clef de tache YAML : ` - community.general.ldap_entry:` ou ` module.x.y:`
|
|
|
|
|
|
motif = re.compile(r"^\s*(?:-\s+)?([a-z0-9_]+\.[a-z0-9_]+)\.[a-z0-9_]+\s*:", re.M)
|
|
|
|
|
|
# ON NE LIT QUE LES FICHIERS DE TACHES. Un `defaults/main.yml` peut contenir
|
|
|
|
|
|
# `net.ipv4.ip_forward: 1` — une clef de sysctl que le motif prendrait pour un module,
|
|
|
|
|
|
# et la preuve crierait a une collection manquante nommee `net.ipv4`. Un module ne
|
|
|
|
|
|
# peut apparaitre que la ou il y a des taches.
|
|
|
|
|
|
fichiers = list((RACINE / "playbooks").rglob("*.yml"))
|
|
|
|
|
|
for sous in ("tasks", "handlers"):
|
|
|
|
|
|
fichiers += list((RACINE / "roles").rglob(f"{sous}/*.yml"))
|
|
|
|
|
|
utilisees: dict[str, str] = {}
|
|
|
|
|
|
for f in fichiers:
|
|
|
|
|
|
try:
|
|
|
|
|
|
txt = f.read_text(encoding="utf-8")
|
|
|
|
|
|
except Exception: # noqa: BLE001
|
|
|
|
|
|
continue
|
|
|
|
|
|
for coll in motif.findall(txt):
|
|
|
|
|
|
if coll == "ansible.builtin":
|
|
|
|
|
|
continue
|
|
|
|
|
|
utilisees.setdefault(coll, str(f.relative_to(RACINE)))
|
|
|
|
|
|
|
|
|
|
|
|
manquantes = [f"{c} (ex. {utilisees[c]})" for c in sorted(utilisees) if c not in declarees]
|
|
|
|
|
|
sans_epingle = [c for c in sorted(declarees) if not declarees[c]]
|
|
|
|
|
|
fautes = []
|
|
|
|
|
|
if manquantes:
|
|
|
|
|
|
fautes.append("appelee(s) mais NON DECLAREE(S) dans requirements.yml : "
|
|
|
|
|
|
+ ", ".join(manquantes))
|
|
|
|
|
|
if sans_epingle:
|
|
|
|
|
|
fautes.append("declaree(s) SANS VERSION — le deploiement prendra ce qui passe : "
|
|
|
|
|
|
+ ", ".join(sans_epingle))
|
portabilite : declarer ce dont le moteur depend — quatre defauts reveles par le runner
Premiere materialisation d'une VM de tenant depuis le runner du SITE. Elle a
echoue quatre fois d'affilee, sur quatre dependances que le moteur ne declarait
nulle part. Chacune fonctionnait chez le mainteneur pour une raison DIFFERENTE,
et aucune de ces raisons n'existe sur une autre machine.
1. VERSIONS DES COLLECTIONS. `requirements.yml` les nommait sans les epingler. Le
runner a recu `community.general` 13.3.0 quand le poste porte la 10.3.0 — et la
11 a retire les modules Proxmox de cette collection.
ERROR! couldn't resolve module/action 'community.general.proxmox_pool'
2. INSTALLATION EN AVANT. `ansible-galaxy` ne retrograde pas : declarer la 10.3.0
ne suffisait pas a defaire une 13.3.0 deja posee. `--force`.
3. EMPLACEMENT. Le `Makefile` pose `ANSIBLE_HOME ?= $(CURDIR)/.ansible` : sous
`make`, Ansible ne lit QUE `<moteur>/.ansible/collections`. Le role deposait
dans `<racine>/.ansible/collections` — a cote, jamais lu. Invisible chez le
mainteneur, ou les collections viennent du paquet systeme, toujours dans le
chemin quel que soit ANSIBLE_HOME. Et le garde-fou d'installation suivait le
DEPOT des archives : changer la destination ne le declenchait pas. Il mesure
desormais la destination — meme piege qu'en 2026-08-24, deplace d'un cran.
4. BIBLIOTHEQUES PYTHON. Le venv recevait `ansible-core` et `pyyaml`, ecrits en
dur. Les modules Proxmox tournent `delegate_to: localhost` et exigent
`proxmoxer` SUR LE CONTROLEUR.
La bibliotheque Python proxmoxer est absente
Ne d'ici : `requirements-python.txt`, avec sa frontiere ecrite — il ne declare
QUE ce qui tourne sur le controleur. `python-ldap` et `psycopg2` s'executent
sur leurs cibles ; le poste du mainteneur ne les a pas et la flotte se deploie,
ce qui prouve que la ligne est au bon endroit.
P51 garde les trois faiblesses de cette famille : une dependance utilisee sans
etre declaree (le defaut du 2026-08-24, `ansible.posix`), une dependance declaree
sans version, et `proxmoxer` absent alors que des playbooks Proxmox existent.
Quatre controles negatifs verifies.
RESULTAT : ops-01 materialisee par le runner du site. L'agent invite rapporte
`eth0 10.17.19.41/24`, Debian 13 trixie — l'adressage derive du plan, applique.
Un seul executant masque ces ecarts indefiniment ; un second les revele tous en
une soiree. Meme mecanique que le second tenant en aout.
make prouver : CONFORME, 51 OK, 0 echec.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 22:38:35 -04:00
|
|
|
|
# LES BIBLIOTHEQUES PYTHON DU CONTROLEUR, MEME EXIGENCE (2026-08-27). Les collections
|
|
|
|
|
|
# etaient declarees ; ce dont leurs modules dependent ne l'etait nulle part. Le venv
|
|
|
|
|
|
# d'un poste recevait `ansible-core` et `pyyaml`, ecrits en dur dans le role. Le runner
|
|
|
|
|
|
# a echoue sur « la bibliotheque Python proxmoxer est absente » — que le poste du
|
|
|
|
|
|
# mainteneur possedait par son paquet systeme, sans que personne ne l'ait declaree.
|
|
|
|
|
|
py = RACINE / "requirements-python.txt"
|
|
|
|
|
|
if not py.is_file():
|
|
|
|
|
|
fautes.append("requirements-python.txt est absent : les bibliotheques du "
|
|
|
|
|
|
"controleur ne sont declarees nulle part.")
|
|
|
|
|
|
else:
|
|
|
|
|
|
lignes = [l.strip() for l in py.read_text(encoding="utf-8").splitlines()
|
|
|
|
|
|
if l.strip() and not l.strip().startswith("#")]
|
|
|
|
|
|
floues = [l for l in lignes if "==" not in l]
|
|
|
|
|
|
if not lignes:
|
|
|
|
|
|
fautes.append("requirements-python.txt ne declare aucune bibliotheque.")
|
|
|
|
|
|
if floues:
|
|
|
|
|
|
fautes.append("bibliotheque(s) Python sans version exacte : " + ", ".join(floues))
|
|
|
|
|
|
# Le moteur appelle des modules Proxmox EN LOCAL : `proxmoxer` est donc exige sur
|
|
|
|
|
|
# le controleur. C'est la dependance dont l'absence a bloque la premiere
|
|
|
|
|
|
# materialisation depuis le runner ; on la nomme pour qu'elle ne reparte pas.
|
|
|
|
|
|
if any((RACINE / "playbooks").rglob("*proxmox*")) and \
|
|
|
|
|
|
not any(l.lower().startswith("proxmoxer") for l in lignes):
|
|
|
|
|
|
fautes.append("le moteur porte des playbooks Proxmox mais ne declare pas "
|
|
|
|
|
|
"`proxmoxer`, exige sur le CONTROLEUR.")
|
|
|
|
|
|
py_n = len(lignes)
|
|
|
|
|
|
|
collections : epingler les versions — le runner echouait la ou le poste reussissait
Premiere materialisation de VM depuis le runner du SITE :
ERROR! couldn't resolve module/action 'community.general.proxmox_pool'
Meme depot, meme playbook, meme plan que chez le mainteneur. La difference tenait
a une seule chose que le moteur ne disait pas : la VERSION de ses collections.
Le poste porte `community.general` 10.3.0. Le runner, monte un jour plus tard, a
recu la 13.3.0 — et la version 11 a RETIRE les modules Proxmox de cette collection
(ils vivent desormais dans `community.proxmox`). `requirements.yml` nommait ses
collections sans dire lesquelles : chaque machine installait donc ce qui etait
courant le jour de son montage. Une dependance non epinglee n'est pas une
dependance, c'est un pari sur l'etat d'Internet a la date du deploiement.
TROIS CORRECTIONS.
`requirements.yml` epingle les trois collections aux versions eprouvees.
`serveur_ops` installe avec `--force`. Sans lui, ansible-galaxy laisse en place une
version SUPERIEURE a celle demandee : il ne retrograde pas. Un poste peut etre en
avance, pas seulement en retard, et le depot doit faire autorite dans les deux sens.
P51 garde les deux faiblesses de ce fichier — celle du 2026-08-24, une collection
utilisee sans etre declaree (`ansible.posix`), et celle d'aujourd'hui, declaree sans
version. La preuve ne lit que les fichiers de TACHES : un `defaults/main.yml` porte
`net.ipv4.ip_forward`, qu'un motif trop large prend pour un module.
MIGRATION CONNUE, PAS FAITE : passer les quatre modules Proxmox a
`community.proxmox` permettra de suivre `community.general` au-dela de la 11.
make prouver : CONFORME, 51 OK, 0 echec.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 18:58:23 -04:00
|
|
|
|
if fautes:
|
|
|
|
|
|
return False, " | ".join(fautes)
|
portabilite : declarer ce dont le moteur depend — quatre defauts reveles par le runner
Premiere materialisation d'une VM de tenant depuis le runner du SITE. Elle a
echoue quatre fois d'affilee, sur quatre dependances que le moteur ne declarait
nulle part. Chacune fonctionnait chez le mainteneur pour une raison DIFFERENTE,
et aucune de ces raisons n'existe sur une autre machine.
1. VERSIONS DES COLLECTIONS. `requirements.yml` les nommait sans les epingler. Le
runner a recu `community.general` 13.3.0 quand le poste porte la 10.3.0 — et la
11 a retire les modules Proxmox de cette collection.
ERROR! couldn't resolve module/action 'community.general.proxmox_pool'
2. INSTALLATION EN AVANT. `ansible-galaxy` ne retrograde pas : declarer la 10.3.0
ne suffisait pas a defaire une 13.3.0 deja posee. `--force`.
3. EMPLACEMENT. Le `Makefile` pose `ANSIBLE_HOME ?= $(CURDIR)/.ansible` : sous
`make`, Ansible ne lit QUE `<moteur>/.ansible/collections`. Le role deposait
dans `<racine>/.ansible/collections` — a cote, jamais lu. Invisible chez le
mainteneur, ou les collections viennent du paquet systeme, toujours dans le
chemin quel que soit ANSIBLE_HOME. Et le garde-fou d'installation suivait le
DEPOT des archives : changer la destination ne le declenchait pas. Il mesure
desormais la destination — meme piege qu'en 2026-08-24, deplace d'un cran.
4. BIBLIOTHEQUES PYTHON. Le venv recevait `ansible-core` et `pyyaml`, ecrits en
dur. Les modules Proxmox tournent `delegate_to: localhost` et exigent
`proxmoxer` SUR LE CONTROLEUR.
La bibliotheque Python proxmoxer est absente
Ne d'ici : `requirements-python.txt`, avec sa frontiere ecrite — il ne declare
QUE ce qui tourne sur le controleur. `python-ldap` et `psycopg2` s'executent
sur leurs cibles ; le poste du mainteneur ne les a pas et la flotte se deploie,
ce qui prouve que la ligne est au bon endroit.
P51 garde les trois faiblesses de cette famille : une dependance utilisee sans
etre declaree (le defaut du 2026-08-24, `ansible.posix`), une dependance declaree
sans version, et `proxmoxer` absent alors que des playbooks Proxmox existent.
Quatre controles negatifs verifies.
RESULTAT : ops-01 materialisee par le runner du site. L'agent invite rapporte
`eth0 10.17.19.41/24`, Debian 13 trixie — l'adressage derive du plan, applique.
Un seul executant masque ces ecarts indefiniment ; un second les revele tous en
une soiree. Meme mecanique que le second tenant en aout.
make prouver : CONFORME, 51 OK, 0 echec.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 22:38:35 -04:00
|
|
|
|
return True, (f"{len(utilisees)} collection(s) et {py_n} bibliotheque(s) Python "
|
collections : epingler les versions — le runner echouait la ou le poste reussissait
Premiere materialisation de VM depuis le runner du SITE :
ERROR! couldn't resolve module/action 'community.general.proxmox_pool'
Meme depot, meme playbook, meme plan que chez le mainteneur. La difference tenait
a une seule chose que le moteur ne disait pas : la VERSION de ses collections.
Le poste porte `community.general` 10.3.0. Le runner, monte un jour plus tard, a
recu la 13.3.0 — et la version 11 a RETIRE les modules Proxmox de cette collection
(ils vivent desormais dans `community.proxmox`). `requirements.yml` nommait ses
collections sans dire lesquelles : chaque machine installait donc ce qui etait
courant le jour de son montage. Une dependance non epinglee n'est pas une
dependance, c'est un pari sur l'etat d'Internet a la date du deploiement.
TROIS CORRECTIONS.
`requirements.yml` epingle les trois collections aux versions eprouvees.
`serveur_ops` installe avec `--force`. Sans lui, ansible-galaxy laisse en place une
version SUPERIEURE a celle demandee : il ne retrograde pas. Un poste peut etre en
avance, pas seulement en retard, et le depot doit faire autorite dans les deux sens.
P51 garde les deux faiblesses de ce fichier — celle du 2026-08-24, une collection
utilisee sans etre declaree (`ansible.posix`), et celle d'aujourd'hui, declaree sans
version. La preuve ne lit que les fichiers de TACHES : un `defaults/main.yml` porte
`net.ipv4.ip_forward`, qu'un motif trop large prend pour un module.
MIGRATION CONNUE, PAS FAITE : passer les quatre modules Proxmox a
`community.proxmox` permettra de suivre `community.general` au-dela de la 11.
make prouver : CONFORME, 51 OK, 0 echec.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 18:58:23 -04:00
|
|
|
|
f"declarees et epinglees : "
|
|
|
|
|
|
+ ", ".join(f"{c}=={declarees[c]}" for c in sorted(utilisees)))
|
|
|
|
|
|
|
|
|
|
|
|
|
frontiere : le devis sait desormais refuser SANS consigner
L'outil ne savait qu'AUTORISER — `"action": "pass"` etait en dur dans l'emetteur.
Une regle de silence ne pouvait donc pas naitre du depot, et j'en avais pose deux
a la main sur le boitier : exactement ce que ce projet refuse.
CE QUI L'A MOTIVE. Le journal de la frontiere ecrivait 982 000 entrees par jour,
dont 82 % un balayage Internet contre le port VNC et le reste du bavardage de
decouverte du reseau local. Sa fenetre utile etait tombee a QUARANTE-QUATRE
SECONDES. J'y ai cherche la trace d'un flux du site vers les hyperviseurs, je n'ai
rien trouve, et j'en ai conclu a tort qu'aucune regle ne bloquait. Un journal noye
ment aussi surement qu'un journal mort. Mesure apres declaration : ~20 700/jour.
Une regle de silence ne change AUCUN comportement : ce qu'elle vise etait deja
refuse par le defaut. Elle ne supprime qu'une trace que personne ne lira.
TROIS PIECES.
`cle_regle` accepte une action sans changer d'un octet la cle des regles `pass`
deja posees. L'ajout naif d'un champ les aurait toutes detruites pour les recreer
a l'identique, sur la frontiere, en production. Le plan l'a confirme : 7 a creer,
0 a retirer, 121 inchangees.
`_corps_regle` lit l'action, la consignation et la SEQUENCE depuis le devis. La
sequence est ce qui rend un `block` sur : OPNsense evalue en `quick`, donc un
blocage large emis avant les `pass` fermerait courrier, web et acces distant.
`devis_opnsense` lit `opnsense_silences` et en fabrique regles et alias, motif
compris — une regle `block` muette sans raison ecrite est indiscernable d'un oubli.
P50 garde ces deux dangers. Controles negatifs verifies : un silence en sequence 1
echoue, un silence sans motif echoue.
Carte : 28 pieces d'audit (P48 l'avait vu juste).
make prouver : CONFORME, 50 OK, 0 echec.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 18:10:39 -04:00
|
|
|
|
def preuve_silences_declares() -> tuple[bool, str]:
|
|
|
|
|
|
"""Un `block` muet doit etre DECLARE, place APRES les `pass`, et porter son motif.
|
|
|
|
|
|
|
|
|
|
|
|
POURQUOI (mesure du 2026-08-27). Le journal de la frontiere ecrivait 982 000 entrees
|
|
|
|
|
|
par jour, dont 82 % un balayage Internet contre le port VNC. Sa fenetre utile etait
|
|
|
|
|
|
tombee a QUARANTE-QUATRE SECONDES : j'y ai cherche la trace d'un flux du site vers les
|
|
|
|
|
|
hyperviseurs, je n'ai rien trouve, et j'en ai conclu a tort qu'aucune regle ne
|
|
|
|
|
|
bloquait. Un journal noye ment aussi surement qu'un journal mort.
|
|
|
|
|
|
|
|
|
|
|
|
La reponse — taire ce qui est deja refuse — introduit dans l'outil sa PREMIERE regle
|
|
|
|
|
|
de blocage. Elle amene deux dangers que cette preuve garde :
|
|
|
|
|
|
|
|
|
|
|
|
1. L'ORDRE. OPNsense evalue en `quick` : la premiere regle qui correspond gagne. Un
|
|
|
|
|
|
blocage large emis avant les `pass` du devis fermerait d'un coup le courrier, le
|
|
|
|
|
|
web et l'acces distant. Les silences doivent donc porter une sequence STRICTEMENT
|
|
|
|
|
|
superieure a celle de toute regle `pass`.
|
|
|
|
|
|
|
|
|
|
|
|
2. LE MOTIF. Une regle `block` sans raison ecrite est indiscernable d'un oubli pour
|
|
|
|
|
|
qui relira cette frontiere dans six mois. Le silence se justifie ou n'existe pas.
|
|
|
|
|
|
|
|
|
|
|
|
On rejoue le devis en memoire — rien n'est ecrit, rien n'est joint sur le reseau.
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
sys.path.insert(0, str(RACINE / "scripts"))
|
|
|
|
|
|
import devis_opnsense as DO
|
|
|
|
|
|
devis = DO.construire(DO.decouvrir_du_site())
|
|
|
|
|
|
except Exception as e: # noqa: BLE001
|
|
|
|
|
|
return False, (f"Le devis de la frontiere n'a pas pu etre rejoue : {e}. "
|
|
|
|
|
|
f"Sans lui, on ne sait pas ce que la frontiere recevrait.")
|
|
|
|
|
|
|
|
|
|
|
|
regles = devis.get("regles") or []
|
2026-10-01 15:39:46 -04:00
|
|
|
|
# UN SILENCE EST UN `block` (2026-10-01). Les `reject` sont les refus AUDIBLES des
|
|
|
|
|
|
# interfaces internes (P53) : journalises par conception, ils ne sont pas des silences.
|
|
|
|
|
|
silences = [r for r in regles if str(r.get("action") or "pass") == "block"]
|
frontiere : le devis sait desormais refuser SANS consigner
L'outil ne savait qu'AUTORISER — `"action": "pass"` etait en dur dans l'emetteur.
Une regle de silence ne pouvait donc pas naitre du depot, et j'en avais pose deux
a la main sur le boitier : exactement ce que ce projet refuse.
CE QUI L'A MOTIVE. Le journal de la frontiere ecrivait 982 000 entrees par jour,
dont 82 % un balayage Internet contre le port VNC et le reste du bavardage de
decouverte du reseau local. Sa fenetre utile etait tombee a QUARANTE-QUATRE
SECONDES. J'y ai cherche la trace d'un flux du site vers les hyperviseurs, je n'ai
rien trouve, et j'en ai conclu a tort qu'aucune regle ne bloquait. Un journal noye
ment aussi surement qu'un journal mort. Mesure apres declaration : ~20 700/jour.
Une regle de silence ne change AUCUN comportement : ce qu'elle vise etait deja
refuse par le defaut. Elle ne supprime qu'une trace que personne ne lira.
TROIS PIECES.
`cle_regle` accepte une action sans changer d'un octet la cle des regles `pass`
deja posees. L'ajout naif d'un champ les aurait toutes detruites pour les recreer
a l'identique, sur la frontiere, en production. Le plan l'a confirme : 7 a creer,
0 a retirer, 121 inchangees.
`_corps_regle` lit l'action, la consignation et la SEQUENCE depuis le devis. La
sequence est ce qui rend un `block` sur : OPNsense evalue en `quick`, donc un
blocage large emis avant les `pass` fermerait courrier, web et acces distant.
`devis_opnsense` lit `opnsense_silences` et en fabrique regles et alias, motif
compris — une regle `block` muette sans raison ecrite est indiscernable d'un oubli.
P50 garde ces deux dangers. Controles negatifs verifies : un silence en sequence 1
echoue, un silence sans motif echoue.
Carte : 28 pieces d'audit (P48 l'avait vu juste).
make prouver : CONFORME, 50 OK, 0 echec.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 18:10:39 -04:00
|
|
|
|
if not silences:
|
|
|
|
|
|
return True, ("Aucune regle de blocage au devis — rien a garder. La preuve "
|
|
|
|
|
|
"redeviendra parlante des qu'un silence sera declare.")
|
|
|
|
|
|
|
|
|
|
|
|
seq_pass = [int(r.get("sequence") or 1) for r in regles
|
|
|
|
|
|
if str(r.get("action") or "pass") == "pass"]
|
|
|
|
|
|
plancher = max(seq_pass) if seq_pass else 1
|
|
|
|
|
|
fautes = []
|
|
|
|
|
|
for r in silences:
|
|
|
|
|
|
seq = int(r.get("sequence") or 1)
|
|
|
|
|
|
if seq <= plancher:
|
|
|
|
|
|
fautes.append(
|
|
|
|
|
|
f"{r['interface']}/{r['role']} : sequence {seq} <= {plancher}, la plus "
|
|
|
|
|
|
f"haute des regles `pass`. En `quick`, ce blocage gagnerait AVANT elles.")
|
|
|
|
|
|
if r.get("journaliser"):
|
|
|
|
|
|
fautes.append(f"{r['interface']}/{r['role']} : consigne — ce n'est pas un silence.")
|
|
|
|
|
|
raison = str(r.get("raison") or "").strip()
|
|
|
|
|
|
if len(raison) < 30:
|
|
|
|
|
|
fautes.append(
|
|
|
|
|
|
f"{r['interface']}/{r['role']} : motif absent ou trop court "
|
|
|
|
|
|
f"({len(raison)} car.). Un blocage muet sans raison ecrite est un oubli.")
|
|
|
|
|
|
if fautes:
|
|
|
|
|
|
return False, " | ".join(fautes)
|
|
|
|
|
|
return True, (f"{len(silences)} silence(s) declare(s), tous en sequence > {plancher} "
|
|
|
|
|
|
f"(la plus haute des {len(seq_pass)} regles `pass`), tous non consignes "
|
|
|
|
|
|
f"et tous motives.")
|
|
|
|
|
|
|
|
|
|
|
|
|
flux : P49 — le registre des flux avait derive sans bruit
`docs/registre-flux.md` est GENERE depuis les `roles/*/meta/flux.yml`, et c'est le
document qu'un humain lit pour savoir ce que le pare-feu laisse passer. Son
EXISTENCE etait verifiee depuis longtemps ; sa FRAICHEUR ne l'etait pas.
Il avait derive : la garde d'administration y portait encore `10.0.0.0/24` alors
que le reseau d'administration vaut `10.17.0.0/24`, deux flux `client_resolveur`
ajoutes depuis n'y figuraient pas, et un hote manquait des listes de sources. Un
lecteur y aurait lu un pare-feu qui n'existe plus.
L'inventaire avait deja sa garde — P03, le diff-vide du plan. Le registre des flux
est le meme genre d'artefact : genere, versionne, lu par un humain. Il lui
manquait la meme. `generer_registre` etant une fonction PURE, P49 la rejoue en
memoire et compare — une preuve qui repare ce qu'elle mesure ne mesure plus rien.
Controle negatif ideal, et il ne s'invente pas : la version commitee elle-meme.
Restauree, la preuve echoue ; regeneree, elle passe.
CE QUE CETTE DECOUVERTE CORRIGE AUSSI DANS MA TETE. J'avais decrit le symptome
comme « le runner salit ses propres clones » — une contradiction structurelle
entre un depot-clone et un repertoire de travail. C'etait faux, et la question de
l'exploitant l'a mis au jour. Regenerer un artefact DOIT produire un diff quand
les sources ont change ; ce qui manquait n'etait pas une architecture, c'etait une
garde. Un symptome observe depuis un seul endroit ressemble toujours a une
propriete de cet endroit.
Ce commit emporte aussi la regeneration elle-meme : le registre du moteur, et les
quatorze fichiers nftables de Chezlepro, remis en accord avec leurs sources.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 16:58:17 -04:00
|
|
|
|
def preuve_registre_flux_a_jour() -> tuple[bool, str]:
|
|
|
|
|
|
"""La matrice d'audit des flux dit ce que le moteur declare AUJOURD'HUI.
|
|
|
|
|
|
|
|
|
|
|
|
POURQUOI (mesure du 2026-08-26). `docs/registre-flux.md` est GENERE depuis les
|
|
|
|
|
|
`roles/*/meta/flux.yml`, et c'est le document qu'un humain lit pour savoir ce que le
|
|
|
|
|
|
pare-feu laisse passer. Son existence etait verifiee (P31) ; sa FRAICHEUR ne l'etait
|
|
|
|
|
|
pas.
|
|
|
|
|
|
|
|
|
|
|
|
Il avait derive sans bruit : la garde d'administration y portait encore `10.0.0.0/24`
|
|
|
|
|
|
alors que le reseau d'administration vaut `10.17.0.0/24`, et deux flux `client_resolveur`
|
|
|
|
|
|
ajoutes depuis n'y figuraient pas. Un lecteur y aurait lu un pare-feu qui n'existe plus.
|
|
|
|
|
|
|
|
|
|
|
|
L'INVENTAIRE AVAIT DEJA SA GARDE — P03, le diff-vide du plan. Le registre des flux est
|
|
|
|
|
|
le meme genre d'artefact : genere, versionne, lu par un humain. Il lui manquait la
|
|
|
|
|
|
meme.
|
|
|
|
|
|
|
|
|
|
|
|
`generer_registre` est une fonction PURE : on la rejoue en memoire et on compare. Rien
|
|
|
|
|
|
n'est ecrit — une preuve qui repare ce qu'elle mesure ne mesure plus rien.
|
|
|
|
|
|
"""
|
|
|
|
|
|
fichier = RACINE / "docs" / "registre-flux.md"
|
|
|
|
|
|
if not fichier.is_file():
|
|
|
|
|
|
return False, "docs/registre-flux.md est introuvable."
|
|
|
|
|
|
try:
|
|
|
|
|
|
sys.path.insert(0, str(RACINE / "scripts"))
|
|
|
|
|
|
import resoudre_flux as RF
|
|
|
|
|
|
attendu = RF.generer_registre(RF.charger_flux())
|
|
|
|
|
|
except Exception as e: # noqa: BLE001
|
|
|
|
|
|
return False, (f"Le registre des flux n'a pas pu etre rejoue : {e}. "
|
|
|
|
|
|
f"Tant qu'on ne sait pas le regenerer, on ne sait pas s'il dit vrai.")
|
|
|
|
|
|
|
|
|
|
|
|
actuel = fichier.read_text(encoding="utf-8")
|
|
|
|
|
|
if actuel == attendu:
|
|
|
|
|
|
return True, ("Le registre des flux reproduit exactement ce que les "
|
|
|
|
|
|
f"`meta/flux.yml` declarent ({len(attendu.splitlines())} lignes).")
|
|
|
|
|
|
|
|
|
|
|
|
a, b = actuel.splitlines(), attendu.splitlines()
|
|
|
|
|
|
ecarts = [f"ligne {i + 1} : le fichier dit « {x[:70]} », le moteur « {y[:70]} »"
|
|
|
|
|
|
for i, (x, y) in enumerate(zip(a, b)) if x != y][:3]
|
|
|
|
|
|
if len(a) != len(b):
|
|
|
|
|
|
ecarts.append(f"longueurs differentes : {len(a)} lignes contre {len(b)}")
|
|
|
|
|
|
return False, ("`docs/registre-flux.md` ne dit plus ce que le moteur declare. "
|
|
|
|
|
|
"Rejouer `make flux` et commiter le resultat.\n - "
|
|
|
|
|
|
+ "\n - ".join(ecarts))
|
|
|
|
|
|
|
|
|
|
|
|
|
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")}
|
2026-09-14 14:09:13 -04:00
|
|
|
|
|
|
|
|
|
|
# `_` EST UN CARACTERE DE NOM DE PAGE (2026-09-14). La classe n'en contenait pas, donc
|
|
|
|
|
|
# un lien vers `Role-serveur_postgresql` n'etait NI suivi, NI signale casse : il
|
|
|
|
|
|
# n'existait tout simplement pas pour cette preuve. Un motif trop etroit ne rend pas
|
|
|
|
|
|
# une garde prudente, il la rend aveugle.
|
|
|
|
|
|
MOTIF = r"\]\(([A-Za-zÀ-ÿ0-9'’_\-]+)\)"
|
|
|
|
|
|
liens = set(re.findall(MOTIF, texte))
|
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
|
|
|
|
casses = sorted(l for l in liens if l not in pages)
|
|
|
|
|
|
|
2026-09-14 14:09:13 -04:00
|
|
|
|
# ATTEIGNABLE, PAS CITE (2026-09-14). La version d'avant exigeait que `_Sidebar.md`
|
|
|
|
|
|
# cite CHAQUE page. Son propre motif dit pourtant « n'est lue par personne » — or une
|
|
|
|
|
|
# page citee par une page citee EST lue.
|
|
|
|
|
|
#
|
|
|
|
|
|
# CE QUE L'EXIGENCE LITTERALE COUTAIT : elle interdisait toute page d'index. Les
|
|
|
|
|
|
# soixante-huit fiches de role passent par `Roles`, qui les rassemble ; les citer une
|
|
|
|
|
|
# a une dans la navigation en ferait un mur de noms ou plus personne ne trouverait les
|
|
|
|
|
|
# vingt-sept unites redigees. La garde forcait donc a degrader ce qu'elle protegeait.
|
|
|
|
|
|
#
|
|
|
|
|
|
# On suit maintenant les liens de proche en proche depuis la navigation. Une page
|
|
|
|
|
|
# qu'AUCUN chemin n'atteint reste orpheline, et c'est bien ce qu'on voulait mesurer.
|
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
|
|
|
|
sidebar = (wiki / "_Sidebar.md")
|
|
|
|
|
|
orphelines: list[str] = []
|
|
|
|
|
|
if sidebar.is_file():
|
|
|
|
|
|
nav = sidebar.read_text(encoding="utf-8", errors="ignore")
|
2026-09-14 14:09:13 -04:00
|
|
|
|
atteintes = {l for l in re.findall(MOTIF, nav) if l in pages}
|
|
|
|
|
|
a_suivre = list(atteintes)
|
|
|
|
|
|
while a_suivre:
|
|
|
|
|
|
courante = a_suivre.pop()
|
|
|
|
|
|
f = wiki / f"{courante}.md"
|
|
|
|
|
|
if not f.is_file():
|
|
|
|
|
|
continue
|
|
|
|
|
|
for l in re.findall(MOTIF, f.read_text(encoding="utf-8", errors="ignore")):
|
|
|
|
|
|
if l in pages and l not in atteintes:
|
|
|
|
|
|
atteintes.add(l)
|
|
|
|
|
|
a_suivre.append(l)
|
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
|
|
|
|
orphelines = sorted(p for p in pages
|
|
|
|
|
|
if p not in ("_Sidebar", "README", "Home")
|
2026-09-14 14:09:13 -04:00
|
|
|
|
and p not in atteintes)
|
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
|
|
|
|
|
|
|
|
|
|
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).
|
|
|
|
|
|
|
les depots tiers passent par le cache, et le tenant n a plus le sien
Deux mouvements d une seule doctrine : le site fournit tout ce dont un
tenant a besoin pour venir au monde.
1. LES DEPOTS TIERS. Grafana, Icinga, Smallstep et Collabora ne publient
qu en HTTPS ; apt-cacher-ng ne relaie pas un tunnel, et le socle pose donc
Acquire::https::Proxy DIRECT. Chaque machine sortait elle-meme sur
Internet. Le cache declare desormais un Remap par fournisseur, et chaque
role demande en {{ ..._depot_schema }}:// - http des qu un cache est
declare, https sinon. Le TLS n est rompu nulle part : il est TERMINE au
cache, qui est notre machine, et l integrite vient des signatures.
avant : 4 fournisseurs en HTTPS direct, 14 machines sortant seules
apres : 0 source en HTTPS direct, 0 erreur apt sur 14 machines
Trois lecons. Le remap appartient au cache qui SORT : pose sur un cache
chaine, il tente le HTTPS a travers son amont et rend 503. apt_repository
AJOUTE au lieu de remplacer, donc l ancienne ligne https sortait toujours.
Et la liste des fournisseurs ne se devine pas - j en avais trois, l audit
en a revele un quatrieme.
2. LE CACHE DU TENANT. Sa ligne portait son propre retrait depuis toujours
- service MUTUALISABLE, un ecosysteme au premier age peut pointer sur celui
de son hote. Retiree. Ce qu on perd, dit franchement : plus de trafic
inter-zone et plus de charge sur site-cache-01, contre un service de moins
a poser, superviser et reproduire.
3. UN ROLE QU ON RETIRE DOIT DEFAIRE CE QU IL A FAIT. Le retrait a montre
que rien ne nettoie derriere. Le fichier apt visait un cache eteint en
ecrasant le plancher qui fonctionnait - le defaut deja paye a quinze
machines. Et les sondes du cache restaient, le porteur poussant pour des
services qu Icinga ne definit plus (404). Le socle retire le premier,
client_sante derive les sondes attendues et retire les orphelines.
P65 refuse tout role visant un depot relaye en https ecrit en dur. Sa
limite est dite : elle empeche une regression sur ce qui est connu, elle ne
decouvre pas l inconnu.
Mesure : Icinga 87 OK sur 96, prouver 65 OK, lint 0 defaut.
Reste, et c est dit : apt-cacher-ng tourne toujours sur forge-01 que plus
aucun plan ne declare.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-10 14:53:48 -04:00
|
|
|
|
def preuve_depots_tiers_par_le_cache() -> tuple[bool, str]:
|
|
|
|
|
|
"""Aucun role ne vise un depot tiers en `https://` ecrit en dur.
|
|
|
|
|
|
|
|
|
|
|
|
LE CACHE NE SERT QUE CE QU'ON LUI DEMANDE EN CLAIR (2026-09-10). Les trois
|
|
|
|
|
|
fournisseurs applicatifs de Set-OPS — Grafana, Icinga, Smallstep — ne publient qu'en
|
|
|
|
|
|
HTTPS, et `apt-cacher-ng` ne relaie pas un tunnel. Tant qu'un role ecrit `https://`,
|
|
|
|
|
|
la machine sort elle-meme sur Internet, quel que soit le mandataire configure.
|
|
|
|
|
|
|
|
|
|
|
|
Le remede est en deux moities, et une seule ne sert a rien : le cache DECLARE un
|
|
|
|
|
|
`Remap-*` par fournisseur, et chaque role DEMANDE en `{{ ..._depot_schema }}://`, qui
|
|
|
|
|
|
vaut `http` des qu'un cache d'amorcage est declare. Cette preuve garde la seconde
|
|
|
|
|
|
moitie — la premiere se voit dans `serveur_artefacts_remaps`.
|
|
|
|
|
|
|
|
|
|
|
|
Elle vaut surtout pour les roles A VENIR : le jour ou l'on ajoutera un depot tiers,
|
|
|
|
|
|
la copie du geste sera refusee ici plutot que decouverte a la prochaine mesure de
|
|
|
|
|
|
trafic sortant.
|
|
|
|
|
|
"""
|
|
|
|
|
|
hotes = [r["hote"] for r in (
|
|
|
|
|
|
yaml.safe_load((RACINE / "roles" / "serveur_artefacts" / "defaults" / "main.yml")
|
|
|
|
|
|
.read_text(encoding="utf-8")) or {}
|
|
|
|
|
|
).get("serveur_artefacts_remaps") or []]
|
|
|
|
|
|
if not hotes:
|
|
|
|
|
|
return False, "`serveur_artefacts_remaps` est vide : aucun depot tiers n'est relaye."
|
|
|
|
|
|
fautifs: list[str] = []
|
|
|
|
|
|
for chemin in sorted((RACINE / "roles").glob("*/defaults/main.yml")):
|
|
|
|
|
|
texte = chemin.read_text(encoding="utf-8")
|
|
|
|
|
|
for ligne_no, ligne in enumerate(texte.splitlines(), 1):
|
|
|
|
|
|
nu = ligne.strip()
|
|
|
|
|
|
if nu.startswith("#"):
|
|
|
|
|
|
continue
|
|
|
|
|
|
for h in hotes:
|
|
|
|
|
|
if f"https://{h}" in nu:
|
|
|
|
|
|
fautifs.append(f"{chemin.relative_to(RACINE)}:{ligne_no} vise "
|
|
|
|
|
|
f"https://{h} en dur")
|
|
|
|
|
|
if fautifs:
|
|
|
|
|
|
return False, ("Depot tiers vise en HTTPS direct — la machine sortira sur Internet "
|
|
|
|
|
|
"au lieu de passer par le cache :\n - " + "\n - ".join(fautifs))
|
|
|
|
|
|
return True, (f"{len(hotes)} depot(s) tiers relaye(s) par le cache, aucun role ne les "
|
|
|
|
|
|
f"vise en https:// ecrit en dur.")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-10 20:21:13 -04:00
|
|
|
|
def preuve_clients_oidc_vises_sur_une_exposition() -> tuple[bool, str]:
|
|
|
|
|
|
"""Chaque client OIDC pointe un nom que le plan EXPOSE vraiment.
|
|
|
|
|
|
|
|
|
|
|
|
UNE LISTE QUI SUIT UNE AUTRE PREND DU RETARD (2026-09-10). Les URI de redirection des
|
|
|
|
|
|
clients Keycloak repetent, a la main, les FQDN que `plan/applications.yml` declare dans
|
|
|
|
|
|
`expose:`. Rien ne les relie : renommer une exposition regenere `hosts.yml`, le
|
|
|
|
|
|
certificat et la zone DNS — et laisse le client OIDC viser l'ancien nom.
|
|
|
|
|
|
|
|
|
|
|
|
Le defaut ne se voit pas au deploiement. Keycloak accepte sans broncher une URI de
|
|
|
|
|
|
redirection vers un nom qui ne resout plus ; c'est l'utilisateur qui le decouvre, au
|
|
|
|
|
|
retour du SSO, devant un `invalid_redirect_uri` ou une page morte.
|
|
|
|
|
|
|
|
|
|
|
|
Cette garde est ecrite EN MEME TEMPS que le premier renommage (`grafana` ->
|
|
|
|
|
|
`observatoire`), parce que c'est le seul moment ou l'on sait encore que les deux listes
|
|
|
|
|
|
existent.
|
|
|
|
|
|
"""
|
|
|
|
|
|
instance = instance_courante()
|
|
|
|
|
|
f_app = instance / "plan" / "applications.yml"
|
|
|
|
|
|
if not f_app.is_file():
|
|
|
|
|
|
return True, "Aucun plan d'applications : pas de client OIDC a verifier."
|
|
|
|
|
|
apps = (yaml.safe_load(f_app.read_text(encoding="utf-8")) or {}).get("applications") or {}
|
|
|
|
|
|
exposes = {str(f).strip().lower()
|
|
|
|
|
|
for a in apps.values() if isinstance(a, dict)
|
|
|
|
|
|
for f in (a.get("expose") or [])}
|
|
|
|
|
|
|
|
|
|
|
|
from urllib.parse import urlsplit
|
|
|
|
|
|
fautifs: list[str] = []
|
|
|
|
|
|
clients = 0
|
|
|
|
|
|
for gv in sorted((instance / "inventories").glob("*/group_vars/*.yml")):
|
|
|
|
|
|
data = yaml.safe_load(gv.read_text(encoding="utf-8")) or {}
|
|
|
|
|
|
if not isinstance(data, dict):
|
|
|
|
|
|
continue
|
|
|
|
|
|
for cl in (data.get("serveur_keycloak_clients") or []):
|
|
|
|
|
|
if not isinstance(cl, dict):
|
|
|
|
|
|
continue
|
|
|
|
|
|
clients += 1
|
|
|
|
|
|
nom = str(cl.get("clientId") or "?")
|
|
|
|
|
|
for champ in ("redirect_uris", "web_origins"):
|
|
|
|
|
|
for uri in (cl.get(champ) or []):
|
|
|
|
|
|
hote = urlsplit(str(uri)).hostname or ""
|
|
|
|
|
|
if hote in ("", "*"):
|
|
|
|
|
|
continue
|
|
|
|
|
|
if hote.lower() not in exposes:
|
|
|
|
|
|
fautifs.append(f"{gv.name}: client `{nom}` {champ} vise `{hote}`, "
|
|
|
|
|
|
f"qu'aucune application n'expose")
|
|
|
|
|
|
if not clients:
|
|
|
|
|
|
return True, "Aucun client OIDC declare dans cette instance."
|
|
|
|
|
|
if fautifs:
|
|
|
|
|
|
return False, ("Client OIDC vise un nom non expose — le SSO renverra l'utilisateur "
|
|
|
|
|
|
"dans le vide :\n - " + "\n - ".join(sorted(set(fautifs))))
|
|
|
|
|
|
return True, (f"{clients} client(s) OIDC, toutes leurs URI visent un FQDN que le plan "
|
|
|
|
|
|
f"expose ({len(exposes)} exposition(s)).")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-10 20:38:52 -04:00
|
|
|
|
def preuve_nom_public_vient_du_plan() -> tuple[bool, str]:
|
|
|
|
|
|
"""Un service servi sous un nom expose porte CE nom, pas celui que son role devine.
|
|
|
|
|
|
|
|
|
|
|
|
LE DEFAUT, MESURE LE 2026-09-10. Quatre roles — grafana, forgejo, nextcloud, keycloak —
|
|
|
|
|
|
portaient en defaut une devinette du nom sous lequel ils sont servis, de la forme
|
|
|
|
|
|
`grafana.{{ domaine_interne }}`. Tant que le plan suit la meme convention, la devinette
|
|
|
|
|
|
tombe juste et rien ne revele qu'il y a deux sources.
|
|
|
|
|
|
|
|
|
|
|
|
Renommer une exposition l'a revele d'un coup : nginx servait `observatoire`, le
|
|
|
|
|
|
certificat le portait, les deux zones le publiaient — et Grafana continuait de fabriquer
|
|
|
|
|
|
son URL de retour OIDC avec `grafana`. Le SSO renvoyait l'utilisateur sur un nom qui
|
|
|
|
|
|
n'existait plus.
|
|
|
|
|
|
|
|
|
|
|
|
`instancier` derive donc `<groupe>_hostname` de l'exposition UNIQUE que le plan declare.
|
|
|
|
|
|
Cette preuve garde la derivation : elle refuse qu'un hote serve un groupe expose sans
|
|
|
|
|
|
porter le nom du plan. Elle ne juge pas les groupes a deux expositions — aucun nom
|
|
|
|
|
|
canonique n'en sort, et le role garde alors la main.
|
2026-09-14 17:47:36 -04:00
|
|
|
|
|
|
|
|
|
|
ELLE REGARDE LES DEUX COTES DE LA CLOTURE DEPUIS LE 2026-09-14, et elle n'en regardait
|
|
|
|
|
|
qu'un. Ecrite pour l'instance MONTEE, elle ne voyait que des locataires. L'inventaire
|
|
|
|
|
|
du SITE ne derivait donc aucun nom — cinq groupes exposes, cinq devinettes de role.
|
|
|
|
|
|
|
|
|
|
|
|
Quatre tombaient juste par convention. Le cinquieme non : le plan du site expose
|
|
|
|
|
|
`observatoire.genese.internal`, `serveur_grafana` devinait `grafana.…`, et Grafana
|
|
|
|
|
|
fabriquait son `root_url` et ses liens avec un nom que rien ne sert. Exactement le
|
|
|
|
|
|
defaut du 2026-09-10, quatre jours plus tard, de l'autre cote — parce que la garde
|
|
|
|
|
|
avait ete posee la ou il s'etait montre, et pas la ou il pouvait se montrer aussi.
|
2026-09-10 20:38:52 -04:00
|
|
|
|
"""
|
2026-09-14 17:47:36 -04:00
|
|
|
|
lots: list[tuple[str, dict, dict]] = []
|
|
|
|
|
|
|
2026-09-10 20:38:52 -04:00
|
|
|
|
inv = RACINE / INV
|
2026-09-14 17:47:36 -04:00
|
|
|
|
if inv.is_file():
|
|
|
|
|
|
instance = instance_courante()
|
|
|
|
|
|
f_app = instance / "plan" / "applications.yml"
|
|
|
|
|
|
if f_app.is_file():
|
|
|
|
|
|
data = yaml.safe_load(inv.read_text(encoding="utf-8")) or {}
|
|
|
|
|
|
enfants = (data.get("all") or {}).get("children") or {}
|
|
|
|
|
|
hv: dict = {}
|
|
|
|
|
|
for etat in ("hotes_actifs", "hotes_planifies"):
|
|
|
|
|
|
for nom, v in ((enfants.get(etat) or {}).get("hosts") or {}).items():
|
|
|
|
|
|
hv[nom] = v or {}
|
|
|
|
|
|
lots.append((instance.name, _noms_exposes(f_app), {"enfants": enfants, "hv": hv}))
|
|
|
|
|
|
|
|
|
|
|
|
# LE SITE A UN INVENTAIRE DYNAMIQUE : on le lui demande, on ne lit pas un fichier.
|
|
|
|
|
|
site = _plan_du_site_applications()
|
|
|
|
|
|
if site:
|
|
|
|
|
|
rendu = _inventaire_du_site()
|
|
|
|
|
|
if rendu:
|
|
|
|
|
|
lots.append(("SITE", _noms_exposes(site), rendu))
|
|
|
|
|
|
|
|
|
|
|
|
if not lots:
|
|
|
|
|
|
return True, "Ni instance montee ni plan de site : rien a verifier."
|
|
|
|
|
|
|
|
|
|
|
|
fautes: list[str] = []
|
|
|
|
|
|
verifies = 0
|
|
|
|
|
|
for etiquette, attendu, rendu in lots:
|
|
|
|
|
|
enfants, hv = rendu["enfants"], rendu["hv"]
|
2026-09-15 11:47:50 -04:00
|
|
|
|
for (hote, groupe), fqdn in sorted(attendu.items()):
|
|
|
|
|
|
if hote not in ((enfants.get(groupe) or {}).get("hosts") or {}):
|
|
|
|
|
|
continue # le plan declare l'app ailleurs que la ou le groupe est pose
|
|
|
|
|
|
vu = str((hv.get(hote) or {}).get(f"{groupe}_hostname") or "")
|
|
|
|
|
|
verifies += 1
|
|
|
|
|
|
if vu != fqdn:
|
|
|
|
|
|
fautes.append(f"[{etiquette}] {hote} porte {groupe} sans "
|
|
|
|
|
|
f"`{groupe}_hostname` = `{fqdn}` "
|
|
|
|
|
|
f"(inventaire : {vu or 'absent'})")
|
2026-09-14 17:47:36 -04:00
|
|
|
|
if fautes:
|
|
|
|
|
|
return False, ("Un service ne porte pas le nom que le plan lui donne — ses URL de "
|
|
|
|
|
|
"retour viseront l'ancien :\n - " + "\n - ".join(fautes[:6]))
|
|
|
|
|
|
if not verifies:
|
|
|
|
|
|
return True, "Aucun groupe expose : rien a deriver."
|
|
|
|
|
|
return True, (f"{verifies} service(s) expose(s) portent le nom du plan, "
|
|
|
|
|
|
f"sur {len(lots)} inventaire(s) : "
|
|
|
|
|
|
+ ", ".join(e for e, _, _ in lots) + ".")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _noms_exposes(f_app) -> dict:
|
2026-09-15 11:47:50 -04:00
|
|
|
|
"""(machine, groupe) -> le nom unique que le plan lui donne.
|
|
|
|
|
|
|
|
|
|
|
|
PAR HOTE, ET PAS PAR GROUPE SEUL (2026-09-15). Cette preuve indexait par groupe, comme
|
|
|
|
|
|
la derivation qu'elle garde — elle validait donc la meme erreur des deux cotes. Sur
|
|
|
|
|
|
Chezlepro, `serveur_oauth2_proxy` sert la vigie sur un hote et la console sur un
|
|
|
|
|
|
autre : les deux ont recu le DERNIER nom, et la preuve a rendu vert.
|
|
|
|
|
|
|
|
|
|
|
|
Une garde qui reproduit le raisonnement qu'elle verifie ne verifie rien.
|
|
|
|
|
|
"""
|
2026-09-10 20:38:52 -04:00
|
|
|
|
apps = (yaml.safe_load(f_app.read_text(encoding="utf-8")) or {}).get("applications") or {}
|
2026-09-15 11:47:50 -04:00
|
|
|
|
attendu: dict[tuple[str, str], str] = {}
|
2026-09-10 20:38:52 -04:00
|
|
|
|
for a in apps.values():
|
|
|
|
|
|
if not isinstance(a, dict):
|
|
|
|
|
|
continue
|
|
|
|
|
|
exp = [str(f).strip() for f in (a.get("expose") or []) if str(f).strip()]
|
2026-09-15 11:47:50 -04:00
|
|
|
|
g, h = str(a.get("groupe") or ""), str(a.get("hote") or "")
|
|
|
|
|
|
if g and h and len(exp) == 1:
|
|
|
|
|
|
attendu[(h, g)] = exp[0]
|
2026-09-14 17:47:36 -04:00
|
|
|
|
return attendu
|
2026-09-10 20:38:52 -04:00
|
|
|
|
|
|
|
|
|
|
|
2026-09-14 17:47:36 -04:00
|
|
|
|
def _plan_du_site_applications():
|
|
|
|
|
|
"""Le `applications.yml` du site MONTE, ou None s'il n'y en a pas."""
|
|
|
|
|
|
lien = RACINE / "underlay.yml"
|
|
|
|
|
|
if not lien.exists():
|
|
|
|
|
|
return None
|
|
|
|
|
|
f = lien.resolve().parent / "plan" / "applications.yml"
|
|
|
|
|
|
return f if f.is_file() else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _inventaire_du_site():
|
|
|
|
|
|
"""L'inventaire DYNAMIQUE du site, dans la forme que cette preuve consomme.
|
|
|
|
|
|
|
|
|
|
|
|
On l'EXECUTE au lieu de lire un fichier : c'est un script, et son resultat est la
|
|
|
|
|
|
seule chose qu'Ansible verra. Juger sa source reviendrait a relire le raisonnement
|
|
|
|
|
|
au lieu du resultat.
|
|
|
|
|
|
"""
|
|
|
|
|
|
import json
|
|
|
|
|
|
import subprocess
|
|
|
|
|
|
try:
|
|
|
|
|
|
sortie = subprocess.run(
|
|
|
|
|
|
[sys.executable, str(RACINE / "scripts" / "site_inventaire.py"), "--list"],
|
|
|
|
|
|
capture_output=True, text=True, timeout=120, cwd=RACINE)
|
|
|
|
|
|
except (OSError, subprocess.SubprocessError):
|
|
|
|
|
|
return None
|
|
|
|
|
|
if sortie.returncode != 0:
|
|
|
|
|
|
return None
|
|
|
|
|
|
try:
|
|
|
|
|
|
d = json.loads(sortie.stdout)
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
return None
|
|
|
|
|
|
return {"enfants": {g: {"hosts": {h: None for h in (v.get("hosts") or [])}}
|
|
|
|
|
|
for g, v in d.items() if g != "_meta"},
|
|
|
|
|
|
"hv": (d.get("_meta") or {}).get("hostvars") or {}}
|
2026-09-10 20:38:52 -04:00
|
|
|
|
|
|
|
|
|
|
|
reconstruction a froid : les noms derives naissent justes, et une cle vide tombe
Quatrieme reconstruction depuis zero, 32 min 14 s, 14/14. Elle avait un but
precis : trois roles dependent desormais d une variable que le generateur
pose. A froid, si instancier ne la posait pas, le defaut du role reprendrait
la main en silence — juste pour forge et cloud, faux pour observatoire.
Le certificat ne a froid porte observatoire et vigie, aucun ancien nom, et les
deux retours SSO derivent juste sans reprise.
Un seul echec, sans rapport : sur une machine des quatorze, get_url a rendu
0 octet SANS ERREUR — le cache a servi un 200 au corps vide. Le defaut s est
lu deux cents lignes plus loin, dans un apt qui accusait la signature. La
garde posee hier ne mordait pas : retirer une ressource vide ne vaut qu au
passage suivant, quand le fichier existe deja.
Les cinq roles qui telechargent une cle la mesurent maintenant dans la meme
execution. P68 garde le motif. infra-mail-01 redeployee, make valider a
0 echec sur 14 machines.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-11 07:57:24 -04:00
|
|
|
|
def preuve_cle_telechargee_non_vide() -> tuple[bool, str]:
|
|
|
|
|
|
"""Une cle de depot TELECHARGEE est mesuree avant d'etre utilisee.
|
|
|
|
|
|
|
|
|
|
|
|
UNE REUSSITE N'EST PAS UN CONTENU (mesure le 2026-09-11). Sur une reconstruction a
|
|
|
|
|
|
froid, `get_url` a rendu 0 octet SANS ERREUR sur une machine des quatorze : le cache a
|
|
|
|
|
|
servi un 200 au corps vide. Le role a continue, satisfait.
|
|
|
|
|
|
|
|
|
|
|
|
Le defaut ne s'est pas lu la. Il s'est lu deux cents lignes plus loin, dans un `apt` qui
|
|
|
|
|
|
accusait la SIGNATURE : « Missing key 35BAA0B3..., which is needed to verify signature ».
|
|
|
|
|
|
Un message qui envoie chercher une cle revoquee chez le fournisseur, alors que le
|
|
|
|
|
|
fichier local faisait zero octet. C'est la meme famille que l'index tronque du cache la
|
|
|
|
|
|
veille : l'octet manquant se denonce toujours ailleurs qu'ou il manque.
|
|
|
|
|
|
|
|
|
|
|
|
La garde posee d'abord — « retirer une ressource VIDE avant de la redemander » — ne
|
|
|
|
|
|
mordait qu'au passage SUIVANT : au premier, le fichier n'existe pas encore, il n'y a
|
|
|
|
|
|
rien a retirer. Elle repare le second essai, elle ne protege pas le premier.
|
|
|
|
|
|
|
|
|
|
|
|
Cette preuve exige donc que tout role qui TELECHARGE une cle la MESURE dans la meme
|
|
|
|
|
|
execution. Les roles qui deposent une cle embarquee (`copy` depuis `files/`) n'entrent
|
|
|
|
|
|
pas dans le compte : rien de reseau ne s'interpose, l'octet ne peut pas manquer.
|
|
|
|
|
|
"""
|
|
|
|
|
|
manquants: list[str] = []
|
|
|
|
|
|
verifies = 0
|
|
|
|
|
|
for chemin in sorted((RACINE / "roles").glob("*/tasks/main.yml")):
|
|
|
|
|
|
role = chemin.parent.parent.name
|
|
|
|
|
|
try:
|
|
|
|
|
|
taches = yaml.safe_load(chemin.read_text(encoding="utf-8")) or []
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
continue
|
|
|
|
|
|
if not isinstance(taches, list):
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
def _mod(t: dict, nom: str) -> dict | None:
|
|
|
|
|
|
for cle in (nom, f"ansible.builtin.{nom}"):
|
|
|
|
|
|
if isinstance(t.get(cle), dict):
|
|
|
|
|
|
return t[cle]
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
telecharge = [t for t in taches if isinstance(t, dict)
|
|
|
|
|
|
and (_mod(t, "get_url") or {}).get("dest", "")
|
|
|
|
|
|
and "depot_cle_fichier" in str((_mod(t, "get_url") or {}).get("dest"))]
|
|
|
|
|
|
if not telecharge:
|
|
|
|
|
|
continue
|
|
|
|
|
|
verifies += 1
|
|
|
|
|
|
mesure = any(
|
|
|
|
|
|
isinstance(t, dict) and _mod(t, "assert")
|
|
|
|
|
|
and any("stat.size" in str(c) and ">" in str(c)
|
|
|
|
|
|
for c in (_mod(t, "assert") or {}).get("that") or [])
|
|
|
|
|
|
for t in taches)
|
|
|
|
|
|
if not mesure:
|
|
|
|
|
|
manquants.append(f"{role} telecharge une cle de depot sans mesurer sa taille")
|
|
|
|
|
|
if not verifies:
|
|
|
|
|
|
return False, ("Aucun role ne telecharge de cle de depot : la preuve a perdu son "
|
|
|
|
|
|
"objet, ou le motif a change.")
|
|
|
|
|
|
if manquants:
|
|
|
|
|
|
return False, ("Cle de depot telechargee sans mesure — un corps vide passera, et "
|
|
|
|
|
|
"apt accusera la signature :\n - " + "\n - ".join(manquants))
|
|
|
|
|
|
return True, (f"{verifies} role(s) telechargent une cle de depot, tous la mesurent "
|
|
|
|
|
|
f"avant de s'en servir.")
|
|
|
|
|
|
|
|
|
|
|
|
|
pools : le genome ne nait plus chez un locataire
`Chezlepro-17` contenait VINGT ET UNE VM : les quatorze du locataire ET les sept du
genome. `OPS-Chezlepro` et `OPS-Technolibre`, crees a la main, etaient vides.
La cause : `site-creer` appelle `cloner-vm`, qui derive son pool par `--pool-actif`,
c est-a-dire le pool du TENANT lie. Les machines du site heritaient du locataire
courant. Range a la main, ca se serait defait au prochain `site-creer` — sans un mot,
parce que la VM est bien creee, bien nommee, bien adressee. Seule son appartenance
est fausse, et rien ne la regarde.
- `--pool-site` rend le nom invariable du pool du genome. Option DISTINCTE, pas un
drapeau sur la premiere : une fonction qui repond aux deux questions finit par se
tromper d appelant.
- `cloner-vm` accepte une surcharge POOL= ; `site-creer` la nomme. Substitution au
niveau MAKE, pas shell : POOL arrive du sur-make comme variable make, et $${POOL}
ne l aurait jamais vue.
- P71 exige que `site-creer` nomme son pool. Eprouvee dans les deux sens : passe sur
le Makefile sain, tire des qu on retire l argument.
Applique au cluster : Site-OPS 9 VM (7 du site + 2 gabarits), OPS-Chezlepro 14,
OPS-Patient0 5. Chezlepro-17, Patient0-29 et Set-OPS supprimes une fois vides. Les
pools anterieurs a Set-OPS et les quinze VM hors pool n ont pas ete touches.
make prouver : 70 OK, 0 echec, 1 saute.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-13 04:12:27 -04:00
|
|
|
|
|
|
|
|
|
|
def preuve_pool_du_site_nest_pas_celui_du_tenant() -> tuple[bool, str]:
|
|
|
|
|
|
"""Les machines du site naissent-elles dans le pool du SITE ?
|
|
|
|
|
|
|
|
|
|
|
|
MESURE DU 2026-09-13 : `Chezlepro-17` contenait VINGT ET UNE VM — les quatorze du
|
|
|
|
|
|
locataire ET les sept du genome. Cause : `site-creer` appelle `cloner-vm`, qui derive
|
|
|
|
|
|
son pool par `--pool-actif`, c'est-a-dire le pool du TENANT lie.
|
|
|
|
|
|
|
|
|
|
|
|
Le site ne derive d'aucun index : son pool est invariable. Un appel qui ne le dit pas
|
|
|
|
|
|
herite du locataire, et le rangement fait a la main se defait au prochain `site-creer`
|
|
|
|
|
|
— sans un mot, parce que la VM est bien creee, bien nommee, bien adressee.
|
|
|
|
|
|
|
|
|
|
|
|
LA GARDE EST TEXTUELLE, ET C'EST LE BON NIVEAU : ce qu'on protege, c'est qu'un appel
|
|
|
|
|
|
NOMME son pool. Derouler make pour le lire couterait un cluster joignable, ce que ces
|
|
|
|
|
|
preuves n'ont jamais.
|
|
|
|
|
|
"""
|
|
|
|
|
|
import re as _re
|
|
|
|
|
|
mk = (RACINE / "Makefile").read_text(encoding="utf-8")
|
|
|
|
|
|
m = _re.search(r"^site-creer:.*?(?=^\.PHONY|^[a-zA-Z0-9_-]+:)", mk, _re.M | _re.S)
|
|
|
|
|
|
if not m:
|
|
|
|
|
|
return False, "Cible `site-creer` introuvable dans le Makefile."
|
|
|
|
|
|
recette = m.group(0)
|
|
|
|
|
|
if "cloner-vm" not in recette:
|
|
|
|
|
|
return True, "`site-creer` ne clone aucune VM : rien a nommer."
|
|
|
|
|
|
if not _re.search(r"POOL=.*--pool-site", recette):
|
|
|
|
|
|
return False, ("`site-creer` appelle `cloner-vm` sans nommer le pool du site : "
|
|
|
|
|
|
"les machines du genome naitront dans le pool du TENANT lie.")
|
|
|
|
|
|
|
|
|
|
|
|
devis = (RACINE / "scripts" / "devis_proxmox_pools.py").read_text(encoding="utf-8")
|
|
|
|
|
|
if "--pool-site" not in devis:
|
|
|
|
|
|
return False, "`--pool-site` est invoque par le Makefile mais le devis ne l'offre pas."
|
|
|
|
|
|
return True, "`site-creer` nomme `--pool-site` ; le pool du genome ne peut plus etre celui d'un tenant."
|
|
|
|
|
|
|
|
|
|
|
|
|
annuaire : un compte de service par consommateur, et la porte se ferme
Keycloak, Dovecot, Postfix et Icinga Web 2 se liaient TOUS avec cn=admin, le compte
d administration de la base. C est le rootDN : slapd lui fait contourner toutes les
ACL. Un seul secret, quatre services, tous les droits sur l arbre — pour ce qui est,
trois fois sur quatre, une simple lecture.
Et les droits livres par Debian etaient intacts : `to * by * read`. Sur ldap://, sans
s authentifier, une machine du reseau enumerait tous les comptes et toutes les
adresses. Des comptes a droits mesures n auraient rien valu tant que cette ligne
restait : on aurait ferme la porte en laissant la fenetre.
L indice etait deja dans le depot. `validatePasswordPolicy` existe parce que slapd
n applique pas ses controles de qualite au rootDN : la consequence etait compensee,
la cause intacte.
- ou=services, un compte par consommateur, secret propre en voute
- sept regles d acces posees EN ENTIER (state: exact) : l ordre est la regle, et
inserer c est parier sur ce que le paquet aura mis avant nous
- amorcage_acces garde le compte d administration, NOMME comme l exception : il ne
consomme pas l annuaire, il le provisionne depuis la socket locale
- la sonde passe de -x a -Y EXTERNAL : elle lisait en anonyme et aurait annonce un
annuaire VIDE sur un annuaire parfaitement sain
- la rotation du compte d administration devient possible (elle n etait posee qu a
l installation, par debconf : la voute et slapd divergeaient en silence)
Quatre marches payees en chemin :
1. un cinquieme appelant oublie, dont l echec etait masque par no_log — la garde
refuse desormais SANS no_log : elle nomme la cle absente, jamais son contenu
2. la federation Keycloak ne reecrivait son bindDn que si l URL ou le mode changeaient
— nouveau secret, ancien nom, error code 49
3. la rotation placee APRES les taches qui se lient en administrateur
4. ansible-vault et son tube : sortie non bloquante = echec silencieux, la voute
paraissait tournee et etait identique a l octet
P72 exige que tout role incluant resoudre_annuaire NOMME son compte, et qu aucun sauf
amorcage_acces ne nomme admin. Eprouvee dans les deux sens.
Verifie sur l infrastructure : chaque compte lit ce qu il doit, aucun ne voit les
autres, la lecture anonyme rend 0 entree, et les quatre services repondent (doveadm
user, postmap -q, decouverte OIDC 200, portier SSO 200).
vault_openldap_admin et vault_ldap_bind_postfix renouveles : les deux avaient transite
en clair par une session d exploitation. Les anciennes valeurs rendent Invalid
credentials (49).
make prouver : 71 OK, 0 echec, 1 saute. ansible-lint : 0 failure, profil production.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-13 13:36:38 -04:00
|
|
|
|
|
intrants du site : ce qu un locataire doit savoir pour l habiter, derive
Un locataire ECRIT les adresses des services de son site — resolveur, cache, forge,
depot de sauvegarde, plan d administration, sortie. Une copie se perime, et deux
l avaient fait en deux jours avec la meme forme : `serveur_ops_forge_amont` visait
10.0.33.11 quand la forge sert en 10.37.33.11, et `ac-racine-site.crt` portait la
racine d avant la reconstruction du site. Rien ne les relisait.
`make site-intrants` lit le plan du site et rend le contrat — sept valeurs, toutes
derivees. `make site-intrants-verifier` les confronte a ce que le locataire declare,
et P73 en fait une preuve. Elle a trouve le defaut de la forge des sa premiere
execution.
Le contrat se DERIVE du plan du site, pas d une liste tenue a part : ajouter un
service prete au site l ajoute au contrat, sans qu on ait a y penser.
P69 RESTREINTE AU COUPLE MONTE. Elle balayait tous les depots OPS-* et les comparait
au site monte. Elle avait raison tant qu un seul site existait : une adresse en
10.x.3z ne pouvait designer que lui. Deux sites decoupent leurs zones de la meme
facon — c est le but, un locataire doit pouvoir habiter l un ou l autre sans se
renumeroter. Le troisieme octet a cesse de distinguer « mon site » d « un autre
site » : 10.31.34.11, juste pour un locataire de TechnoLibre, etait declare faux
parce que Chezlepro etait monte.
Un locataire n appartient a aucun site — il en habite un, choisi par le symlink au
deploiement. La seule paire jugeable est celle qui est montee. Meme portee que P73.
Une marche payee : la premiere version de site_intrants recopiait la resolution
d instance au lieu de la partager. P41 a mordu — neuf modules avaient deja porte
chacun leur copie, et cinq defauts en etaient sortis en cinq jours.
make prouver : 72 OK, 0 echec, 1 saute. ansible-lint : 0 failure.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-13 16:18:12 -04:00
|
|
|
|
|
gabarit dore : une seule declaration, et les deux souches se rejoignent
Le plan du site disait 9006 (modeleSetOPS-minimal), l underlay 99998 — que le plan
nomme lui-meme `precedent`. Le Makefile derive VMID_MODELE du PLAN : les locataires
clonaient 9006. `site_machines` lisait l autre : les machines du SITE clonaient 99998.
POURQUOI CA NE S EST JAMAIS VU. Les deux VMID designent un gabarit valide. Les deux
clonent, les deux demarrent, rien n echoue. La flotte etait issue de deux souches, et
aucun message ne pouvait le dire puisqu aucune operation n avait echoue.
`site_machines` lit desormais underlay.gabarit(), la meme source que tout le monde. Le
motif de la seconde declaration est preserve : cette source lit le PLAN DU SITE, jamais
celui du tenant actif, donc elle ne depend d aucun symlink `instance`.
materialisation.vmid_modele retire des deux cartes. `precedent:` reste au plan — il dit
d ou l on vient et n est jamais clone.
P74 refuse toute redeclaration. Eprouvee dans les deux sens.
SITE-Technolibre visait 99998, repris de la carte de Chezlepro au lieu de son plan.
Corrige avant son premier clonage.
make prouver : 73 OK, 0 echec, 1 saute.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-13 17:54:56 -04:00
|
|
|
|
|
|
|
|
|
|
def preuve_gabarit_une_seule_declaration() -> tuple[bool, str]:
|
|
|
|
|
|
"""Le gabarit dore est-il declare a UN SEUL endroit ?
|
|
|
|
|
|
|
|
|
|
|
|
C'est de lui que descend chaque VM de chaque ecosysteme. Il vivait a deux endroits :
|
|
|
|
|
|
`plan/10-intrants.yml` (`gabarit:`) et `underlay.yml`
|
|
|
|
|
|
(`materialisation.vmid_modele`). Les deux ont diverge — le plan disait 9006, l'underlay
|
|
|
|
|
|
99998, que le plan nomme justement `precedent`.
|
|
|
|
|
|
|
|
|
|
|
|
CE QUE LA DIVERGENCE PRODUISAIT, et pourquoi elle etait invisible : les deux VMID
|
|
|
|
|
|
designent un gabarit VALIDE, donc les deux clonent. Les machines du SITE naissaient de
|
|
|
|
|
|
l'ancien, celles des LOCATAIRES du nouveau — le Makefile derivait deja du plan. Aucune
|
|
|
|
|
|
erreur, aucun message : deux flottes issues de deux souches.
|
|
|
|
|
|
|
|
|
|
|
|
Le champ `precedent:` est ADMIS et meme utile — il dit d'ou l'on vient. Ce qui est
|
|
|
|
|
|
refuse, c'est une seconde declaration de ce qu'il faut CLONER.
|
|
|
|
|
|
"""
|
|
|
|
|
|
import yaml as _y
|
|
|
|
|
|
try:
|
|
|
|
|
|
sys.path.insert(0, str(RACINE / "scripts"))
|
|
|
|
|
|
import underlay as _u
|
|
|
|
|
|
c = _u.chemin()
|
|
|
|
|
|
if not c:
|
|
|
|
|
|
return True, "Aucun site monte : aucun gabarit a declarer."
|
|
|
|
|
|
carte = _y.safe_load(c.read_text(encoding="utf-8")) or {}
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
return True, f"Carte illisible ({type(e).__name__}) : rien a comparer."
|
|
|
|
|
|
|
|
|
|
|
|
mat = ((carte.get("underlay") or {}).get("materialisation") or {})
|
|
|
|
|
|
if "vmid_modele" in mat:
|
|
|
|
|
|
return False, ("`underlay.yml` redeclare `materialisation.vmid_modele` — le gabarit "
|
|
|
|
|
|
"se declare au PLAN du site (`gabarit.vmid`), et nulle part ailleurs. "
|
|
|
|
|
|
"Deux declarations divergent, et les deux clonent : la flotte se "
|
|
|
|
|
|
"retrouve issue de deux souches sans qu'aucun message ne le dise.")
|
|
|
|
|
|
g = _u.gabarit()
|
|
|
|
|
|
if not g.get("vmid"):
|
|
|
|
|
|
return False, ("Aucun `gabarit.vmid` au plan du site — chaque VM en descend, et il "
|
|
|
|
|
|
"ne peut pas etre decide par un tenant.")
|
|
|
|
|
|
p = f", precedent {g['precedent']}" if g.get("precedent") else ""
|
|
|
|
|
|
return True, (f"Gabarit declare une seule fois : VMID {g['vmid']} "
|
|
|
|
|
|
f"« {g.get('nom', '?')} »{p}.")
|
|
|
|
|
|
|
|
|
|
|
|
|
intrants du site : ce qu un locataire doit savoir pour l habiter, derive
Un locataire ECRIT les adresses des services de son site — resolveur, cache, forge,
depot de sauvegarde, plan d administration, sortie. Une copie se perime, et deux
l avaient fait en deux jours avec la meme forme : `serveur_ops_forge_amont` visait
10.0.33.11 quand la forge sert en 10.37.33.11, et `ac-racine-site.crt` portait la
racine d avant la reconstruction du site. Rien ne les relisait.
`make site-intrants` lit le plan du site et rend le contrat — sept valeurs, toutes
derivees. `make site-intrants-verifier` les confronte a ce que le locataire declare,
et P73 en fait une preuve. Elle a trouve le defaut de la forge des sa premiere
execution.
Le contrat se DERIVE du plan du site, pas d une liste tenue a part : ajouter un
service prete au site l ajoute au contrat, sans qu on ait a y penser.
P69 RESTREINTE AU COUPLE MONTE. Elle balayait tous les depots OPS-* et les comparait
au site monte. Elle avait raison tant qu un seul site existait : une adresse en
10.x.3z ne pouvait designer que lui. Deux sites decoupent leurs zones de la meme
facon — c est le but, un locataire doit pouvoir habiter l un ou l autre sans se
renumeroter. Le troisieme octet a cesse de distinguer « mon site » d « un autre
site » : 10.31.34.11, juste pour un locataire de TechnoLibre, etait declare faux
parce que Chezlepro etait monte.
Un locataire n appartient a aucun site — il en habite un, choisi par le symlink au
deploiement. La seule paire jugeable est celle qui est montee. Meme portee que P73.
Une marche payee : la premiere version de site_intrants recopiait la resolution
d instance au lieu de la partager. P41 a mordu — neuf modules avaient deja porte
chacun leur copie, et cinq defauts en etaient sortis en cinq jours.
make prouver : 72 OK, 0 echec, 1 saute. ansible-lint : 0 failure.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-13 16:18:12 -04:00
|
|
|
|
def preuve_locataire_suit_son_site() -> tuple[bool, str]:
|
|
|
|
|
|
"""Le locataire monte designe-t-il encore les services de son site REEL ?
|
|
|
|
|
|
|
|
|
|
|
|
UN SITE EST UNE INTERFACE. Un locataire qui l'habite a besoin d'un petit nombre de
|
|
|
|
|
|
valeurs : ou resoudre, ou prendre ses paquets et ses binaires, ou cloner le genome, ou
|
|
|
|
|
|
deposer son etat. Il les ECRIT dans ses intrants — et une copie se perime.
|
|
|
|
|
|
|
|
|
|
|
|
DEUX FOIS EN DEUX JOURS, avec la meme forme :
|
|
|
|
|
|
- `serveur_ops_forge_amont: https://10.0.33.11` alors que la forge sert en
|
|
|
|
|
|
10.37.33.11 depuis que le site a pris son propre index. Le commentaire au-dessus
|
|
|
|
|
|
expliquait encore pourquoi l'ancienne adresse etait dans les SAN du certificat :
|
|
|
|
|
|
le raisonnement intact, la valeur perimee.
|
|
|
|
|
|
- une carte de site dans le depot du LOCATAIRE, decrivant un index 23 quand le vrai
|
|
|
|
|
|
disait 31.
|
|
|
|
|
|
|
|
|
|
|
|
P69 couvrait deja `dns_amorcage` et `artefacts_amorcage`. Celle-ci couvre le reste du
|
|
|
|
|
|
contrat, et se derive du site plutot que d'une liste tenue a part : ajouter un service
|
|
|
|
|
|
prete au site l'ajoute ici, sans qu'on ait a y penser.
|
|
|
|
|
|
"""
|
|
|
|
|
|
import subprocess
|
|
|
|
|
|
r = subprocess.run([sys.executable, str(RACINE / "scripts" / "site_intrants.py"),
|
|
|
|
|
|
"--verifier"], capture_output=True, text=True, cwd=RACINE)
|
|
|
|
|
|
sortie = (r.stdout + r.stderr).strip()
|
|
|
|
|
|
if r.returncode == 0:
|
|
|
|
|
|
return True, sortie.splitlines()[0] if sortie else "Aucun site monte."
|
|
|
|
|
|
return False, " ".join(l.strip(" -") for l in sortie.splitlines() if l.strip())
|
|
|
|
|
|
|
|
|
|
|
|
|
annuaire : un compte de service par consommateur, et la porte se ferme
Keycloak, Dovecot, Postfix et Icinga Web 2 se liaient TOUS avec cn=admin, le compte
d administration de la base. C est le rootDN : slapd lui fait contourner toutes les
ACL. Un seul secret, quatre services, tous les droits sur l arbre — pour ce qui est,
trois fois sur quatre, une simple lecture.
Et les droits livres par Debian etaient intacts : `to * by * read`. Sur ldap://, sans
s authentifier, une machine du reseau enumerait tous les comptes et toutes les
adresses. Des comptes a droits mesures n auraient rien valu tant que cette ligne
restait : on aurait ferme la porte en laissant la fenetre.
L indice etait deja dans le depot. `validatePasswordPolicy` existe parce que slapd
n applique pas ses controles de qualite au rootDN : la consequence etait compensee,
la cause intacte.
- ou=services, un compte par consommateur, secret propre en voute
- sept regles d acces posees EN ENTIER (state: exact) : l ordre est la regle, et
inserer c est parier sur ce que le paquet aura mis avant nous
- amorcage_acces garde le compte d administration, NOMME comme l exception : il ne
consomme pas l annuaire, il le provisionne depuis la socket locale
- la sonde passe de -x a -Y EXTERNAL : elle lisait en anonyme et aurait annonce un
annuaire VIDE sur un annuaire parfaitement sain
- la rotation du compte d administration devient possible (elle n etait posee qu a
l installation, par debconf : la voute et slapd divergeaient en silence)
Quatre marches payees en chemin :
1. un cinquieme appelant oublie, dont l echec etait masque par no_log — la garde
refuse desormais SANS no_log : elle nomme la cle absente, jamais son contenu
2. la federation Keycloak ne reecrivait son bindDn que si l URL ou le mode changeaient
— nouveau secret, ancien nom, error code 49
3. la rotation placee APRES les taches qui se lient en administrateur
4. ansible-vault et son tube : sortie non bloquante = echec silencieux, la voute
paraissait tournee et etait identique a l octet
P72 exige que tout role incluant resoudre_annuaire NOMME son compte, et qu aucun sauf
amorcage_acces ne nomme admin. Eprouvee dans les deux sens.
Verifie sur l infrastructure : chaque compte lit ce qu il doit, aucun ne voit les
autres, la lecture anonyme rend 0 entree, et les quatre services repondent (doveadm
user, postmap -q, decouverte OIDC 200, portier SSO 200).
vault_openldap_admin et vault_ldap_bind_postfix renouveles : les deux avaient transite
en clair par une session d exploitation. Les anciennes valeurs rendent Invalid
credentials (49).
make prouver : 71 OK, 0 echec, 1 saute. ansible-lint : 0 failure, profil production.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-13 13:36:38 -04:00
|
|
|
|
def preuve_annuaire_sans_compte_maitre() -> tuple[bool, str]:
|
|
|
|
|
|
"""Aucun service ne se lie-t-il a l'annuaire avec son compte d'administration ?
|
|
|
|
|
|
|
|
|
|
|
|
MESURE DU 2026-09-13 : les quatre consommateurs — Keycloak, Dovecot, Postfix,
|
|
|
|
|
|
Icinga Web 2 — s'y liaient tous avec `cn=admin`. C'est le rootDN : slapd lui fait
|
|
|
|
|
|
CONTOURNER TOUTES LES ACL, y compris ses propres controles de qualite de mot de passe.
|
|
|
|
|
|
Un seul secret, quatre services, tous les droits sur l'arbre — pour ce qui est, trois
|
|
|
|
|
|
fois sur quatre, une simple lecture.
|
|
|
|
|
|
|
|
|
|
|
|
LE DEPOT PORTAIT DEJA L'INDICE. `validatePasswordPolicy` existe parce que « slapd
|
|
|
|
|
|
n'applique pas ses controles de qualite au rootDN » : la consequence etait compensee,
|
|
|
|
|
|
la cause intacte.
|
|
|
|
|
|
|
|
|
|
|
|
CE QUI EST EXIGE ICI : tout role qui inclut `resoudre_annuaire` NOMME son compte de
|
|
|
|
|
|
service. Une seule exception, et elle est nommee — `amorcage_acces` ne consomme pas
|
|
|
|
|
|
l'annuaire, il le PROVISIONNE : il cree le compte d'amorcage depuis la socket locale
|
|
|
|
|
|
de la machine qui porte l'annuaire. C'est le geste de l'exploitant.
|
|
|
|
|
|
|
|
|
|
|
|
Sans cette garde, un role neuf heriterait du defaut du role partage et se lierait
|
|
|
|
|
|
silencieusement avec ce que ce defaut designe.
|
|
|
|
|
|
"""
|
|
|
|
|
|
import re as _re
|
|
|
|
|
|
roles_dir = RACINE / "roles"
|
|
|
|
|
|
incluent: dict[str, str] = {}
|
|
|
|
|
|
for role in sorted(p.name for p in roles_dir.iterdir() if p.is_dir()):
|
|
|
|
|
|
td = roles_dir / role / "tasks"
|
|
|
|
|
|
if not td.is_dir():
|
|
|
|
|
|
continue
|
|
|
|
|
|
for tf in sorted(td.glob("*.yml")):
|
|
|
|
|
|
texte = tf.read_text(encoding="utf-8")
|
|
|
|
|
|
if _re.search(r"name:\s*resoudre_annuaire\b", texte):
|
|
|
|
|
|
incluent[role] = texte
|
|
|
|
|
|
|
|
|
|
|
|
if not incluent:
|
|
|
|
|
|
return True, "Aucun role n'inclut `resoudre_annuaire` : rien a garder."
|
|
|
|
|
|
|
|
|
|
|
|
muets, maitres = [], []
|
|
|
|
|
|
for role, texte in sorted(incluent.items()):
|
|
|
|
|
|
cn = _re.search(r"resoudre_annuaire_bind_cn:\s*[\"']?([\w-]+)", texte)
|
|
|
|
|
|
if cn is None:
|
|
|
|
|
|
muets.append(role)
|
|
|
|
|
|
elif cn.group(1) == "admin" and role != "amorcage_acces":
|
|
|
|
|
|
maitres.append(role)
|
|
|
|
|
|
|
|
|
|
|
|
if maitres:
|
|
|
|
|
|
return False, ("Se lie(nt) a l'annuaire avec son compte d'administration — tous les "
|
|
|
|
|
|
"droits sur l'arbre pour une lecture : " + ", ".join(maitres))
|
|
|
|
|
|
if muets:
|
|
|
|
|
|
return False, ("N'a/ont pas nomme leur compte de service et heriteront du defaut du "
|
|
|
|
|
|
"role partage : " + ", ".join(muets))
|
|
|
|
|
|
return True, (f"{len(incluent)} role(s) consultent l'annuaire, chacun avec SON compte de "
|
|
|
|
|
|
"service ; seul `amorcage_acces` garde celui d'administration, et il "
|
|
|
|
|
|
"provisionne au lieu de consommer.")
|
|
|
|
|
|
|
|
|
|
|
|
|
depot de binaires : le site tient ce que les runners allaient chercher
Le cache du site couvrait apt ; quatre artefacts arrivaient autrement, parce qu ils
ne vivent dans aucun depot apt. Le controleur les tire puis les pousse par SSH.
Mesure du 2026-09-12 : le cache du runner du site est ABSENT. Un second locataire
monte depuis lui sortait chercher 570 Mo sur codeberg.org, github.com et
download.nextcloud.com, alors que le meme ecosysteme ne demandait plus un seul
paquet a Debian. Le poste du mainteneur les a depuis toujours : personne ne l avait vu.
Pas de relais transparent, et la mesure tranche : github.com redirige vers une URL
signee valable une heure, differente a chaque requete. Un cache qui la prend pour
cle ne fait jamais mouche. Le relais marcherait pour deux amonts sur quatre.
Donc un vrai depot, dans le service qui existe deja. LocalDirs d apt-cacher-ng publie
un repertoire du disque sous un prefixe, eprouve AVANT d ecrire le role. Aucun service,
aucun port, aucun certificat, aucun flux nouveaux : l ingress 3142 pair flotte couvre
exactement ce chemin.
Les versions ne sont pas recopiees : le role lit les defauts des quatre consommateurs.
Les quatre roles recoivent une tache AJOUTEE, placee avant leur stat de cache — si le
depot sert, le stat le voit et la tache amont se saute d elle-meme. Aucune tache
existante n a change.
P70 exige que tout dest ecrit sous un cache_local figure au depot. Une liste qui suit
une autre prend du retard ; celle-ci est nee avec sa garde.
Deux marches payees en chemin :
- failed_when: false REECRIT le verdict, donc la premiere garde de signature ne
gardait rien. Elles mesurent le fichier desormais.
- file: state=directory cree les parents en 0750 : apt-cacher-ng, qui ne tourne pas
en root, rendait 403 sur chaque fichier. Un chemin se traverse en entier.
Verifie sur l infrastructure : 6/6 artefacts servis (200/206) depuis le runner du site
ET depuis une machine du locataire a travers la frontiere ; les 6 empreintes SHA-256
sont identiques a celles qui ont construit Chezlepro ; second passage changed=0.
make prouver : 69 OK, 0 echec, 1 saute. ansible-lint : 0 failure, profil production.
Inclut aussi force: true sur cinq telechargements de cles : une reprise conditionnelle
ne reprend rien (304 Not Modified, size 0, attempts 5).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-12 23:45:00 -04:00
|
|
|
|
def preuve_depot_binaires_complet() -> tuple[bool, str]:
|
|
|
|
|
|
"""Le depot du site tient-il TOUT ce que les roles vont chercher sur Internet ?
|
|
|
|
|
|
|
|
|
|
|
|
UNE LISTE QUI SUIT UNE AUTRE PREND DU RETARD, et celle-ci a ete ecrite le meme jour
|
|
|
|
|
|
que la garde — deliberement. Le depot des binaires directs (`serveur_artefacts_directs`)
|
|
|
|
|
|
enumere ce que le cache du site tient ; les roles consommateurs, eux, declarent ce
|
|
|
|
|
|
qu'ils telechargent. Deux listes, un seul fait.
|
|
|
|
|
|
|
|
|
|
|
|
CE QUE LE RETARD PRODUIRAIT : une version montee dans `serveur_forgejo_version` sans
|
|
|
|
|
|
l'etre dans le depot. Le runner ne trouve pas le fichier, retombe sur codeberg.org,
|
|
|
|
|
|
et tout fonctionne — en sortant sur Internet, sans que rien ne le dise. C'est
|
|
|
|
|
|
exactement la famille de defaut que ce depot traque : du correct qui ment.
|
|
|
|
|
|
|
|
|
|
|
|
LA MESURE EST TEXTUELLE, ET C'EST SUFFISANT. On releve, dans les taches des roles,
|
|
|
|
|
|
chaque `dest:` ecrit sous `<role>_cache_local`, et on exige que son nom de fichier
|
|
|
|
|
|
figure au depot. Rendre Jinja n'apporterait rien : c'est le NOM qui doit correspondre,
|
|
|
|
|
|
et il est ecrit des deux cotes dans la meme forme.
|
|
|
|
|
|
"""
|
|
|
|
|
|
import re as _re
|
|
|
|
|
|
roles_dir = RACINE / "roles"
|
|
|
|
|
|
defauts = (roles_dir / "serveur_artefacts" / "defaults" / "main.yml").read_text(encoding="utf-8")
|
|
|
|
|
|
tenus = set(_re.findall(r'^\s*-\s*nom:\s*"([^"]+)"', defauts, _re.M))
|
|
|
|
|
|
if not tenus:
|
|
|
|
|
|
return False, "Le depot de binaires ne declare aucun artefact (serveur_artefacts_directs vide)."
|
|
|
|
|
|
|
|
|
|
|
|
attendus: dict[str, str] = {}
|
|
|
|
|
|
for role in sorted(p.name for p in roles_dir.iterdir() if p.is_dir()):
|
|
|
|
|
|
for tf in sorted((roles_dir / role / "tasks").glob("*.yml")) if (roles_dir / role / "tasks").is_dir() else []:
|
|
|
|
|
|
texte = tf.read_text(encoding="utf-8")
|
|
|
|
|
|
for dest in _re.findall(r'^\s*dest:\s*"\{\{\s*\w+_cache_local\s*\}\}/([^"]+)"', texte, _re.M):
|
|
|
|
|
|
attendus[dest] = f"{role}/tasks/{tf.name}"
|
|
|
|
|
|
|
|
|
|
|
|
manquants = sorted(f"{nom} ({ou})" for nom, ou in attendus.items() if nom not in tenus)
|
|
|
|
|
|
if manquants:
|
|
|
|
|
|
return False, ("Telecharges par un role mais ABSENTS du depot du site — "
|
|
|
|
|
|
"chaque runner sortira les chercher : " + " ; ".join(manquants))
|
|
|
|
|
|
orphelins = sorted(tenus - set(attendus))
|
|
|
|
|
|
detail = f" ({len(orphelins)} tenu(s) que plus aucun role ne demande : {', '.join(orphelins)})" if orphelins else ""
|
|
|
|
|
|
return True, f"{len(attendus)} artefact(s) direct(s) tenus par le depot du site{detail}."
|
|
|
|
|
|
|
|
|
|
|
|
|
pools nommes comme les depots, et les cles sortent du poste
LE NOM D UN POOL EST CELUI DE SON DEPOT. Chezlepro-17 devient OPS-Chezlepro :
le seed se lisait dans le nom, ce qui obligeait a connaitre le codage — et
surtout le nom CHANGEAIT si l index changeait, ce que la renumerotation du
site a montre le jour meme.
Site-OPS ne derive de rien, et c est le point : les machines du genome ne
dependent d aucun index, elles sont l infrastructure SUR laquelle les index
vivent. Sans ce bloc elles restaient hors de tout pool.
P69 — l amorcage d un tenant designe-t-il le site REEL ? dns_amorcage et
artefacts_amorcage sont ecrits a la main, volontairement : au moment ou ils
servent la machine ne resout aucun nom. Mais ils designent des machines DU
SITE, et n ont pas suivi son renumerotage. La reconstruction du locataire
s est arretee sur Failed to update apt cache, a quinze couches de sa cause.
La preuve ne juge que les valeurs qui PRETENDENT designer le site : viser
9.9.9.9 est un choix, pas un oubli.
LES CLES SORTENT DU POSTE, EN CLAIR, ET C EST RAISONNE. Support perdu : LUKS
s en charge. Poste compromis : la seconde couche n aide pas, les originaux
sont dans ~/.config sur ce meme poste. Elle coutait une phrase de passe
stockee nulle part — le seul point que la procedure ne couvre pas. Option
--support-chiffre explicite ; le defaut reste GPG, parce qu un support non
chiffre est le cas le plus frequent.
Et ma note qui disait les cles sorties depuis le 5 septembre etait fausse :
le support ne portait que le depot hors site du 1er.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-12 21:37:28 -04:00
|
|
|
|
def preuve_amorcage_suit_le_site() -> tuple[bool, str]:
|
|
|
|
|
|
"""Les adresses d'amorcage d'un tenant designent-elles le site REEL ?
|
|
|
|
|
|
|
|
|
|
|
|
UNE SECONDE LISTE QUI SUIT UNE PREMIERE (2026-09-12). `dns_amorcage` et
|
|
|
|
|
|
`artefacts_amorcage` sont ecrits A LA MAIN dans les intrants d'un tenant, et c'est
|
|
|
|
|
|
voulu : au moment ou ils servent, la premiere machine du tenant ne resout aucun nom.
|
|
|
|
|
|
Une adresse, pas un nom — la declaration le dit explicitement.
|
|
|
|
|
|
|
|
|
|
|
|
Mais ces adresses designent des machines DU SITE. Quand le site a pris son propre
|
|
|
|
|
|
index et que ses zones sont passees de `10.0.3x` a `10.37.3x`, elles n'ont pas suivi :
|
|
|
|
|
|
la reconstruction du locataire s'est arretee sur `infra-pki-01`, apt muet, avec un
|
|
|
|
|
|
`resolv.conf` pointant une machine qui n'existait plus.
|
|
|
|
|
|
|
|
|
|
|
|
Le message ne disait pas « adresse perimee ». Il disait « Failed to update apt cache »,
|
|
|
|
|
|
a quinze couches de sa cause.
|
|
|
|
|
|
|
|
|
|
|
|
CE QUE CETTE PREUVE NE FAIT PAS : juger une adresse qui ne designe pas le site. Un
|
2026-09-27 21:53:36 -04:00
|
|
|
|
tenant peut legitimement s'amorcer sur un resolveur public — `OPS-Technolibre`
|
|
|
|
|
|
vise `9.9.9.9`, et c'est un choix, pas un oubli. On ne verifie que
|
pools nommes comme les depots, et les cles sortent du poste
LE NOM D UN POOL EST CELUI DE SON DEPOT. Chezlepro-17 devient OPS-Chezlepro :
le seed se lisait dans le nom, ce qui obligeait a connaitre le codage — et
surtout le nom CHANGEAIT si l index changeait, ce que la renumerotation du
site a montre le jour meme.
Site-OPS ne derive de rien, et c est le point : les machines du genome ne
dependent d aucun index, elles sont l infrastructure SUR laquelle les index
vivent. Sans ce bloc elles restaient hors de tout pool.
P69 — l amorcage d un tenant designe-t-il le site REEL ? dns_amorcage et
artefacts_amorcage sont ecrits a la main, volontairement : au moment ou ils
servent la machine ne resout aucun nom. Mais ils designent des machines DU
SITE, et n ont pas suivi son renumerotage. La reconstruction du locataire
s est arretee sur Failed to update apt cache, a quinze couches de sa cause.
La preuve ne juge que les valeurs qui PRETENDENT designer le site : viser
9.9.9.9 est un choix, pas un oubli.
LES CLES SORTENT DU POSTE, EN CLAIR, ET C EST RAISONNE. Support perdu : LUKS
s en charge. Poste compromis : la seconde couche n aide pas, les originaux
sont dans ~/.config sur ce meme poste. Elle coutait une phrase de passe
stockee nulle part — le seul point que la procedure ne couvre pas. Option
--support-chiffre explicite ; le defaut reste GPG, parce qu un support non
chiffre est le cas le plus frequent.
Et ma note qui disait les cles sorties depuis le 5 septembre etait fausse :
le support ne portait que le depot hors site du 1er.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-12 21:37:28 -04:00
|
|
|
|
les valeurs qui PRETENDENT designer une machine du site : meme troisieme octet de
|
|
|
|
|
|
zone, autre deuxieme octet. Une adresse etrangere au site n'est pas notre affaire.
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
sys.path.insert(0, str(RACINE / "scripts"))
|
|
|
|
|
|
import underlay as _u
|
|
|
|
|
|
if not _u.charger():
|
|
|
|
|
|
return True, "Aucun underlay monte : aucun site a suivre."
|
|
|
|
|
|
attendus = {
|
|
|
|
|
|
"dns_amorcage": _u.adresses_site_portant("serveur_resolveur"),
|
|
|
|
|
|
"artefacts_amorcage": _u.adresses_site_portant("serveur_artefacts"),
|
|
|
|
|
|
}
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
return True, f"Site illisible ({type(e).__name__}) : rien a comparer."
|
|
|
|
|
|
if not any(attendus.values()):
|
|
|
|
|
|
return True, "Le site ne porte ni resolveur ni cache : rien a suivre."
|
|
|
|
|
|
|
|
|
|
|
|
# Les troisiemes octets des zones du site — la signature d'une adresse « du site ».
|
|
|
|
|
|
octets_site = {a.split(".")[2] for v in attendus.values() for a in v if a.count(".") == 3}
|
|
|
|
|
|
|
intrants du site : ce qu un locataire doit savoir pour l habiter, derive
Un locataire ECRIT les adresses des services de son site — resolveur, cache, forge,
depot de sauvegarde, plan d administration, sortie. Une copie se perime, et deux
l avaient fait en deux jours avec la meme forme : `serveur_ops_forge_amont` visait
10.0.33.11 quand la forge sert en 10.37.33.11, et `ac-racine-site.crt` portait la
racine d avant la reconstruction du site. Rien ne les relisait.
`make site-intrants` lit le plan du site et rend le contrat — sept valeurs, toutes
derivees. `make site-intrants-verifier` les confronte a ce que le locataire declare,
et P73 en fait une preuve. Elle a trouve le defaut de la forge des sa premiere
execution.
Le contrat se DERIVE du plan du site, pas d une liste tenue a part : ajouter un
service prete au site l ajoute au contrat, sans qu on ait a y penser.
P69 RESTREINTE AU COUPLE MONTE. Elle balayait tous les depots OPS-* et les comparait
au site monte. Elle avait raison tant qu un seul site existait : une adresse en
10.x.3z ne pouvait designer que lui. Deux sites decoupent leurs zones de la meme
facon — c est le but, un locataire doit pouvoir habiter l un ou l autre sans se
renumeroter. Le troisieme octet a cesse de distinguer « mon site » d « un autre
site » : 10.31.34.11, juste pour un locataire de TechnoLibre, etait declare faux
parce que Chezlepro etait monte.
Un locataire n appartient a aucun site — il en habite un, choisi par le symlink au
deploiement. La seule paire jugeable est celle qui est montee. Meme portee que P73.
Une marche payee : la premiere version de site_intrants recopiait la resolution
d instance au lieu de la partager. P41 a mordu — neuf modules avaient deja porte
chacun leur copie, et cinq defauts en etaient sortis en cinq jours.
make prouver : 72 OK, 0 echec, 1 saute. ansible-lint : 0 failure.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-13 16:18:12 -04:00
|
|
|
|
# LE COUPLE MONTE, ET LUI SEUL (2026-09-13).
|
|
|
|
|
|
#
|
|
|
|
|
|
# Cette preuve balayait TOUS les depots `OPS-*` et les comparait au site MONTE. Elle
|
|
|
|
|
|
# avait raison tant qu'un seul site existait : une adresse « en 10.x.3z » ne pouvait
|
|
|
|
|
|
# designer que lui.
|
|
|
|
|
|
#
|
|
|
|
|
|
# Deux sites decoupent leurs zones de la meme facon — c'est le but, un locataire doit
|
|
|
|
|
|
# pouvoir habiter l'un ou l'autre sans se renumeroter. Le troisieme octet a donc cesse
|
|
|
|
|
|
# de distinguer « mon site » d'« un autre site » : `10.31.34.11`, parfaitement juste
|
|
|
|
|
|
# pour un locataire de TechnoLibre, etait declare faux parce que Chezlepro etait monte.
|
|
|
|
|
|
#
|
|
|
|
|
|
# UN LOCATAIRE N'APPARTIENT A AUCUN SITE — il en habite un, choisi par le symlink au
|
|
|
|
|
|
# moment du deploiement. La seule paire qu'on puisse juger est donc celle qui est
|
|
|
|
|
|
# montee. Meme portee que P73, et pour la meme raison.
|
|
|
|
|
|
from inventory_rules import dossier_inventaire, instance_courante
|
pools nommes comme les depots, et les cles sortent du poste
LE NOM D UN POOL EST CELUI DE SON DEPOT. Chezlepro-17 devient OPS-Chezlepro :
le seed se lisait dans le nom, ce qui obligeait a connaitre le codage — et
surtout le nom CHANGEAIT si l index changeait, ce que la renumerotation du
site a montre le jour meme.
Site-OPS ne derive de rien, et c est le point : les machines du genome ne
dependent d aucun index, elles sont l infrastructure SUR laquelle les index
vivent. Sans ce bloc elles restaient hors de tout pool.
P69 — l amorcage d un tenant designe-t-il le site REEL ? dns_amorcage et
artefacts_amorcage sont ecrits a la main, volontairement : au moment ou ils
servent la machine ne resout aucun nom. Mais ils designent des machines DU
SITE, et n ont pas suivi son renumerotage. La reconstruction du locataire
s est arretee sur Failed to update apt cache, a quinze couches de sa cause.
La preuve ne juge que les valeurs qui PRETENDENT designer le site : viser
9.9.9.9 est un choix, pas un oubli.
LES CLES SORTENT DU POSTE, EN CLAIR, ET C EST RAISONNE. Support perdu : LUKS
s en charge. Poste compromis : la seconde couche n aide pas, les originaux
sont dans ~/.config sur ce meme poste. Elle coutait une phrase de passe
stockee nulle part — le seul point que la procedure ne couvre pas. Option
--support-chiffre explicite ; le defaut reste GPG, parce qu un support non
chiffre est le cas le plus frequent.
Et ma note qui disait les cles sorties depuis le 5 septembre etait fausse :
le support ne portait que le depot hors site du 1er.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-12 21:37:28 -04:00
|
|
|
|
fautes, verifies = [], 0
|
intrants du site : ce qu un locataire doit savoir pour l habiter, derive
Un locataire ECRIT les adresses des services de son site — resolveur, cache, forge,
depot de sauvegarde, plan d administration, sortie. Une copie se perime, et deux
l avaient fait en deux jours avec la meme forme : `serveur_ops_forge_amont` visait
10.0.33.11 quand la forge sert en 10.37.33.11, et `ac-racine-site.crt` portait la
racine d avant la reconstruction du site. Rien ne les relisait.
`make site-intrants` lit le plan du site et rend le contrat — sept valeurs, toutes
derivees. `make site-intrants-verifier` les confronte a ce que le locataire declare,
et P73 en fait une preuve. Elle a trouve le defaut de la forge des sa premiere
execution.
Le contrat se DERIVE du plan du site, pas d une liste tenue a part : ajouter un
service prete au site l ajoute au contrat, sans qu on ait a y penser.
P69 RESTREINTE AU COUPLE MONTE. Elle balayait tous les depots OPS-* et les comparait
au site monte. Elle avait raison tant qu un seul site existait : une adresse en
10.x.3z ne pouvait designer que lui. Deux sites decoupent leurs zones de la meme
facon — c est le but, un locataire doit pouvoir habiter l un ou l autre sans se
renumeroter. Le troisieme octet a cesse de distinguer « mon site » d « un autre
site » : 10.31.34.11, juste pour un locataire de TechnoLibre, etait declare faux
parce que Chezlepro etait monte.
Un locataire n appartient a aucun site — il en habite un, choisi par le symlink au
deploiement. La seule paire jugeable est celle qui est montee. Meme portee que P73.
Une marche payee : la premiere version de site_intrants recopiait la resolution
d instance au lieu de la partager. P41 a mordu — neuf modules avaient deja porte
chacun leur copie, et cinq defauts en etaient sortis en cinq jours.
make prouver : 72 OK, 0 echec, 1 saute. ansible-lint : 0 failure.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-13 16:18:12 -04:00
|
|
|
|
_f = dossier_inventaire() / "group_vars" / "all" / "10-intrants.yml"
|
|
|
|
|
|
for intrants in ([_f] if _f.is_file() else []):
|
pools nommes comme les depots, et les cles sortent du poste
LE NOM D UN POOL EST CELUI DE SON DEPOT. Chezlepro-17 devient OPS-Chezlepro :
le seed se lisait dans le nom, ce qui obligeait a connaitre le codage — et
surtout le nom CHANGEAIT si l index changeait, ce que la renumerotation du
site a montre le jour meme.
Site-OPS ne derive de rien, et c est le point : les machines du genome ne
dependent d aucun index, elles sont l infrastructure SUR laquelle les index
vivent. Sans ce bloc elles restaient hors de tout pool.
P69 — l amorcage d un tenant designe-t-il le site REEL ? dns_amorcage et
artefacts_amorcage sont ecrits a la main, volontairement : au moment ou ils
servent la machine ne resout aucun nom. Mais ils designent des machines DU
SITE, et n ont pas suivi son renumerotage. La reconstruction du locataire
s est arretee sur Failed to update apt cache, a quinze couches de sa cause.
La preuve ne juge que les valeurs qui PRETENDENT designer le site : viser
9.9.9.9 est un choix, pas un oubli.
LES CLES SORTENT DU POSTE, EN CLAIR, ET C EST RAISONNE. Support perdu : LUKS
s en charge. Poste compromis : la seconde couche n aide pas, les originaux
sont dans ~/.config sur ce meme poste. Elle coutait une phrase de passe
stockee nulle part — le seul point que la procedure ne couvre pas. Option
--support-chiffre explicite ; le defaut reste GPG, parce qu un support non
chiffre est le cas le plus frequent.
Et ma note qui disait les cles sorties depuis le 5 septembre etait fausse :
le support ne portait que le depot hors site du 1er.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-12 21:37:28 -04:00
|
|
|
|
try:
|
|
|
|
|
|
d = yaml.safe_load(intrants.read_text(encoding="utf-8")) or {}
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
continue
|
|
|
|
|
|
for cle, bons in attendus.items():
|
|
|
|
|
|
brut = str(d.get(cle) or "").strip()
|
|
|
|
|
|
if not brut or not bons:
|
|
|
|
|
|
continue
|
|
|
|
|
|
adr = brut.split(":")[0].split(",")[0].strip()
|
|
|
|
|
|
if adr.count(".") != 3 or adr.split(".")[2] not in octets_site:
|
|
|
|
|
|
continue # ne pretend pas designer le site : pas notre affaire
|
|
|
|
|
|
verifies += 1
|
|
|
|
|
|
if adr not in bons:
|
intrants du site : ce qu un locataire doit savoir pour l habiter, derive
Un locataire ECRIT les adresses des services de son site — resolveur, cache, forge,
depot de sauvegarde, plan d administration, sortie. Une copie se perime, et deux
l avaient fait en deux jours avec la meme forme : `serveur_ops_forge_amont` visait
10.0.33.11 quand la forge sert en 10.37.33.11, et `ac-racine-site.crt` portait la
racine d avant la reconstruction du site. Rien ne les relisait.
`make site-intrants` lit le plan du site et rend le contrat — sept valeurs, toutes
derivees. `make site-intrants-verifier` les confronte a ce que le locataire declare,
et P73 en fait une preuve. Elle a trouve le defaut de la forge des sa premiere
execution.
Le contrat se DERIVE du plan du site, pas d une liste tenue a part : ajouter un
service prete au site l ajoute au contrat, sans qu on ait a y penser.
P69 RESTREINTE AU COUPLE MONTE. Elle balayait tous les depots OPS-* et les comparait
au site monte. Elle avait raison tant qu un seul site existait : une adresse en
10.x.3z ne pouvait designer que lui. Deux sites decoupent leurs zones de la meme
facon — c est le but, un locataire doit pouvoir habiter l un ou l autre sans se
renumeroter. Le troisieme octet a cesse de distinguer « mon site » d « un autre
site » : 10.31.34.11, juste pour un locataire de TechnoLibre, etait declare faux
parce que Chezlepro etait monte.
Un locataire n appartient a aucun site — il en habite un, choisi par le symlink au
deploiement. La seule paire jugeable est celle qui est montee. Meme portee que P73.
Une marche payee : la premiere version de site_intrants recopiait la resolution
d instance au lieu de la partager. P41 a mordu — neuf modules avaient deja porte
chacun leur copie, et cinq defauts en etaient sortis en cinq jours.
make prouver : 72 OK, 0 echec, 1 saute. ansible-lint : 0 failure.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-13 16:18:12 -04:00
|
|
|
|
fautes.append(f"{instance_courante().resolve().name} : `{cle}` vise {adr}, "
|
pools nommes comme les depots, et les cles sortent du poste
LE NOM D UN POOL EST CELUI DE SON DEPOT. Chezlepro-17 devient OPS-Chezlepro :
le seed se lisait dans le nom, ce qui obligeait a connaitre le codage — et
surtout le nom CHANGEAIT si l index changeait, ce que la renumerotation du
site a montre le jour meme.
Site-OPS ne derive de rien, et c est le point : les machines du genome ne
dependent d aucun index, elles sont l infrastructure SUR laquelle les index
vivent. Sans ce bloc elles restaient hors de tout pool.
P69 — l amorcage d un tenant designe-t-il le site REEL ? dns_amorcage et
artefacts_amorcage sont ecrits a la main, volontairement : au moment ou ils
servent la machine ne resout aucun nom. Mais ils designent des machines DU
SITE, et n ont pas suivi son renumerotage. La reconstruction du locataire
s est arretee sur Failed to update apt cache, a quinze couches de sa cause.
La preuve ne juge que les valeurs qui PRETENDENT designer le site : viser
9.9.9.9 est un choix, pas un oubli.
LES CLES SORTENT DU POSTE, EN CLAIR, ET C EST RAISONNE. Support perdu : LUKS
s en charge. Poste compromis : la seconde couche n aide pas, les originaux
sont dans ~/.config sur ce meme poste. Elle coutait une phrase de passe
stockee nulle part — le seul point que la procedure ne couvre pas. Option
--support-chiffre explicite ; le defaut reste GPG, parce qu un support non
chiffre est le cas le plus frequent.
Et ma note qui disait les cles sorties depuis le 5 septembre etait fausse :
le support ne portait que le depot hors site du 1er.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-12 21:37:28 -04:00
|
|
|
|
f"le site porte {', '.join(bons)}")
|
|
|
|
|
|
if not verifies:
|
|
|
|
|
|
return True, "Aucun tenant ne s'amorce sur une adresse du site."
|
|
|
|
|
|
if fautes:
|
|
|
|
|
|
return False, ("Adresse d'amorcage perimee — la premiere machine du tenant ne "
|
|
|
|
|
|
"resoudra rien, et l'erreur parlera d'apt :\n - "
|
|
|
|
|
|
+ "\n - ".join(fautes))
|
|
|
|
|
|
return True, (f"{verifies} adresse(s) d'amorcage designent bien une machine du site.")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-15 18:37:38 -04:00
|
|
|
|
# --- LES REPLIS SILENCIEUX (P79) -----------------------------------------------------
|
|
|
|
|
|
#
|
|
|
|
|
|
# Chaque temoin ci-dessous prend ses donnees en PARAMETRE : on peut lui remettre la faute
|
|
|
|
|
|
# sous les yeux sans toucher au depot, et c'est ainsi qu'ils ont ete eprouves.
|
|
|
|
|
|
|
|
|
|
|
|
def _yaml_ou_vide(chemin: Path) -> dict:
|
|
|
|
|
|
"""Le contenu d'un fichier YAML, ou {} s'il n'existe pas (registre facultatif)."""
|
|
|
|
|
|
if not chemin.is_file():
|
|
|
|
|
|
return {}
|
|
|
|
|
|
return yaml.safe_load(chemin.read_text(encoding="utf-8")) or {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _repli_pattes(serveurs: dict, if_zones: dict) -> list[str]:
|
|
|
|
|
|
"""Chaque zone OCCUPEE par une machine active du site a sa patte a la frontiere.
|
|
|
|
|
|
|
|
|
|
|
|
Un site qui n'a pas encore de table par zone est plat, et la patte unique est alors
|
|
|
|
|
|
juste. Des que la table existe, une zone qui y manque retombe sur la patte plate.
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not if_zones:
|
|
|
|
|
|
return []
|
|
|
|
|
|
occupees: dict[str, list[str]] = {}
|
|
|
|
|
|
for nom, s in (serveurs or {}).items():
|
|
|
|
|
|
if isinstance(s, dict) and str(s.get("etat", "actif")) == "actif" and s.get("reseau"):
|
|
|
|
|
|
occupees.setdefault(str(s["reseau"]), []).append(str(nom))
|
|
|
|
|
|
return [f"patte : la zone `{z}` ({', '.join(sorted(n))}) manque a `opnsense_if_zones` "
|
|
|
|
|
|
f"— ses regles tomberaient sur la patte plate, justes et jamais rencontrees"
|
|
|
|
|
|
for z, n in sorted(occupees.items()) if z not in if_zones]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _repli_edge(nom: str, applications: dict, domaines: dict, a_un_edge: bool) -> list[str]:
|
|
|
|
|
|
"""Dans un ecosysteme qui A un edge, aucune exposition ne retombe sur son propre groupe.
|
|
|
|
|
|
|
|
|
|
|
|
Le temoin n'est pas la derivation : la presence d'un edge vient de l'inventaire, et les
|
|
|
|
|
|
edges legitimes de ce que `domaines.yml` ECRIT. Le repli « le service se sert lui-meme »
|
|
|
|
|
|
n'est juste que la ou il n'y a personne pour le servir.
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not a_un_edge:
|
|
|
|
|
|
return []
|
|
|
|
|
|
from inventory_rules import expositions_des_applications
|
|
|
|
|
|
doms = (domaines or {}).get("domaines_publics") or {}
|
|
|
|
|
|
ecrits = {str(c["edge"]) for c in doms.values() if isinstance(c, dict) and c.get("edge")}
|
|
|
|
|
|
return [f"edge : {nom} — `{e['fqdn']}` retombe sur son propre groupe (`{e['edge']}`) "
|
|
|
|
|
|
f"alors que l'ecosysteme a un edge : ni vhost ni SAN ne le retiendront"
|
|
|
|
|
|
for e in expositions_des_applications(applications, domaines)
|
2026-09-29 12:34:47 -04:00
|
|
|
|
# Sans port, aucun vhost ne peut la servir : elle se sert elle-meme, a juste
|
|
|
|
|
|
# titre (`sauvegarde`, `pki`, `dns` au site — voir `expositions_des_applications`).
|
|
|
|
|
|
if e.get("port") and e["edge"] != "serveur_nginx" and str(e["edge"]) not in ecrits]
|
2026-09-15 18:37:38 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _repli_hotes_du_site(enfants: dict, hv: dict) -> list[str]:
|
|
|
|
|
|
"""Ce que l'inventaire DYNAMIQUE du site donne a ses machines, sans group_vars pour
|
|
|
|
|
|
rattraper un defaut de role."""
|
|
|
|
|
|
def membres(g: str) -> set[str]:
|
|
|
|
|
|
return set(((enfants.get(g) or {}).get("hosts")) or {})
|
|
|
|
|
|
|
|
|
|
|
|
fautes: list[str] = []
|
|
|
|
|
|
pki = membres("client_pki")
|
|
|
|
|
|
for h in sorted(membres("serveur_nginx")):
|
|
|
|
|
|
v = hv.get(h) or {}
|
|
|
|
|
|
cert = str(v.get("serveur_nginx_certificat") or "")
|
|
|
|
|
|
if not cert or "snakeoil" in cert:
|
|
|
|
|
|
fautes.append(f"bouchon : {h} sert `{cert or 'le defaut du role, snakeoil'}` "
|
|
|
|
|
|
f"— du TLS qui ressemble a du TLS")
|
|
|
|
|
|
if h in pki and "nginx" not in (v.get("client_pki_reload_services") or []):
|
|
|
|
|
|
fautes.append(f"rechargement : {h} renouvelle son certificat sans recharger "
|
|
|
|
|
|
f"nginx — le nouveau reste sur disque, l'ancien est servi")
|
|
|
|
|
|
if membres("serveur_artefacts"):
|
|
|
|
|
|
for h, v in sorted(hv.items()):
|
|
|
|
|
|
if (v or {}).get("dns_amorcage") and not (v or {}).get("artefacts_amorcage"):
|
|
|
|
|
|
fautes.append(f"jumeaux : {h} recoit `dns_amorcage` sans "
|
|
|
|
|
|
f"`artefacts_amorcage` — le gabarit garde son ancien proxy apt")
|
|
|
|
|
|
return fautes
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _repli_devis_internet(flux: dict, roles_presents: set[str], regles: list[dict]) -> list[str]:
|
|
|
|
|
|
"""Une sortie qui NOMME un role absent du site ne devient pas une sortie vers l'Internet.
|
|
|
|
|
|
|
|
|
|
|
|
Le temoin lit les `meta/flux.yml` : les ports qu'un role n'emploie QUE vers des roles
|
|
|
|
|
|
absents ne doivent porter aucune regle `!SETOPS_INTERNES`. Un port qu'il emploie aussi
|
|
|
|
|
|
vers un pair lointain (`externe`, un depot) y a sa place, et il est ecarte.
|
|
|
|
|
|
"""
|
|
|
|
|
|
connus = {d.name for d in (RACINE / "roles").iterdir() if d.is_dir()}
|
|
|
|
|
|
fautes: set[str] = set()
|
|
|
|
|
|
for role in sorted(roles_presents):
|
|
|
|
|
|
absents, lointains = set(), set()
|
|
|
|
|
|
for fl in flux.get(role, []):
|
|
|
|
|
|
if fl.get("sens") != "egress":
|
|
|
|
|
|
continue
|
|
|
|
|
|
pairs = fl.get("pair") if isinstance(fl.get("pair"), list) else [fl.get("pair")]
|
|
|
|
|
|
ports = fl["port"] if isinstance(fl.get("port"), list) else [fl.get("port")]
|
|
|
|
|
|
nommes = {str(p) for p in pairs} & connus
|
|
|
|
|
|
if nommes and not nommes & roles_presents:
|
|
|
|
|
|
absents.update(str(p) for p in ports)
|
|
|
|
|
|
elif not nommes:
|
|
|
|
|
|
lointains.update(str(p) for p in ports)
|
|
|
|
|
|
suspects = absents - lointains
|
|
|
|
|
|
for r in regles:
|
|
|
|
|
|
if (str(r.get("tenant")) == "SITE" and r.get("role") == role
|
|
|
|
|
|
and str(r.get("destination")) == "!SETOPS_INTERNES"):
|
|
|
|
|
|
for port in sorted(suspects & {str(p) for p in (r.get("ports") or [])}):
|
|
|
|
|
|
fautes.add(f"internet : {role} sort vers l'Internet sur {port}, un port "
|
|
|
|
|
|
f"qu'il n'emploie que vers un role absent du site")
|
|
|
|
|
|
return sorted(fautes)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _flux_genere_perime(base: Path) -> list[str]:
|
|
|
|
|
|
"""Les regles d'hote d'un ecosysteme, rejouees pour LUI — pas pour l'instance montee.
|
|
|
|
|
|
|
|
|
|
|
|
`make flux` n'ecrit que pour l'instance montee. On rejoue la generation dans un dossier
|
|
|
|
|
|
temporaire qui reprend l'instance par liens, et l'on compare : rien n'est ecrit dans le
|
|
|
|
|
|
depot.
|
|
|
|
|
|
"""
|
|
|
|
|
|
import os
|
|
|
|
|
|
import subprocess
|
|
|
|
|
|
import tempfile
|
|
|
|
|
|
reel = base / "flux-genere"
|
|
|
|
|
|
with tempfile.TemporaryDirectory(prefix="p79-") as tmp:
|
|
|
|
|
|
for e in base.iterdir():
|
|
|
|
|
|
if e.name not in ("flux-genere", ".git"):
|
|
|
|
|
|
os.symlink(e, Path(tmp) / e.name)
|
|
|
|
|
|
r = subprocess.run([sys.executable, str(RACINE / "scripts" / "resoudre_flux.py"), "nftables"],
|
|
|
|
|
|
env=dict(os.environ, SETOPS_INSTANCE=tmp), capture_output=True,
|
|
|
|
|
|
text=True, cwd=RACINE, timeout=300)
|
|
|
|
|
|
if r.returncode != 0:
|
|
|
|
|
|
derniere = (r.stderr.strip().splitlines() or ["sans message"])[-1]
|
|
|
|
|
|
return [f"flux : {base.name} — la generation echoue ({derniere[:100]})"]
|
|
|
|
|
|
gen = Path(tmp) / "flux-genere"
|
|
|
|
|
|
a = {p.name for p in reel.glob("*.nft")} if reel.is_dir() else set()
|
|
|
|
|
|
b = {p.name for p in gen.glob("*.nft")} if gen.is_dir() else set()
|
|
|
|
|
|
differents = sorted(n for n in a & b if (reel / n).read_bytes() != (gen / n).read_bytes())
|
|
|
|
|
|
fautes = []
|
|
|
|
|
|
if b - a:
|
|
|
|
|
|
fautes.append(f"flux : {base.name} — aucune regle generee pour {', '.join(sorted(b - a))}")
|
|
|
|
|
|
if a - b:
|
|
|
|
|
|
fautes.append(f"flux : {base.name} — regles d'une machine retiree du plan : "
|
|
|
|
|
|
f"{', '.join(sorted(a - b))}")
|
|
|
|
|
|
if differents:
|
|
|
|
|
|
fautes.append(f"flux : {base.name} — perimees par rapport au plan : {', '.join(differents)}")
|
|
|
|
|
|
return fautes
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def preuve_une_derivation_vide_se_dit() -> tuple[bool, str]:
|
|
|
|
|
|
"""Une derivation qui ne trouve rien ne passe pas pour une derivation qui n'a rien a trouver.
|
|
|
|
|
|
|
|
|
|
|
|
POURQUOI (2026-09-14 et 15). En deux jours, pour monter l'edge du site puis les trois
|
|
|
|
|
|
consoles, les memes defauts sont revenus sous des noms differents. Chaque fois, un
|
|
|
|
|
|
repli a rendu un SUCCES au lieu d'un refus : deploiement vert, devis muet, harnais vert.
|
|
|
|
|
|
|
|
|
|
|
|
1. `site_inventaire` derivait `dns_amorcage` sans son jumeau `artefacts_amorcage` : les
|
|
|
|
|
|
machines nees avant le renumerotage gardaient le proxy apt du gabarit.
|
|
|
|
|
|
2. Une zone manquait a `opnsense_if_zones` : `_if_de()` retombait sur la patte plate,
|
|
|
|
|
|
20 regles posees et jamais rencontrees.
|
|
|
|
|
|
3. L'edge d'une exposition retombait sur le groupe de l'application : vhost de 43
|
|
|
|
|
|
octets, certificat sans les noms publies.
|
|
|
|
|
|
4. L'edge du site servait `ssl-cert-snakeoil.pem`, le defaut du role, faute de
|
|
|
|
|
|
group_vars dans un inventaire dynamique.
|
|
|
|
|
|
5. Un certificat renouvele sans `client_pki_reload_services` : servi perime.
|
|
|
|
|
|
6. Une sortie vers un role absent du site devenait une sortie vers l'Internet.
|
|
|
|
|
|
7. `make flux`, lance avec un locataire monte, n'a rien regenere pour l'autre : 502
|
|
|
|
|
|
derriere un TLS parfait.
|
|
|
|
|
|
|
|
|
|
|
|
Chaque temoin lit sa source DIRECTEMENT (inventaire, plan, registre, table ecrite) et
|
|
|
|
|
|
ne passe pas par la derivation qu'il juge : une garde qui reproduit le raisonnement
|
|
|
|
|
|
qu'elle verifie ne verifie rien (cf. P43, P67).
|
|
|
|
|
|
|
|
|
|
|
|
CE QU'ELLE NE COUVRE PAS. La collision des noms publics par groupe (P67 la garde).
|
|
|
|
|
|
`client_pki` qui rend `changed=0` sans comparer ses SAN a ceux qu'il derive : cela se
|
|
|
|
|
|
mesure sur la machine (`make certificats-plan`), pas dans le depot. Et un registre
|
|
|
|
|
|
facultatif exige par `include_vars` : celui-la echouait bruyamment, ce n'etait pas un
|
|
|
|
|
|
repli.
|
|
|
|
|
|
"""
|
|
|
|
|
|
sys.path.insert(0, str(RACINE / "scripts"))
|
|
|
|
|
|
import instances as mod_instances
|
|
|
|
|
|
|
|
|
|
|
|
fautes: list[str] = []
|
|
|
|
|
|
couverts: list[str] = []
|
|
|
|
|
|
|
|
|
|
|
|
for i in mod_instances.decouvrir():
|
|
|
|
|
|
if i.get("site"):
|
|
|
|
|
|
continue
|
|
|
|
|
|
base = RACINE.parent / i["nom"]
|
|
|
|
|
|
inv = next((base / "inventories" / x / "hosts.yml" for x in ("principal", "production")
|
|
|
|
|
|
if (base / "inventories" / x / "hosts.yml").is_file()), None)
|
|
|
|
|
|
if inv is None:
|
|
|
|
|
|
continue
|
|
|
|
|
|
enfants = ((_yaml_ou_vide(inv).get("all") or {}).get("children")) or {}
|
|
|
|
|
|
a_un_edge = bool(((enfants.get("serveur_nginx") or {}).get("hosts")) or {})
|
|
|
|
|
|
fautes += _repli_edge(i["nom"], _yaml_ou_vide(base / "plan" / "applications.yml"),
|
|
|
|
|
|
_yaml_ou_vide(base / "plan" / "domaines.yml"), a_un_edge)
|
|
|
|
|
|
gv_nginx = inv.parent / "group_vars" / "serveur_nginx.yml"
|
|
|
|
|
|
if a_un_edge and "nginx" not in (_yaml_ou_vide(gv_nginx).get("client_pki_reload_services") or []):
|
|
|
|
|
|
fautes.append(f"rechargement : {i['nom']} — l'edge renouvelle son certificat sans "
|
|
|
|
|
|
f"recharger nginx")
|
|
|
|
|
|
if (base / "flux-genere").is_dir():
|
|
|
|
|
|
fautes += _flux_genere_perime(base)
|
|
|
|
|
|
couverts.append(i["nom"])
|
|
|
|
|
|
|
|
|
|
|
|
lien = RACINE / "underlay.yml"
|
|
|
|
|
|
if lien.exists():
|
|
|
|
|
|
site = lien.resolve().parent
|
|
|
|
|
|
inventaire = _inventaire_du_site()
|
|
|
|
|
|
if inventaire is None:
|
|
|
|
|
|
fautes.append(f"site : l'inventaire dynamique de {site.name} ne s'execute pas — "
|
|
|
|
|
|
f"rien de ce qu'il derive ne peut etre juge")
|
|
|
|
|
|
else:
|
|
|
|
|
|
enfants, hv = inventaire["enfants"], inventaire["hv"]
|
|
|
|
|
|
fautes += _repli_edge(site.name, _yaml_ou_vide(site / "plan" / "applications.yml"),
|
|
|
|
|
|
_yaml_ou_vide(site / "plan" / "domaines.yml"),
|
|
|
|
|
|
bool(((enfants.get("serveur_nginx") or {}).get("hosts")) or {}))
|
|
|
|
|
|
fautes += _repli_hotes_du_site(enfants, hv)
|
|
|
|
|
|
fautes += _repli_pattes(_yaml_ou_vide(site / "plan" / "serveurs.yml").get("serveurs") or {},
|
|
|
|
|
|
_yaml_ou_vide(site / "opnsense.yml").get("opnsense_if_zones") or {})
|
|
|
|
|
|
import contextlib
|
|
|
|
|
|
import io
|
|
|
|
|
|
import devis_opnsense as D
|
|
|
|
|
|
import resoudre_flux as RF
|
|
|
|
|
|
with contextlib.redirect_stderr(io.StringIO()):
|
|
|
|
|
|
regles = D.construire(D.decouvrir_du_site()).get("regles") or []
|
|
|
|
|
|
presents = {g for g, v in enfants.items() if (v.get("hosts") or {})}
|
|
|
|
|
|
fautes += _repli_devis_internet(RF.charger_flux(), presents, regles)
|
|
|
|
|
|
couverts.append(site.name)
|
|
|
|
|
|
|
|
|
|
|
|
if fautes:
|
|
|
|
|
|
return False, (f"{len(fautes)} repli(s) silencieux — une derivation vide rend un "
|
|
|
|
|
|
f"succes :\n - " + "\n - ".join(fautes[:10])
|
|
|
|
|
|
+ ("\n - …" if len(fautes) > 10 else ""))
|
|
|
|
|
|
if not couverts:
|
|
|
|
|
|
return True, "Aucun ecosysteme decouvert : aucune derivation a juger."
|
|
|
|
|
|
return True, (f"{len(couverts)} ecosysteme(s) ({', '.join(couverts)}) : pattes, edges, "
|
|
|
|
|
|
f"certificats, rechargements, jumeaux d'amorcage, sorties du devis et regles "
|
|
|
|
|
|
f"d'hote par instance — aucune derivation vide ne passe pour un succes.")
|
|
|
|
|
|
|
|
|
|
|
|
|
remise au client : deux temps, un outil, une garde
Livrer se terminait par une phrase — tes cles te seront remises separement — et
rien n ecrivait la suite. Temps 1 l identite (sa cle de voute, sa voute, sa racine
d AC), temps 2 la machine a echeance (sa cle entre, la notre sort, voute re-cletee,
secrets tournes). scripts/remise.py refuse une destination interne, un paquet sans
racine d AC, et tout ce qui n est pas l ecosysteme monte. Le registre remise.yml
declare enfin le responsable designe (D-18). P80 refuse un registre incomplet, un
second temps echu, un second temps declare fait sans revocation au plan, et un
secret dans un fichier versionne.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 11:16:27 -04:00
|
|
|
|
def preuve_remise_tenue() -> tuple[bool, str]:
|
|
|
|
|
|
"""Un ecosysteme remis l'est vraiment — et son second temps ne s'oublie pas.
|
|
|
|
|
|
|
|
|
|
|
|
POURQUOI (2026-09-16). Remettre un ecosysteme se terminait par une phrase — « tes cles
|
|
|
|
|
|
te seront remises separement » — et rien n'ecrivait ce qui se passait ensuite. Le geste
|
|
|
|
|
|
qui donne le controle d'une organisation etait le seul geste lourd du depot sans
|
|
|
|
|
|
procedure, sans outil et sans garde.
|
|
|
|
|
|
|
|
|
|
|
|
LA REMISE SE FAIT EN DEUX TEMPS (`docs/remise-au-client.md`) : l'IDENTITE d'abord — le
|
|
|
|
|
|
client gouverne ses gens des le premier jour — puis la MACHINE, a une echeance
|
|
|
|
|
|
convenue : sa cle SSH entre, celle de l'hebergeur sort, la voute change de mot de
|
|
|
|
|
|
passe.
|
|
|
|
|
|
|
|
|
|
|
|
CE QUE CETTE PREUVE REFUSE, et chaque refus ferme un etat qui s'installerait tout seul :
|
|
|
|
|
|
|
|
|
|
|
|
- un registre INCOMPLET : remis a personne, ou sans echeance. Une remise dont on ne
|
|
|
|
|
|
sait pas a qui elle a ete faite n'est pas une remise, c'est un depot ;
|
|
|
|
|
|
- un second temps ECHU et non fait. C'est le defaut qui se produit par simple
|
|
|
|
|
|
ecoulement du temps : personne ne decide de garder l'acces machine d'un client —
|
|
|
|
|
|
on oublie de le rendre, et le silence transforme l'oubli en etat de fait ;
|
|
|
|
|
|
- un second temps DECLARE FAIT pendant que le plan ne revoque AUCUNE cle
|
|
|
|
|
|
d'administration. C'est le seul mensonge que ce fichier puisse porter sans qu'on
|
|
|
|
|
|
s'en apercoive, parce qu'il flatte tout le monde ;
|
|
|
|
|
|
- un SECRET dans le registre. Il est versionne et pousse sur trois forges : il porte
|
|
|
|
|
|
des empreintes, jamais des valeurs.
|
|
|
|
|
|
|
|
|
|
|
|
CE QU'ELLE NE JUGE PAS. Un ecosysteme SANS registre : tous ne sont pas remis, et
|
2026-09-27 21:53:36 -04:00
|
|
|
|
plusieurs ne le seront jamais — le lab, l'ecosysteme de l'hebergeur
|
remise au client : deux temps, un outil, une garde
Livrer se terminait par une phrase — tes cles te seront remises separement — et
rien n ecrivait la suite. Temps 1 l identite (sa cle de voute, sa voute, sa racine
d AC), temps 2 la machine a echeance (sa cle entre, la notre sort, voute re-cletee,
secrets tournes). scripts/remise.py refuse une destination interne, un paquet sans
racine d AC, et tout ce qui n est pas l ecosysteme monte. Le registre remise.yml
declare enfin le responsable designe (D-18). P80 refuse un registre incomplet, un
second temps echu, un second temps declare fait sans revocation au plan, et un
secret dans un fichier versionne.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 11:16:27 -04:00
|
|
|
|
lui-meme. Exiger un registre partout ferait du bruit la ou il n'y a rien a tenir.
|
|
|
|
|
|
|
|
|
|
|
|
ET CE QU'ELLE NE PEUT PAS PROUVER : que l'hebergeur ne puisse PLUS entrer. Le plan
|
|
|
|
|
|
declare la revocation, le deploiement l'applique ; le verifier depuis l'exterieur
|
|
|
|
|
|
demande d'essayer d'entrer, donc une machine vivante. Meme partage que partout ici.
|
|
|
|
|
|
"""
|
|
|
|
|
|
sys.path.insert(0, str(RACINE / "scripts"))
|
|
|
|
|
|
import datetime as _dt2
|
|
|
|
|
|
|
|
|
|
|
|
import instances as mod_instances
|
|
|
|
|
|
|
|
|
|
|
|
# CE QUI DISTINGUE UN SECRET D'UN NOM LONG (mesure du 2026-09-16, sur le premier
|
|
|
|
|
|
# registre sain). Mon premier motif prenait toute chaine de 24 caracteres sans espace
|
|
|
|
|
|
# pour un secret — donc `setops-vault-ops-technolibre`, qui est un NOM DE FICHIER, et
|
|
|
|
|
|
# que le registre doit nommer. La preuve refusait alors la remise la plus normale, et
|
|
|
|
|
|
# ce faux positif masquait les cinq autres verdicts.
|
|
|
|
|
|
#
|
|
|
|
|
|
# Ce qu'on cherche vraiment : de l'ENTROPIE — un melange de casses et de chiffres, ou
|
|
|
|
|
|
# l'alphabet de base64. Un nom de fichier, une organisation, une date et un courriel
|
|
|
|
|
|
# n'en ont aucune ; une empreinte SHA256 est le contenu legitime de ce fichier.
|
|
|
|
|
|
motif_empreinte = re.compile(r"^[0-9a-f]{64}$")
|
|
|
|
|
|
|
|
|
|
|
|
def _opaque(v: str) -> bool:
|
|
|
|
|
|
if motif_empreinte.match(v) or len(v) < 20:
|
|
|
|
|
|
return False
|
|
|
|
|
|
if not re.fullmatch(r"[A-Za-z0-9+/=_.-]+", v):
|
|
|
|
|
|
return False
|
|
|
|
|
|
melange = (any(c.islower() for c in v) and any(c.isupper() for c in v)
|
|
|
|
|
|
and any(c.isdigit() for c in v))
|
|
|
|
|
|
return melange or bool(re.fullmatch(r"[A-Za-z0-9+/]{24,}={0,2}", v))
|
|
|
|
|
|
|
|
|
|
|
|
def _valeurs(n):
|
|
|
|
|
|
if isinstance(n, dict):
|
|
|
|
|
|
for k, v in n.items():
|
|
|
|
|
|
yield from _valeurs(k)
|
|
|
|
|
|
yield from _valeurs(v)
|
|
|
|
|
|
elif isinstance(n, list):
|
|
|
|
|
|
for v in n:
|
|
|
|
|
|
yield from _valeurs(v)
|
|
|
|
|
|
elif isinstance(n, str):
|
|
|
|
|
|
yield n
|
|
|
|
|
|
|
|
|
|
|
|
fautes: list[str] = []
|
|
|
|
|
|
tenus: list[str] = []
|
|
|
|
|
|
aujourd_hui = _dt2.date.today()
|
|
|
|
|
|
|
|
|
|
|
|
for i in mod_instances.decouvrir():
|
|
|
|
|
|
base = RACINE.parent / i["nom"]
|
|
|
|
|
|
f = base / "remise.yml"
|
|
|
|
|
|
if not f.is_file():
|
|
|
|
|
|
continue
|
|
|
|
|
|
try:
|
|
|
|
|
|
r = (yaml.safe_load(f.read_text(encoding="utf-8")) or {}).get("remise") or {}
|
|
|
|
|
|
except Exception as e: # noqa: BLE001
|
|
|
|
|
|
fautes.append(f"{i['nom']} : registre de remise illisible ({type(e).__name__})")
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
t1 = r.get("temps_1") or {}
|
|
|
|
|
|
t2 = r.get("temps_2") or {}
|
|
|
|
|
|
recu = (t1.get("recu_par") or {})
|
|
|
|
|
|
if not t1.get("remis_le"):
|
|
|
|
|
|
fautes.append(f"{i['nom']} : registre sans date de remise")
|
|
|
|
|
|
if not (recu.get("nom") and recu.get("courriel")):
|
|
|
|
|
|
fautes.append(f"{i['nom']} : remis a personne — le responsable designe (D-18) "
|
|
|
|
|
|
f"n'est pas nomme")
|
|
|
|
|
|
if not t2.get("du_le"):
|
|
|
|
|
|
fautes.append(f"{i['nom']} : aucune echeance pour le second temps — la "
|
|
|
|
|
|
f"revocation n'a alors jamais de retard, donc jamais lieu")
|
|
|
|
|
|
|
|
|
|
|
|
for v in _valeurs(r):
|
|
|
|
|
|
if _opaque(v):
|
|
|
|
|
|
fautes.append(f"{i['nom']} : le registre porte une valeur opaque "
|
|
|
|
|
|
f"({v[:8]}…) — il est versionne et pousse ; il porte des "
|
|
|
|
|
|
f"empreintes, jamais des secrets")
|
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
|
|
# Les cles d'administration declarees au plan — lues ici, pas demandees a l'outil
|
|
|
|
|
|
# de remise : une garde qui interroge ce qu'elle surveille ne surveille rien.
|
|
|
|
|
|
revoquees = 0
|
|
|
|
|
|
for nom in ("principal", "production", "lab"):
|
|
|
|
|
|
intrants = base / "inventories" / nom / "group_vars" / "all" / "10-intrants.yml"
|
|
|
|
|
|
if not intrants.is_file():
|
|
|
|
|
|
continue
|
|
|
|
|
|
try:
|
|
|
|
|
|
d = yaml.safe_load(intrants.read_text(encoding="utf-8")) or {}
|
|
|
|
|
|
except Exception: # noqa: BLE001
|
|
|
|
|
|
continue
|
|
|
|
|
|
if "ssh_baseline_cles_admin" in d:
|
|
|
|
|
|
revoquees = sum(1 for e in (d.get("ssh_baseline_cles_admin") or [])
|
|
|
|
|
|
if isinstance(e, dict) and str(e.get("etat", "present")) == "absent")
|
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
|
|
if t2.get("fait_le"):
|
|
|
|
|
|
if not revoquees:
|
|
|
|
|
|
fautes.append(f"{i['nom']} : le registre dit le second temps fait le "
|
|
|
|
|
|
f"{t2['fait_le']}, et le plan ne revoque AUCUNE cle "
|
|
|
|
|
|
f"d'administration")
|
|
|
|
|
|
tenus.append(f"{i['nom']} (remis et re-clete)")
|
|
|
|
|
|
elif t2.get("du_le"):
|
|
|
|
|
|
try:
|
|
|
|
|
|
echeance = _dt2.date.fromisoformat(str(t2["du_le"]))
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
fautes.append(f"{i['nom']} : echeance du second temps illisible "
|
|
|
|
|
|
f"({t2['du_le']})")
|
|
|
|
|
|
continue
|
|
|
|
|
|
if echeance < aujourd_hui:
|
|
|
|
|
|
fautes.append(f"{i['nom']} : second temps DU DEPUIS "
|
|
|
|
|
|
f"{(aujourd_hui - echeance).days} jour(s) ({echeance}) — "
|
|
|
|
|
|
f"l'hebergeur garde l'acces machine d'un client qui se croit "
|
|
|
|
|
|
f"chez lui")
|
|
|
|
|
|
else:
|
|
|
|
|
|
tenus.append(f"{i['nom']} (second temps du le {echeance})")
|
|
|
|
|
|
|
|
|
|
|
|
if fautes:
|
|
|
|
|
|
return False, ("Remise non tenue :\n - " + "\n - ".join(fautes[:6])
|
|
|
|
|
|
+ ("\n - …" if len(fautes) > 6 else ""))
|
|
|
|
|
|
if not tenus:
|
|
|
|
|
|
return True, "Aucun ecosysteme remis a un client : rien a tenir."
|
|
|
|
|
|
return True, f"{len(tenus)} remise(s) tenue(s) : {', '.join(tenus)}."
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-16 14:36:59 -04:00
|
|
|
|
def preuve_console_dit_sa_portee() -> tuple[bool, str]:
|
|
|
|
|
|
"""Une console ne sert jamais un inventaire vide en silence, et rien n'echappe a sa garde.
|
|
|
|
|
|
|
|
|
|
|
|
POURQUOI (2026-09-16). Servie par le runner d'un SITE, la console d'exploitation
|
|
|
|
|
|
affichait ZERO serveur, ZERO application, ZERO base — sans une erreur. `serveur_ops`
|
|
|
|
|
|
retire le lien `instance` sur un runner d'hebergeur (a juste titre), `charger_yaml`
|
|
|
|
|
|
rend un inventaire vide sur un fichier absent, et la page dessinait ce vide comme un
|
|
|
|
|
|
plan vide. Le site a sept machines ; sa console n'en montrait aucune.
|
|
|
|
|
|
|
|
|
|
|
|
CE QU'ELLE VERIFIE, hors ligne :
|
|
|
|
|
|
|
la console mesure le pouvoir sur la voute, pas sur la carte
Le document des responsabilites attache chaque pouvoir a une voute : calculer n'en demande
aucune, configurer demande celle du tenant, materialiser celle du site. Le code lisait les
symlinks, c'est-a-dire les cartes. Le runner de Chezlepro-locataire montait la carte du
site sans en avoir jamais eu la voute : sa console se declarait poste et offrait 126 etapes
sur 126. Ces gestes seraient partis puis tombes sur un secret vide — un echec au milieu du
chemin, la ou un refus net aurait dit la verite avant de commencer.
contexte() derive desormais les pouvoirs des voutes presentes, et la portee decoule des
pouvoirs au lieu de les preceder. On ne prouve pas qu'une voute s'ouvre, le mot de passe se
tape a l'execution ; mais son absence est decisive et se mesure sans rien ouvrir. Lire une
carte reste permis : le pouvoir fabric suit toujours le symlink, consulter un miroir n'est
pas engendrer.
serveur_ops retire aussi le lien quand la fabric n'est plus declaree — il ne retirait rien,
et un runner gardait le pouvoir que son plan ne lui donnait plus. Il ne retire qu'un lien,
jamais un fichier : une vraie carte a cette place n'a pas ete ecrite par ce role, et il le
dit plutot que de detruire ce qui n'est pas le sien.
Valide : syntax-check et ansible-lint sur le role (profil production, 0/0), make test a 0
echec, P81 et P83 vertes. Six tests montent quatre faux disques et exigent la portee qui
leur revient, dont le defaut lui-meme : carte presente, voute absente, portee tenant.
Limite : P02 reste en echec pour la raison anterieure deja consignee.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 20:12:18 -04:00
|
|
|
|
1. La portee se DERIVE de ce que la machine porte, et jamais d'un reglage declare.
|
|
|
|
|
|
Un reglage serait une seconde liste, et une liste qui suit une autre prend du
|
|
|
|
|
|
retard. Depuis le 2026-09-20 elle se derive des VOUTES et non des seuls symlinks :
|
|
|
|
|
|
`configurer` exige la voute du tenant, `materialiser` celle du site. Une carte
|
|
|
|
|
|
lue ne vaut pas une voute ouverte — cf. `scripts/tests/test_portee_console.py`.
|
2026-09-16 14:36:59 -04:00
|
|
|
|
2. Une console qui a quelque chose a piloter nomme sa SOURCE d'inventaire. Aucune
|
|
|
|
|
|
portee pilotable ne rend `inventaire: null`.
|
|
|
|
|
|
3. La source du SITE rend vraiment des machines quand le site en declare : c'est le
|
|
|
|
|
|
defaut d'origine, et il ne doit pas pouvoir revenir.
|
|
|
|
|
|
4. TOUTE route POST figure dans `POUVOIR_REQUIS`. C'est la garde anti-derive : une
|
|
|
|
|
|
route d'ecriture ajoutee demain sans y penser s'executerait sur une console qui
|
|
|
|
|
|
n'en a pas le pouvoir, et personne ne le verrait avant l'incident.
|
|
|
|
|
|
|
|
|
|
|
|
CE QU'ELLE NE PROUVE PAS : qu'une console REFUSE vraiment. Cela demande de la lancer et
|
|
|
|
|
|
de lui parler ; ici on verifie qu'elle a de quoi refuser, et que rien n'y echappe.
|
|
|
|
|
|
"""
|
|
|
|
|
|
sys.path.insert(0, str(RACINE / "scripts"))
|
|
|
|
|
|
import inventory_gui as gui
|
|
|
|
|
|
|
|
|
|
|
|
fautes: list[str] = []
|
|
|
|
|
|
ctx = gui.contexte()
|
|
|
|
|
|
|
|
|
|
|
|
if ctx["portee"] not in ("poste", "tenant", "site", "orphelin"):
|
|
|
|
|
|
fautes.append(f"portee inconnue : {ctx['portee']}")
|
|
|
|
|
|
if ctx["portee"] != "orphelin" and not ctx.get("inventaire"):
|
|
|
|
|
|
fautes.append(f"portee `{ctx['portee']}` sans source d'inventaire nommee — c'est "
|
|
|
|
|
|
f"exactement le vide silencieux que cette preuve existe pour refuser")
|
|
|
|
|
|
|
|
|
|
|
|
# La source du site, verifiee CHEZ le site, quand un underlay est monte.
|
|
|
|
|
|
if gui.FICHIER_UNDERLAY.exists():
|
|
|
|
|
|
try:
|
|
|
|
|
|
data = gui.inventaire_du_site()
|
|
|
|
|
|
hotes = {h for g in (data.get("all") or {}).get("children", {}).values()
|
|
|
|
|
|
for h in (g.get("hosts") or {})}
|
|
|
|
|
|
except Exception as e: # noqa: BLE001
|
|
|
|
|
|
hotes = set()
|
|
|
|
|
|
fautes.append(f"l'inventaire du site ne s'execute pas ({type(e).__name__}) : "
|
|
|
|
|
|
f"une console de site n'aurait rien a montrer")
|
|
|
|
|
|
if not hotes:
|
|
|
|
|
|
fautes.append("un underlay est monte et l'inventaire du site ne rend AUCUNE "
|
|
|
|
|
|
"machine — la console de site afficherait un ecran vide")
|
|
|
|
|
|
|
|
|
|
|
|
# Toute route POST doit exiger un pouvoir. On lit la SOURCE, pas la table : une table
|
|
|
|
|
|
# qui se decrirait elle-meme ne prouverait rien.
|
|
|
|
|
|
source = (RACINE / "scripts" / "inventory_gui.py").read_text(encoding="utf-8")
|
|
|
|
|
|
debut = source.find("def do_POST")
|
|
|
|
|
|
routes = set(re.findall(r'chemin == "(/api/[a-z-]+)"', source[debut:])) if debut >= 0 else set()
|
|
|
|
|
|
if not routes:
|
|
|
|
|
|
fautes.append("aucune route POST trouvee dans le GUI : la lecture de la source a "
|
|
|
|
|
|
"change de forme, et cette preuve ne prouve plus rien")
|
|
|
|
|
|
manquantes = sorted(routes - set(gui.POUVOIR_REQUIS))
|
|
|
|
|
|
if manquantes:
|
|
|
|
|
|
fautes.append(f"route(s) POST sans pouvoir exige : {', '.join(manquantes)} — elles "
|
|
|
|
|
|
f"s'executeraient sur une console qui n'en a pas le droit")
|
|
|
|
|
|
inutiles = sorted(set(gui.POUVOIR_REQUIS) - routes)
|
|
|
|
|
|
if inutiles:
|
|
|
|
|
|
fautes.append(f"`POUVOIR_REQUIS` nomme des routes qui n'existent plus : "
|
|
|
|
|
|
f"{', '.join(inutiles)}")
|
|
|
|
|
|
|
2026-09-16 14:57:39 -04:00
|
|
|
|
# LA PAGE DOIT PORTER LA MEME COUPURE. Un serveur qui refuse pendant qu'une page
|
|
|
|
|
|
# propose fait decouvrir l'interdit AU CLIC — au milieu d'un geste, sur une console
|
|
|
|
|
|
# qu'on croyait la bonne. Ce n'est pas la garde, c'est ce qui evite d'y aller.
|
|
|
|
|
|
bloc = re.search(r"const POUVOIR_PAR_MODE = \{([^}]*)\}", source, re.S)
|
|
|
|
|
|
if not bloc:
|
|
|
|
|
|
fautes.append("la page ne porte plus `POUVOIR_PAR_MODE` : les boutons "
|
|
|
|
|
|
"proposeraient des gestes que le serveur refuse")
|
|
|
|
|
|
else:
|
|
|
|
|
|
for mode in ("creer", "verifier", "deployer", "pousser"):
|
|
|
|
|
|
if mode not in bloc.group(1):
|
|
|
|
|
|
fautes.append(f"le mode `{mode}` n'est rattache a aucun pouvoir dans la page")
|
|
|
|
|
|
|
2026-09-16 14:36:59 -04:00
|
|
|
|
if fautes:
|
|
|
|
|
|
return False, ("La console ne dit pas ce qu'elle peut :\n - " + "\n - ".join(fautes))
|
la console mesure le pouvoir sur la voute, pas sur la carte
Le document des responsabilites attache chaque pouvoir a une voute : calculer n'en demande
aucune, configurer demande celle du tenant, materialiser celle du site. Le code lisait les
symlinks, c'est-a-dire les cartes. Le runner de Chezlepro-locataire montait la carte du
site sans en avoir jamais eu la voute : sa console se declarait poste et offrait 126 etapes
sur 126. Ces gestes seraient partis puis tombes sur un secret vide — un echec au milieu du
chemin, la ou un refus net aurait dit la verite avant de commencer.
contexte() derive desormais les pouvoirs des voutes presentes, et la portee decoule des
pouvoirs au lieu de les preceder. On ne prouve pas qu'une voute s'ouvre, le mot de passe se
tape a l'execution ; mais son absence est decisive et se mesure sans rien ouvrir. Lire une
carte reste permis : le pouvoir fabric suit toujours le symlink, consulter un miroir n'est
pas engendrer.
serveur_ops retire aussi le lien quand la fabric n'est plus declaree — il ne retirait rien,
et un runner gardait le pouvoir que son plan ne lui donnait plus. Il ne retire qu'un lien,
jamais un fichier : une vraie carte a cette place n'a pas ete ecrite par ce role, et il le
dit plutot que de detruire ce qui n'est pas le sien.
Valide : syntax-check et ansible-lint sur le role (profil production, 0/0), make test a 0
echec, P81 et P83 vertes. Six tests montent quatre faux disques et exigent la portee qui
leur revient, dont le defaut lui-meme : carte presente, voute absente, portee tenant.
Limite : P02 reste en echec pour la raison anterieure deja consignee.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 20:12:18 -04:00
|
|
|
|
return True, (f"Portee `{ctx['portee']}` derivee des voutes portees, source d'inventaire "
|
2026-09-16 14:36:59 -04:00
|
|
|
|
f"`{ctx['inventaire']}`, et {len(routes)} route(s) POST exigent toutes "
|
|
|
|
|
|
f"un pouvoir.")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-16 18:08:20 -04:00
|
|
|
|
# --- LE DNS PUBLIC (P82) : chaque temoin prend ses donnees en parametre --------------
|
|
|
|
|
|
|
|
|
|
|
|
_PRIVEE = ("10.", "127.", "192.168.", "169.254.") + tuple(f"172.{i}." for i in range(16, 32))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _dns_zones_declarees(plans: dict) -> tuple[dict, list[str]]:
|
|
|
|
|
|
"""{zone: ecosysteme} des zones `primaire-cache`, et les fautes de declaration."""
|
|
|
|
|
|
attendu, fautes = {}, []
|
|
|
|
|
|
for eco, doms in plans.items():
|
|
|
|
|
|
for zone, conf in (doms or {}).items():
|
|
|
|
|
|
if isinstance(conf, dict) and str(conf.get("autorite")) == "primaire-cache":
|
|
|
|
|
|
if zone.rstrip(".").endswith(".internal"):
|
|
|
|
|
|
fautes.append(f"{eco} : `{zone}` est declaree `primaire-cache` — une zone "
|
|
|
|
|
|
f"interne publiee serait la carte de ses machines offerte au monde")
|
|
|
|
|
|
attendu[zone] = eco
|
|
|
|
|
|
return attendu, fautes
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _dns_zones_servies(attendu: dict, servies: set, a_un_serveur: bool) -> list[str]:
|
|
|
|
|
|
fautes = []
|
|
|
|
|
|
if attendu and not a_un_serveur:
|
|
|
|
|
|
fautes.append(f"{len(attendu)} zone(s) `primaire-cache` declaree(s) et aucun serveur public "
|
|
|
|
|
|
f"au site : l'autorite est declaree, jamais exercee")
|
|
|
|
|
|
return fautes
|
|
|
|
|
|
for z in sorted(set(attendu) - servies):
|
|
|
|
|
|
fautes.append(f"`{z}` ({attendu[z]}) n'est servie par aucun serveur public du site")
|
|
|
|
|
|
for z in sorted(servies - set(attendu)):
|
|
|
|
|
|
fautes.append(f"`{z}` est servie par le serveur public sans etre declaree `primaire-cache`")
|
|
|
|
|
|
return fautes
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _dns_paires(declarations: dict) -> list[str]:
|
|
|
|
|
|
"""{site: [(pair, etat)]} -> fautes. Une relation vaut des deux cotes, ou pas du tout."""
|
|
|
|
|
|
fautes = []
|
|
|
|
|
|
for site, liens in declarations.items():
|
|
|
|
|
|
for pair, etat in liens:
|
|
|
|
|
|
retour = dict(declarations.get(pair, []))
|
|
|
|
|
|
if site not in retour:
|
|
|
|
|
|
fautes.append(f"{site} declare une replication avec {pair}, qui ne la declare pas — "
|
|
|
|
|
|
f"le second serveur de noms serait vide sans que personne le voie")
|
|
|
|
|
|
elif retour[site] != etat:
|
|
|
|
|
|
fautes.append(f"{site} -> {pair} est `{etat}`, {pair} -> {site} est `{retour[site]}`")
|
|
|
|
|
|
return fautes
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _dns_exposition(flux: list, dnssec_par_zone: dict) -> list[str]:
|
|
|
|
|
|
"""Exposer le serveur public a Internet exige DNSSEC sur TOUTES ses zones."""
|
|
|
|
|
|
expose = any(f.get("sens") == "ingress" and "externe" in
|
|
|
|
|
|
(f.get("pair") if isinstance(f.get("pair"), list) else [f.get("pair")])
|
|
|
|
|
|
for f in (flux or []))
|
|
|
|
|
|
if not expose:
|
|
|
|
|
|
return []
|
|
|
|
|
|
return [f"`{z}` : le serveur public est expose a Internet et la zone n'est pas signee — son "
|
|
|
|
|
|
f"hebergeur, ou le site pair, pourrait la falsifier sans que personne le detecte"
|
|
|
|
|
|
for z, signe in sorted(dnssec_par_zone.items()) if not signe]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-16 18:35:36 -04:00
|
|
|
|
def _dns_question_avant_transfert(flux: list) -> list[str]:
|
|
|
|
|
|
"""La sortie vers les primaires porte l'UDP (le SOA) ET le TCP (le transfert)."""
|
|
|
|
|
|
protos = {str(f.get("protocole")) for f in (flux or [])
|
|
|
|
|
|
if f.get("sens") == "egress" and "primaires_dns_locataires" in
|
|
|
|
|
|
(f.get("pair") if isinstance(f.get("pair"), list) else [f.get("pair")])}
|
|
|
|
|
|
if not protos:
|
|
|
|
|
|
return []
|
|
|
|
|
|
manque = sorted({"udp", "tcp"} - protos)
|
|
|
|
|
|
return [f"la sortie vers les primaires ne declare pas `{m}` — "
|
|
|
|
|
|
+ ("le secondaire ne peut jamais demander le SOA, donc jamais savoir qu'une zone a change"
|
|
|
|
|
|
if m == "udp" else "le transfert lui-meme ne peut pas passer")
|
|
|
|
|
|
for m in manque]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-16 18:08:20 -04:00
|
|
|
|
def preuve_dns_public_tient() -> tuple[bool, str]:
|
|
|
|
|
|
"""Le DNS public du site sert exactement les zones que ses locataires publient.
|
|
|
|
|
|
|
|
|
|
|
|
POURQUOI (2026-09-16). Le locataire ecrit sa zone publique (`autorite: primaire-cache`),
|
|
|
|
|
|
le serveur public du site la sert, et un site pair la replique. Chaque maillon peut se
|
|
|
|
|
|
rompre EN SILENCE : une zone que personne ne sert se lit « serveur de noms defaillant »
|
|
|
|
|
|
chez le registraire, et une zone `.internal` servie publie la carte d'un locataire.
|
|
|
|
|
|
|
|
|
|
|
|
CE QU'ELLE REFUSE, chaque temoin lisant sa source directement :
|
|
|
|
|
|
|
|
|
|
|
|
1. une zone `.internal` declaree `primaire-cache` ;
|
|
|
|
|
|
2. une zone `primaire-cache` d'un locataire du site que le serveur public ne sert pas,
|
|
|
|
|
|
ou un serveur public qui sert une zone que personne ne publie — la liste servie
|
|
|
|
|
|
est lue dans l'inventaire du site, la liste attendue dans les plans des locataires ;
|
|
|
|
|
|
3. une adresse publique PRIVEE transmise a un locataire ;
|
|
|
|
|
|
4. une replication entre sites declaree d'un seul cote ;
|
|
|
|
|
|
5. la regle apprise en eprouvant PowerDNS : l'instance publique n'autorise que la boucle
|
|
|
|
|
|
locale au transfert (`allow-axfr-ips` et TSIG sont ALTERNATIFS — y lister le
|
|
|
|
|
|
secondaire rendrait la signature decorative) ;
|
2026-09-16 18:35:36 -04:00
|
|
|
|
7. une sortie vers les primaires sans UDP : le secondaire demande le SOA en UDP avant
|
|
|
|
|
|
de transferer en TCP. Mesure en production le 2026-09-16 — le TCP seul laissait
|
|
|
|
|
|
passer un tirage force et empechait tous les tirages normaux ;
|
2026-09-16 18:08:20 -04:00
|
|
|
|
6. LA CONDITION DE LA PHASE 2, ECRITE COMME UNE GARDE : exposer le serveur public a
|
|
|
|
|
|
Internet sans DNSSEC sur toutes ses zones. Sans signature, l'hebergeur ou le site
|
|
|
|
|
|
pair pourraient falsifier la zone d'un client sans que personne le detecte.
|
|
|
|
|
|
"""
|
|
|
|
|
|
lien = RACINE / "underlay.yml"
|
|
|
|
|
|
if not lien.exists():
|
|
|
|
|
|
return True, "Aucun site monte : aucun DNS public a tenir."
|
|
|
|
|
|
sys.path.insert(0, str(RACINE / "scripts"))
|
|
|
|
|
|
import devis_reseau
|
|
|
|
|
|
|
|
|
|
|
|
fautes: list[str] = []
|
|
|
|
|
|
plans, dnssec, ips = {}, {}, {}
|
|
|
|
|
|
for nom, _p, _n in devis_reseau.decouvrir_du_site():
|
|
|
|
|
|
base = devis_reseau.DOSSIER_INSTANCES / nom
|
|
|
|
|
|
doms = _yaml_ou_vide(base / "plan" / "domaines.yml").get("domaines_publics") or {}
|
|
|
|
|
|
plans[nom] = doms
|
|
|
|
|
|
for z, c in doms.items():
|
|
|
|
|
|
if isinstance(c, dict) and str(c.get("autorite")) == "primaire-cache":
|
|
|
|
|
|
dnssec[z] = bool(c.get("dnssec"))
|
|
|
|
|
|
for inv in ("principal", "production"):
|
|
|
|
|
|
intr = _yaml_ou_vide(base / "inventories" / inv / "group_vars" / "all" / "10-intrants.yml")
|
|
|
|
|
|
if intr.get("ip_publique_site"):
|
|
|
|
|
|
ips[nom] = str(intr["ip_publique_site"])
|
|
|
|
|
|
|
|
|
|
|
|
attendu, f1 = _dns_zones_declarees(plans)
|
|
|
|
|
|
fautes += f1
|
|
|
|
|
|
|
|
|
|
|
|
inventaire = _inventaire_du_site()
|
|
|
|
|
|
if inventaire is None:
|
|
|
|
|
|
fautes.append("l'inventaire du site ne s'execute pas : impossible de savoir ce qui est servi")
|
|
|
|
|
|
else:
|
|
|
|
|
|
enfants, hv = inventaire["enfants"], inventaire["hv"]
|
|
|
|
|
|
serveurs = set(((enfants.get("serveur_dns_public") or {}).get("hosts")) or {})
|
|
|
|
|
|
servies = {z for h in serveurs
|
|
|
|
|
|
for r in ((hv.get(h) or {}).get("serveur_dns_public_relations") or [])
|
|
|
|
|
|
for z in (r.get("zones") or [])}
|
|
|
|
|
|
fautes += _dns_zones_servies(attendu, servies, bool(serveurs))
|
|
|
|
|
|
|
|
|
|
|
|
for eco, ip in sorted(ips.items()):
|
|
|
|
|
|
if ip.startswith(_PRIVEE):
|
|
|
|
|
|
fautes.append(f"{eco} : `ip_publique_site` vaut {ip}, une adresse privee")
|
|
|
|
|
|
|
|
|
|
|
|
declarations = {}
|
|
|
|
|
|
for d in sorted(RACINE.parent.glob("SITE-*")):
|
|
|
|
|
|
liens = _yaml_ou_vide(d / "plan" / "10-intrants.yml").get("dns_public_pairs") or []
|
|
|
|
|
|
if liens:
|
|
|
|
|
|
declarations[d.name] = [(str(x.get("site")), str(x.get("etat", "actif")))
|
|
|
|
|
|
for x in liens if isinstance(x, dict)]
|
|
|
|
|
|
fautes += _dns_paires(declarations)
|
|
|
|
|
|
|
|
|
|
|
|
gabarit = RACINE / "roles" / "serveur_powerdns" / "templates" / "pdns-public.conf.j2"
|
|
|
|
|
|
lignes = [l.strip() for l in gabarit.read_text(encoding="utf-8").splitlines()
|
|
|
|
|
|
if l.strip().startswith("allow-axfr-ips")] if gabarit.is_file() else []
|
|
|
|
|
|
if lignes != ["allow-axfr-ips=127.0.0.1/32"]:
|
|
|
|
|
|
fautes.append(f"l'instance publique autorise au transfert autre chose que la boucle locale "
|
|
|
|
|
|
f"({lignes or 'rien de declare : le defaut de PowerDNS s applique'}) — "
|
|
|
|
|
|
f"TSIG deviendrait decoratif")
|
|
|
|
|
|
|
|
|
|
|
|
flux_public = _yaml_ou_vide(RACINE / "roles" / "serveur_dns_public" / "meta" / "flux.yml").get("flux") or []
|
|
|
|
|
|
fautes += _dns_exposition(flux_public, dnssec)
|
2026-09-16 18:35:36 -04:00
|
|
|
|
fautes += _dns_question_avant_transfert(flux_public)
|
2026-09-16 18:08:20 -04:00
|
|
|
|
|
|
|
|
|
|
if fautes:
|
|
|
|
|
|
return False, "DNS public :\n - " + "\n - ".join(fautes)
|
|
|
|
|
|
return True, (f"{len(attendu)} zone(s) publique(s) declaree(s), toutes servies par le site ; "
|
|
|
|
|
|
f"{sum(len(v) for v in declarations.values())} replication(s) declaree(s) des deux "
|
|
|
|
|
|
f"cotes ; transfert ouvert a la cle seule ; aucune exposition sans DNSSEC.")
|
|
|
|
|
|
|
|
|
|
|
|
|
assistants : cent trente-deux cibles, et aucune ne disait dans quel ordre
La console offrait des boutons sans sequence. Rien n'y apprenait que site-creer precede
forge-amorcer, que le premier passage de site-deployer-tout s'arrete sur une forge vide
sans que ce soit un echec, ni que rien n'est pret avant valider : cet ordre vivait en
prose dans des documents que la console ne porte pas.
La vue Assistants conduit 17 runbooks et 126 etapes. Les 132 cibles documentees y sont,
chacune portee par un assistant ou exemptee avec son motif — une exemption muette est
refusee. Le registre ne recopie pas le Makefile : il declare l'ordre, la nature, la portee
et le pourquoi, et le libelle de chaque etape est lu dans le Makefile au moment de servir.
P83 est ecrite en meme temps que la liste, pas apres, parce qu'une liste qui suit une
autre prend du retard. Onze tests lui presentent des registres faux, un par forme de
retard, et exigent qu'elle les refuse.
Le navigateur ne nomme pas une commande, il nomme une place : la route lance ce que le
registre declare a cet index-la, avec les seules variables declarees. L'index compte, le
premier jour d'un site jouant site-deployer-tout deux fois. Une etape qui ecrit attend que
la precedente ait reussi ; une mesure reste toujours offerte, parce que mesurer apres un
echec est exactement ce qu'on fait ensuite.
Valide : runbooks.py verifier a 0 ecart, make test a 0 echec, les 83 preuves rejouees, et
la console lancee pour de vrai — 17 runbooks servis, six requetes malformees refusees une
a une, une etape de mesure executee de bout en bout avec son journal.
Limite, anterieure a ce travail : P02 (test_ecriture_plan) echoue sur domaines.yml, a
l'identique sur une copie de HEAD. Ajouter ou retirer un domaine public depuis la vue
Domaines leverait a l'enregistrement. Non corrige ici.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 16:31:16 -04:00
|
|
|
|
# --- LES ASSISTANTS (P83) : un registre qui ne peut pas prendre de retard ------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def preuve_runbooks_suivent_le_makefile() -> tuple[bool, str]:
|
|
|
|
|
|
"""Le registre des runbooks decrit-il encore le moteur reel ?
|
|
|
|
|
|
|
|
|
|
|
|
POURQUOI (2026-09-20). Le Makefile porte 132 cibles documentees ; aucune ne dit dans
|
|
|
|
|
|
quel ORDRE elles se jouent. Cet ordre vivait en prose dans les documents, et la
|
|
|
|
|
|
console offrait des boutons sans sequence. `docs/runbooks-construction.yml` le declare
|
|
|
|
|
|
— et devient, par construction, une SECONDE LISTE a cote du Makefile.
|
|
|
|
|
|
|
|
|
|
|
|
Une liste qui suit une autre prend du retard sur elle : c'est arrive quatre fois dans
|
|
|
|
|
|
ce depot en une seule journee. La regle qui en est sortie est d'ecrire la garde EN
|
|
|
|
|
|
MEME TEMPS que la seconde liste. Cette preuve EST cette garde.
|
|
|
|
|
|
|
|
|
|
|
|
CE QU'ELLE REFUSE :
|
|
|
|
|
|
|
|
|
|
|
|
1. une etape qui vise une cible que le Makefile ne porte pas (cible renommee) ;
|
|
|
|
|
|
2. une cible documentee que nul runbook ne porte et que nul motif n'exempte — la
|
|
|
|
|
|
console cacherait alors un pouvoir que le moteur possede ;
|
|
|
|
|
|
3. une cible a la fois portee et exemptee, ou une exemption sans motif ;
|
|
|
|
|
|
4. une portee de runbook que la console ne saurait pas traduire en pouvoir : la
|
|
|
|
|
|
garde fine de `/api/runbook-etape` laisserait passer un geste qu'elle ne sait
|
|
|
|
|
|
pas peser.
|
|
|
|
|
|
|
|
|
|
|
|
CE QU'ELLE NE PROUVE PAS : qu'une etape REUSSIT. Elle lit le depot, elle ne lance
|
|
|
|
|
|
rien — comme les 82 autres.
|
|
|
|
|
|
"""
|
|
|
|
|
|
sys.path.insert(0, str(RACINE / "scripts"))
|
|
|
|
|
|
import runbooks as _rb
|
|
|
|
|
|
import inventory_gui as _gui
|
|
|
|
|
|
|
|
|
|
|
|
ecarts = _rb.verifier(RACINE)
|
|
|
|
|
|
|
|
|
|
|
|
# La console doit savoir peser CHAQUE portee que le registre emploie.
|
|
|
|
|
|
portees = {rb["portee"] for rb in _rb.assembler(RACINE)}
|
|
|
|
|
|
inconnues = sorted(portees - set(_gui.POUVOIR_PAR_PORTEE))
|
|
|
|
|
|
for p in inconnues:
|
|
|
|
|
|
ecarts.append(f"portee « {p} » employee par un runbook mais absente de "
|
|
|
|
|
|
f"POUVOIR_PAR_PORTEE : la garde de /api/runbook-etape ne saurait "
|
|
|
|
|
|
f"pas quoi exiger.")
|
|
|
|
|
|
|
|
|
|
|
|
if ecarts:
|
|
|
|
|
|
return False, (f"{len(ecarts)} ecart(s) entre le registre des runbooks et le "
|
|
|
|
|
|
f"moteur : " + " | ".join(ecarts[:4]))
|
|
|
|
|
|
|
|
|
|
|
|
rbs = _rb.assembler(RACINE)
|
|
|
|
|
|
etapes = sum(len(r["etapes"]) for r in rbs)
|
|
|
|
|
|
cibles = len(_rb.cibles_du_makefile(RACINE))
|
|
|
|
|
|
return True, (f"{len(rbs)} runbooks, {etapes} etapes, {cibles} cibles documentees : "
|
|
|
|
|
|
f"chacune portee par un assistant ou exemptee avec son motif.")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-04 20:16:55 -04:00
|
|
|
|
def preuve_fiche_du_site() -> tuple[bool, str]:
|
|
|
|
|
|
"""La fiche que chaque site destine a ses locataires dit-elle ce qu'ils portent deja ?
|
|
|
|
|
|
|
|
|
|
|
|
POURQUOI (2026-10-04, `docs/conception-contextes.md` §2.4). Le site informait le locataire
|
|
|
|
|
|
par trois canaux, sans fiche : des copies ecrites a la main (index, adresse publique,
|
|
|
|
|
|
intrants, racine), une lecture DIRECTE du site par l'instancier (temps, delegation DNS,
|
|
|
|
|
|
routage SDN), et le genome. La fiche les reunit. Avant qu'un seul consommateur bascule sur
|
|
|
|
|
|
elle (etape 3), elle doit dire EXACTEMENT ce que ces canaux ont depose : sinon la bascule
|
|
|
|
|
|
changerait la flotte au lieu de la decrire.
|
|
|
|
|
|
|
|
|
|
|
|
Pour chaque couple (site, locataire) dont le site est l'hebergeur ACTIF : ecarts entre
|
|
|
|
|
|
`site.fiche_pour(locataire)` et les copies du locataire, sa racine, et son inventaire
|
|
|
|
|
|
genere. `scripts/tests/test_contexte.py` prouve que chaque sorte d'ecart est vue.
|
|
|
|
|
|
"""
|
|
|
|
|
|
sys.path.insert(0, str(RACINE / "scripts"))
|
|
|
|
|
|
import contexte as _ctx
|
|
|
|
|
|
couples = _ctx.couples_hebergeur()
|
|
|
|
|
|
if not couples:
|
|
|
|
|
|
return True, "Aucun site frere : aucune fiche a confronter."
|
|
|
|
|
|
ecarts = [f"{s.nom} -> {l.nom} : {e}" for s, l in couples for e in _ctx.verifier_fiche(s, l)]
|
|
|
|
|
|
if ecarts:
|
|
|
|
|
|
return False, (f"{len(ecarts)} ecart(s) entre la fiche du site et ce que le locataire "
|
|
|
|
|
|
f"porte : " + " | ".join(ecarts[:4]))
|
|
|
|
|
|
return True, (f"{len(couples)} fiche(s) de site : chacune dit exactement ce que son locataire "
|
|
|
|
|
|
f"porte (copies, racine, inventaire genere).")
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
PREUVES: list[dict] = [
|
|
|
|
|
|
{"id": "P01", "titre": "Lint (ansible-lint)", "refs": ["AFF-006"],
|
|
|
|
|
|
"cmds": [["ansible-lint", "-q"]]},
|
plan : sauvegarder n emportait plus quarante lignes de commentaire
En voulant generer deux formulaires de plus, j ai trouve pire que ce que
je cherchais.
CE QUI ETAIT DEJA LA. Les quatre ecrivains de registre ecrasaient le
fichier au safe_dump. Mesure sur les fichiers reels : domaines.yml 6->3,
applications.yml 27->5, serveurs.yml 18->3. Quarante lignes, detruites
par n importe quel clic sur Sauvegarder dans les vues Serveurs,
Applications ou Domaines. Parmi elles, celle qui explique pourquoi
backup-01 a ete retire, et celle qui dit dans quel ordre les deux roles
du runner s appliquent. C etait l incident du 2026-08-18, jamais corrige
pour les registres du plan. Les quatre passent par _ecrire_registre :
aller-retour a vide identique a l octet, sur les quatre fichiers.
TROIS ECARTS DE SCHEMA, trouves en confrontant le schema aux VALIDATEURS
et non aux seuls plans :
- edge designe un GROUPE, pas un hote. Le schema disait serveurs : un
formulaire genere aurait offert une valeur qu aucun hote ne reconnait,
donc aucun SAN, donc la panne du 2026-08-25 reintroduite ;
- exposition, entierement valide par le moteur, manquait au schema ;
- liens etait items: {type: object} — une liste d objets sans forme.
Et mail, offert par la vue Domaines depuis sa creation, decrit ici comme
un booleen, saisi la-bas comme du texte, lu par rien : retire.
P62 garde tout ca. Elle separe l entite du reste mecaniquement : un
validateur lit son entite par des variables LOCALES, les autres registres
par ses PARAMETRES. Controle negatif rejoue.
LES FORMULAIRES. Serveurs de BD et Domaines sont generes, chargement et
sauvegarde compris. Quatre registres sur six. Le generateur a appris la
liste d objets.
LIMITE : restent serveurs et applications, les deux plus gros ; et je n ai
toujours pas ouvert ces pages dans un navigateur.
make prouver : CONFORME, 61 OK, 0 echec, 1 saute (62 preuves).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-08 17:54:49 -04:00
|
|
|
|
{"id": "P02", "titre": "Tests unitaires (inventaire, raser, ecritures du plan, rendu du GUI)", "refs": [],
|
2026-08-08 14:22:04 -04:00
|
|
|
|
"cmds": [[sys.executable, "scripts/tests/test_inventory_host.py"],
|
plan : l ecriture des registres devient atomique — tout, ou rien
`path.open("w")` TRONQUE avant d ecrire : entre les deux, le fichier est vide.
Une exception dans yaml.safe_dump, un disque plein, un Ctrl-C, et
instance/plan/serveurs.yml reste mutile.
L asymetrie fait la gravite : hosts.yml se regenere d un
make instancier-appliquer, le PLAN ne se regenere de rien. C est la source
unique de verite. Git est le filet, mais encore faut-il savoir qu on est tombe.
TREIZE SITES, UNE SEULE FONCTION
Le defaut n etait pas dans le GUI seul : douze sites dans sept fichiers, dont
les miroirs CLI des MEMES registres. Corriger le GUI seul aurait recree la
divergence que P41 garde depuis les neuf resolutions d instance. La fonction
vit donc dans inventory_rules.py, que les sept importaient deja. Une source,
pas douze.
TROIS DETAILS QUI FONT LA DIFFERENCE ENTRE « CA MARCHE » ET « CA TIENT »
temporaire dans le MEME dossier os.replace n est atomique qu au sein d un
meme systeme de fichiers ; un /tmp sur une
autre partition casserait la garantie sans
rien dire
fsync AVANT le rename sinon le renommage peut atteindre le disque
avant le contenu : au retour d une coupure
brutale, un fichier neuf et VIDE — le defaut
qu on ferme, deplace d un cran
report des droits mkstemp cree en 0600, le plan est en 0664 et
doit rester lisible par le groupe sur les
runners
LE TEST PORTE SON PROPRE CONTROLE NEGATIF
scripts/tests/test_ecriture_atomique.py rejoue D ABORD l ancienne forme et
verifie qu elle DETRUIT. Sans ce controle, « le fichier est intact » ne
prouverait rien — il pourrait l etre parce que rien n a ete ecrit du tout. Une
garantie qu on n a jamais vue echouer n est pas une garantie, c est une
habitude.
Branche sur P02, dont le titre annoncait « inventory_host » alors qu il lance
maintenant trois tests. Corrige au passage.
LA VOUTE DU GUI : VERIFIEE, PAS DE DEFAUT
Le soupcon etait qu executer_flux pose ANSIBLE_VAULT_PASSWORD_FILE (un seul mot
de passe) alors que creer une VM ouvre DEUX voutes depuis « une voute, une cle ».
Eprouve contre deux voutes JETABLES a mots de passe distincts — jamais les
vraies. Les deux variables se CUMULENT : Ansible essaie tous les secrets, et un
PASSWORD_FILE errone n empeche rien. Confirme en sondant l environnement qu une
recette make recoit reellement : le mot de passe saisi ET les cinq cles
calculees par voutes.py.
Ce qui sauve ce chemin n est donc pas le mot de passe saisi, c est
l IDENTITY_LIST que make pose par-dessus. Chacun couvre ce que l autre ne
couvre pas — le PASSWORD_FILE sert le runner qui n a que sa cle, l IDENTITY_LIST
le poste qui les a toutes. Ecrit au-dessus du code, pour que personne ne
« simplifie » en retirant l un des deux.
make prouver : CONFORME, 59 OK, 0 echec, 1 saute.
make instancier : DIFF VIDE, quatre registres relus, droits 664 preserves.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-08 14:57:57 -04:00
|
|
|
|
[sys.executable, "scripts/tests/test_raser.py"],
|
GUI : la vue Nomenclature, et deux fautes que mes bancs ne voyaient pas
LA VUE. La nomenclature etait le seul registre que le GUI ne savait pas
ecrire du tout : ajouter une fonction exigeait d ouvrir le YAML. Elle a
sa vue, et son formulaire est GENERE depuis le schema. Deuxieme registre
sur six. couverture_gui verifier passe : les 28 champs des plans reels
sont editables.
Elle n est pas un registre comme les autres : elle decrit la REGLE dont
VMID, VLAN, adresse et passerelle se derivent. Chaque fonction montre ce
qu elle derive et les VM qui la portent ; l index est montre mais pas
editable, parce qu il est alloue par le site ; valider_nomenclature
refuse de retirer une fonction encore portee, ou de designer une zone
non declaree.
DEUX FAUTES, ET POURQUOI MES BANCS NE LES VOYAIENT PAS.
Le formulaire des bases, livre la veille, etait casse dans un navigateur.
Il lisait data.schema, or il n existe aucun data global : c est une const
locale de charger(). ReferenceError a l ouverture, et zone morte dans
sauvegarderBases. Je l avais eprouve sous node EN LUI PASSANT data : le
banc reproduisait la fonction, pas sa portee. D ou test_rendu_gui.py, qui
charge le JS entier dans un DOM simule et dessine les douze vues, avec son
controle negatif.
Le schema decrivait reservations comme une table de zones ; le fichier
reel est un bloc plat. P61 comparait des NOMS aplatis, donc ne voyait
rien. Elle compare desormais aussi la FORME.
ECRIRE SANS DEPLACER UN COMMENTAIRE. _fusion_chirurgicale remplace le
bloc entier des qu une valeur change : quinze entrees compactes devenaient
42 lignes, et le commentaire du poste d exploitation se retrouvait en tete
du bloc, ou il affirmait que collab etait le poste d exploitation. Un
commentaire deplace n est pas laid, il est faux. _fusion_table edite les
tables ligne a ligne ; le diff fait trois lignes.
Au passage : sort_keys triait le schema, donc l ordre des cases a l ecran
(reserve_max avant reserve_min) ; et _ecrire_index_nomenclature ecrivait
encore par write_text, oubliee au passage des ecritures atomiques.
LIMITE : deux registres sur six sont generes, et je n ai toujours pas
ouvert cette page dans un navigateur.
make prouver : CONFORME, 60 OK, 0 echec, 1 saute.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-08 17:08:45 -04:00
|
|
|
|
[sys.executable, "scripts/tests/test_ecriture_atomique.py"],
|
|
|
|
|
|
# Le rendu, et non la seule syntaxe : `verifier_gui.py` passait au vert sur
|
|
|
|
|
|
# une page qui levait `ReferenceError` a l'ouverture.
|
|
|
|
|
|
[sys.executable, "scripts/tests/test_rendu_gui.py"],
|
|
|
|
|
|
# Le fichier le plus dense en memoire de decision du depot : ecrire dedans
|
|
|
|
|
|
# ne doit deplacer aucun commentaire.
|
plan : sauvegarder n emportait plus quarante lignes de commentaire
En voulant generer deux formulaires de plus, j ai trouve pire que ce que
je cherchais.
CE QUI ETAIT DEJA LA. Les quatre ecrivains de registre ecrasaient le
fichier au safe_dump. Mesure sur les fichiers reels : domaines.yml 6->3,
applications.yml 27->5, serveurs.yml 18->3. Quarante lignes, detruites
par n importe quel clic sur Sauvegarder dans les vues Serveurs,
Applications ou Domaines. Parmi elles, celle qui explique pourquoi
backup-01 a ete retire, et celle qui dit dans quel ordre les deux roles
du runner s appliquent. C etait l incident du 2026-08-18, jamais corrige
pour les registres du plan. Les quatre passent par _ecrire_registre :
aller-retour a vide identique a l octet, sur les quatre fichiers.
TROIS ECARTS DE SCHEMA, trouves en confrontant le schema aux VALIDATEURS
et non aux seuls plans :
- edge designe un GROUPE, pas un hote. Le schema disait serveurs : un
formulaire genere aurait offert une valeur qu aucun hote ne reconnait,
donc aucun SAN, donc la panne du 2026-08-25 reintroduite ;
- exposition, entierement valide par le moteur, manquait au schema ;
- liens etait items: {type: object} — une liste d objets sans forme.
Et mail, offert par la vue Domaines depuis sa creation, decrit ici comme
un booleen, saisi la-bas comme du texte, lu par rien : retire.
P62 garde tout ca. Elle separe l entite du reste mecaniquement : un
validateur lit son entite par des variables LOCALES, les autres registres
par ses PARAMETRES. Controle negatif rejoue.
LES FORMULAIRES. Serveurs de BD et Domaines sont generes, chargement et
sauvegarde compris. Quatre registres sur six. Le generateur a appris la
liste d objets.
LIMITE : restent serveurs et applications, les deux plus gros ; et je n ai
toujours pas ouvert ces pages dans un navigateur.
make prouver : CONFORME, 61 OK, 0 echec, 1 saute (62 preuves).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-08 17:54:49 -04:00
|
|
|
|
[sys.executable, "scripts/tests/test_nomenclature_ecriture.py"],
|
|
|
|
|
|
# Les quatre registres du plan : sauvegarder ne doit emporter aucun
|
|
|
|
|
|
# commentaire. Quarante lignes etaient detruites a chaque clic.
|
|
|
|
|
|
[sys.executable, "scripts/tests/test_ecriture_plan.py"]]},
|
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},
|
site : le plancher survit au redemarrage, et la zone dit les vraies adresses
Le decoupage du site en quatre zones a deplace cinq machines. Ni le plancher
/etc/hosts ni la zone DNS n'avaient suivi. Quatre defauts, tous dans le moteur.
LE PLANCHER ETAIT EFFACE A CHAQUE DEMARRAGE — ET LE PREMIER CORRECTIF N'EN
ETAIT PAS UN.
`hosts_statiques` posait `99-setops-hosts.cfg` avec `manage_etc_hosts: false`,
pendant que `cloud_init` posait `99_setops.cfg` avec `true`. Dans `cloud.cfg.d`
l'ordre est LEXICAL et le dernier gagne : `-` vaut 0x2D, `_` vaut 0x5F. On a
donc retire la cle de `cloud_init` — le role qui POSSEDE le fichier decide —
puis renomme notre fragment `zz-` pour passer apres le `99_chezlepro.cfg` du
gabarit dore.
Et ca ne suffisait toujours pas. Redemarrage d'epreuve : plancher encore efface.
La cause reelle est ailleurs — Proxmox inscrit `manage_etc_hosts: true` dans la
USER-DATA de son lecteur cloud-init, et la user-data prime sur `cloud.cfg.d`
tout entier. Aucun fragment ne pouvait gagner ; renommer pour parler en dernier
ne servait a rien, le dernier mot n'appartenant pas a ce repertoire.
Ce que cloud-init regenere, il le regenere depuis `hosts.debian.tmpl` — le
gabarit le documente lui-meme. `hosts_statiques` le pose desormais avec le MEME
contenu que /etc/hosts, et une garde compare les deux a chaque passage.
Redemarrage d'epreuve : les neuf entrees sont la.
La garde precedente affirmait « conforme » en mesurant l'ordre lexical — vrai,
et sans rapport avec ce qui se passait. Une garde qui mesure la mauvaise chose
est pire qu'aucune.
LA ZONE DNS NE PUBLIAIT PAS LES NOMS DE SERVICE.
`forge.genese.internal` et `pki.genese.internal` — des noms que les certificats
portent et que les clients appellent — n'avaient aucun enregistrement. Seul le
plancher savait les resoudre. Trois causes empilees :
- le plan du site coupait `serveur_powerdns_publier_expositions`, au motif que
« le site n'a pas d'edge » : ca confondait PUBLIC et EXPOSE ;
- `expositions_des_applications` rendait `domaine: None` faute de
`domaines.yml`, et le modele de zone ecarte les expositions dont le domaine
n'est pas la zone. Repli ajoute, symetrique de celui deja ecrit pour `edge` :
sans domaine public declare, le domaine est celui que porte le FQDN ;
- `serveur_powerdns` exigeait les deux registres et echouait si `domaines.yml`
manquait — le meme tout-ou-rien que `hosts_statiques` a corrige le meme jour.
Puis `named-checkzone` a refuse la zone : `dns.genese.internal` heritait d'un
CNAME par defaut du role ET d'un A par exposition. La garde a bien joue son role
— elle a arrete une zone cassee avant qu'elle soit servie. Le plan l'emporte
desormais sur le defaut du role.
Un service ne doit pas dependre d'un plancher pour etre joignable : le plancher
est un filet, pas le sol.
VERIFICATION. Les huit noms — cinq machines, trois services — resolvent vers les
bonnes adresses depuis les cinq hotes, par le plancher ET par le DNS, et le
plancher survit au redemarrage.
P46 refuse desormais deux choses : plus d'un role ecrivant `manage_etc_hosts`,
et l'absence du gabarit maitre. Controle negatif verifie.
RESTE NOMME, PAS CORRIGE : la zone INVERSE. `serveur_powerdns_zone_inverse`
derive d'un supernet /16 — la forme d'un tenant. Un site declare plusieurs /24
et n'a pas de supernet unique : aucune zone inverse n'est generee.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 20:31:01 -04:00
|
|
|
|
{"id": "P46", "titre": "Plancher /etc/hosts : un seul role en decide",
|
|
|
|
|
|
"refs": [], "func": preuve_un_seul_maitre_du_plancher},
|
dns : quatre zones inverses pour le site, et rien de plus
Le site ne servait aucun PTR. `serveur_powerdns_zone_inverse` derivait d'un
supernet /16 — la forme d'un TENANT, qui tire tout de son index. Un site ne
derive pas : il declare plusieurs /24 et n'a pas de supernet unique, si bien que
la derivation rendait une chaine vide et qu'aucune zone n'etait generee.
Le site revendique desormais exactement ce qu'il occupe :
31.0.10.in-addr.arpa 32.0.10.in-addr.arpa
33.0.10.in-addr.arpa 34.0.10.in-addr.arpa
Revendiquer `0.10.in-addr.arpa` d'un seul geste aurait ete plus simple et faux :
cette zone couvre aussi la frontiere, le transit et les hyperviseurs, qui ne sont
pas a lui. Une autorite qu'on s'attribue sans l'exercer est une panne differee —
le resolveur repondrait NXDOMAIN pour des adresses qu'un autre sait nommer.
LA DERIVATION VIT DANS UN FILTRE (`zones_inverses`) parce qu'elle a DEUX
appelants : `serveur_powerdns` ecrit ces zones, `serveur_resolveur` les delegue a
l'autoritatif. Deux calculs separes finiraient par diverger, et la divergence ne
se verrait qu'au premier PTR interroge. Le nom d'une zone dit sa profondeur —
trois etiquettes numeriques valent un /24, deux valent un /16 — et le modele en
deduit seul la forme du PTR.
DEUX CHEMINS MORTS TROUVES EN ROUTE.
Les zones etaient servies, et personne ne les demandait. `serveur_resolveur`
deleguait la zone directe par une `stub-zone` mais pas les inverses : `dig -x`
rendait vide depuis les cinq machines alors que la meme requete posee directement
a l'autoritatif repondait juste. Un service correct derriere un chemin que rien
n'emprunte.
Puis, les stubs poses, Unbound repondait toujours NXDOMAIN avec le drapeau `aa` —
une reponse AUTORITAIRE, sans jamais consulter le stub. Il embarque des
`local-zone` pour tout l'espace RFC1918 inverse. `nodefault` n'y change rien : ce
mode n'agit que si le nom correspond EXACTEMENT a une zone par defaut, et la
sienne est `10.in-addr.arpa`, le /8 entier. C'est `transparent` qui laisse la
requete suivre son cours — pour nos quatre zones seulement, la ou
`unblock-lan-zones` aurait ouvert tout l'espace prive.
Rien ne distinguait ce blocage d'une absence : le meme NXDOMAIN qu'un nom qui
n'existe pas.
AUSSI : les zones inverses sont desormais validees par `named-checkzone` comme la
directe (un fichier mal forme etait refuse en silence par PowerDNS), et une zone
qu'un ecosysteme cesse de revendiquer est retiree du repertoire.
VERIFICATION. Les cinq PTR resolvent depuis les cinq hotes par le resolveur, les
huit noms directs par le plancher ET par le DNS, et les deux roles sont
idempotents (changed=0).
P47 evalue la derivation sur cinq cas — un site a quatre zones, un tenant a une,
deux machines d'un meme /24 qui n'en font qu'une, aucune adresse, une adresse
illisible. Controle negatif verifie.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 20:58:50 -04:00
|
|
|
|
{"id": "P47", "titre": "Zones inverses : couvrir l'occupe, et rien de plus",
|
|
|
|
|
|
"refs": [], "func": preuve_zones_inverses_couvrent_l_occupe},
|
carte : P48 — l'index du mainteneur ne peut plus mentir
`docs/carte-set-ops.md` est l'index du MAINTENEUR : l'ordre de lecture du corpus,
et surtout le catalogue des MECANISMES TRANSVERSES avec, pour chacun, OU IL VIT
DANS LE CODE. Son but est ecrit en toutes lettres : « ne plus re-deterrer ce qui
existe ».
Elle n'avait aucune garde, alors que `catalogue-services.md` a la sienne depuis
P38. Ses sept chiffres etaient faux — 54 roles annonces contre 60, 34 documents
contre 38, 15 pieces d'audit contre 27, 70 decisions contre 78.
Le defaut couteux n'est pourtant pas la. C'est le POINTEUR MORT : la carte dit ou
vit un mecanisme, quelqu'un ne l'y trouve pas, et le reimplemente a cote —
exactement la panne qu'elle existe pour prevenir. Aucun de ces nombres ne fait
travailler personne ; mais un document dont les faits verifiables sont faux cesse
d'etre consulte, et c'est alors ses pointeurs qu'on perd.
P48 verifie les deux : 84 chemins cites existent, et 7 chiffres correspondent a
la mesure. Controle negatif verifie sur les DEUX moities — un chiffre fausse, un
pointeur casse, la preuve echoue dans les deux cas.
QUATRE DISTINCTIONS ont du etre ecrites pour qu'elle ne soit pas fausse dans
l'autre sens : un gabarit de nom (`preuve-<date>.md`) decrit une forme, pas un
fichier ; un chemin hors depot (`~/.config/setops-vault-pass`) vit sur le poste de
l'exploitant, et c'est tout l'interet de la doctrine des voutes ; un fragment
(`meta/acces.yml`) vaut comme SUFFIXE, parce qu'un index se lit ainsi ; et un
artefact GENERE (`hosts.yml`) n'a pas a exister dans le moteur. Les quatre sont
nommees dans le code plutot que sautees en silence.
Le tableau « Le depot en chiffres » remplace les comptes en prose : ce qu'on
n'entretient pas, on ne l'affirme pas — ou bien on le fait recompter.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 13:56:58 -04:00
|
|
|
|
{"id": "P48", "titre": "La carte d'orientation designe ce qui existe, et compte juste",
|
|
|
|
|
|
"refs": [], "func": preuve_carte_dit_vrai},
|
flux : P49 — le registre des flux avait derive sans bruit
`docs/registre-flux.md` est GENERE depuis les `roles/*/meta/flux.yml`, et c'est le
document qu'un humain lit pour savoir ce que le pare-feu laisse passer. Son
EXISTENCE etait verifiee depuis longtemps ; sa FRAICHEUR ne l'etait pas.
Il avait derive : la garde d'administration y portait encore `10.0.0.0/24` alors
que le reseau d'administration vaut `10.17.0.0/24`, deux flux `client_resolveur`
ajoutes depuis n'y figuraient pas, et un hote manquait des listes de sources. Un
lecteur y aurait lu un pare-feu qui n'existe plus.
L'inventaire avait deja sa garde — P03, le diff-vide du plan. Le registre des flux
est le meme genre d'artefact : genere, versionne, lu par un humain. Il lui
manquait la meme. `generer_registre` etant une fonction PURE, P49 la rejoue en
memoire et compare — une preuve qui repare ce qu'elle mesure ne mesure plus rien.
Controle negatif ideal, et il ne s'invente pas : la version commitee elle-meme.
Restauree, la preuve echoue ; regeneree, elle passe.
CE QUE CETTE DECOUVERTE CORRIGE AUSSI DANS MA TETE. J'avais decrit le symptome
comme « le runner salit ses propres clones » — une contradiction structurelle
entre un depot-clone et un repertoire de travail. C'etait faux, et la question de
l'exploitant l'a mis au jour. Regenerer un artefact DOIT produire un diff quand
les sources ont change ; ce qui manquait n'etait pas une architecture, c'etait une
garde. Un symptome observe depuis un seul endroit ressemble toujours a une
propriete de cet endroit.
Ce commit emporte aussi la regeneration elle-meme : le registre du moteur, et les
quatorze fichiers nftables de Chezlepro, remis en accord avec leurs sources.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 16:58:17 -04:00
|
|
|
|
{"id": "P49", "titre": "Registre des flux : la matrice d'audit est a jour",
|
|
|
|
|
|
"refs": [], "func": preuve_registre_flux_a_jour},
|
frontiere : le devis sait desormais refuser SANS consigner
L'outil ne savait qu'AUTORISER — `"action": "pass"` etait en dur dans l'emetteur.
Une regle de silence ne pouvait donc pas naitre du depot, et j'en avais pose deux
a la main sur le boitier : exactement ce que ce projet refuse.
CE QUI L'A MOTIVE. Le journal de la frontiere ecrivait 982 000 entrees par jour,
dont 82 % un balayage Internet contre le port VNC et le reste du bavardage de
decouverte du reseau local. Sa fenetre utile etait tombee a QUARANTE-QUATRE
SECONDES. J'y ai cherche la trace d'un flux du site vers les hyperviseurs, je n'ai
rien trouve, et j'en ai conclu a tort qu'aucune regle ne bloquait. Un journal noye
ment aussi surement qu'un journal mort. Mesure apres declaration : ~20 700/jour.
Une regle de silence ne change AUCUN comportement : ce qu'elle vise etait deja
refuse par le defaut. Elle ne supprime qu'une trace que personne ne lira.
TROIS PIECES.
`cle_regle` accepte une action sans changer d'un octet la cle des regles `pass`
deja posees. L'ajout naif d'un champ les aurait toutes detruites pour les recreer
a l'identique, sur la frontiere, en production. Le plan l'a confirme : 7 a creer,
0 a retirer, 121 inchangees.
`_corps_regle` lit l'action, la consignation et la SEQUENCE depuis le devis. La
sequence est ce qui rend un `block` sur : OPNsense evalue en `quick`, donc un
blocage large emis avant les `pass` fermerait courrier, web et acces distant.
`devis_opnsense` lit `opnsense_silences` et en fabrique regles et alias, motif
compris — une regle `block` muette sans raison ecrite est indiscernable d'un oubli.
P50 garde ces deux dangers. Controles negatifs verifies : un silence en sequence 1
echoue, un silence sans motif echoue.
Carte : 28 pieces d'audit (P48 l'avait vu juste).
make prouver : CONFORME, 50 OK, 0 echec.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 18:10:39 -04:00
|
|
|
|
{"id": "P50", "titre": "Silences : un refus muet est declare, place en dernier, et motive",
|
|
|
|
|
|
"refs": [], "func": preuve_silences_declares},
|
collections : epingler les versions — le runner echouait la ou le poste reussissait
Premiere materialisation de VM depuis le runner du SITE :
ERROR! couldn't resolve module/action 'community.general.proxmox_pool'
Meme depot, meme playbook, meme plan que chez le mainteneur. La difference tenait
a une seule chose que le moteur ne disait pas : la VERSION de ses collections.
Le poste porte `community.general` 10.3.0. Le runner, monte un jour plus tard, a
recu la 13.3.0 — et la version 11 a RETIRE les modules Proxmox de cette collection
(ils vivent desormais dans `community.proxmox`). `requirements.yml` nommait ses
collections sans dire lesquelles : chaque machine installait donc ce qui etait
courant le jour de son montage. Une dependance non epinglee n'est pas une
dependance, c'est un pari sur l'etat d'Internet a la date du deploiement.
TROIS CORRECTIONS.
`requirements.yml` epingle les trois collections aux versions eprouvees.
`serveur_ops` installe avec `--force`. Sans lui, ansible-galaxy laisse en place une
version SUPERIEURE a celle demandee : il ne retrograde pas. Un poste peut etre en
avance, pas seulement en retard, et le depot doit faire autorite dans les deux sens.
P51 garde les deux faiblesses de ce fichier — celle du 2026-08-24, une collection
utilisee sans etre declaree (`ansible.posix`), et celle d'aujourd'hui, declaree sans
version. La preuve ne lit que les fichiers de TACHES : un `defaults/main.yml` porte
`net.ipv4.ip_forward`, qu'un motif trop large prend pour un module.
MIGRATION CONNUE, PAS FAITE : passer les quatre modules Proxmox a
`community.proxmox` permettra de suivre `community.general` au-dela de la 11.
make prouver : CONFORME, 51 OK, 0 echec.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 18:58:23 -04:00
|
|
|
|
{"id": "P51", "titre": "Collections : toutes declarees, toutes epinglees",
|
|
|
|
|
|
"refs": [], "func": preuve_collections_declarees_et_epinglees},
|
creer-vm : prouver la materialisation sans entrer chez le tenant
`creer-vm` confirmait son succes en attendant une reponse SSH. Le runner du SITE
materialise le terrain de TOUS les tenants, mais la frontiere lui refuse d'entrer
chez eux — c'est le sens meme de leur isolation. La premiere VM qu'il a creee a
donc ete declaree en echec apres 600 secondes alors qu'elle tournait, avec
l'adresse exacte que le plan lui destinait :
Attente de SSH sur ops-01 ............ ECHEC: injoignable apres 600s.
LA TENTATION ETAIT D'OUVRIR LE SSH du runner vers tous les tenants. Ca aurait
repare la mesure en detruisant ce qu'elle protege : une machine capable d'entrer
chez chaque locataire est precisement ce que cette architecture refuse d'avoir.
L'agent invite repond sans rien ouvrir — l'API des hyperviseurs est deja le flux
par lequel la VM vient d'etre creee, donc qui peut la creer peut la voir naitre —
et il PROUVE DAVANTAGE. « Quelque chose ecoute sur le port 22 » ne dit ni quel
systeme a demarre, ni si cloud-init a pose la bonne adresse. Sur ops-01 :
10.17.19.41 portee sur asgard — Debian GNU/Linux 13 (trixie) 6.12.101
Ses echecs distinguent deux causes tres differentes : « la machine demarre mais
rapporte une AUTRE adresse — cloud-init, ou le pont sur lequel elle est posee »,
et « introuvable sur la fabric ».
ATTENDRE LA DISPONIBILITE A CHANGE DE MAIN. Guetter cloud-init et la liberation de
dpkg appartient a qui va CONFIGURER : `deployer` le fait desormais, la ou il se
contentait d'un `ping` unique. Il y gagne ce qu'il n'avait pas — attendre le verrou
APT, faute de quoi la premiere couche echouait dessus. Contrepartie assumee : un
nom d'hote errone patiente au lieu d'echouer vite ; echouer vite interdirait de
chainer creation et deploiement, le geste central d'une reconstruction.
P52 garde le couplage ferme. Deux controles negatifs verifies.
make prouver : CONFORME, 52 OK, 0 echec.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 22:48:49 -04:00
|
|
|
|
{"id": "P52", "titre": "Materialiser n'exige pas d'entrer dans le tenant",
|
|
|
|
|
|
"refs": [], "func": preuve_materialiser_n_exige_pas_d_entrer},
|
flux : l interne refuse a voix haute, la bordure se tait (P53)
Decision de l exploitant : block vers l Internet, reject a l interieur, parce que
c est prudent. Ce n est pas le refus qui informe, c est CE QU IL FAIT AU SILENCE :
sous drop partout, un timeout voulait dire aucune machine, aucune route, ou une
politique. Quand la politique parle, il n en reste qu une.
nftables par hote policy drop + reject with icmpx type admin-prohibited
pare-feu Proxmox policy_in = REJECT (POLITIQUE_VM, source unique)
frontiere OPNsense block — INCHANGE, et c est la condition
admin-prohibited ET NON tcp reset : un RST est indiscernable d un port ferme sans
service. La chaine forward reste muette : elle porte le trafic qui TRAVERSE l hote,
et y repondre ferait parler cette machine au nom d une destination qui n est pas
elle.
POURQUOI C EST PRUDENT : l obscurite etait deja nulle a l interieur (chaque machine
porte un /etc/hosts qui liste ses voisines), et la bordure protege le reject —
rien d indeclare ne franchit le perimetre, donc il ne repond jamais a l Internet.
982 000 entrees par jour a la frontiere, dont 82 % un balayage VNC.
LA MESURE A CORRIGE LA MESURE, DEUX FOIS.
Ma preuve interdisait le litteral DROP et a fait echouer un code JUSTE : la
detection d une politique posee AU DATACENTER, qui est un garde-fou. Une preuve qui
interdit un mot au lieu de mesurer une propriete finit par accuser ce qu elle
devrait proteger.
Et l absence parlait deja : EHOSTUNREACH en 3,05 s pour une machine inexistante,
timeout a 6 s pour un refus de la frontiere. Mes deux erreurs de diagnostic ne
venaient pas du drop mais de ma SONDE — curl et bash /dev/tcp ecrasent les deux
dans un meme echec.
Applique : 6 VM en REJECT, 0 creee, 0 retiree. Rien ne se ferme.
Trois controles negatifs verifies.
make verifier : vert. make prouver : CONFORME, 53 OK, 0 echec, 0 saute.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-08-28 16:43:22 -04:00
|
|
|
|
{"id": "P53", "titre": "L'interne refuse a voix haute, la bordure se tait",
|
|
|
|
|
|
"refs": [], "func": preuve_interne_parle_bordure_se_tait},
|
2026-08-28 17:14:37 -04:00
|
|
|
|
{"id": "P54", "titre": "L'insemination ne reclame aucun secret du tenant",
|
|
|
|
|
|
"refs": [], "func": preuve_insemination_sans_secret},
|
cles : le terrain etait inoccupable — la cle du tenant nait avec ses machines
Le deploiement lance depuis ops-01 s est arrete au premier geste, sur les quinze
machines a la fois : Permission denied (publickey). Les VM neuves n acceptaient que
la cle de l exploitant. Celle du runner EST declaree au plan, mais c est le SOCLE
qui la depose — et le socle doit etre applique par quelqu un qui peut deja entrer.
Boucle fermee : le tenant recevait un terrain qu il ne pouvait pas occuper.
DEUX CLES, DEUX PORTEES :
la cle du SITE -> sur le SEUL runner du tenant l insemination
la cle du TENANT -> sur TOUTES ses machines il va les configurer
POSER UNE CLE AU CLONAGE N EST PAS ENTRER CHEZ LE TENANT. C est un parametre de
creation, au meme titre que l adresse ou le disque : le site ecrit les conditions
de NAISSANCE, il n ouvre aucune session. Le site n obtient aucun acces sur ces
machines ; seul le runner du tenant en obtient un.
Forme tranchee par l exploitant : le site renseigne le seul runner, qui se charge
de toute sa flotte. Le plancher /etc/hosts suit le meme chemin — ops-01 a le sien
depuis son insemination et resout ses quinze voisines par leur nom.
LA REVOCATION EST HONOREE A LA NAISSANCE : une entree a etat absent n est pas
reposee. Sans cette lecture, une cle retiree de la flotte serait ressuscitee sur
chaque VM creee ensuite — panne lente, silencieuse, invisible au plan.
P55 garde les deux moities : la cle du site ne nait que sur un porteur de
serveur_ops_tenant, et aucune machine ne reste sans celle de son tenant. Une
frontiere tenue a une seule couche n est pas tenue. Deux controles negatifs.
make verifier : vert. make prouver : CONFORME, 55 OK, 0 echec, 0 saute.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-08-30 08:54:17 -04:00
|
|
|
|
{"id": "P55", "titre": "La cle du SITE ne nait que sur le runner d'un tenant",
|
|
|
|
|
|
"refs": [], "func": preuve_cle_du_site_bornee_au_runner},
|
gabarit : minimal — il etait un cache du socle, et il perimait sans le dire
Il portait DIX-SEPT roles : exactement ceux du socle et du durcissement, que le
deploiement rejoue a l identique. C etait donc un CACHE — et comme tout cache, il
perimait sans le dire.
MESURE : derniere recapture le 2026-08-09, et SIX de ses roles avaient change depuis
— common_packages, cloud_init, ssh_baseline, ssh_hardening, auditd,
nftables_baseline. Rien ne le signalait : le deploiement masquait la derive en
reappliquant tout, donc personne ne pouvait la voir. Aucune preuve du harnais ne
regardait sa fraicheur.
IL NE GARDE QUE CE QUI DOIT EXISTER AVANT QU ANSIBLE PUISSE AGIR :
qemu_guest_agent l agent repond AVANT SSH — P52 s en sert
cloud_init le seul chemin vers la premiere seconde
sudo_ansible la porte par ou tout entre
ssh_baseline le serveur SSH
Ce ne sont pas des choix d efficacite, ce sont des conditions d existence.
CE QUE CA COUTE, ET QUI EST COUVERT : une VM neuve n est plus durcie a la naissance.
Elle nait cependant DERRIERE LE PARE-FEU DE L HYPERVISEUR, policy_in=REJECT arme au
clonage — verifie sur obs-01. La fenetre d exposition est fermee par la fabric, pas
par le gabarit. Mon objection initiale tombait devant la mesure.
P56 GARDE LES DEUX MOITIES. Qu il ne REGROSSISSE pas : un role ajoute recree le
cache, donc la peremption invisible. Et que rien de retire ne soit PERDU : un role
absent du gabarit ET du socle disparaitrait de toutes les machines neuves, sans
erreur ni trace, et la panne arriverait des mois plus tard sur une machine qu on
croyait durcie. Verifie : 14 retires, 14 repris, zero orphelin. Deux controles
negatifs.
make verifier : vert. make prouver : CONFORME, 56 OK, 0 echec.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-09-01 10:35:20 -04:00
|
|
|
|
{"id": "P56", "titre": "Gabarit minimal, et rien de retire n'est perdu",
|
|
|
|
|
|
"refs": [], "func": preuve_gabarit_minimal_et_repris},
|
documentation : la tournee des 74 documents, parce qu un balayage ne lit pas
La revision a commence par un balayage par motifs — chemins morts, cibles make
absentes, comptes derives. Il a trouve une trentaine d ecarts et rate presque
tout le reste : un motif ne voit que ce qui s exprime en motif.
make hote-planifier en est l exemple. La cible EXISTE, donc le controle passait
au vert. C est une cible depreciee qui refuse et sort en 2, recommandee par
AGENTS.md, et qui contredit la REGLE D OR du meme fichier trois ecrans plus
haut. Il fallait lire pour la voir.
74 documents lus un par un. 66 corriges, 8 exacts.
CE QUI ETAIT FRANCHEMENT FAUX
AGENTS.md, la source d autorite, annoncait la flotte pas encore executee contre
des VM reelles. Elle a ete rasee et remontee depuis zero trois fois.
ecosysteme-chezlepro.md, le document montre a un client, portait la meme
phrase : il se sous-vendait gravement.
courriel-conception.md s ouvrait sur aucun role n est encore ecrit, au-dessus de
son propre paragraphe 1 qui les nomme. autorisation.md se terminait sur rien n
est construit alors qu il rapporte des mesures datees du role en fonctionnement.
hebergeur-exploitation.md disait rien n est fait d un depot qui existe.
filiation-emancipation.md se contredisait a deux ecrans de distance.
DES MODELES DECRITS D APRES UN MONDE ANTERIEUR
Le resolveur : cinq documents decrivaient un Unbound par VM en opt-in, trois le
donnaient en exemple d integration FACULTATIVE — il est universel depuis le
2026-08-24. L adressage de nomenclature-vm.md : reseau unique, VLAN 11-15, VMID
a cinq chiffres. Le nommage SDN de sdn-evpn.md contre le code : c est le wiki
qui avait raison.
CE QUI CASSE AU PREMIER ESSAI
Le nom du gabarit dore etait faux a quatre endroits, dont la procedure qui le
FABRIQUE et le critere R2 de l epreuve d operateur independant.
preparer-un-site-hebergeur.md avertissait qu une VM faite a la main serait
detruite : raser derive du plan, il ne la detruira jamais — le risque est l
inverse. Un mot de passe d essai en clair dans un depot public.
DEUX PREUVES ETENDUES, ET UNE QUI SE TROMPAIT ELLE-MEME
P57 couvre les groupes : elle a signale aussitot 29 groupes annonces au-dessus d
un tableau qui en cite 40. P29 confronte le tableau de authentification.md aux
declarations reelles : 12 annonces, 21 reels.
Et P57 imposait un chiffre faux — 56 preuves alors que le depot en porte 57, la
conditionnelle vivant hors de tout comptage. Un garde-fou qui fait respecter une
erreur ajoute l assurance a l erreur.
CE QUI RESTE, ET QU AUCUNE PREUVE NE TIENT
Deux comptes trouves a la main. Et une lacune reelle : rien ne garde les
meta/acces.yml — ni qu un service web-sso en porte un, ni que le groupe qu il
nomme existe. P29 tient les positions d authentification, personne ne tient les
habilitations.
make prouver : CONFORME, 56 OK, 0 echec, 1 saute. 0 lien mort.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-06 16:18:23 -04:00
|
|
|
|
{"id": "P57", "titre": "Comptes en prose : les chiffres du depot sur lui-meme",
|
|
|
|
|
|
"refs": [], "func": preuve_comptes_de_la_prose},
|
preuves : trois gardes pour ce que ma lecture ne tiendra pas
Une revision de documentation vieillit comme le reste. Ce qui tient, c est ce
qu une machine verifie — et trois lacunes etaient nommees sans etre gardees.
P58 HABILITATIONS. autorisation.md posait la regle (un service nomme un
GROUPE, jamais une personne, D-66) et meta/acces.yml la portait ; rien ne
la verifiait. P29 gardait les POSITIONS d authentification, personne ne
gardait les DROITS.
Le controle qui porte la preuve est un croisement : une entree
porte_par: role-realm affirme que l habilitation voyage par un role de
realm projete depuis un groupe LDAP. P58 le confronte a serveur_keycloak.
Sans ca, un service annonce une habilitation que rien ne transporte, et l
ecran reste vide sans que personne sache pourquoi.
CE QU ELLE N EXIGE PAS, et c est le point le plus important : que les
groupes nommes existent dans l annuaire. Ce serait contredire le regime du
paragraphe 2 — le depot AMORCE un acces et se retire, les appartenances
appartiennent a une personne. dev et personnel n existent dans aucun code,
et ce n est pas un defaut.
P59 ENUMERATIONS ANNONCEES. Les deux ecarts trouves a la main pendant la
tournee — cinq portes annoncees devant une table de six, huit lignes
renvoyees vers une fiche qui en compte dix — etaient d une forme que P57
ne voit pas.
Ma premiere version a signale CINQ ecarts, et les cinq etaient du bruit :
dans « reprise dans les deux devis : », le nombre qualifie autre chose que
la liste. Cent pour cent de faux positifs — la preuve qui crie sur un cas
sain et qu on apprend a ignorer. Resserree aux deux formes ou le nombre ne
peut compter rien d autre. Etroite et vraie plutot que large et devineuse.
P60 WIKI PUBLIE. Le wiki est publie DEPUIS le depot ; rien ne mesurait l ecart,
et il s est creuse de VINGT-SEPT JOURS en silence. Deux unites jamais
publiees, vingt et une differentes : pour qui lit la forge plutot que le
depot, toute la revision n existait pas.
Le harnais est STATIQUE, zero appel reseau — cloner la forge romprait la
seule propriete qui fasse qu une preuve vaille hors de ce poste. La mesure
passe donc par un TEMOIN que make wiki-publier depose. Amorce avec la
valeur MESUREE : le wiki d eregion porte b6167f2, dont le message dit
source: ac85278.
Ce qu elle ne prouve pas : un temoin dit ce qui est PARTI, jamais ce qui
est ARRIVE.
LES TROIS SONT EPROUVEES DANS LES DEUX SENS
Douze essais negatifs, douze refus : groupe non projete, acces.yml disparu,
personne au lieu d un groupe, mecanisme invente, raison manquante, compte
revenu a cinq, septieme porte ajoutee sans toucher au compte, renvoi croise
fausse, temoin absent, temoin d un autre depot. Une garantie qu on n a jamais vu
dire non n est pas une garantie, c est une habitude.
ETAT : NON CONFORME, 58 OK, 1 echec, 1 saute.
P60 est rouge, et c est le comportement voulu : le registre a le droit de
perdre. Le retard qu elle signale est reel et anterieur a elle. Une commande le
ferme, et elle vient ensuite.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-08 12:47:18 -04:00
|
|
|
|
{"id": "P58", "titre": "Habilitations : chaque service dit a quel GROUPE, et par quoi",
|
|
|
|
|
|
"refs": [], "func": preuve_habilitations_declarees},
|
|
|
|
|
|
{"id": "P59", "titre": "Enumerations annoncees : le nombre correspond a ce qui suit",
|
|
|
|
|
|
"refs": [], "func": preuve_enumerations_annoncees},
|
|
|
|
|
|
{"id": "P60", "titre": "Wiki publie : la forge sert ce que le depot dit",
|
|
|
|
|
|
"refs": ["AFF-002"], "func": preuve_wiki_publie_a_jour},
|
schema du plan : la forme des registres devient derivee, et gardee
Etape 2 du chantier « l UI reflete fidelement la structure ». Le GUI porte
CHAMPS_ECRITS_PAR_GUI, une liste tenue A LA MAIN de ce qu il sait ecrire, que
P19 confronte au reel. C est une copie — gardee, donc honnete, mais une copie :
quelqu un doit penser a l allonger.
`make schema` produit docs/audit/schema-plan.json : six registres, 42 champs,
leurs types, leurs enumerations et ce qui est requis.
CE QUE LE SCHEMA EST, ET CE QU IL N EST PAS
schema -> la FORME -> generera les champs du formulaire
validateurs -> la COHERENCE -> refusent une saisie incoherente
Un JSON Schema ne sait pas dire qu un `consommateur` designe une application
inexistante, ni qu une integration universelle recopiee au plan est un defaut.
Vouloir l y mettre creerait la seconde source de verite que tout ce depot
refuse. Les valider_* restent l autorite.
LES ENUMERATIONS SONT IMPORTEES, JAMAIS RECOPIEES
ETATS_SERVEUR, PORTEES_BD et AUTORITES_DNS viennent des constantes que les
validateurs appliquent. Une enumeration recopiee diverge — c est la lecon des
neuf resolutions d instance que P41 garde depuis.
CE QUE L ETAPE 1 AVAIT TROUVE, ET QUE P61 A CONFIRME
Le recensement montrait `categorie` et `service` presents dans TOUS les plans et
absents de CHAMPS_ECRITS_PAR_GUI, dont la ligne `nomenclature` est vide : le GUI
ne sait pas les editer, l operateur doit ouvrir le YAML. P61 a refuse le premier
schema pour cette raison exacte. Les trois tables imbriquees de la nomenclature
sont donc DECRITES et non resumees en « object ».
31 champs observes dans l instance courante, 42 decrits par le schema. La
difference n est pas du bavardage : observer une instance n est pas un schema.
`noeud`, `stockage` et `coeurs` sont legitimes et simplement inutilises ici — un
schema derive de l observation les INTERDIRAIT.
P61, EPROUVEE DANS LES DEUX SENS
fichier genere perime -> REFUSE
champ du plan absent du schema -> REFUSE
champ decrit mais inutilise au plan -> COMPTE, pas refuse
Le troisieme point est delibere : refuser obligerait a retirer du schema un
champ valide des que plus personne ne s en sert. Meme mesure que les lacunes
nommees de P29.
UN DEFAUT DE MON INSTRUMENT, PAYE EN ROUTE
P61 comparait des noms a plat quand couverture_gui aplatit les tables
imbriquees : elle criait sur un schema correct. L instrument mesurait autre
chose que la cible. On aplatit desormais des deux cotes.
make prouver : CONFORME, 60 OK, 0 echec, 1 saute.
Prochaine etape : generer les formulaires depuis ce schema, et retirer
CHAMPS_ECRITS_PAR_GUI.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-08 15:19:20 -04:00
|
|
|
|
{"id": "P61", "titre": "Schema du plan : il decrit tout ce que les plans contiennent",
|
|
|
|
|
|
"refs": ["AFF-033"], "func": preuve_schema_du_plan},
|
plan : sauvegarder n emportait plus quarante lignes de commentaire
En voulant generer deux formulaires de plus, j ai trouve pire que ce que
je cherchais.
CE QUI ETAIT DEJA LA. Les quatre ecrivains de registre ecrasaient le
fichier au safe_dump. Mesure sur les fichiers reels : domaines.yml 6->3,
applications.yml 27->5, serveurs.yml 18->3. Quarante lignes, detruites
par n importe quel clic sur Sauvegarder dans les vues Serveurs,
Applications ou Domaines. Parmi elles, celle qui explique pourquoi
backup-01 a ete retire, et celle qui dit dans quel ordre les deux roles
du runner s appliquent. C etait l incident du 2026-08-18, jamais corrige
pour les registres du plan. Les quatre passent par _ecrire_registre :
aller-retour a vide identique a l octet, sur les quatre fichiers.
TROIS ECARTS DE SCHEMA, trouves en confrontant le schema aux VALIDATEURS
et non aux seuls plans :
- edge designe un GROUPE, pas un hote. Le schema disait serveurs : un
formulaire genere aurait offert une valeur qu aucun hote ne reconnait,
donc aucun SAN, donc la panne du 2026-08-25 reintroduite ;
- exposition, entierement valide par le moteur, manquait au schema ;
- liens etait items: {type: object} — une liste d objets sans forme.
Et mail, offert par la vue Domaines depuis sa creation, decrit ici comme
un booleen, saisi la-bas comme du texte, lu par rien : retire.
P62 garde tout ca. Elle separe l entite du reste mecaniquement : un
validateur lit son entite par des variables LOCALES, les autres registres
par ses PARAMETRES. Controle negatif rejoue.
LES FORMULAIRES. Serveurs de BD et Domaines sont generes, chargement et
sauvegarde compris. Quatre registres sur six. Le generateur a appris la
liste d objets.
LIMITE : restent serveurs et applications, les deux plus gros ; et je n ai
toujours pas ouvert ces pages dans un navigateur.
make prouver : CONFORME, 61 OK, 0 echec, 1 saute (62 preuves).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-08 17:54:49 -04:00
|
|
|
|
{"id": "P62", "titre": "Schema du plan : il decrit tout ce que le MOTEUR accepte",
|
|
|
|
|
|
"refs": ["AFF-033"], "func": preuve_schema_couvre_les_validateurs},
|
durcissement : cloud-init nait avec la VM et ne lui survit pas
cloud-init n est pas un logiciel d installation : c est une SOURCE DE
VERITE EXTERNE. Il se reveille a chaque demarrage et relit le lecteur
attache par l hyperviseur, qui peut redefinir comptes, cles SSH, mots de
passe et reseau. Sur une machine que le plan possede, c est un second
maitre — que le plan ne decrit pas, que make valider ne mesure pas, et
qui parle en premier.
Sa tache est finie a la premiere seconde : c est parce qu il a REUSSI a
poser l adresse et les cles qu Ansible a pu entrer.
TROIS MOITIES, ET ELLES SE DEFONT SEPAREMENT.
- le GABARIT le garde : sans lui un clone n a ni adresse ni nom ;
- le SOCLE ne l installe plus : le garder produisait un va-et-vient a
chaque deploiement, deux changed par passage, idempotence perdue ;
- le DURCISSEMENT le retire (roles/cloud_init_retrait, en dernier).
P63 garde les trois, plus le CONTENU du role : une coquille vide passerait
les trois premiers controles sans rien fermer. Quatre controles negatifs
rejoues.
CE QUI REND LE RETRAIT SUR EST MESURE, PAS SUPPOSE (obs-01, 2026-09-09) :
/etc/network/interfaces.d/50-cloud-init n appartient a aucun paquet — dpkg
-S ne le trouve pas — et le postrm ne le nomme jamais, meme en purge.
L adresse survit. Le role le verifie quand meme, avant et apres, et n
accuse que si le retrait l a emporte : une VM qui perd ce fichier ne se
plaint pas, elle repart sans adresse et plus personne ne peut entrer.
DEUX CHOIX DITS FRANCHEMENT. cloud-guest-utils reste (growpart : ni
service, ni port, ni source de donnees). Les ~29 paquets orphelins ne sont
pas retires par defaut : autoremove deciderait a partir des drapeaux dpkg,
et un durcissement ne doit pas pouvoir surprendre.
NON DEPLOYE : le code est ecrit, valide et prouve ; il n a pas ete
applique a la flotte. Essai a blanc sur obs-01 : cloud-init a retirer,
configuration reseau intacte.
make prouver : CONFORME, 62 OK, 0 echec, 1 saute (63 preuves).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-09 09:25:42 -04:00
|
|
|
|
{"id": "P63", "titre": "cloud-init nait avec la VM et ne lui survit pas", "refs": [],
|
|
|
|
|
|
"func": preuve_cloud_init_rendu_puis_retire},
|
supervision : la sonde se declare dans le role, comme le flux
LE CONSTAT. 39 roles declarent leurs flux, 32 leur empreinte, 32 leur
authentification — tous derives. Et 19 groupes sur 19 declaraient une
surveillance en prose que RIEN n executait ; Icinga en surveillait deux.
La carte disait ce qui etait surveille, et personne ne surveillait.
LE MECANISME. Un role declare ses sondes dans meta/supervision.yml et
depose lui-meme son script dans /usr/local/lib/setops/sondes/. Le porteur
client_sante les fait toutes tourner et pousse un resultat passif par
sonde, sans savoir ce qu elles mesurent. serveur_icinga derive les objets
Service ET le filtre de permission d API des memes declarations. Ajouter
une sonde ne demande de toucher ni au porteur ni a Icinga.
PREMIERE SONDE : client_pki/certificat. Heures restantes sur le certificat
reellement pose, chaine verifiee, et empreinte SERVIE comparee au disque
quand un service le consomme. 14/14 au tenant, 7/7 au site.
QUATRE OBSTACLES, ET TROIS SONT LA MEME LECON.
La sonde a rendu 14/14 en CRITIQUE sur une PKI saine : openssl verify
-CAfile racine ne trouve pas l intermediaire qui signe nos certificats.
step certificate verify, lui, repond VALIDE.
Deployee au site, elle a rendu 5/7 : le seuil d avertissement (12 h)
etait AU-DESSUS du point de renouvellement (8 h, le tiers restant). Elle
criait avant que le mecanisme ne soit cense agir. Seuils ramenes a 6 h et
3 h. Un seuil se DERIVE du moment ou le mecanisme surveille agit.
Une alarme toujours allumee ne vaut pas mieux qu une alarme jamais
allumee : elle apprend a ne plus regarder. Une sonde se prouve DEUX FOIS,
verte sur le sain et rouge sur le casse.
Le filtre d API etait ecrit avant la lecture des declarations : les
services auraient existe et Icinga aurait refuse leurs resultats.
Et mon controle negatif a casse un service reel : substituer le certificat
d hote a fait propager un cert sans sa clef vers node_exporter. Un controle
negatif se fait sur une COPIE.
P64 tient les deux bouts : declaree sans etre deposee, ou deposee sans
etre declaree. Trois controles negatifs rejoues.
make prouver : CONFORME, 64 OK, 0 echec, 0 saute.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-09 21:45:49 -04:00
|
|
|
|
{"id": "P64", "titre": "Sondes de supervision : declarees ET deposees", "refs": [],
|
|
|
|
|
|
"func": preuve_sondes_declarees_et_deposees},
|
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"]]},
|
les depots tiers passent par le cache, et le tenant n a plus le sien
Deux mouvements d une seule doctrine : le site fournit tout ce dont un
tenant a besoin pour venir au monde.
1. LES DEPOTS TIERS. Grafana, Icinga, Smallstep et Collabora ne publient
qu en HTTPS ; apt-cacher-ng ne relaie pas un tunnel, et le socle pose donc
Acquire::https::Proxy DIRECT. Chaque machine sortait elle-meme sur
Internet. Le cache declare desormais un Remap par fournisseur, et chaque
role demande en {{ ..._depot_schema }}:// - http des qu un cache est
declare, https sinon. Le TLS n est rompu nulle part : il est TERMINE au
cache, qui est notre machine, et l integrite vient des signatures.
avant : 4 fournisseurs en HTTPS direct, 14 machines sortant seules
apres : 0 source en HTTPS direct, 0 erreur apt sur 14 machines
Trois lecons. Le remap appartient au cache qui SORT : pose sur un cache
chaine, il tente le HTTPS a travers son amont et rend 503. apt_repository
AJOUTE au lieu de remplacer, donc l ancienne ligne https sortait toujours.
Et la liste des fournisseurs ne se devine pas - j en avais trois, l audit
en a revele un quatrieme.
2. LE CACHE DU TENANT. Sa ligne portait son propre retrait depuis toujours
- service MUTUALISABLE, un ecosysteme au premier age peut pointer sur celui
de son hote. Retiree. Ce qu on perd, dit franchement : plus de trafic
inter-zone et plus de charge sur site-cache-01, contre un service de moins
a poser, superviser et reproduire.
3. UN ROLE QU ON RETIRE DOIT DEFAIRE CE QU IL A FAIT. Le retrait a montre
que rien ne nettoie derriere. Le fichier apt visait un cache eteint en
ecrasant le plancher qui fonctionnait - le defaut deja paye a quinze
machines. Et les sondes du cache restaient, le porteur poussant pour des
services qu Icinga ne definit plus (404). Le socle retire le premier,
client_sante derive les sondes attendues et retire les orphelines.
P65 refuse tout role visant un depot relaye en https ecrit en dur. Sa
limite est dite : elle empeche une regression sur ce qui est connu, elle ne
decouvre pas l inconnu.
Mesure : Icinga 87 OK sur 96, prouver 65 OK, lint 0 defaut.
Reste, et c est dit : apt-cacher-ng tourne toujours sur forge-01 que plus
aucun plan ne declare.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-10 14:53:48 -04:00
|
|
|
|
{"id": "P65", "titre": "Depots tiers : demandes au cache, jamais en HTTPS direct",
|
|
|
|
|
|
"refs": [], "func": preuve_depots_tiers_par_le_cache},
|
2026-09-10 20:21:13 -04:00
|
|
|
|
{"id": "P66", "titre": "Clients OIDC : chaque URI vise un nom que le plan expose",
|
|
|
|
|
|
"refs": [], "func": preuve_clients_oidc_vises_sur_une_exposition},
|
2026-09-10 20:38:52 -04:00
|
|
|
|
{"id": "P67", "titre": "Nom public : le service porte celui du plan, pas celui du role",
|
|
|
|
|
|
"refs": [], "func": preuve_nom_public_vient_du_plan},
|
reconstruction a froid : les noms derives naissent justes, et une cle vide tombe
Quatrieme reconstruction depuis zero, 32 min 14 s, 14/14. Elle avait un but
precis : trois roles dependent desormais d une variable que le generateur
pose. A froid, si instancier ne la posait pas, le defaut du role reprendrait
la main en silence — juste pour forge et cloud, faux pour observatoire.
Le certificat ne a froid porte observatoire et vigie, aucun ancien nom, et les
deux retours SSO derivent juste sans reprise.
Un seul echec, sans rapport : sur une machine des quatorze, get_url a rendu
0 octet SANS ERREUR — le cache a servi un 200 au corps vide. Le defaut s est
lu deux cents lignes plus loin, dans un apt qui accusait la signature. La
garde posee hier ne mordait pas : retirer une ressource vide ne vaut qu au
passage suivant, quand le fichier existe deja.
Les cinq roles qui telechargent une cle la mesurent maintenant dans la meme
execution. P68 garde le motif. infra-mail-01 redeployee, make valider a
0 echec sur 14 machines.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-11 07:57:24 -04:00
|
|
|
|
{"id": "P68", "titre": "Cle de depot telechargee : mesuree avant d'etre utilisee",
|
|
|
|
|
|
"refs": [], "func": preuve_cle_telechargee_non_vide},
|
pools nommes comme les depots, et les cles sortent du poste
LE NOM D UN POOL EST CELUI DE SON DEPOT. Chezlepro-17 devient OPS-Chezlepro :
le seed se lisait dans le nom, ce qui obligeait a connaitre le codage — et
surtout le nom CHANGEAIT si l index changeait, ce que la renumerotation du
site a montre le jour meme.
Site-OPS ne derive de rien, et c est le point : les machines du genome ne
dependent d aucun index, elles sont l infrastructure SUR laquelle les index
vivent. Sans ce bloc elles restaient hors de tout pool.
P69 — l amorcage d un tenant designe-t-il le site REEL ? dns_amorcage et
artefacts_amorcage sont ecrits a la main, volontairement : au moment ou ils
servent la machine ne resout aucun nom. Mais ils designent des machines DU
SITE, et n ont pas suivi son renumerotage. La reconstruction du locataire
s est arretee sur Failed to update apt cache, a quinze couches de sa cause.
La preuve ne juge que les valeurs qui PRETENDENT designer le site : viser
9.9.9.9 est un choix, pas un oubli.
LES CLES SORTENT DU POSTE, EN CLAIR, ET C EST RAISONNE. Support perdu : LUKS
s en charge. Poste compromis : la seconde couche n aide pas, les originaux
sont dans ~/.config sur ce meme poste. Elle coutait une phrase de passe
stockee nulle part — le seul point que la procedure ne couvre pas. Option
--support-chiffre explicite ; le defaut reste GPG, parce qu un support non
chiffre est le cas le plus frequent.
Et ma note qui disait les cles sorties depuis le 5 septembre etait fausse :
le support ne portait que le depot hors site du 1er.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-12 21:37:28 -04:00
|
|
|
|
{"id": "P69", "titre": "Amorcage d'un tenant : l'adresse designe le site REEL",
|
|
|
|
|
|
"refs": [], "func": preuve_amorcage_suit_le_site},
|
depot de binaires : le site tient ce que les runners allaient chercher
Le cache du site couvrait apt ; quatre artefacts arrivaient autrement, parce qu ils
ne vivent dans aucun depot apt. Le controleur les tire puis les pousse par SSH.
Mesure du 2026-09-12 : le cache du runner du site est ABSENT. Un second locataire
monte depuis lui sortait chercher 570 Mo sur codeberg.org, github.com et
download.nextcloud.com, alors que le meme ecosysteme ne demandait plus un seul
paquet a Debian. Le poste du mainteneur les a depuis toujours : personne ne l avait vu.
Pas de relais transparent, et la mesure tranche : github.com redirige vers une URL
signee valable une heure, differente a chaque requete. Un cache qui la prend pour
cle ne fait jamais mouche. Le relais marcherait pour deux amonts sur quatre.
Donc un vrai depot, dans le service qui existe deja. LocalDirs d apt-cacher-ng publie
un repertoire du disque sous un prefixe, eprouve AVANT d ecrire le role. Aucun service,
aucun port, aucun certificat, aucun flux nouveaux : l ingress 3142 pair flotte couvre
exactement ce chemin.
Les versions ne sont pas recopiees : le role lit les defauts des quatre consommateurs.
Les quatre roles recoivent une tache AJOUTEE, placee avant leur stat de cache — si le
depot sert, le stat le voit et la tache amont se saute d elle-meme. Aucune tache
existante n a change.
P70 exige que tout dest ecrit sous un cache_local figure au depot. Une liste qui suit
une autre prend du retard ; celle-ci est nee avec sa garde.
Deux marches payees en chemin :
- failed_when: false REECRIT le verdict, donc la premiere garde de signature ne
gardait rien. Elles mesurent le fichier desormais.
- file: state=directory cree les parents en 0750 : apt-cacher-ng, qui ne tourne pas
en root, rendait 403 sur chaque fichier. Un chemin se traverse en entier.
Verifie sur l infrastructure : 6/6 artefacts servis (200/206) depuis le runner du site
ET depuis une machine du locataire a travers la frontiere ; les 6 empreintes SHA-256
sont identiques a celles qui ont construit Chezlepro ; second passage changed=0.
make prouver : 69 OK, 0 echec, 1 saute. ansible-lint : 0 failure, profil production.
Inclut aussi force: true sur cinq telechargements de cles : une reprise conditionnelle
ne reprend rien (304 Not Modified, size 0, attempts 5).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-12 23:45:00 -04:00
|
|
|
|
{"id": "P70", "titre": "Depot de binaires : il tient tout ce que les roles vont chercher",
|
|
|
|
|
|
"refs": [], "func": preuve_depot_binaires_complet},
|
pools : le genome ne nait plus chez un locataire
`Chezlepro-17` contenait VINGT ET UNE VM : les quatorze du locataire ET les sept du
genome. `OPS-Chezlepro` et `OPS-Technolibre`, crees a la main, etaient vides.
La cause : `site-creer` appelle `cloner-vm`, qui derive son pool par `--pool-actif`,
c est-a-dire le pool du TENANT lie. Les machines du site heritaient du locataire
courant. Range a la main, ca se serait defait au prochain `site-creer` — sans un mot,
parce que la VM est bien creee, bien nommee, bien adressee. Seule son appartenance
est fausse, et rien ne la regarde.
- `--pool-site` rend le nom invariable du pool du genome. Option DISTINCTE, pas un
drapeau sur la premiere : une fonction qui repond aux deux questions finit par se
tromper d appelant.
- `cloner-vm` accepte une surcharge POOL= ; `site-creer` la nomme. Substitution au
niveau MAKE, pas shell : POOL arrive du sur-make comme variable make, et $${POOL}
ne l aurait jamais vue.
- P71 exige que `site-creer` nomme son pool. Eprouvee dans les deux sens : passe sur
le Makefile sain, tire des qu on retire l argument.
Applique au cluster : Site-OPS 9 VM (7 du site + 2 gabarits), OPS-Chezlepro 14,
OPS-Patient0 5. Chezlepro-17, Patient0-29 et Set-OPS supprimes une fois vides. Les
pools anterieurs a Set-OPS et les quinze VM hors pool n ont pas ete touches.
make prouver : 70 OK, 0 echec, 1 saute.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-13 04:12:27 -04:00
|
|
|
|
{"id": "P71", "titre": "Pool du site : le genome ne nait pas chez un tenant",
|
|
|
|
|
|
"refs": [], "func": preuve_pool_du_site_nest_pas_celui_du_tenant},
|
annuaire : un compte de service par consommateur, et la porte se ferme
Keycloak, Dovecot, Postfix et Icinga Web 2 se liaient TOUS avec cn=admin, le compte
d administration de la base. C est le rootDN : slapd lui fait contourner toutes les
ACL. Un seul secret, quatre services, tous les droits sur l arbre — pour ce qui est,
trois fois sur quatre, une simple lecture.
Et les droits livres par Debian etaient intacts : `to * by * read`. Sur ldap://, sans
s authentifier, une machine du reseau enumerait tous les comptes et toutes les
adresses. Des comptes a droits mesures n auraient rien valu tant que cette ligne
restait : on aurait ferme la porte en laissant la fenetre.
L indice etait deja dans le depot. `validatePasswordPolicy` existe parce que slapd
n applique pas ses controles de qualite au rootDN : la consequence etait compensee,
la cause intacte.
- ou=services, un compte par consommateur, secret propre en voute
- sept regles d acces posees EN ENTIER (state: exact) : l ordre est la regle, et
inserer c est parier sur ce que le paquet aura mis avant nous
- amorcage_acces garde le compte d administration, NOMME comme l exception : il ne
consomme pas l annuaire, il le provisionne depuis la socket locale
- la sonde passe de -x a -Y EXTERNAL : elle lisait en anonyme et aurait annonce un
annuaire VIDE sur un annuaire parfaitement sain
- la rotation du compte d administration devient possible (elle n etait posee qu a
l installation, par debconf : la voute et slapd divergeaient en silence)
Quatre marches payees en chemin :
1. un cinquieme appelant oublie, dont l echec etait masque par no_log — la garde
refuse desormais SANS no_log : elle nomme la cle absente, jamais son contenu
2. la federation Keycloak ne reecrivait son bindDn que si l URL ou le mode changeaient
— nouveau secret, ancien nom, error code 49
3. la rotation placee APRES les taches qui se lient en administrateur
4. ansible-vault et son tube : sortie non bloquante = echec silencieux, la voute
paraissait tournee et etait identique a l octet
P72 exige que tout role incluant resoudre_annuaire NOMME son compte, et qu aucun sauf
amorcage_acces ne nomme admin. Eprouvee dans les deux sens.
Verifie sur l infrastructure : chaque compte lit ce qu il doit, aucun ne voit les
autres, la lecture anonyme rend 0 entree, et les quatre services repondent (doveadm
user, postmap -q, decouverte OIDC 200, portier SSO 200).
vault_openldap_admin et vault_ldap_bind_postfix renouveles : les deux avaient transite
en clair par une session d exploitation. Les anciennes valeurs rendent Invalid
credentials (49).
make prouver : 71 OK, 0 echec, 1 saute. ansible-lint : 0 failure, profil production.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-13 13:36:38 -04:00
|
|
|
|
{"id": "P72", "titre": "Annuaire : aucun service ne se lie avec le compte du maitre",
|
|
|
|
|
|
"refs": [], "func": preuve_annuaire_sans_compte_maitre},
|
intrants du site : ce qu un locataire doit savoir pour l habiter, derive
Un locataire ECRIT les adresses des services de son site — resolveur, cache, forge,
depot de sauvegarde, plan d administration, sortie. Une copie se perime, et deux
l avaient fait en deux jours avec la meme forme : `serveur_ops_forge_amont` visait
10.0.33.11 quand la forge sert en 10.37.33.11, et `ac-racine-site.crt` portait la
racine d avant la reconstruction du site. Rien ne les relisait.
`make site-intrants` lit le plan du site et rend le contrat — sept valeurs, toutes
derivees. `make site-intrants-verifier` les confronte a ce que le locataire declare,
et P73 en fait une preuve. Elle a trouve le defaut de la forge des sa premiere
execution.
Le contrat se DERIVE du plan du site, pas d une liste tenue a part : ajouter un
service prete au site l ajoute au contrat, sans qu on ait a y penser.
P69 RESTREINTE AU COUPLE MONTE. Elle balayait tous les depots OPS-* et les comparait
au site monte. Elle avait raison tant qu un seul site existait : une adresse en
10.x.3z ne pouvait designer que lui. Deux sites decoupent leurs zones de la meme
facon — c est le but, un locataire doit pouvoir habiter l un ou l autre sans se
renumeroter. Le troisieme octet a cesse de distinguer « mon site » d « un autre
site » : 10.31.34.11, juste pour un locataire de TechnoLibre, etait declare faux
parce que Chezlepro etait monte.
Un locataire n appartient a aucun site — il en habite un, choisi par le symlink au
deploiement. La seule paire jugeable est celle qui est montee. Meme portee que P73.
Une marche payee : la premiere version de site_intrants recopiait la resolution
d instance au lieu de la partager. P41 a mordu — neuf modules avaient deja porte
chacun leur copie, et cinq defauts en etaient sortis en cinq jours.
make prouver : 72 OK, 0 echec, 1 saute. ansible-lint : 0 failure.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-13 16:18:12 -04:00
|
|
|
|
{"id": "P73", "titre": "Le locataire designe les services de son site REEL",
|
|
|
|
|
|
"refs": [], "func": preuve_locataire_suit_son_site},
|
gabarit dore : une seule declaration, et les deux souches se rejoignent
Le plan du site disait 9006 (modeleSetOPS-minimal), l underlay 99998 — que le plan
nomme lui-meme `precedent`. Le Makefile derive VMID_MODELE du PLAN : les locataires
clonaient 9006. `site_machines` lisait l autre : les machines du SITE clonaient 99998.
POURQUOI CA NE S EST JAMAIS VU. Les deux VMID designent un gabarit valide. Les deux
clonent, les deux demarrent, rien n echoue. La flotte etait issue de deux souches, et
aucun message ne pouvait le dire puisqu aucune operation n avait echoue.
`site_machines` lit desormais underlay.gabarit(), la meme source que tout le monde. Le
motif de la seconde declaration est preserve : cette source lit le PLAN DU SITE, jamais
celui du tenant actif, donc elle ne depend d aucun symlink `instance`.
materialisation.vmid_modele retire des deux cartes. `precedent:` reste au plan — il dit
d ou l on vient et n est jamais clone.
P74 refuse toute redeclaration. Eprouvee dans les deux sens.
SITE-Technolibre visait 99998, repris de la carte de Chezlepro au lieu de son plan.
Corrige avant son premier clonage.
make prouver : 73 OK, 0 echec, 1 saute.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-13 17:54:56 -04:00
|
|
|
|
{"id": "P74", "titre": "Gabarit dore : une seule declaration, au plan du site",
|
|
|
|
|
|
"refs": [], "func": preuve_gabarit_une_seule_declaration},
|
2026-09-13 20:10:24 -04:00
|
|
|
|
{"id": "P75", "titre": "Les parametres de clonage traversent les trois maillons",
|
|
|
|
|
|
"refs": [], "func": preuve_parametres_clone_traversent},
|
supervision : les huit derniers roles, et deux defauts que l epreuve a trouves
Les 33 roles serveur_* declarent maintenant une sonde. Les huit qui manquaient
sont ceux dont la verite ne ressemble pas a « ce service repond-il ».
Quatre marqueurs du site : ce que tasks/main.yml verifie UNE FOIS au deploiement
cesse d etre vrai sans que rien ne tombe. La racine du cache se retrouve chainee,
la forge du genome repond en n ayant plus rien dedans, un locataire n est plus
admis a resoudre, l isolation d un depot glisse.
serveur_ops_site ne sert rien : il detient un pouvoir. La sonde verifie que la
carte est la, que la voute du site est chiffree et que sa cle est en 0600 — sans
lire le contenu d aucun des trois.
serveur_icingaweb2 surveille la vitrine de la supervision elle-meme : si la
console meurt, tout reste vert et l exploitant est aveugle.
DEFAUT 1 — quatre gabarits qu Ansible aurait refuse de rendre. Jinja lit le
{# de ${#tableau[@]} comme un debut de commentaire. Le depot connaissait le
remede et l appliquait la ou quelqu un s etait fait prendre, nulle part ailleurs.
P76 rend desormais chaque gabarit de role, avec les delimiteurs qu Ansible en
tirerait — pas une recherche de motif.
DEFAUT 2 — la doctrine promettait 54 greffons et citait check_pgsql. La flotte a
monitoring-plugins-basic : 53, sans check_pgsql ni check_dns ni check_ldap. Le
paquet qui les porte traine samba et snmp sur chaque machine. Un greffon absent
sort en 127, qui n est pas un code Nagios.
21 controles negatifs sur les machines reelles du site. ansible-lint production
0/91, harnais 75 OK.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-14 15:43:52 -04:00
|
|
|
|
{"id": "P76", "titre": "Tout gabarit de role se rend vraiment",
|
|
|
|
|
|
"refs": [], "func": preuve_gabarits_shell_rendent},
|
grafana : les panneaux declares deviennent des tableaux
La moitie qui manquait. Les roles declaraient leurs panneaux, Prometheus en
derivait ses cibles, les expressions repondaient, et rien ne les assemblait.
serveur_grafana lit les memes meta/metriques.yml que serveur_prometheus et en
assemble un tableau par role — la raison du panneau devient sa description dans
Grafana. Moisson des tableaux orphelins, prefixe setops-role- pour ne jamais
toucher un tableau ecrit a la main, JSON valide avant d etre pose (Grafana ne
tombe pas sur un tableau illisible : il le saute en silence).
client_metrique declare quatre panneaux sans exportateur — le cas symetrique.
Celui qui compte : la memoire disponible, depuis que le ballon est actif.
Eprouve sur le site : les deux tableaux charges dans le stockage unifie, un
temoin orphelin retire, les quatre panneaux de flotte a 10 series chacun.
Un panneau etait faux — /var/lib/lxcfs rapporte toujours zero octet libre et un
min() en meurt. Corrige en liste blanche : un montage inconnu manque au graphe,
ce qui se voit, au lieu de l ecraser.
P77 garde la table des unites : une unite inventee retombe sur short, lisible et
fausse. 76 OK.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-14 16:32:30 -04:00
|
|
|
|
{"id": "P77", "titre": "Panneaux declares : assemblables, et gradues",
|
|
|
|
|
|
"refs": [], "func": preuve_panneaux_assembles},
|
TLS des bases : le consommateur suit son serveur, il ne le devine plus
La supervision du SITE etait morte depuis 11:10 et rien ne le disait. icingadb
refuse par pg_hba — hostssl impose cote serveur, connexion en clair cote client.
icinga2 tournait, redis tournait, les sondes poussaient, et rien n atteignait la
base : les verdicts se calculaient dans le vide.
La cause est une seconde liste tenue a la main. tls_force allume hostssl ; chaque
consommateur avait SON interrupteur a allumer dans les group_vars. Chezlepro
avait les trois, le site avait le premier. serveur_forgejo disait pire que rien :
sslmode disable ecrit en dur, le contraire de ce que le serveur imposait.
resoudre_base expose resoudre_base_db_tls_force, lu dans les hostvars de la
machine qui PORTE la base. Les trois interrupteurs en derivent. Un serveur qui
ne declare rien ne force rien : on ne casse pas un ecosysteme qui n a pas
bascule.
P78 refuse une valeur ecrite chez un consommateur, et nomme les deux roles sans
reglage TLS plutot que de rendre un vert muet sur eux.
Apres : icingadb active, TLSv1.3 vu par PostgreSQL, 16 hotes et 88 services en
base. Les cinq sondes des marqueurs du site, INCONNU faute de deploiement,
rapportent leur phrase.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-14 18:42:21 -04:00
|
|
|
|
{"id": "P78", "titre": "Un consommateur de base suit le verrou TLS de son serveur",
|
|
|
|
|
|
"refs": [], "func": preuve_consommateurs_suivent_le_verrou_tls},
|
2026-09-15 18:37:38 -04:00
|
|
|
|
{"id": "P79", "titre": "Replis silencieux : une derivation vide ne passe pas pour un succes",
|
|
|
|
|
|
"refs": [], "func": preuve_une_derivation_vide_se_dit},
|
remise au client : deux temps, un outil, une garde
Livrer se terminait par une phrase — tes cles te seront remises separement — et
rien n ecrivait la suite. Temps 1 l identite (sa cle de voute, sa voute, sa racine
d AC), temps 2 la machine a echeance (sa cle entre, la notre sort, voute re-cletee,
secrets tournes). scripts/remise.py refuse une destination interne, un paquet sans
racine d AC, et tout ce qui n est pas l ecosysteme monte. Le registre remise.yml
declare enfin le responsable designe (D-18). P80 refuse un registre incomplet, un
second temps echu, un second temps declare fait sans revocation au plan, et un
secret dans un fichier versionne.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 11:16:27 -04:00
|
|
|
|
{"id": "P80", "titre": "Remise au client : inscrite, nommee, et son second temps a l'heure",
|
|
|
|
|
|
"refs": [], "func": preuve_remise_tenue},
|
2026-09-16 14:36:59 -04:00
|
|
|
|
{"id": "P81", "titre": "La console dit sa portee, et ne sert pas un inventaire vide en silence",
|
|
|
|
|
|
"refs": [], "func": preuve_console_dit_sa_portee},
|
2026-09-16 18:08:20 -04:00
|
|
|
|
{"id": "P82", "titre": "DNS public : les zones publiees sont servies, signees avant d'etre exposees",
|
|
|
|
|
|
"refs": [], "func": preuve_dns_public_tient},
|
assistants : cent trente-deux cibles, et aucune ne disait dans quel ordre
La console offrait des boutons sans sequence. Rien n'y apprenait que site-creer precede
forge-amorcer, que le premier passage de site-deployer-tout s'arrete sur une forge vide
sans que ce soit un echec, ni que rien n'est pret avant valider : cet ordre vivait en
prose dans des documents que la console ne porte pas.
La vue Assistants conduit 17 runbooks et 126 etapes. Les 132 cibles documentees y sont,
chacune portee par un assistant ou exemptee avec son motif — une exemption muette est
refusee. Le registre ne recopie pas le Makefile : il declare l'ordre, la nature, la portee
et le pourquoi, et le libelle de chaque etape est lu dans le Makefile au moment de servir.
P83 est ecrite en meme temps que la liste, pas apres, parce qu'une liste qui suit une
autre prend du retard. Onze tests lui presentent des registres faux, un par forme de
retard, et exigent qu'elle les refuse.
Le navigateur ne nomme pas une commande, il nomme une place : la route lance ce que le
registre declare a cet index-la, avec les seules variables declarees. L'index compte, le
premier jour d'un site jouant site-deployer-tout deux fois. Une etape qui ecrit attend que
la precedente ait reussi ; une mesure reste toujours offerte, parce que mesurer apres un
echec est exactement ce qu'on fait ensuite.
Valide : runbooks.py verifier a 0 ecart, make test a 0 echec, les 83 preuves rejouees, et
la console lancee pour de vrai — 17 runbooks servis, six requetes malformees refusees une
a une, une etape de mesure executee de bout en bout avec son journal.
Limite, anterieure a ce travail : P02 (test_ecriture_plan) echoue sur domaines.yml, a
l'identique sur une copie de HEAD. Ajouter ou retirer un domaine public depuis la vue
Domaines leverait a l'enregistrement. Non corrige ici.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 16:31:16 -04:00
|
|
|
|
{"id": "P83", "titre": "Assistants : le registre des runbooks ne prend pas de retard sur le Makefile",
|
|
|
|
|
|
"refs": [], "func": preuve_runbooks_suivent_le_makefile},
|
2026-10-04 20:16:55 -04:00
|
|
|
|
{"id": "P84", "titre": "La fiche du site dit exactement ce que chaque locataire porte",
|
|
|
|
|
|
"refs": [], "func": preuve_fiche_du_site},
|
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
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
documentation : la tournee des 74 documents, parce qu un balayage ne lit pas
La revision a commence par un balayage par motifs — chemins morts, cibles make
absentes, comptes derives. Il a trouve une trentaine d ecarts et rate presque
tout le reste : un motif ne voit que ce qui s exprime en motif.
make hote-planifier en est l exemple. La cible EXISTE, donc le controle passait
au vert. C est une cible depreciee qui refuse et sort en 2, recommandee par
AGENTS.md, et qui contredit la REGLE D OR du meme fichier trois ecrans plus
haut. Il fallait lire pour la voir.
74 documents lus un par un. 66 corriges, 8 exacts.
CE QUI ETAIT FRANCHEMENT FAUX
AGENTS.md, la source d autorite, annoncait la flotte pas encore executee contre
des VM reelles. Elle a ete rasee et remontee depuis zero trois fois.
ecosysteme-chezlepro.md, le document montre a un client, portait la meme
phrase : il se sous-vendait gravement.
courriel-conception.md s ouvrait sur aucun role n est encore ecrit, au-dessus de
son propre paragraphe 1 qui les nomme. autorisation.md se terminait sur rien n
est construit alors qu il rapporte des mesures datees du role en fonctionnement.
hebergeur-exploitation.md disait rien n est fait d un depot qui existe.
filiation-emancipation.md se contredisait a deux ecrans de distance.
DES MODELES DECRITS D APRES UN MONDE ANTERIEUR
Le resolveur : cinq documents decrivaient un Unbound par VM en opt-in, trois le
donnaient en exemple d integration FACULTATIVE — il est universel depuis le
2026-08-24. L adressage de nomenclature-vm.md : reseau unique, VLAN 11-15, VMID
a cinq chiffres. Le nommage SDN de sdn-evpn.md contre le code : c est le wiki
qui avait raison.
CE QUI CASSE AU PREMIER ESSAI
Le nom du gabarit dore etait faux a quatre endroits, dont la procedure qui le
FABRIQUE et le critere R2 de l epreuve d operateur independant.
preparer-un-site-hebergeur.md avertissait qu une VM faite a la main serait
detruite : raser derive du plan, il ne la detruira jamais — le risque est l
inverse. Un mot de passe d essai en clair dans un depot public.
DEUX PREUVES ETENDUES, ET UNE QUI SE TROMPAIT ELLE-MEME
P57 couvre les groupes : elle a signale aussitot 29 groupes annonces au-dessus d
un tableau qui en cite 40. P29 confronte le tableau de authentification.md aux
declarations reelles : 12 annonces, 21 reels.
Et P57 imposait un chiffre faux — 56 preuves alors que le depot en porte 57, la
conditionnelle vivant hors de tout comptage. Un garde-fou qui fait respecter une
erreur ajoute l assurance a l erreur.
CE QUI RESTE, ET QU AUCUNE PREUVE NE TIENT
Deux comptes trouves a la main. Et une lacune reelle : rien ne garde les
meta/acces.yml — ni qu un service web-sso en porte un, ni que le groupe qu il
nomme existe. P29 tient les positions d authentification, personne ne tient les
habilitations.
make prouver : CONFORME, 56 OK, 0 echec, 1 saute. 0 lien mort.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-06 16:18:23 -04:00
|
|
|
|
# LA PREUVE CONDITIONNELLE VIT ICI, PAS DANS `main()` — POUR ETRE COMPTABLE (2026-09-06).
|
|
|
|
|
|
#
|
|
|
|
|
|
# P16 exige la voute : elle est SAUTEE quand la cle n'est pas la. Definie a l'interieur de
|
|
|
|
|
|
# `main()`, elle echappait a `len(PREUVES)` — et la preuve des comptes en prose (P57)
|
|
|
|
|
|
# imposait donc « 56 preuves » a toute la documentation, alors que le depot en porte 57
|
|
|
|
|
|
# (P01 a P57, sans trou). Un garde-fou qui fait respecter un chiffre faux est pire qu'aucun
|
|
|
|
|
|
# garde-fou : il donne l'assurance en plus de l'erreur.
|
|
|
|
|
|
PREUVE_CONDITIONNELLE_INVENTAIRE: dict = {
|
|
|
|
|
|
"id": "P16", "titre": "Inventaire Ansible complet (--list)",
|
|
|
|
|
|
"refs": ["AFF-030"], "func": preuve_inventaire_ansible}
|
|
|
|
|
|
|
|
|
|
|
|
# TOUTES les preuves du depot, conditionnelles comprises. C'est ce nombre que la
|
|
|
|
|
|
# documentation doit annoncer.
|
|
|
|
|
|
TOUTES_LES_PREUVES: list[dict] = PREUVES + [PREUVE_CONDITIONNELLE_INVENTAIRE]
|
|
|
|
|
|
|
|
|
|
|
|
|
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:
|
voutes : une voute, une cle — separer avant de distribuer
Decision de l'exploitant : chaque runner est maitre de sa voute et en detient la
cle. C'est ce qui rend un runner autonome, donc ce qui rend l'emancipation
atteignable. Elle exigeait un prealable, mesure ce matin : UN SEUL mot de passe
ouvrait les SIX voutes de la flotte, celle de la fabric comprise.
DISTRIBUER AVANT DE SEPARER AURAIT ETE PIRE QUE LE STATU QUO : poser « la » cle
sur chaque runner rendait chaque runner capable d'ouvrir les autres. Compromettre
le plus petit locataire donnait les secrets de l'hebergeur.
Cinq cles, une par ecosysteme, sous ~/.config/setops-vault-<depot>. Verification
croisee apres rechiffrement : la diagonale, et rien qu'elle. L'ancienne cle
maitresse n'ouvre plus aucune des six.
UN SEUL MOT DE PASSE NE POUVAIT PLUS SUFFIRE, et pas pour la raison qu'on croit :
`cloner_vm_debian.yml` charge la voute du TENANT puis celle de l'UNDERLAY dans la
meme execution. `ANSIBLE_VAULT_IDENTITY_LIST` en porte plusieurs et les essaie
toutes — un seul export suffit pour les 28 appels a ansible-playbook, sans en
toucher un seul. La liste se derive dans scripts/voutes.py.
CE QUI BORNE LE POUVOIR N'EST PAS LA LISTE MAIS LA PRESENCE DES FICHIERS. Sur le
poste, toutes les cles sont la — c'est l'humain qui les detient toutes, et P03 lit
les inventaires de tous les freres. Sur un runner, une seule existe. Le code est
identique, le pouvoir ne l'est pas.
DEUX PREUVES ONT DIT CE QUI MANQUAIT. P03 est tombee des la separation : elle lit
les inventaires voisins, donc il lui faut leurs cles — c'est elle qui a etabli que
la liste devait couvrir le voisinage. P16 s'est mise a SAUTER : sa garde ne
connaissait que ANSIBLE_VAULT_PASSWORD_FILE. Une preuve sautee se lit trop
facilement comme une preuve passee.
COUT ASSUME : le chiffre et sa cle cohabiteront sur la meme machine des que les
runners recevront la leur. Le pari tient parce que le perimetre est borne.
Les six voutes sauvegardees avant rechiffrement. L'ancienne cle maitresse reste
sur le poste : la retirer est une decision, pas un nettoyage.
make verifier : vert. make prouver : CONFORME, 52 OK, 0 echec, 0 saute — sans
ANSIBLE_VAULT_PASSWORD_FILE.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-08-28 14:23:33 -04:00
|
|
|
|
"""Vrai si l'inventaire chiffre un group_vars et qu'aucun mot de passe n'est fourni.
|
|
|
|
|
|
|
|
|
|
|
|
DEUX FACONS DE FOURNIR UNE CLE DEPUIS LA SEPARATION DES VOUTES (2026-08-28) : l'unique
|
|
|
|
|
|
`ANSIBLE_VAULT_PASSWORD_FILE`, et `ANSIBLE_VAULT_IDENTITY_LIST`, qui en porte
|
|
|
|
|
|
plusieurs — indispensable des qu'une execution touche deux voutes (le tenant et
|
|
|
|
|
|
l'underlay). N'en regarder qu'une faisait SAUTER P16 alors que la cle etait la, et
|
|
|
|
|
|
une preuve sautee se lit trop facilement comme une preuve passee.
|
|
|
|
|
|
"""
|
|
|
|
|
|
if os.environ.get("ANSIBLE_VAULT_PASSWORD_FILE") or os.environ.get("ANSIBLE_VAULT_IDENTITY_LIST"):
|
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
|
|
|
|
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).
|
documentation : la tournee des 74 documents, parce qu un balayage ne lit pas
La revision a commence par un balayage par motifs — chemins morts, cibles make
absentes, comptes derives. Il a trouve une trentaine d ecarts et rate presque
tout le reste : un motif ne voit que ce qui s exprime en motif.
make hote-planifier en est l exemple. La cible EXISTE, donc le controle passait
au vert. C est une cible depreciee qui refuse et sort en 2, recommandee par
AGENTS.md, et qui contredit la REGLE D OR du meme fichier trois ecrans plus
haut. Il fallait lire pour la voir.
74 documents lus un par un. 66 corriges, 8 exacts.
CE QUI ETAIT FRANCHEMENT FAUX
AGENTS.md, la source d autorite, annoncait la flotte pas encore executee contre
des VM reelles. Elle a ete rasee et remontee depuis zero trois fois.
ecosysteme-chezlepro.md, le document montre a un client, portait la meme
phrase : il se sous-vendait gravement.
courriel-conception.md s ouvrait sur aucun role n est encore ecrit, au-dessus de
son propre paragraphe 1 qui les nomme. autorisation.md se terminait sur rien n
est construit alors qu il rapporte des mesures datees du role en fonctionnement.
hebergeur-exploitation.md disait rien n est fait d un depot qui existe.
filiation-emancipation.md se contredisait a deux ecrans de distance.
DES MODELES DECRITS D APRES UN MONDE ANTERIEUR
Le resolveur : cinq documents decrivaient un Unbound par VM en opt-in, trois le
donnaient en exemple d integration FACULTATIVE — il est universel depuis le
2026-08-24. L adressage de nomenclature-vm.md : reseau unique, VLAN 11-15, VMID
a cinq chiffres. Le nommage SDN de sdn-evpn.md contre le code : c est le wiki
qui avait raison.
CE QUI CASSE AU PREMIER ESSAI
Le nom du gabarit dore etait faux a quatre endroits, dont la procedure qui le
FABRIQUE et le critere R2 de l epreuve d operateur independant.
preparer-un-site-hebergeur.md avertissait qu une VM faite a la main serait
detruite : raser derive du plan, il ne la detruira jamais — le risque est l
inverse. Un mot de passe d essai en clair dans un depot public.
DEUX PREUVES ETENDUES, ET UNE QUI SE TROMPAIT ELLE-MEME
P57 couvre les groupes : elle a signale aussitot 29 groupes annonces au-dessus d
un tableau qui en cite 40. P29 confronte le tableau de authentification.md aux
declarations reelles : 12 annonces, 21 reels.
Et P57 imposait un chiffre faux — 56 preuves alors que le depot en porte 57, la
conditionnelle vivant hors de tout comptage. Un garde-fou qui fait respecter une
erreur ajoute l assurance a l erreur.
CE QUI RESTE, ET QU AUCUNE PREUVE NE TIENT
Deux comptes trouves a la main. Et une lacune reelle : rien ne garde les
meta/acces.yml — ni qu un service web-sso en porte un, ni que le groupe qu il
nomme existe. P29 tient les positions d authentification, personne ne tient les
habilitations.
make prouver : CONFORME, 56 OK, 0 echec, 1 saute. 0 lien mort.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-06 16:18:23 -04:00
|
|
|
|
preuve_inv = PREUVE_CONDITIONNELLE_INVENTAIRE
|
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:
|
2026-10-04 15:46:43 -04:00
|
|
|
|
# NOMMER CE QUI ECHOUE (2026-10-04) : le verdict seul (« 6 echec ») obligeait a
|
|
|
|
|
|
# reconstituer le contexte et relire le code pour savoir lesquelles.
|
|
|
|
|
|
for p, s, d in resultats:
|
|
|
|
|
|
if s == "ECHEC":
|
|
|
|
|
|
print(f" ECHEC {p['id']} — {p.get('titre') or p.get('nom') or ''} : "
|
|
|
|
|
|
f"{' '.join(str(d).split())[:300]}")
|
2026-07-20 21:03:20 -04:00
|
|
|
|
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:]))
|