#!/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."}, "integrations": {"type": "array", "items": {"type": "string"}, "libelle": "Intégrations facultatives", "aide": "Seulement les facultatives. Les universelles viennent du role et sont refusees ici."}, "noeud": {"type": "string", "libelle": "Nœud 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": "Mémoire (Mo)", "aide": "Vide = derive des empreintes des roles."}, "coeurs": {"type": "integer", "libelle": "Cœurs", "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."}, "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", "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", "enum": sorted(AUTORITES_DNS), "libelle": "Autorité 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": "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", "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": "Réservations", "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": "Réserve (min)"}, "reserve_max": {"type": "integer", "libelle": "Réserve (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"), ("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"] 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_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"] 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) 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())