From 0eaceb1048e12c58a600ccae3b0e246f17165f50 Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Tue, 8 Sep 2026 15:19:20 -0400 Subject: [PATCH] schema du plan : la forme des registres devient derivee, et gardee MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q --- AGENTS.md | 2 +- Makefile | 4 + docs/audit/preuve-2026-09-08.md | 11 +- docs/audit/schema-plan.json | 350 ++++++++++++++++++++++++++++++++ docs/carte-set-ops.md | 3 +- docs/devis-services.md | 2 +- scripts/prouver.py | 81 ++++++++ scripts/schema_plan.py | 265 ++++++++++++++++++++++++ wiki/La-preuve.md | 2 +- 9 files changed, 711 insertions(+), 9 deletions(-) create mode 100644 docs/audit/schema-plan.json create mode 100644 scripts/schema_plan.py diff --git a/AGENTS.md b/AGENTS.md index 0272258..008ceb1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -188,7 +188,7 @@ Si `ansible-lint` n’est pas disponible, le signaler clairement. Ne pas invente ## Écrire, puis relire (D-68) `--syntax-check` et `ansible-lint` prouvent que le dépôt est cohérent **avec lui-même**. -C'est aussi ce que font les 60 preuves de `make prouver` : elles lisent le dépôt, sans le +C'est aussi ce que font les 61 preuves de `make prouver` : elles lisent le dépôt, sans le moindre appel réseau. **Aucune ne demande au système déployé s'il ressemble à ce que le dépôt annonce.** diff --git a/Makefile b/Makefile index b574fb9..1c58b3f 100644 --- a/Makefile +++ b/Makefile @@ -797,6 +797,10 @@ flux-verifier: ## Verifie que le registre des flux correspond aux meta/flux.yml valider: ansible-runtime ## Passe la recette de validation sur la flotte ansible-playbook -i $(INVENTAIRE_PRODUCTION) playbooks/valider.yml +.PHONY: schema +schema: ## Regenere docs/audit/schema-plan.json depuis les registres et les validateurs + python3 scripts/schema_plan.py + .PHONY: wiki-publier wiki-publier: ## Publie le wiki (wiki/) vers la forge @set -e; \ diff --git a/docs/audit/preuve-2026-09-08.md b/docs/audit/preuve-2026-09-08.md index e3126de..96f068d 100644 --- a/docs/audit/preuve-2026-09-08.md +++ b/docs/audit/preuve-2026-09-08.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/principal/hosts.yml` -- **Verdict** : ✅ CONFORME (59 OK · 0 echec · 1 saute) +- **Verdict** : ✅ CONFORME (60 OK · 0 echec · 1 saute) ## Preuves @@ -43,7 +43,7 @@ | P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 3 pool(s) Proxmox, 34 VM placee(s), aucun nom ni VMID en collision. | | P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 32 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 21, 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, 3 zone(s), 15 VNet(s), 15 sous-reseau(x), aucune collision. | -| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 57 scripts expliques et atteignables, 114 cibles make documentees, 65 roles avec README. | +| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 58 scripts expliques et atteignables, 115 cibles make documentees, 65 roles avec README. | | P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 37 exigence(s) de role, toutes satisfaites (131 cle(s) declaree(s) par l'instance). | | P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 33 revendication(s) de port, aucune collision entre roles co-localises (37 groupes). | | P34 | Chaque document declare son lecteur | — | ✅ OK | 43 document(s) declarent leur lecteur (34 genere(s) exempte(s)). | @@ -53,14 +53,14 @@ | P38 | Catalogue des services : la carte dit ce que le moteur fait | — | ✅ OK | Catalogue a jour : 39 role(s) serveur/client tous nommes, 40 groupe(s) cite(s) en table existent tous. | | P39 | Glossaire : tout mot employe est enseigne | — | ✅ OK | Glossaire complet : 81 terme(s) du jargon expliques, 15 lien(s) valides, 27 page(s) de wiki toutes atteignables. | | P40 | Parente : l'ecosysteme sait de quoi il descend | — | ✅ OK | Parente coherente : 4 depot(s), tous retrouves, tous porteurs d'un remote. | -| P41 | Resolution d'instance : une seule, partagee | — | ✅ OK | Resolution unique : 53 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). | +| P41 | Resolution d'instance : une seule, partagee | — | ✅ OK | Resolution unique : 54 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). | | P42 | L'edge porte les noms qu'il publie | — | ✅ OK | 4 edge(s) emettent un certificat portant les noms publies (OPS-Chezlepro-lab/principal, OPS-Chezlepro/principal, OPS-Technolibre/principal, OPS-Patient0/product | | P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 7 machine(s) du plan retrouvees, 104 regle(s) du site. | | P44 | Integrations : le serveur avant ses clients | — | ✅ OK | 4 integration(s) appliquent leur serveur avant leurs clients. | | 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 87 chemin(s) qui existent, et ses 7 chiffres correspondent a la mesure. | +| P48 | La carte d'orientation designe ce qui existe, et compte juste | — | ✅ OK | La carte designe 89 chemin(s) qui existent, et ses 7 chiffres correspondent a la mesure. | | P49 | Registre des flux : la matrice d'audit est a jour | — | ✅ OK | Le registre des flux reproduit exactement ce que les `meta/flux.yml` declarent (117 lignes). | | P50 | Silences : un refus muet est declare, place en dernier, et motive | — | ✅ OK | 2 silence(s) declare(s), tous en sequence > 1 (la plus haute des 169 regles `pass`), tous non consignes et tous motives. | | P51 | Collections : toutes declarees, toutes epinglees | — | ✅ OK | 3 collection(s) et 2 bibliotheque(s) Python declarees et epinglees : ansible.posix==1.6.2, community.general==10.3.0, community.postgresql==3.10.2 | @@ -69,10 +69,11 @@ | P54 | L'insemination ne reclame aucun secret du tenant | — | ✅ OK | 2 couche(s) d'insemination (serveur_debian, serveur_ops), 10 role(s) applique(s), aucun secret de tenant reclame. | | P55 | La cle du SITE ne nait que sur le runner d'un tenant | — | ✅ OK | 14 hote(s) : la cle du SITE ne nait que sur 1 runner(s) de tenant, celle du tenant sur 14. | | P56 | Gabarit minimal, et rien de retire n'est perdu | — | ✅ OK | Gabarit minimal : 4 role(s), tous indispensables au premier demarrage ; 14 role(s) retire(s), tous repris par le socle ou le durcissement. | -| P57 | Comptes en prose : les chiffres du depot sur lui-meme | — | ✅ OK | Les comptes ecrits en prose correspondent a la mesure (60 preuves, 65 roles, 40 groupes). | +| P57 | Comptes en prose : les chiffres du depot sur lui-meme | — | ✅ OK | Les comptes ecrits en prose correspondent a la mesure (61 preuves, 65 roles, 40 groupes). | | P58 | Habilitations : chaque service dit a quel GROUPE, et par quoi | — | ✅ OK | 8 habilitation(s) declarees, toutes nommant un groupe, un mecanisme connu et une raison ; les `role-realm` sont projetees. | | P59 | Enumerations annoncees : le nombre correspond a ce qui suit | — | ✅ OK | 2 enumeration(s) annoncee(s) correspondent a ce qu'elles annoncent (formes non ambigues seulement). | | P60 | Wiki publie : la forge sert ce que le depot dit | AFF-002 | ✅ OK | Le wiki publie correspond au depot : `wiki/` n'a pas bouge depuis `e2935ed` (publie le 2026-09-08). | +| P61 | Schema du plan : il decrit tout ce que les plans contiennent | AFF-033 | ✅ OK | Le schema decrit 42 champ(s) sur 6 registres, et couvre tout ce que les plans reels contiennent ; applications:1, domaines_publics:1, nomenclature:9, serveurs:3 | ## Couverture des affirmations ✅ du registre diff --git a/docs/audit/schema-plan.json b/docs/audit/schema-plan.json new file mode 100644 index 0000000..28a2cd4 --- /dev/null +++ b/docs/audit/schema-plan.json @@ -0,0 +1,350 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "GENERE par scripts/schema_plan.py (make schema). Ne pas editer a la main. Decrit la FORME des registres ; la COHERENCE reste aux validateurs de inventory_rules.py.", + "registres": { + "applications": { + "entite": { + "additionalProperties": false, + "properties": { + "expose": { + "description": "FQDN publies par l'edge. Derivent le vhost, les SAN et le plancher /etc/hosts.", + "items": { + "type": "string" + }, + "title": "Exposition publique", + "type": "array" + }, + "groupe": { + "description": "La capacite appliquee. Doit avoir un playbook homonyme.", + "title": "Groupe (role)", + "type": "string", + "x-source-valeurs": "groupes_operationnels" + }, + "hote": { + "description": "Une VM declaree au plan. Un hote inconnu est un « hote fantome » (P06).", + "title": "Hote", + "type": "string", + "x-source-valeurs": "serveurs" + }, + "liens": { + "description": "Roles acceptes par le role porteur (meta/liens.yml).", + "items": { + "type": "object" + }, + "title": "Liens (bindings)", + "type": "array" + }, + "port": { + "title": "Port", + "type": "integer" + }, + "requiert": { + "description": "Dependance applicative. Indicative : l'ordre de deploiement vient des couches.", + "items": { + "type": "string" + }, + "title": "Requiert", + "type": "array" + }, + "websocket": { + "description": "L'edge doit relayer la mise a niveau de connexion.", + "title": "WebSocket", + "type": "boolean" + } + }, + "required": [ + "groupe", + "hote" + ], + "type": "object" + }, + "title": "Applications", + "x-fichier": "plan/applications.yml", + "x-racine": "applications" + }, + "bases_donnees": { + "entite": { + "additionalProperties": false, + "properties": { + "base": { + "title": "Base", + "type": "string" + }, + "consommateur": { + "title": "Consommateur", + "type": "string" + }, + "portee": { + "description": "Comment le consommateur est designe : par application, par groupe ou par hote.", + "enum": [ + "application", + "groupe", + "hote" + ], + "title": "Portee", + "type": "string" + }, + "proprietaire": { + "title": "Proprietaire", + "type": "string" + }, + "secret": { + "description": "NOM d'une variable Vault, jamais une valeur. Le secret ne quitte pas le role.", + "title": "Secret (voute)", + "type": "string" + }, + "serveur": { + "title": "Serveur de BD", + "type": "string", + "x-source-valeurs": "serveurs_bd" + }, + "usage": { + "title": "Usage", + "type": "string" + } + }, + "required": [ + "base", + "consommateur", + "proprietaire", + "secret", + "serveur" + ], + "type": "object" + }, + "title": "Bases de donnees", + "x-fichier": "plan/bases-donnees.yml", + "x-racine": "bases_donnees" + }, + "domaines_publics": { + "entite": { + "additionalProperties": false, + "properties": { + "autorite": { + "enum": [ + "auto-heberge", + "delegue", + "primaire-cache" + ], + "title": "Autorite DNS", + "type": "string" + }, + "dnssec": { + "title": "DNSSEC", + "type": "boolean" + }, + "edge": { + "title": "Edge", + "type": "string", + "x-source-valeurs": "serveurs" + }, + "mail": { + "description": "La zone porte des enregistrements de messagerie.", + "title": "Courriel", + "type": "boolean" + }, + "secondaires": { + "items": { + "type": "string" + }, + "title": "Secondaires", + "type": "array" + } + }, + "type": "object" + }, + "title": "Domaines publics", + "x-fichier": "plan/domaines.yml", + "x-racine": "domaines_publics" + }, + "nomenclature": { + "entite": { + "additionalProperties": false, + "properties": { + "categories": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "libelle": { + "description": "Nom lisible de la zone (Frontiere, Identite...).", + "title": "Libelle", + "type": "string" + } + }, + "required": [ + "libelle" + ], + "type": "object" + }, + "description": "Une zone de securite par cle. Le numero derive le 3e octet et le VLAN.", + "title": "Categories (zones)", + "type": "object" + }, + "cidr_hote": { + "title": "CIDR d'hote", + "type": "integer" + }, + "fonctions": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "categorie": { + "description": "La zone. Fixe le 3e octet (15 + categorie) et le VLAN.", + "title": "Categorie", + "type": "integer" + }, + "service": { + "description": "Fixe le bloc d'adresses de l'hote dans la zone.", + "title": "Service", + "type": "integer" + } + }, + "required": [ + "categorie", + "service" + ], + "type": "object" + }, + "description": "categorie + service par fonction. VMID et IP en derivent.", + "title": "Fonctions", + "type": "object" + }, + "index": { + "description": "LE seul champ d'adressage. Tout en derive ; P20 refuse d'en stocker un autre.", + "maximum": 255, + "minimum": 0, + "title": "Index (seed)", + "type": "integer" + }, + "reservations": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "passerelle": { + "description": "Dernier octet de la passerelle. P23 refuse un SVI qui s'en ecarte.", + "title": "Passerelle", + "type": "integer" + }, + "reserve_max": { + "title": "Reserve (max)", + "type": "integer" + }, + "reserve_min": { + "title": "Reserve (min)", + "type": "integer" + } + }, + "type": "object" + }, + "description": "Adresses soustraites a la derivation dans chaque zone.", + "title": "Reservations", + "type": "object" + } + }, + "required": [ + "index" + ], + "type": "object" + }, + "title": "Nomenclature", + "x-fichier": "plan/nomenclature.yml", + "x-racine": null + }, + "serveurs": { + "entite": { + "additionalProperties": false, + "properties": { + "coeurs": { + "description": "Vide = derive des empreintes des roles.", + "title": "Coeurs", + "type": "integer" + }, + "disque": { + "description": "Ex. `32G`. Vide = derive des empreintes des roles.", + "title": "Disque", + "type": "string" + }, + "etat": { + "description": "`planifie` = decrit mais pas deploye ; `actif` = joignable par Ansible.", + "enum": [ + "actif", + "planifie" + ], + "title": "Etat", + "type": "string" + }, + "fonction": { + "description": "Determine VMID, IP, VLAN et passerelle. Doit exister dans la nomenclature.", + "title": "Fonction", + "type": "string", + "x-source-valeurs": "nomenclature.fonctions" + }, + "integrations": { + "description": "Seulement les facultatives. Les universelles viennent du role et sont refusees ici.", + "items": { + "type": "string" + }, + "title": "Integrations facultatives", + "type": "array" + }, + "memoire": { + "description": "Vide = derive des empreintes des roles.", + "title": "Memoire (Mo)", + "type": "integer" + }, + "noeud": { + "description": "Surcharge le defaut de `make config`.", + "title": "Noeud Proxmox", + "type": "string" + }, + "stockage": { + "description": "Surcharge le defaut.", + "title": "Stockage", + "type": "string" + } + }, + "required": [ + "fonction" + ], + "type": "object" + }, + "title": "Serveurs (VM)", + "x-fichier": "plan/serveurs.yml", + "x-racine": "serveurs" + }, + "serveurs_bd": { + "entite": { + "additionalProperties": false, + "properties": { + "groupe": { + "title": "Groupe", + "type": "string", + "x-source-valeurs": "groupes_operationnels" + }, + "hote": { + "title": "Hote", + "type": "string", + "x-source-valeurs": "serveurs" + }, + "port": { + "title": "Port", + "type": "integer" + }, + "type": { + "title": "Moteur", + "type": "string" + } + }, + "required": [ + "hote", + "type" + ], + "type": "object" + }, + "title": "Serveurs de bases de donnees", + "x-fichier": "plan/bases-donnees.yml", + "x-racine": "serveurs_bd" + } + }, + "title": "Registres du plan Set-OPS" +} diff --git a/docs/carte-set-ops.md b/docs/carte-set-ops.md index 802ad99..c5f1826 100644 --- a/docs/carte-set-ops.md +++ b/docs/carte-set-ops.md @@ -26,7 +26,7 @@ README de rôles). Cette page comble ces deux trous. | rôles | 65 | `roles/*/` | | README de rôles | 65 | `roles/*/README.md` — l'écart avec la ligne au-dessus est la dette | | documents | 39 | `docs/*.md` | -| pièces d'audit | 38 | `docs/audit/*` | +| pièces d'audit | 39 | `docs/audit/*` | | unités de wiki | 27 | `wiki/*.md` | | décisions en vigueur | 81 | lignes `\| **D-nn** \|` de `decisions-architecture.md` | | décisions renversées | 3 | lignes `\| **D-nn** —` du même document | @@ -58,6 +58,7 @@ Ce que je re-découvre sinon. **Consulter avant de concevoir un nouveau mécanis | Mécanisme | Ce que c'est | Où, dans le code | Doc | |---|---|---|---| | Plan → inventaire | `plan/*.yml` → `hosts.yml` généré | `scripts/instancier.py`, `scripts/inventory_rules.py` | `plan-et-generation.md` | +| **Schéma des registres** | la **forme** des six registres — champs, types, énumérations, requis — **dérivée** des validateurs, jamais écrite à la main. Sert à générer les formulaires plutôt qu'à les écrire. La **cohérence** reste aux `valider_*` : un schéma ne sait pas dire qu'un `consommateur` désigne une application inexistante | `scripts/schema_plan.py` (`make schema`) → `docs/audit/schema-plan.json` ; garde **P61** | `plan-et-generation.md` | | Nomenclature dérivée | VMID / IP / VLAN / FQDN dérivés | `inventory_rules.deriver_nomenclature` + `plan/nomenclature.yml` | `nomenclature-vm.md` | | Dimensionnement | RAM/CPU/disque sommés par logiciel | `roles/*/meta/empreinte.yml` → `deriver_ressources` | `dimensionnement-ressources.md` | | **Bindings app→app** | lien **côté app** (`liens`) résolu en host_vars | `plan/applications.yml` `liens:` + `roles/*/meta/liens.yml` + `instancier.resoudre_liens` | `bindings-conception.md` | diff --git a/docs/devis-services.md b/docs/devis-services.md index 5769881..7e3b476 100644 --- a/docs/devis-services.md +++ b/docs/devis-services.md @@ -30,7 +30,7 @@ make placement-plan # chaque VM est-elle là où le plan la met ## Le trou qu'il comble -`scripts/prouver.py` porte 60 preuves (dont une conditionnelle, sautée sans la clé de la voûte). Elles sont toutes **statiques** : elles lisent le +`scripts/prouver.py` porte 61 preuves (dont une conditionnelle, sautée sans la clé de la voûte). Elles sont toutes **statiques** : elles lisent le dépôt. Zéro appel réseau, zéro SSH, zéro `ansible`. Elles établissent que le dépôt est cohérent **avec lui-même** — que les handlers existent, que les intrants ont un propriétaire, que rien n'est codé en dur. diff --git a/scripts/prouver.py b/scripts/prouver.py index 2d0085b..b21ecc7 100644 --- a/scripts/prouver.py +++ b/scripts/prouver.py @@ -251,6 +251,85 @@ def _lignes_premiere_table(texte: str) -> int: return n +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) + sous = p.get("additionalProperties") + if isinstance(sous, dict): + vus |= _noms(sous.get("properties")) + 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()} + + 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)}") + + 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 "" + return True, (f"Le schema decrit {n} champ(s) sur {len(decrit)} registres, et couvre " + f"tout ce que les plans reels contiennent{suffixe}.") + + def preuve_wiki_publie_a_jour() -> tuple[bool, str]: """Le wiki publie sur la forge correspond a `wiki/` dans le depot. @@ -2758,6 +2837,8 @@ PREUVES: list[dict] = [ "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}, + {"id": "P61", "titre": "Schema du plan : il decrit tout ce que les plans contiennent", + "refs": ["AFF-033"], "func": preuve_schema_du_plan}, {"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": [], diff --git a/scripts/schema_plan.py b/scripts/schema_plan.py new file mode 100644 index 0000000..b6b71ba --- /dev/null +++ b/scripts/schema_plan.py @@ -0,0 +1,265 @@ +#!/usr/bin/env python3 +"""JSON Schema des registres du plan — DERIVE, jamais ecrit a la main. + +CE QUE CE SCHEMA EST, ET CE QU'IL N'EST PAS. + +Il decrit la **forme** des six registres du plan : quels champs existent, de quel type, +lesquels sont requis, quelles valeurs sont admises. Il sert a **generer les formulaires** +du GUI plutot qu'a les ecrire un par un. + +Il ne remplace PAS les validateurs (`inventory_rules.valider_*`). Ceux-ci portent la +**coherence** — un `consommateur` qui designe une application inexistante, une `fonction` +absente de la nomenclature, une integration universelle recopiee dans le plan. Aucune de +ces regles ne s'exprime en JSON Schema, et vouloir les y mettre creerait la seconde source +de verite que tout ce depot refuse. + + schema -> la FORME -> genere les champs du formulaire + validateurs -> la COHERENCE -> refusent une saisie incoherente + +D'OU VIENNENT LES VALEURS ADMISES. Elles sont **importees** des constantes que les +validateurs appliquent (`ETATS_SERVEUR`, `PORTEES_BD`, `AUTORITES_DNS`), jamais recopiees. +Une enumeration recopiee diverge : c'est la lecon des neuf resolutions d'instance que P41 +garde depuis. + +CE QUI RESTE DECLARE ICI, ET POURQUOI. Le libelle, l'aide et le fait qu'un champ soit +**derive** (donc non saisissable) ne se lisent nulle part ailleurs : ce sont des decisions +d'interface. Elles vivent donc dans `REGISTRES` ci-dessous, a cote de la seule chose qui +les justifie. La preuve **P61** confronte cette declaration aux champs REELLEMENT presents +dans tous les plans et modeles : un champ oublie ici et utilise la-bas fait echouer le +harnais. + +Usage : + python3 scripts/schema_plan.py # ecrit docs/audit/schema-plan.json + python3 scripts/schema_plan.py --verifier # compare sans ecrire (code 1 si perime) +""" +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from inventory_rules import ( # noqa: E402 + AUTORITES_DNS, + ETATS_SERVEUR, + PORTEES_BD, + ecriture_atomique, +) + +RACINE = Path(__file__).resolve().parents[1] +CIBLE = RACINE / "docs" / "audit" / "schema-plan.json" + +# `derive: true` = le moteur le calcule, l'interface l'AFFICHE sans le proposer a la +# saisie. C'est la difference entre montrer et laisser modifier, et elle est structurante : +# P20 refuse tout adressage stocke, donc un formulaire qui offrirait `vlan` ou `vmid` +# inviterait a violer une regle que le harnais refuse ensuite. +REGISTRES: dict = { + "serveurs": { + "titre": "Serveurs (VM)", + "fichier": "plan/serveurs.yml", + "racine": "serveurs", + "champs": { + "fonction": {"type": "string", "requis": True, + "libelle": "Fonction", + "aide": "Determine VMID, IP, VLAN et passerelle. Doit exister dans la nomenclature.", + "source_valeurs": "nomenclature.fonctions"}, + "etat": {"type": "string", "enum": sorted(ETATS_SERVEUR), + "libelle": "Etat", + "aide": "`planifie` = decrit mais pas deploye ; `actif` = joignable par Ansible."}, + "integrations": {"type": "array", "items": {"type": "string"}, + "libelle": "Integrations facultatives", + "aide": "Seulement les facultatives. Les universelles viennent du role et sont refusees ici."}, + "noeud": {"type": "string", "libelle": "Noeud Proxmox", + "aide": "Surcharge le defaut de `make config`."}, + "stockage": {"type": "string", "libelle": "Stockage", "aide": "Surcharge le defaut."}, + "disque": {"type": "string", "libelle": "Disque", + "aide": "Ex. `32G`. Vide = derive des empreintes des roles."}, + "memoire": {"type": "integer", "libelle": "Memoire (Mo)", + "aide": "Vide = derive des empreintes des roles."}, + "coeurs": {"type": "integer", "libelle": "Coeurs", + "aide": "Vide = derive des empreintes des roles."}, + }, + }, + "applications": { + "titre": "Applications", + "fichier": "plan/applications.yml", + "racine": "applications", + "champs": { + "groupe": {"type": "string", "requis": True, "libelle": "Groupe (role)", + "aide": "La capacite appliquee. Doit avoir un playbook homonyme.", + "source_valeurs": "groupes_operationnels"}, + "hote": {"type": "string", "requis": True, "libelle": "Hote", + "aide": "Une VM declaree au plan. Un hote inconnu est un « hote fantome » (P06).", + "source_valeurs": "serveurs"}, + "port": {"type": "integer", "libelle": "Port"}, + "expose": {"type": "array", "items": {"type": "string"}, + "libelle": "Exposition publique", + "aide": "FQDN publies par l'edge. Derivent le vhost, les SAN et le plancher /etc/hosts."}, + "requiert": {"type": "array", "items": {"type": "string"}, + "libelle": "Requiert", + "aide": "Dependance applicative. Indicative : l'ordre de deploiement vient des couches."}, + "liens": {"type": "array", "items": {"type": "object"}, + "libelle": "Liens (bindings)", + "aide": "Roles acceptes par le role porteur (meta/liens.yml)."}, + "websocket": {"type": "boolean", "libelle": "WebSocket", + "aide": "L'edge doit relayer la mise a niveau de connexion."}, + }, + }, + "bases_donnees": { + "titre": "Bases de donnees", + "fichier": "plan/bases-donnees.yml", + "racine": "bases_donnees", + "champs": { + "serveur": {"type": "string", "requis": True, "libelle": "Serveur de BD", + "source_valeurs": "serveurs_bd"}, + "base": {"type": "string", "requis": True, "libelle": "Base"}, + "proprietaire": {"type": "string", "requis": True, "libelle": "Proprietaire"}, + "secret": {"type": "string", "requis": True, "libelle": "Secret (voute)", + "aide": "NOM d'une variable Vault, jamais une valeur. Le secret ne quitte pas le role."}, + "consommateur": {"type": "string", "requis": True, "libelle": "Consommateur"}, + "portee": {"type": "string", "enum": sorted(PORTEES_BD), "libelle": "Portee", + "aide": "Comment le consommateur est designe : par application, par groupe ou par hote."}, + "usage": {"type": "string", "libelle": "Usage"}, + }, + }, + "serveurs_bd": { + "titre": "Serveurs de bases de donnees", + "fichier": "plan/bases-donnees.yml", + "racine": "serveurs_bd", + "champs": { + "type": {"type": "string", "requis": True, "libelle": "Moteur"}, + "hote": {"type": "string", "requis": True, "libelle": "Hote", "source_valeurs": "serveurs"}, + "port": {"type": "integer", "libelle": "Port"}, + "groupe": {"type": "string", "libelle": "Groupe", "source_valeurs": "groupes_operationnels"}, + }, + }, + "domaines_publics": { + "titre": "Domaines publics", + "fichier": "plan/domaines.yml", + "racine": "domaines_publics", + "champs": { + "autorite": {"type": "string", "enum": sorted(AUTORITES_DNS), "libelle": "Autorite DNS"}, + "edge": {"type": "string", "libelle": "Edge", "source_valeurs": "serveurs"}, + "secondaires": {"type": "array", "items": {"type": "string"}, "libelle": "Secondaires"}, + "dnssec": {"type": "boolean", "libelle": "DNSSEC"}, + "mail": {"type": "boolean", "libelle": "Courriel", + "aide": "La zone porte des enregistrements de messagerie."}, + }, + }, + "nomenclature": { + "titre": "Nomenclature", + "fichier": "plan/nomenclature.yml", + "racine": None, # pas d'entites : des clefs a la racine + "champs": { + "index": {"type": "integer", "requis": True, "libelle": "Index (seed)", + "minimum": 0, "maximum": 255, + "aide": "LE seul champ d'adressage. Tout en derive ; P20 refuse d'en stocker un autre."}, + "cidr_hote": {"type": "integer", "libelle": "CIDR d'hote"}, + # LES TROIS TABLES IMBRIQUEES SONT DECRITES, PAS RESUMEES EN « object ». + # C'est P61 qui l'a impose : `categorie` et `service` etaient presents dans + # tous les plans et absents du schema — donc un formulaire genere n'aurait + # jamais su les editer, exactement le trou que la nomenclature avait deja + # dans `CHAMPS_ECRITS_PAR_GUI` (zero champ declare). + "categories": {"type": "object", "libelle": "Categories (zones)", + "aide": "Une zone de securite par cle. Le numero derive le 3e octet et le VLAN.", + "entree": { + "libelle": {"type": "string", "requis": True, + "libelle": "Libelle", + "aide": "Nom lisible de la zone (Frontiere, Identite...)."}}}, + "fonctions": {"type": "object", "libelle": "Fonctions", + "aide": "categorie + service par fonction. VMID et IP en derivent.", + "entree": { + "categorie": {"type": "integer", "requis": True, + "libelle": "Categorie", + "aide": "La zone. Fixe le 3e octet (15 + categorie) et le VLAN."}, + "service": {"type": "integer", "requis": True, + "libelle": "Service", + "aide": "Fixe le bloc d'adresses de l'hote dans la zone."}}}, + "reservations": {"type": "object", "libelle": "Reservations", + "aide": "Adresses soustraites a la derivation dans chaque zone.", + "entree": { + "passerelle": {"type": "integer", "libelle": "Passerelle", + "aide": "Dernier octet de la passerelle. P23 refuse un SVI qui s'en ecarte."}, + "reserve_min": {"type": "integer", "libelle": "Reserve (min)"}, + "reserve_max": {"type": "integer", "libelle": "Reserve (max)"}}}, + }, + }, +} + + +def construire() -> dict: + """Le JSON Schema, derive de REGISTRES et des constantes des validateurs.""" + defs = {} + for nom, reg in REGISTRES.items(): + props, requis = {}, [] + for champ, d in reg["champs"].items(): + p = {"type": d["type"], "title": d["libelle"]} + for cle_src, cle_dst in (("aide", "description"), ("enum", "enum"), + ("items", "items"), ("minimum", "minimum"), + ("maximum", "maximum")): + if cle_src in d: + p[cle_dst] = d[cle_src] + if "entree" in d: + # Une table dont chaque VALEUR a une forme connue : le formulaire genere + # une sous-fiche par cle, au lieu d'offrir un bloc YAML libre. + sous, sous_requis = {}, [] + for sc, sd in d["entree"].items(): + q = {"type": sd["type"], "title": sd["libelle"]} + if "aide" in sd: + q["description"] = sd["aide"] + sous[sc] = q + if sd.get("requis"): + sous_requis.append(sc) + p["additionalProperties"] = {"type": "object", "properties": sous, + "additionalProperties": False} + if sous_requis: + p["additionalProperties"]["required"] = sorted(sous_requis) + if "source_valeurs" in d: + # Extension hors JSON Schema : dit au formulaire d'offrir une LISTE + # fermee, alimentee a l'execution. C'est ce qui rend l'hote fantome + # impossible a saisir plutot que refuse apres coup. + p["x-source-valeurs"] = d["source_valeurs"] + props[champ] = p + if d.get("requis"): + requis.append(champ) + entite = {"type": "object", "properties": props, + "additionalProperties": False} + if requis: + entite["required"] = sorted(requis) + defs[nom] = {"title": reg["titre"], "x-fichier": reg["fichier"], + "x-racine": reg["racine"], "entite": entite} + return { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Registres du plan Set-OPS", + "description": ("GENERE par scripts/schema_plan.py (make schema). Ne pas editer a " + "la main. Decrit la FORME des registres ; la COHERENCE reste aux " + "validateurs de inventory_rules.py."), + "registres": defs, + } + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("--verifier", action="store_true", + help="compare sans ecrire ; code 1 si le fichier est perime") + args = ap.parse_args(argv) + rendu = json.dumps(construire(), ensure_ascii=False, indent=2, sort_keys=True) + "\n" + if args.verifier: + actuel = CIBLE.read_text(encoding="utf-8") if CIBLE.is_file() else "" + if actuel != rendu: + print(f"erreur: {CIBLE.relative_to(RACINE)} est PERIME. Regenerer : make schema", + file=sys.stderr) + return 1 + n = sum(len(r["champs"]) for r in REGISTRES.values()) + print(f"CONFORME : {len(REGISTRES)} registres, {n} champs decrits.") + return 0 + with ecriture_atomique(CIBLE) as f: + f.write(rendu) + print(f"Ecrit : {CIBLE.relative_to(RACINE)}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/wiki/La-preuve.md b/wiki/La-preuve.md index f52e176..9d09992 100644 --- a/wiki/La-preuve.md +++ b/wiki/La-preuve.md @@ -26,7 +26,7 @@ Trois idées la portent : - **Le registre** : `docs/audit/affirmations.md` — chaque affirmation du dépôt (README, docs, aide `make`, GUI) reliée à une preuve et un statut (✅/🟡/❌/⚪). -- **Le harnais** : `make prouver` rejoue les preuves automatisables (**P01–P60**, sans trou dans la série) et écrit +- **Le harnais** : `make prouver` rejoue les preuves automatisables (**P01–P61**, sans trou dans la série) et écrit `docs/audit/preuve-.md`. `make verifier` les inclut : il **échoue** si une preuve échoue. - **Chaque preuve garde une classe d'erreur.** Extrait :