From 9e8679215d757c0c56597ee0ccab54151f32d7e4 Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Wed, 26 Aug 2026 13:56:58 -0400 Subject: [PATCH] =?UTF-8?q?carte=20:=20P48=20=E2=80=94=20l'index=20du=20ma?= =?UTF-8?q?inteneur=20ne=20peut=20plus=20mentir?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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-.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 --- CHANGELOG.md | 27 ++++++ docs/audit/preuve-2026-08-26.md | 3 +- docs/carte-set-ops.md | 28 ++++-- scripts/prouver.py | 154 ++++++++++++++++++++++++++++++++ 4 files changed, 205 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 89865af..6683e85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,33 @@ qu'elle porte, et on refuse si ça diffère de ce que porte le runner. sans que rien ne le signale — dont le correctif qui désarme le pare-feu Proxmox. Un écosystème qui se reproduit depuis une forge en retard reproduit ses défauts.* +### P48 — la carte d'orientation 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 **mécanismes transverses** avec, pour chacun, **où il vit dans le +code**. Son but est écrit en toutes lettres : *« ne plus re-déterrer ce qui existe »*. + +Elle n'avait aucune garde, alors que `catalogue-services.md` a la sienne depuis P38. Ses +**sept** chiffres étaient faux — 54 rôles annoncés contre 60, 34 documents contre 38, 15 +pièces d'audit contre 27, 70 décisions contre 78. + +Le défaut coûteux n'est pourtant pas là. C'est le **pointeur mort** : la carte dit où vit +un mécanisme, quelqu'un ne l'y trouve pas, et le réimplémente à côté — exactement la panne +qu'elle existe pour prévenir. *Aucun de ces nombres ne fait travailler personne ; mais un +document dont les faits vérifiables sont faux cesse d'être consulté, et c'est alors ses +pointeurs qu'on perd.* + +P48 vérifie les deux : **84 chemins** cités existent, et **7 chiffres** correspondent à la +mesure. Quatre distinctions ont dû être écrites pour qu'elle ne soit pas fausse dans +l'autre sens — un gabarit de nom (`preuve-.md`) décrit une forme, pas un fichier ; un +chemin hors dépôt (`~/.config/setops-vault-pass`) vit sur le poste de l'exploitant ; un +fragment (`meta/acces.yml`) vaut comme suffixe ; et un artefact **généré** (`hosts.yml`) n'a +pas à exister dans le moteur. Les quatre sont nommées dans le code plutôt que sautées en +silence. + +*Le tableau « Le dépôt en chiffres » remplace les comptes en prose : ce qu'on n'entretient +pas, on ne l'affirme pas — ou bien on le fait recompter.* + ### D-81 — la forge du site fait autorité, et `make genome-etat` le vérifie Décision de l'exploitant : **la forge du SITE fait autorité pour le génome.** Toute autre diff --git a/docs/audit/preuve-2026-08-26.md b/docs/audit/preuve-2026-08-26.md index 7a24e08..e5281ad 100644 --- a/docs/audit/preuve-2026-08-26.md +++ b/docs/audit/preuve-2026-08-26.md @@ -7,7 +7,7 @@ > [`docs/audit/affirmations.md`](affirmations.md). - **Instance** : `/home/danallaire/Espace Chezlepro/DépôtsSurForge/Set-OPS-public/instance` — inventaire `/home/danallaire/Espace Chezlepro/DépôtsSurForge/Set-OPS-public/instance/inventories/production/hosts.yml` -- **Verdict** : ✅ CONFORME (46 OK · 0 echec · 1 saute) +- **Verdict** : ✅ CONFORME (47 OK · 0 echec · 1 saute) ## Preuves @@ -60,6 +60,7 @@ | P45 | Pare-feu Proxmox : arme sur les VNet SDN, jamais ailleurs | — | ✅ OK | Le pare-feu Proxmox ne s'arme que sur un VNet SDN (4 cas evalues, dont un qui doit rendre VRAI). | | P46 | Plancher /etc/hosts : un seul role en decide | — | ✅ OK | Un seul maitre du plancher — roles/hosts_statiques/tasks/main.yml : manage_etc_hosts: false ; et le gabarit maitre est pose (roles/hosts_statiques/templates/hos | | P47 | Zones inverses : couvrir l'occupe, et rien de plus | — | ✅ OK | Les zones inverses couvrent l'occupe et rien de plus (5 cas evalues, dont un site a quatre zones et un tenant a une). | +| P48 | La carte d'orientation designe ce qui existe, et compte juste | — | ✅ OK | La carte designe 84 chemin(s) qui existent, et ses 7 chiffres correspondent a la mesure. | ## Couverture des affirmations ✅ du registre diff --git a/docs/carte-set-ops.md b/docs/carte-set-ops.md index cc99b22..9d224b4 100644 --- a/docs/carte-set-ops.md +++ b/docs/carte-set-ops.md @@ -10,10 +10,26 @@ Point d'entrée vers le corpus documentaire, et **catalogue des mécanismes tran ceux qui vivent dans le code et qu'on *re-découvre* sinon. Créée le 2026-07-03 après un audit du dépôt, **revue le 2026-07-29**. But : ne plus re-déterrer ce qui existe. -Le dépôt est **déjà bien documenté** (34 docs + 15 pièces d'audit + 23 unités de wiki, et un -README pour chacun des 54 rôles). Le manque n'était pas la doc du *modèle*, -mais (a) un index « par où commencer » et (b) une carte des *mécanismes* (dispersés dans le -code + les README de rôles). Cette page comble ces deux trous. +Le dépôt est **déjà bien documenté**. Le manque n'était pas la doc du *modèle*, mais (a) un +index « par où commencer » et (b) une carte des *mécanismes* (dispersés dans le code + les +README de rôles). Cette page comble ces deux trous. + +### Le dépôt en chiffres + +> Ces valeurs sont **mesurées**, pas recopiées : **P48** les recompte et refuse tout écart. +> Elles étaient toutes fausses le 2026-08-26 — de 6 rôles, de 12 pièces d'audit, de 8 +> décisions. Aucune ne faisait travailler personne ; mais une carte dont les faits +> vérifiables sont faux cesse d'être consultée, et c'est alors ses **pointeurs** qu'on perd. + +| Ce qu'on compte | Combien | Comment on le mesure | +|---|---|---| +| rôles | 60 | `roles/*/` | +| README de rôles | 60 | `roles/*/README.md` — l'écart avec la ligne au-dessus est la dette | +| documents | 38 | `docs/*.md` | +| pièces d'audit | 27 | `docs/audit/*` | +| unités de wiki | 27 | `wiki/*.md` | +| décisions en vigueur | 78 | lignes `\| **D-nn** \|` de `decisions-architecture.md` | +| décisions renversées | 3 | lignes `\| **D-nn** —` du même document | ## 1. À lire d'abord (dans l'ordre) @@ -28,11 +44,11 @@ code + les README de rôles). Cette page comble ces deux trous. | **Ordre de déploiement** | `docs/couches-deploiement.yml` (couches) + `docs/dependances-groupes.yml` (graphe) → `playbooks/site.yml` (**généré**, `make site`) | | **Conformité du déployé** | `docs/devis-services.md` — les **cinq devis de service** (`make identite-plan`, `certificats-plan`, `expositions-plan`, `postgresql-plan`, `courriel-plan`). Répondent à ce que `make prouver` ne demande jamais : *ce qui tourne correspond-il à ce qui est déclaré ?* | | **Preuve / recette** | `docs/audit/affirmations.md` (registre), `make prouver` → `docs/audit/preuve-.md` — **statique** : lit le dépôt, aucun appel réseau ; la conformité du déployé est l'affaire des devis de service (ligne au-dessus), `docs/audit/plan-de-recette.md` (**généré** du wiki), `docs/audit/protocole-operateur-independant.md` | -| **Décisions d'architecture** | `docs/decisions-architecture.md` — **70 décisions en vigueur** (D-01 → D-73, 3 renversées), pourquoi, où lire le détail, et ce qui les garde ; plus les **décisions renversées** et leur cause | +| **Décisions d'architecture** | `docs/decisions-architecture.md` — les décisions en vigueur (comptées ci-dessus), pourquoi, où lire le détail, et ce qui les garde ; plus les **décisions renversées** et leur cause | | **SDN / routage** | `docs/sdn-evpn.md` — décision du 2026-08-02 : le routage inter-zone passe des commutateurs aux hyperviseurs (zones EVPN = VRF). **Non éprouvé** : spike avant génération | | **Migration de tenant** | `docs/migration-tenant.md` — recette en 8 étapes, machine à états, gardes ; le receveur se construit **avant** tout gel | | **Exploitation courante** | `docs/runbooks-exploitation.md`, `docs/intrants-communs.md`, `docs/intrants-base-gui-conception.md`, `docs/theme-forgejo-hors-flotte.md` | -| **Pédagogie (le wiki)** | `wiki/` — 21 unités (+ `_Sidebar`) publiées par `make wiki-publier` ; entrer par `wiki/Home.md` | +| **Pédagogie (le wiki)** | `wiki/` — les unités (comptées ci-dessus) publiées par `make wiki-publier` ; entrer par `wiki/Home.md` | | **Vision / positionnement** | `docs/ecosysteme-chezlepro.md`, `docs/positionnement.md`, `docs/pouvoirs-set-ops.md` | ## 2. Les mécanismes transverses (et OÙ ils vivent) diff --git a/scripts/prouver.py b/scripts/prouver.py index a40de57..e3438c6 100644 --- a/scripts/prouver.py +++ b/scripts/prouver.py @@ -1244,6 +1244,158 @@ def preuve_zones_inverses_couvrent_l_occupe() -> tuple[bool, str]: f"a une).") +# Ce que la carte nomme a juste titre et que ce depot ne contient pas : le produit de +# `make instancier`, qui vit dans le depot d'un ecosysteme. +ARTEFACTS_GENERES = {"hosts.yml"} + + +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()]), + "unites de wiki": len(list((RACINE / "wiki").glob("*.md"))), + "decisions en vigueur": len(re.findall(r"^\| \*\*D-\d+\*\* \|", texte, re.M)), + "decisions renversees": len(re.findall(r"^\| \*\*D-\d+\*\* —", texte, re.M)), + } + + +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-.md` decrit + # une forme de fichier, pas un fichier. L'exiger rendrait la preuve fausse. + # Ni un gabarit de nom (`preuve-.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.") + + def preuve_glossaire_enseigne() -> tuple[bool, str]: """Tout mot que le depot emploie devant l'exploitant est explique au glossaire. @@ -1591,6 +1743,8 @@ PREUVES: list[dict] = [ "refs": [], "func": preuve_un_seul_maitre_du_plancher}, {"id": "P47", "titre": "Zones inverses : couvrir l'occupe, et rien de plus", "refs": [], "func": preuve_zones_inverses_couvrent_l_occupe}, + {"id": "P48", "titre": "La carte d'orientation designe ce qui existe, et compte juste", + "refs": [], "func": preuve_carte_dit_vrai}, {"id": "P43", "titre": "Frontiere : le devis voit les machines du site", "refs": [], "func": preuve_devis_frontiere_du_site}, {"id": "P33", "titre": "Aucune collision de port entre roles co-localises", "refs": [],