#!/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//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)."}, "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."}}}, }, }, "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())