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
This commit is contained in:
Daniel Allaire 2026-09-08 15:19:20 -04:00
parent 6077b179af
commit 0eaceb1048
9 changed files with 711 additions and 9 deletions

View file

@ -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.**

View file

@ -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; \

View file

@ -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

350
docs/audit/schema-plan.json Normal file
View file

@ -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"
}

View file

@ -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` |

View file

@ -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.

View file

@ -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": [],

265
scripts/schema_plan.py Normal file
View file

@ -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())

View file

@ -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-<date>.md`. `make verifier` les inclut : il **échoue** si une preuve échoue.
- **Chaque preuve garde une classe d'erreur.** Extrait :