En voulant generer deux formulaires de plus, j ai trouve pire que ce que
je cherchais.
CE QUI ETAIT DEJA LA. Les quatre ecrivains de registre ecrasaient le
fichier au safe_dump. Mesure sur les fichiers reels : domaines.yml 6->3,
applications.yml 27->5, serveurs.yml 18->3. Quarante lignes, detruites
par n importe quel clic sur Sauvegarder dans les vues Serveurs,
Applications ou Domaines. Parmi elles, celle qui explique pourquoi
backup-01 a ete retire, et celle qui dit dans quel ordre les deux roles
du runner s appliquent. C etait l incident du 2026-08-18, jamais corrige
pour les registres du plan. Les quatre passent par _ecrire_registre :
aller-retour a vide identique a l octet, sur les quatre fichiers.
TROIS ECARTS DE SCHEMA, trouves en confrontant le schema aux VALIDATEURS
et non aux seuls plans :
- edge designe un GROUPE, pas un hote. Le schema disait serveurs : un
formulaire genere aurait offert une valeur qu aucun hote ne reconnait,
donc aucun SAN, donc la panne du 2026-08-25 reintroduite ;
- exposition, entierement valide par le moteur, manquait au schema ;
- liens etait items: {type: object} — une liste d objets sans forme.
Et mail, offert par la vue Domaines depuis sa creation, decrit ici comme
un booleen, saisi la-bas comme du texte, lu par rien : retire.
P62 garde tout ca. Elle separe l entite du reste mecaniquement : un
validateur lit son entite par des variables LOCALES, les autres registres
par ses PARAMETRES. Controle negatif rejoue.
LES FORMULAIRES. Serveurs de BD et Domaines sont generes, chargement et
sauvegarde compris. Quatre registres sur six. Le generateur a appris la
liste d objets.
LIMITE : restent serveurs et applications, les deux plus gros ; et je n ai
toujours pas ouvert ces pages dans un navigateur.
make prouver : CONFORME, 61 OK, 0 echec, 1 saute (62 preuves).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
374 lines
22 KiB
Python
374 lines
22 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."},
|
|
"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."},
|
|
# `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.
|
|
"liens": {"type": "array", "libelle": "Liens (bindings)",
|
|
"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"}}},
|
|
},
|
|
},
|
|
"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"]
|
|
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"]
|
|
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())
|