Set-OPS-Public/scripts/schema_plan.py
Daniel Allaire 0eaceb1048 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
2026-09-08 15:19:20 -04:00

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