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
265 lines
14 KiB
Python
265 lines
14 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",
|
|
"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())
|