L edge expose les interfaces web aux personnes ; les VM du site vont directement au service. Declare par domaine (service | edge, defaut edge) ; plancher, zone et certificat du service suivent. Pose au site : 35 planchers sur 35 conformes ; locataires inchanges. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
449 lines
28 KiB
Python
449 lines
28 KiB
Python
#!/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",
|
|
"clef": {"libelle": "Nom d'hôte", "aide": "Le nom court de la VM. Le rang final derive l'adresse."},
|
|
"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": "État",
|
|
"aide": "`planifie` = decrit mais pas deploye ; `actif` = joignable par Ansible."},
|
|
# UN EDITEUR PROPRE, ET C'EST VOULU. La matrice de cases a cocher montre
|
|
# aussi les integrations UNIVERSELLES (non decochables) et les exemptions
|
|
# `sauf_role`. Un champ texte genere serait une regression : un plan
|
|
# silencieux se lirait « cet hote n'est pas supervise », l'inverse exact de
|
|
# la politique. Le schema le DIT, au lieu de laisser le generateur l'ecraser.
|
|
"integrations": {"type": "array", "items": {"type": "string"},
|
|
"libelle": "Intégrations facultatives", "editeur": "matrice",
|
|
"aide": "Seulement les facultatives. Les universelles viennent du role et sont refusees ici."},
|
|
"noeud": {"type": "string", "libelle": "Nœud Proxmox",
|
|
"source_valeurs": "intrants.proxmox_noeuds",
|
|
"defaut_intrant": "proxmox_clone_noeud",
|
|
"aide": "Surcharge le defaut de `make config`."},
|
|
"stockage": {"type": "string", "libelle": "Stockage",
|
|
"source_valeurs": "intrants.proxmox_stockages",
|
|
"defaut_intrant": "proxmox_clone_stockage",
|
|
"aide": "Surcharge le defaut de `make config`."},
|
|
# `defaut_derive` NOMME LE CHAMP DE L'INVENTAIRE qui porte la valeur reellement
|
|
# derivee pour CET hote. Le formulaire ecrit a la main annoncait « 2048 » et
|
|
# « 2 » en repere de saisie : des constantes inventees. Il n'existe aucun
|
|
# defaut fixe — `deriver_ressources` calcule la taille depuis les ROLES que
|
|
# l'hote porte (5632 Mo et 4 coeurs pour `collab-01`). Un repere faux est pire
|
|
# qu'aucun : il fait croire qu'on connait la valeur.
|
|
"disque": {"type": "string", "libelle": "Disque",
|
|
"defaut_derive": "disque_taille",
|
|
"aide": "Ex. `32G`. Vide = derive des empreintes des roles."},
|
|
"memoire": {"type": "integer", "libelle": "Mémoire (Mo)",
|
|
"defaut_derive": "memoire",
|
|
"aide": "Vide = derive des empreintes des roles."},
|
|
"coeurs": {"type": "integer", "libelle": "Cœurs",
|
|
"defaut_derive": "coeurs",
|
|
"aide": "Vide = derive des empreintes des roles."},
|
|
},
|
|
},
|
|
"applications": {
|
|
"titre": "Applications",
|
|
"fichier": "plan/applications.yml",
|
|
"racine": "applications",
|
|
"clef": {"libelle": "Identifiant", "aide": "Nom de l'application dans le plan."},
|
|
"champs": {
|
|
"groupe": {"type": "string", "requis": True, "libelle": "Groupe (rôle)",
|
|
"aide": "La capacite appliquee. Doit avoir un playbook homonyme.",
|
|
"source_valeurs": "groupes_operationnels"},
|
|
"hote": {"type": "string", "requis": True, "libelle": "Hôte",
|
|
"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."},
|
|
# `items: {type: object}` ne disait rien de la FORME : un formulaire genere
|
|
# aurait offert « ajouter un lien » sans savoir quelles cases y mettre.
|
|
# `valider_applications` exige `vers` et `role` — on les nomme.
|
|
# EDITEUR PROPRE lui aussi : il contraint le ROLE du lien a ceux que le
|
|
# groupe porteur accepte (`roles/<groupe>/meta/liens.yml`) et montre les
|
|
# variables injectees. Le generateur, lui, offrirait un champ libre — il
|
|
# saurait moins que l'editeur qu'il remplacerait.
|
|
"liens": {"type": "array", "libelle": "Liens (bindings)", "editeur": "liens",
|
|
"aide": "Roles acceptes par le role porteur (meta/liens.yml).",
|
|
"entrees": {
|
|
"vers": {"type": "string", "requis": True, "libelle": "Vers",
|
|
"source_valeurs": "applications",
|
|
"aide": "L'application liee."},
|
|
"role": {"type": "string", "requis": True, "libelle": "Rôle",
|
|
"aide": "Le role du lien, accepte par 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",
|
|
"clef": {"libelle": "Identifiant", "aide": "Nom de l'entree au registre, ex. `bd_forgejo`."},
|
|
"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": "Propriétaire"},
|
|
"secret": {"type": "string", "requis": True, "libelle": "Secret (Vault)",
|
|
"aide": "NOM d'une variable Vault, jamais une valeur. Le secret ne quitte pas le role."},
|
|
# SOURCE CONDITIONNELLE : ce qu'on peut consommer depend de la PORTEE.
|
|
# Le formulaire ecrit a la main faisait deja ce choix ; le schema le declare
|
|
# au lieu de le laisser vivre dans le JS. C'est ce qui permet de generer le
|
|
# champ sans perdre la regle.
|
|
"consommateur": {"type": "string", "requis": True, "libelle": "Consommateur",
|
|
"aide": "Qui utilise cette base. La liste depend de la portee.",
|
|
"source_selon": {"champ": "portee",
|
|
"cas": {"hote": "serveurs",
|
|
"application": "applications"},
|
|
"defaut": "groupes_operationnels"}},
|
|
"portee": {"type": "string", "enum": sorted(PORTEES_BD), "libelle": "Portée",
|
|
"defaut": "groupe",
|
|
"aide": "Comment le consommateur est designe : par application, par groupe ou par hote."},
|
|
"usage": {"type": "string", "libelle": "Usage", "defaut": "principale"},
|
|
},
|
|
},
|
|
"serveurs_bd": {
|
|
"titre": "Serveurs de bases de donnees",
|
|
"fichier": "plan/bases-donnees.yml",
|
|
"racine": "serveurs_bd",
|
|
"clef": {"libelle": "Nom", "aide": "Nom du serveur de bases, ex. `pg-principal`."},
|
|
"champs": {
|
|
"type": {"type": "string", "requis": True, "libelle": "Moteur"},
|
|
"hote": {"type": "string", "requis": True, "libelle": "Hôte", "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",
|
|
"clef": {"libelle": "Domaine", "aide": "Le nom public, ex. `chezlepro.ca`."},
|
|
"champs": {
|
|
"autorite": {"type": "string", "requis": True, "enum": sorted(AUTORITES_DNS),
|
|
"libelle": "Autorité DNS"},
|
|
# UN GROUPE, PAS UN HOTE. `instancier` compare `edge` aux GROUPES d'un hote
|
|
# (`e.get("edge") in groupes`) pour lui derive ses SAN de certificat. Le schema
|
|
# disait « serveurs » : un formulaire genere aurait offert `web-frontal-01`, et
|
|
# aucun hote n'aurait jamais reconnu cette valeur — donc aucun SAN, donc un
|
|
# certificat correct sur un nom que personne ne peut appeler. C'est exactement
|
|
# la panne du 2026-08-25, reintroduite par le choix d'une liste.
|
|
"edge": {"type": "string", "requis": True, "libelle": "Edge",
|
|
"source_valeurs": "groupes_edge",
|
|
"aide": "Le GROUPE Ansible qui sert cette zone (ex. serveur_nginx)."},
|
|
# OU MENENT LES NOMS, VU DES MACHINES (2026-09-29). L'edge expose aux personnes ;
|
|
# `service` : entre machines, chaque nom mene au service lui-meme (le site).
|
|
# PAS DE `defaut` ICI : le formulaire l'ecrirait a chaque sauvegarde, et charger puis
|
|
# sauvegarder changerait le plan. Absent vaut `edge`, dans le moteur.
|
|
"resolution_interne": {"type": "string", "enum": ["edge", "service"],
|
|
"libelle": "Resolution interne",
|
|
"aide": "`service` : le plancher et la zone menent au service "
|
|
"lui-meme, l'edge ne sert qu'aux personnes."},
|
|
"secondaires": {"type": "array", "items": {"type": "string"}, "libelle": "Secondaires",
|
|
"aide": "Serveurs DNS secondaires de la zone."},
|
|
"dnssec": {"type": "boolean", "libelle": "DNSSEC"},
|
|
# `valider_domaines` valide entierement `exposition`, et le schema l'ignorait.
|
|
# Un formulaire genere ne pouvait donc PAS exprimer ce que le moteur accepte.
|
|
# `mail` faisait l'inverse : offert par le GUI depuis sa creation, decrit ici
|
|
# comme un booleen, saisi la-bas comme du texte, et lu par RIEN. Retire.
|
|
"exposition": {"type": "array", "libelle": "Expositions declarees",
|
|
"aide": "FQDN publies pour cette zone, vers un groupe cible.",
|
|
"entrees": {
|
|
"nom": {"type": "string", "requis": True, "libelle": "Nom",
|
|
"aide": "Sous-domaine, ou `@` pour la zone elle-meme."},
|
|
"cible": {"type": "string", "requis": True, "libelle": "Cible",
|
|
"source_valeurs": "groupes_operationnels",
|
|
"aide": "Le groupe interne qui sert ce nom."},
|
|
"type": {"type": "string", "libelle": "Type", "defaut": "web"}}},
|
|
# LE COURRIEL ET LE RESTE DE LA ZONE (2026-09-16). Rendus seulement pour une zone
|
|
# `primaire-cache` ; `valider_domaines` refuse ailleurs.
|
|
"enregistrements": {"type": "array", "libelle": "Enregistrements publics",
|
|
"aide": "MX, SPF, DMARC, DKIM, CAA et noms historiques de la zone.",
|
|
"entrees": {
|
|
"nom": {"type": "string", "requis": True, "libelle": "Nom",
|
|
"aide": "Relatif a la zone : `@`, `mx`, `_dmarc`."},
|
|
"type": {"type": "string", "requis": True, "libelle": "Type",
|
|
"enum": ["A", "AAAA", "CAA", "CNAME", "MX", "TXT"]},
|
|
"valeur": {"type": "string", "requis": True, "libelle": "Valeur",
|
|
"aide": "Adresse publique, nom d'hote complet ou texte (sans guillemets)."},
|
|
"priorite": {"type": "integer", "libelle": "Priorite",
|
|
"aide": "Requise pour un MX."},
|
|
"etiquette": {"type": "string", "libelle": "Etiquette CAA",
|
|
"enum": ["iodef", "issue", "issuewild"]},
|
|
"ttl": {"type": "integer", "libelle": "TTL",
|
|
"aide": "Secondes (60 a 604800) ; defaut de la zone sinon."}}},
|
|
},
|
|
},
|
|
"acces_admin_vpn": {
|
|
"titre": "Acces d'administration (WireGuard)",
|
|
"fichier": "plan/acces.yml",
|
|
"racine": "acces_admin_vpn",
|
|
"clef": {"libelle": "Pair", "aide": "`personne-appareil`, ex. `daniel-portable` — "
|
|
"un pair par appareil, pour revoquer l'un sans l'autre."},
|
|
"champs": {
|
|
"cle_publique": {"type": "string", "requis": True, "libelle": "Cle publique",
|
|
"aide": "Cle WireGuard PUBLIQUE de l'appareil (44 caracteres). "
|
|
"La privee ne quitte jamais l'appareil."},
|
|
"adresse": {"type": "string", "requis": True, "libelle": "Adresse dans le tunnel",
|
|
"aide": "Un /32 du reseau derive `10.<index>.29.0/24`."},
|
|
"etat": {"type": "string", "libelle": "Etat", "defaut": "present",
|
|
"enum": ["present", "absent"],
|
|
"aide": "`absent` revoque l'acces au prochain passage."},
|
|
},
|
|
},
|
|
"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'hôte",
|
|
"aide": "Masque des sous-reseaux de zone. 24 = 254 hotes par zone."},
|
|
# 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": "Catégories (zones)",
|
|
"aide": "Une zone de securite par cle. Le numero derive le 3e octet et le VLAN.",
|
|
"entree": {
|
|
"libelle": {"type": "string", "requis": True,
|
|
"libelle": "Libellé",
|
|
"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": "Catégorie",
|
|
"source_valeurs": "nomenclature.categories",
|
|
"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."}}},
|
|
# BLOC FIXE, PAS TABLE. Ecrit d'abord en `entree` — donc decrit comme une
|
|
# table de zones, chacune avec sa passerelle. Le fichier reel n'a jamais eu
|
|
# cette forme, et `underlay.py` lit `reservations.passerelle` a plat. Un
|
|
# formulaire genere depuis cette description aurait offert « ajouter une
|
|
# zone » et ecrit une forme que le moteur ne sait pas lire.
|
|
# P61 ne l'a pas vu parce qu'elle comparait des NOMS aplatis : `passerelle`
|
|
# existe des deux cotes, a des profondeurs differentes. Elle compare
|
|
# desormais aussi la FORME.
|
|
"reservations": {"type": "object", "libelle": "Réservations",
|
|
"aide": "Adresses soustraites a la derivation dans la zone.",
|
|
"sous_champs": {
|
|
"passerelle": {"type": "integer", "libelle": "Passerelle",
|
|
"aide": "Dernier octet de la passerelle. P23 refuse un SVI qui s'en ecarte."},
|
|
"reserve_min": {"type": "integer", "libelle": "Réserve (min)",
|
|
"aide": "Premier octet soustrait a la derivation."},
|
|
"reserve_max": {"type": "integer", "libelle": "Réserve (max)",
|
|
"aide": "Dernier octet soustrait a la derivation."}}},
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
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"), ("defaut", "default")):
|
|
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"]
|
|
# Une liste fermee vaut AUSSI dans une table : `categorie` doit
|
|
# designer une zone declaree, sans quoi `deriver_nomenclature` rend
|
|
# None et la VM n'a ni VLAN ni adresse — en silence.
|
|
if "source_valeurs" in sd:
|
|
q["x-source-valeurs"] = sd["source_valeurs"]
|
|
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 "entrees" in d:
|
|
# Une LISTE dont chaque element a une forme connue (par opposition a
|
|
# `entree`, qui est une table a clefs libres). Le formulaire genere une
|
|
# ligne par element, avec un bouton d'ajout.
|
|
sous, sous_requis = {}, []
|
|
for sc, sd in d["entrees"].items():
|
|
q = {"type": sd["type"], "title": sd["libelle"]}
|
|
if "aide" in sd:
|
|
q["description"] = sd["aide"]
|
|
if "defaut" in sd:
|
|
q["default"] = sd["defaut"]
|
|
# Une liste fermee dans une liste : `type` d'un enregistrement public.
|
|
# Sans elle, le formulaire offrirait une case libre que le validateur
|
|
# refuserait ensuite.
|
|
if "enum" in sd:
|
|
q["enum"] = sd["enum"]
|
|
if "source_valeurs" in sd:
|
|
q["x-source-valeurs"] = sd["source_valeurs"]
|
|
sous[sc] = q
|
|
if sd.get("requis"):
|
|
sous_requis.append(sc)
|
|
p["items"] = {"type": "object", "properties": sous, "additionalProperties": False}
|
|
if sous_requis:
|
|
p["items"]["required"] = sorted(sous_requis)
|
|
if "sous_champs" in d:
|
|
# Un OBJET a clefs FIXES (par opposition a `entree`, table ouverte). Le
|
|
# formulaire genere ses cases une fois, sans bouton « ajouter ».
|
|
sous, sous_requis = {}, []
|
|
for sc, sd in d["sous_champs"].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["properties"] = sous
|
|
p["additionalProperties"] = False
|
|
if sous_requis:
|
|
p["required"] = sorted(sous_requis)
|
|
if "source_selon" in d:
|
|
# Meme intention que `x-source-valeurs`, mais la liste change selon la
|
|
# valeur d'un autre champ de la meme entite.
|
|
p["x-source-selon"] = d["source_selon"]
|
|
for cle_src, cle_dst in (("editeur", "x-editeur"),
|
|
("defaut_intrant", "x-defaut-intrant"),
|
|
("defaut_derive", "x-defaut-derive")):
|
|
if cle_src in d:
|
|
p[cle_dst] = d[cle_src]
|
|
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}
|
|
if reg.get("clef"):
|
|
# La CLEF n'est pas une propriete de l'entite — c'est son nom dans la table.
|
|
# Le formulaire doit pourtant l'offrir : sans elle, on ne peut pas creer.
|
|
defs[nom]["x-clef"] = {"title": reg["clef"]["libelle"],
|
|
"description": reg["clef"]["aide"]}
|
|
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)
|
|
# PAS `sort_keys` : l'ordre de `REGISTRES` est celui du FORMULAIRE, et il est
|
|
# délibéré — l'identifiant d'abord, la portee pres du consommateur qu'elle
|
|
# interprete, `reserve_min` avant `reserve_max`. Le tri alphabetique les melangeait
|
|
# sans rien acheter : un dict Python litteral est deja d'ordre stable, donc le
|
|
# fichier genere reste reproductible au diff.
|
|
rendu = json.dumps(construire(), ensure_ascii=False, indent=2) + "\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())
|