From d52f2563664067dd6dca7e64d5900f492455eaa9 Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Sun, 9 Aug 2026 09:40:25 -0400 Subject: [PATCH] P32 / D-72 : tout intrant exige par un role est fourni par l'instance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 34 ++++++ Makefile | 3 + docs/audit/preuve-2026-08-09.md | 9 +- docs/carte-set-ops.md | 2 +- docs/decisions-architecture.md | 1 + scripts/prouver.py | 2 + scripts/verifier_intrants.py | 190 ++++++++++++++++++++++++++++++++ 7 files changed, 236 insertions(+), 5 deletions(-) create mode 100755 scripts/verifier_intrants.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d4eb661..6c4e02b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,39 @@ # CHANGELOG — Set-OPS +## 2026-08-09 — P32 : un `assert` de rôle est un contrat, et l'instance doit l'honorer + +Le premier des trois chantiers que la reconstruction avait rendus évidents. Il aurait +trouvé son défaut nº 1 — `amorcage_acces_courriel` — **sans rien détruire**. + +Un rôle qui `assert` une variable non vide déclare un contrat : sans cette valeur, le +déploiement s'arrête. Rien ne vérifiait que l'instance les honore, et le manque ne se voit +qu'au moment où la garde s'exécute pour de vrai — c'est-à-dire, pour un intrant d'amorçage, +seulement quand on repart de rien. + +**Un intrant est satisfait** par un défaut non vide dans le rôle (y compris un +`{{ vault_* }}`, dont la présence réelle relève de P18), par un `set_fact` de résolveur, ou +par une déclaration de l'inventaire. Aucune voûte n'est déchiffrée : la preuve reste +statique, comme les 31 autres. + +**Deux fois mon instrument a accusé le composant à sa place**, et les deux fois avant la +première exécution utile. Il criait au manque sur `serveur_postfix_mailstore_hote`, qui est +pourtant bel et bien fourni — d'abord parce que je ne lisais que `group_vars/` et +`host_vars/` en oubliant le fichier d'inventaire lui-même, ensuite parce que je n'y +cherchais que les blocs `vars:` alors que `instancier` écrit les valeurs dérivées +**directement sous le nom d'hôte**. Un vérificateur incomplet est pire qu'absent : il fait +douter de ce qui marche. + +**Vérifié dans les deux sens.** Sur l'instance réelle : 30 exigences, toutes satisfaites. +Sur un double où l'on retire la déclaration ajoutée la veille : `amorcage_acces_courriel` +nommé, code de sortie 1 — le défaut nº 1 reproduit à froid. + +Ce qu'il ne fait pas, et c'est écrit dans son en-tête : il ignore les `when:` qui rendent +une assertion conditionnelle, donc il peut signaler un intrant exigé seulement quand une +option est active. Signaler à tort coûte une ligne de déclaration ; ne pas signaler coûte +un déploiement. + +Harnais : **32 preuves, 0 échec, 0 sautée.** + ## 2026-08-09 — La reconstruction from-zero est prouvée : cinq devis sur cinq Écosystème `chezlepro` détruit — 14 VM, disques compris — puis **rejoué depuis le plan diff --git a/Makefile b/Makefile index 91d7fe1..3989c08 100644 --- a/Makefile +++ b/Makefile @@ -438,6 +438,9 @@ certificats-plan: ansible-runtime ## Ecart entre les certificats sur disque et c @ansible-playbook -i $(SETOPS_INVENTAIRE) playbooks/maintenance/devis-certificats.yml >/dev/null @python3 scripts/devis_certificats.py +intrants-verifier: ## Tout intrant qu'un role EXIGE est-il fourni par l'instance ? (lecture seule) + @python3 scripts/verifier_intrants.py + identite-plan: ansible-runtime ## Ecart entre l'identite deployee et ce que le plan derive (aucune ecriture) @ansible-playbook -i $(SETOPS_INVENTAIRE) playbooks/maintenance/devis-identite.yml >/dev/null @python3 scripts/devis_identite.py diff --git a/docs/audit/preuve-2026-08-09.md b/docs/audit/preuve-2026-08-09.md index 1a863dd..24c31f1 100644 --- a/docs/audit/preuve-2026-08-09.md +++ b/docs/audit/preuve-2026-08-09.md @@ -7,7 +7,7 @@ > [`docs/audit/affirmations.md`](affirmations.md). - **Instance** : `instance` — inventaire `instance/inventories/principal/hosts.yml` -- **Verdict** : ✅ CONFORME (30 OK · 0 echec · 1 saute) +- **Verdict** : ✅ CONFORME (32 OK · 0 echec · 0 saute) ## Preuves @@ -28,9 +28,9 @@ | P13 | Invariants structurels/doctrinaux | AFF-015, AFF-022, AFF-037, AFF-038, AFF-062 | ✅ OK | LICENSE, socle dossier, pas de couches paralleles, SSH clef-only, nftables off : OK. | | P14 | Pas de chemin lab/ code en dur | AFF-097 | ✅ OK | Aucun chemin instance/inventories/lab/group_vars code en dur. | | P15 | Modele public socle valide | AFF-022, AFF-099 | ✅ OK | Modele public socle : domaines/serveurs/applications/bases valides. | -| P16 | Inventaire Ansible complet (--list) | AFF-030 | ⚪ SAUTE | Voute chiffree sans ANSIBLE_VAULT_PASSWORD_FILE (prerequis AFF-026). | +| P16 | Inventaire Ansible complet (--list) | AFF-030 | ✅ OK | 14 hotes, 31 groupes (inventaire dechiffre et parse). | | P17 | Tous les modeles valident (registres + underlay) | AFF-022, AFF-099 | ✅ OK | Les 1 modele(s) decouvert(s) valident. | -| P18 | Gabarit de voute complet | AFF-026 | ✅ OK | Gabarit de voute complet : 25 secret(s) exige(s), tous presents. (Voute reelle non lisible ici : verification sautee.) | +| P18 | Gabarit de voute complet | AFF-026 | ✅ OK | Gabarit de voute complet : 25 secret(s) exige(s), tous presents. Voute reelle : 28 cle(s), aucun manque. | | P19 | Le GUI couvre le schema du plan | AFF-002, AFF-095 | ✅ OK | GUI : les 28 champ(s) des plans reels sont editables (2 plan(s) inspecte(s)), registres toleres : nomenclature. | | P20 | Adressage 100% derive du seed (aucun stocke) | AFF-001, AFF-003 | ✅ OK | 2 nomenclature(s) : adressage 100% derive du seed index. | | P21 | Federation : aucun index en collision | AFF-102 | ✅ OK | Federation coherente : 2 instance(s) federee(s), aucun index en collision. | @@ -43,7 +43,8 @@ | P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 2 pool(s) Proxmox, 28 VM placee(s), aucun nom ni VMID en collision. | | P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 23 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 12, socle-identite 2, web-sso 5) ; 2 lacune(s) nommee(s) : serveur_loki, serv | | P30 | SDN EVPN : zones, VNets et sous-reseaux derives | AFF-112 | ✅ OK | CONFORME : SDN EVPN, 2 zone(s), 12 VNet(s), 12 sous-reseau(x), aucune collision. | -| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 37 scripts expliques et atteignables, 87 cibles make documentees, 54 roles avec README. | +| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 38 scripts expliques et atteignables, 88 cibles make documentees, 54 roles avec README. | +| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 30 exigence(s) de role, toutes satisfaites (126 cle(s) declaree(s) par l'instance). | ## Couverture des affirmations ✅ du registre diff --git a/docs/carte-set-ops.md b/docs/carte-set-ops.md index ce75f8b..8b64076 100644 --- a/docs/carte-set-ops.md +++ b/docs/carte-set-ops.md @@ -23,7 +23,7 @@ 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` — **68 décisions en vigueur** (D-01 → D-71, 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` — **69 décisions en vigueur** (D-01 → D-72, 3 renversées), 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` | diff --git a/docs/decisions-architecture.md b/docs/decisions-architecture.md index c7a4875..08ca2e3 100644 --- a/docs/decisions-architecture.md +++ b/docs/decisions-architecture.md @@ -107,6 +107,7 @@ sont les seules vérifiables. | **D-34** | Une **exemption** se dérive 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 ; l'exemption doit suivre step-ca si on le déplace | `roles/client_pki/meta/integration.yml` | P26 | | **D-68** | On **écrit, puis on relit et on compare** — quelle que soit l'interface ; on choisit celle dont le chemin de **lecture** parle le même langage que le chemin d'**écriture** | « toujours préférer l'API » n'aurait prédit aucune des pannes du 2026-08-08 : sur six familles de défauts, deux venaient d'un CLI, une d'un module Ansible (`ldap_entry` crée sans jamais modifier), une d'un `grep` de fichier, une de la précédence Ansible, une de mon comparateur. Le facteur commun est d'avoir écrit sans relire. Et la plupart de la flotte n'a **pas** d'API — Postfix, Dovecot, nginx, slapd, nftables : `postconf -h` / `postconf -e` sont symétriques, c'est tout ce qu'on demande | `devis-services.md` | les 5 devis | | **D-69** | Sur Keycloak : **l'API pour toute map ou collection** (`smtpServer`, `attributes`, `config`), `kcadm` pour les scalaires et les créations | `kcadm -s` sur une map accepte la commande, **sort en succès et n'écrit rien** — mesuré deux fois le 2026-08-08 (`smtpServer` resté vide après deux déploiements verts, puis `post.logout.redirect.uris`). Le CLI reste préféré ailleurs : c'est le vocabulaire de la documentation du produit, donc lisible sans IA | `roles/serveur_keycloak/tasks/` | `make identite-plan` | +| **D-72** | Un `assert` de rôle est un **contrat d'intrant**, et l'instance doit l'honorer — vérifié hors ligne | la première reconstruction from-zero s'est arrêtée sur `amorcage_acces_courriel` : obligatoire depuis le matin, déclaré par aucun tenant, et invisible parce que le compte existait déjà — la garde n'avait jamais eu l'occasion de se déclencher. Un intrant est satisfait par un **défaut non vide**, un `set_fact` de résolveur, ou une déclaration de l'inventaire (fichier `hosts.yml` compris) | `scripts/verifier_intrants.py`, `make intrants-verifier` | **P32** | | **D-70** | La documentation **dit et explique tout ce que le dépôt fait** — et l'exigence est **outillée**, pas seulement énoncée | une exigence qu'on n'outille pas pourrit en silence : la carte annonçait « 28 décisions » quand il y en avait 66, et disait les accès « non construits » alors qu'ils tournaient en production. **P31** garde le couvert — chaque script s'explique et reste atteignable, chaque cible `make` porte son aide (sauf les internes préfixées `_`), chaque rôle a son README. Elle ne garde **pas** la qualité du « pourquoi » : ça se juge en revue, et ça vit dans `CHANGELOG.md` et ici | `devis-services.md`, `CHANGELOG.md` | **P31** | | **D-71** | **Une PKI et un DNS fonctionnels avant toute chose** ; puis, par VM : socle → enrôlement PKI → enregistrement DNS (A **et** PTR) | `deployer-tout` déroule par COUCHES — correct, mais chaque VM réclame alors un certificat à une autorité pas encore debout, et l'échec se lit comme un défaut du rôle et non d'ordre. Les deux hôtes d'amorçage se **dérivent** de `applications..hote` : déplacer l'autorité déplace l'amorçage. **Deux exceptions structurelles assumées** — l'AC s'auto-signe, le DNS pose son propre enregistrement | `Makefile` `_amorcer-socle`, `scripts/socle_amorcage.py` | — | diff --git a/scripts/prouver.py b/scripts/prouver.py index 099f2bb..d4225dd 100644 --- a/scripts/prouver.py +++ b/scripts/prouver.py @@ -571,6 +571,8 @@ PREUVES: list[dict] = [ "cmds": [[sys.executable, "scripts/devis_sdn.py", "--verifier"]]}, {"id": "P31", "titre": "Documentation : tout ce que le depot FAIT est nomme", "refs": [], "func": preuve_documentation_outillage}, + {"id": "P32", "titre": "Intrants exiges par les roles : tous fournis", "refs": [], + "cmds": [[sys.executable, "scripts/verifier_intrants.py"]]}, ] diff --git a/scripts/verifier_intrants.py b/scripts/verifier_intrants.py new file mode 100755 index 0000000..2a93b21 --- /dev/null +++ b/scripts/verifier_intrants.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +"""Tout intrant qu'un rôle EXIGE est-il fourni par l'instance ? + +Un rôle qui `assert` une variable non vide déclare un **contrat** : sans cette valeur, le +déploiement s'arrête. Rien ne vérifiait que l'instance honore ces contrats — et le défaut +ne se voit qu'au moment où la tâche gardée s'exécute vraiment. + +C'est ainsi que la première reconstruction from-zero s'est arrêtée sur +`amorcage_acces_courriel` (2026-08-08) : la valeur était obligatoire depuis le matin, aucun +tenant ne la déclarait, et personne ne pouvait le savoir parce que le compte existait déjà +— donc la garde n'avait jamais eu l'occasion de se déclencher. + +**Un intrant est considéré comme SATISFAIT s'il est** : + - doté d'un défaut non vide dans `/defaults/main.yml` (y compris un `{{ vault_* }}`, + dont la présence réelle est gardée par P18) ; ou + - posé à l'exécution par un `set_fact` d'un rôle (les résolveurs `resoudre_*`) ; ou + - déclaré en clair par l'inventaire de l'instance (`group_vars`, `host_vars`). + +**Ce qu'il ne fait pas, et il faut le savoir** : il ne déchiffre aucune voûte, et il ne +tient pas compte des `when:` qui rendent une assertion conditionnelle. Un intrant exigé +seulement quand une option est active sera donc signalé même si l'option est éteinte. +Signaler à tort coûte une ligne de déclaration ; ne pas signaler coûte un déploiement. +""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +import yaml + +RACINE = Path(__file__).resolve().parent.parent + +# Les deux idiomes d'exigence du depot. `| bool` n'en est pas un : une valeur fausse est +# une reponse valable, pas une absence. +MOTIFS = ( + re.compile(r"^([a-z][a-z0-9_]*)\s*\|\s*length\s*>\s*0\s*$"), + re.compile(r"^([a-z][a-z0-9_]*)\s+is\s+defined\s*$"), +) + + +def _assertions(chemin: Path) -> set[str]: + """Variables exigees par les `assert` d'un fichier de taches.""" + try: + taches = yaml.safe_load(chemin.read_text(encoding="utf-8")) or [] + except yaml.YAMLError: + return set() + trouvees: set[str] = set() + for tache in taches if isinstance(taches, list) else []: + if not isinstance(tache, dict): + continue + bloc = tache.get("ansible.builtin.assert") or tache.get("assert") + if not isinstance(bloc, dict): + continue + conditions = bloc.get("that") or [] + for c in [conditions] if isinstance(conditions, str) else conditions: + for motif in MOTIFS: + m = motif.match(str(c).strip()) + if m: + trouvees.add(m.group(1)) + return trouvees + + +def _defauts_non_vides(role: Path) -> set[str]: + f = role / "defaults" / "main.yml" + if not f.is_file(): + return set() + try: + d = yaml.safe_load(f.read_text(encoding="utf-8")) or {} + except yaml.YAMLError: + return set() + return {k for k, v in d.items() if v not in (None, "", [], {})} + + +def _poses_par_set_fact() -> set[str]: + """Variables qu'un role FABRIQUE a l'execution (resolveurs).""" + poses: set[str] = set() + for f in RACINE.glob("roles/*/tasks/*.yml"): + try: + taches = yaml.safe_load(f.read_text(encoding="utf-8")) or [] + except yaml.YAMLError: + continue + for tache in taches if isinstance(taches, list) else []: + if not isinstance(tache, dict): + continue + bloc = tache.get("ansible.builtin.set_fact") or tache.get("set_fact") + if isinstance(bloc, dict): + poses |= {k for k in bloc if k != "cacheable"} + return poses + + +def _cles_vars(noeud) -> set[str]: + """Variables d'un inventaire YAML, a n'importe quelle profondeur. + + DEUX emplacements, et oublier le second faisait crier au manque a tort : les blocs + `vars:` d'un groupe, ET les cles posees directement sous un nom d'hote dans `hosts:`. + C'est la seconde forme que `instancier` emploie pour les valeurs derivees du plan. + """ + trouvees: set[str] = set() + if isinstance(noeud, dict): + v = noeud.get("vars") + if isinstance(v, dict): + trouvees |= {k for k, val in v.items() if val not in (None, "", [], {})} + h = noeud.get("hosts") + if isinstance(h, dict): + for attributs in h.values(): + if isinstance(attributs, dict): + trouvees |= {k for k, val in attributs.items() + if val not in (None, "", [], {})} + for sous in noeud.values(): + trouvees |= _cles_vars(sous) + elif isinstance(noeud, list): + for sous in noeud: + trouvees |= _cles_vars(sous) + return trouvees + + +def _declares_par_instance(base: Path) -> set[str]: + """Cles declarees EN CLAIR par l'inventaire. Les voutes ne sont jamais dechiffrees. + + Le FICHIER d'inventaire compte autant que `group_vars/` : `instancier` y ecrit les + valeurs derivees du plan — les liens entre applications, notamment. Ne regarder que + les repertoires faisait crier au manque sur `serveur_postfix_mailstore_hote`, qui + etait bel et bien fourni. Mesure du 2026-08-09, avant meme la premiere execution + utile : un verificateur incomplet accuse le composant a la place de l'instrument. + """ + declares: set[str] = set() + for f in base.glob("inventories/*/hosts.yml"): + try: + declares |= _cles_vars(yaml.safe_load(f.read_text(encoding="utf-8"))) + except yaml.YAMLError: + continue + for f in list(base.glob("inventories/*/group_vars/**/*.yml")) + \ + list(base.glob("inventories/*/host_vars/**/*.yml")): + texte = f.read_text(encoding="utf-8", errors="ignore") + if texte.lstrip().startswith("$ANSIBLE_VAULT"): + continue + try: + d = yaml.safe_load(texte) or {} + except yaml.YAMLError: + continue + if isinstance(d, dict): + declares |= {k for k, v in d.items() if v not in (None, "", [], {})} + return declares + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("--instance", default=None, help="racine de l'instance (defaut : instance/)") + a = ap.parse_args(argv) + + base = Path(a.instance) if a.instance else (RACINE / "instance") + if not base.exists(): + print("Aucune instance montee : rien a verifier.", file=sys.stderr) + return 2 + + poses = _poses_par_set_fact() + declares = _declares_par_instance(base) + + manquants: list[tuple[str, str]] = [] + exiges = 0 + for role in sorted(RACINE.glob("roles/*")): + if not role.is_dir(): + continue + defauts = _defauts_non_vides(role) + vus: set[str] = set() + for f in sorted(role.glob("tasks/*.yml")): + vus |= _assertions(f) + for var in sorted(vus): + exiges += 1 + if var in defauts or var in poses or var in declares: + continue + manquants.append((role.name, var)) + + if manquants: + print(f"{len(manquants)} intrant(s) EXIGE(s) que l'instance ne fournit pas :\n") + for r, v in manquants: + print(f" {r:26} exige `{v}` — sans defaut, sans set_fact, absent de l'inventaire") + print("\nLe deploiement s'arretera sur la premiere garde atteinte.") + return 1 + + print(f"CONFORME : {exiges} exigence(s) de role, toutes satisfaites " + f"({len(declares)} cle(s) declaree(s) par l'instance).") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())