Set-OPS-Public/scripts/schema_plan.py
Daniel Allaire be8c3917e5 acces d'administration : un tunnel par locataire, declare par lui, borne a lui
plan/acces.yml chez le locataire (cles publiques), reseau/port/instance derives de l'index.
La garde du devis refuse qu'un tunnel de locataire vise autre chose que son supernet : sans
elle, un ecosysteme s'ouvrirait un acces chez un voisin depuis son propre plan.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 16:01:54 -04:00

441 lines
27 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."},
# 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/<groupe>/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."}}},
},
},
"acces_admin_vpn": {
"titre": "Acces d'administration (WireGuard)",
"fichier": "plan/acces.yml",
"racine": "acces_admin_vpn",
"clef": {"libelle": "Pair", "aide": "`personne-appareil`, ex. `daniel-portable` — "
"un pair par appareil, pour revoquer l'un sans l'autre."},
"champs": {
"cle_publique": {"type": "string", "requis": True, "libelle": "Cle publique",
"aide": "Cle WireGuard PUBLIQUE de l'appareil (44 caracteres). "
"La privee ne quitte jamais l'appareil."},
"adresse": {"type": "string", "requis": True, "libelle": "Adresse dans le tunnel",
"aide": "Un /32 du reseau derive `10.<index>.29.0/24`."},
"etat": {"type": "string", "libelle": "Etat", "defaut": "present",
"enum": ["present", "absent"],
"aide": "`absent` revoque l'acces au prochain passage."},
},
},
"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())