GUI : le formulaire des bases est GENERE depuis le schema

Etape 3, sur un seul registre — `bases_donnees`, le plus simple et le seul ou
observe et editable coincidaient deja. Les cinq autres gardent leurs formulaires
ecrits a la main : on ne bascule pas six vues d un coup.

CE QUI DISPARAIT DU JAVASCRIPT

Huit `<label>` en dur, trois constructions de `<option>`, et la regle qui
choisissait la source du consommateur selon la portee. Cette derniere ne vivait
que dans le JS ; elle est desormais DECLAREE au schema (`x-source-selon`), donc
lisible et gardee.

Le formulaire rend exactement les memes huit champs qu avant — verifie en
EXECUTANT le moteur sous node avec le schema et des donnees reelles, pas
seulement en passant `node --check`.

LA BOUCLE EST FERMEE DES DEUX COTES

Le chemin de SAUVEGARDE enumerait lui aussi les sept champs en dur. Un champ
ajoute au registre serait apparu au formulaire genere et aurait disparu
SILENCIEUSEMENT a l enregistrement — le pire des deux mondes. Il derive
maintenant du schema, valeurs par defaut comprises (`default`).

LA SEPARATION FORME / COHERENCE, MONTREE

  portee=groupe + consommateur APPLICATION   -> REFUSE par valider_bases
  portee=application + consommateur app      -> ACCEPTE
  secret absent                              -> REFUSE

Le schema a rempli la FORME (les defauts `groupe` et `principale` se sont
poses), le validateur a attrape l INCOHERENCE. Aucune de ces trois regles ne
s exprime en JSON Schema, et vouloir l y mettre creerait la seconde source de
verite que ce depot refuse.

CHAMPS_ECRITS_PAR_GUI COMMENCE A DISPARAITRE

Renomme CHAMPS_ECRITS_A_LA_MAIN, et `bases_donnees` en est SORTIE : sa
couverture se derive du schema. P19 lit desormais `champs_ecrits_par_gui()`, qui
reunit les deux. Le jour ou la table sera vide, elle gardera un mecanisme au
lieu d une liste.

UNE GARDE A CORRIGER AU PASSAGE

`declaration_derive()` verifiait que chaque champ declare apparait dans le
SOURCE du GUI. Pour un registre genere il n y apparait plus — c est le but.
Elle aurait crie sur precisement le progres qu elle devait constater. Les
registres generes en sont exemptes : c est P61 qui tient la promesse pour eux.

make prouver : CONFORME, 60 OK, 0 echec, 1 saute.
P07 (node --check), P19 (couverture), P61 (schema) : verts.

Reste : les cinq autres vues, et le trou de la nomenclature — que le passage au
generateur fermera par construction, puisque le schema decrit deja `categorie`
et `service`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
This commit is contained in:
Daniel Allaire 2026-09-08 15:30:08 -04:00
parent 0eaceb1048
commit 03c628d5e1
6 changed files with 260 additions and 70 deletions

View file

@ -72,7 +72,7 @@
| P57 | Comptes en prose : les chiffres du depot sur lui-meme | — | ✅ OK | Les comptes ecrits en prose correspondent a la mesure (61 preuves, 65 roles, 40 groupes). | | P57 | Comptes en prose : les chiffres du depot sur lui-meme | — | ✅ OK | Les comptes ecrits en prose correspondent a la mesure (61 preuves, 65 roles, 40 groupes). |
| P58 | Habilitations : chaque service dit a quel GROUPE, et par quoi | — | ✅ OK | 8 habilitation(s) declarees, toutes nommant un groupe, un mecanisme connu et une raison ; les `role-realm` sont projetees. | | P58 | Habilitations : chaque service dit a quel GROUPE, et par quoi | — | ✅ OK | 8 habilitation(s) declarees, toutes nommant un groupe, un mecanisme connu et une raison ; les `role-realm` sont projetees. |
| P59 | Enumerations annoncees : le nombre correspond a ce qui suit | — | ✅ OK | 2 enumeration(s) annoncee(s) correspondent a ce qu'elles annoncent (formes non ambigues seulement). | | P59 | Enumerations annoncees : le nombre correspond a ce qui suit | — | ✅ OK | 2 enumeration(s) annoncee(s) correspondent a ce qu'elles annoncent (formes non ambigues seulement). |
| P60 | Wiki publie : la forge sert ce que le depot dit | AFF-002 | ✅ OK | Le wiki publie correspond au depot : `wiki/` n'a pas bouge depuis `e2935ed` (publie le 2026-09-08). | | P60 | Wiki publie : la forge sert ce que le depot dit | AFF-002 | ✅ OK | Le wiki publie correspond au depot : `wiki/` n'a pas bouge depuis `0eaceb1` (publie le 2026-09-08). |
| P61 | Schema du plan : il decrit tout ce que les plans contiennent | AFF-033 | ✅ OK | Le schema decrit 42 champ(s) sur 6 registres, et couvre tout ce que les plans reels contiennent ; applications:1, domaines_publics:1, nomenclature:9, serveurs:3 | | P61 | Schema du plan : il decrit tout ce que les plans contiennent | AFF-033 | ✅ OK | Le schema decrit 42 champ(s) sur 6 registres, et couvre tout ce que les plans reels contiennent ; applications:1, domaines_publics:1, nomenclature:9, serveurs:3 |
## Couverture des affirmations ✅ du registre ## Couverture des affirmations ✅ du registre

View file

@ -16,13 +16,13 @@
}, },
"groupe": { "groupe": {
"description": "La capacite appliquee. Doit avoir un playbook homonyme.", "description": "La capacite appliquee. Doit avoir un playbook homonyme.",
"title": "Groupe (role)", "title": "Groupe (rôle)",
"type": "string", "type": "string",
"x-source-valeurs": "groupes_operationnels" "x-source-valeurs": "groupes_operationnels"
}, },
"hote": { "hote": {
"description": "Une VM declaree au plan. Un hote inconnu est un « hote fantome » (P06).", "description": "Une VM declaree au plan. Un hote inconnu est un « hote fantome » (P06).",
"title": "Hote", "title": "Hôte",
"type": "string", "type": "string",
"x-source-valeurs": "serveurs" "x-source-valeurs": "serveurs"
}, },
@ -59,6 +59,10 @@
"type": "object" "type": "object"
}, },
"title": "Applications", "title": "Applications",
"x-clef": {
"description": "Nom de l'application dans le plan.",
"title": "Identifiant"
},
"x-fichier": "plan/applications.yml", "x-fichier": "plan/applications.yml",
"x-racine": "applications" "x-racine": "applications"
}, },
@ -71,26 +75,36 @@
"type": "string" "type": "string"
}, },
"consommateur": { "consommateur": {
"description": "Qui utilise cette base. La liste depend de la portee.",
"title": "Consommateur", "title": "Consommateur",
"type": "string" "type": "string",
"x-source-selon": {
"cas": {
"application": "applications",
"hote": "serveurs"
},
"champ": "portee",
"defaut": "groupes_operationnels"
}
}, },
"portee": { "portee": {
"default": "groupe",
"description": "Comment le consommateur est designe : par application, par groupe ou par hote.", "description": "Comment le consommateur est designe : par application, par groupe ou par hote.",
"enum": [ "enum": [
"application", "application",
"groupe", "groupe",
"hote" "hote"
], ],
"title": "Portee", "title": "Portée",
"type": "string" "type": "string"
}, },
"proprietaire": { "proprietaire": {
"title": "Proprietaire", "title": "Propriétaire",
"type": "string" "type": "string"
}, },
"secret": { "secret": {
"description": "NOM d'une variable Vault, jamais une valeur. Le secret ne quitte pas le role.", "description": "NOM d'une variable Vault, jamais une valeur. Le secret ne quitte pas le role.",
"title": "Secret (voute)", "title": "Secret (Vault)",
"type": "string" "type": "string"
}, },
"serveur": { "serveur": {
@ -99,6 +113,7 @@
"x-source-valeurs": "serveurs_bd" "x-source-valeurs": "serveurs_bd"
}, },
"usage": { "usage": {
"default": "principale",
"title": "Usage", "title": "Usage",
"type": "string" "type": "string"
} }
@ -113,6 +128,10 @@
"type": "object" "type": "object"
}, },
"title": "Bases de donnees", "title": "Bases de donnees",
"x-clef": {
"description": "Nom de l'entree au registre, ex. `bd_forgejo`.",
"title": "Identifiant"
},
"x-fichier": "plan/bases-donnees.yml", "x-fichier": "plan/bases-donnees.yml",
"x-racine": "bases_donnees" "x-racine": "bases_donnees"
}, },
@ -126,7 +145,7 @@
"delegue", "delegue",
"primaire-cache" "primaire-cache"
], ],
"title": "Autorite DNS", "title": "Autorité DNS",
"type": "string" "type": "string"
}, },
"dnssec": { "dnssec": {
@ -154,6 +173,10 @@
"type": "object" "type": "object"
}, },
"title": "Domaines publics", "title": "Domaines publics",
"x-clef": {
"description": "Le nom public, ex. `chezlepro.ca`.",
"title": "Domaine"
},
"x-fichier": "plan/domaines.yml", "x-fichier": "plan/domaines.yml",
"x-racine": "domaines_publics" "x-racine": "domaines_publics"
}, },
@ -167,7 +190,7 @@
"properties": { "properties": {
"libelle": { "libelle": {
"description": "Nom lisible de la zone (Frontiere, Identite...).", "description": "Nom lisible de la zone (Frontiere, Identite...).",
"title": "Libelle", "title": "Libellé",
"type": "string" "type": "string"
} }
}, },
@ -177,7 +200,7 @@
"type": "object" "type": "object"
}, },
"description": "Une zone de securite par cle. Le numero derive le 3e octet et le VLAN.", "description": "Une zone de securite par cle. Le numero derive le 3e octet et le VLAN.",
"title": "Categories (zones)", "title": "Catégories (zones)",
"type": "object" "type": "object"
}, },
"cidr_hote": { "cidr_hote": {
@ -190,7 +213,7 @@
"properties": { "properties": {
"categorie": { "categorie": {
"description": "La zone. Fixe le 3e octet (15 + categorie) et le VLAN.", "description": "La zone. Fixe le 3e octet (15 + categorie) et le VLAN.",
"title": "Categorie", "title": "Catégorie",
"type": "integer" "type": "integer"
}, },
"service": { "service": {
@ -226,18 +249,18 @@
"type": "integer" "type": "integer"
}, },
"reserve_max": { "reserve_max": {
"title": "Reserve (max)", "title": "Réserve (max)",
"type": "integer" "type": "integer"
}, },
"reserve_min": { "reserve_min": {
"title": "Reserve (min)", "title": "Réserve (min)",
"type": "integer" "type": "integer"
} }
}, },
"type": "object" "type": "object"
}, },
"description": "Adresses soustraites a la derivation dans chaque zone.", "description": "Adresses soustraites a la derivation dans chaque zone.",
"title": "Reservations", "title": "Réservations",
"type": "object" "type": "object"
} }
}, },
@ -256,7 +279,7 @@
"properties": { "properties": {
"coeurs": { "coeurs": {
"description": "Vide = derive des empreintes des roles.", "description": "Vide = derive des empreintes des roles.",
"title": "Coeurs", "title": "Cœurs",
"type": "integer" "type": "integer"
}, },
"disque": { "disque": {
@ -270,7 +293,7 @@
"actif", "actif",
"planifie" "planifie"
], ],
"title": "Etat", "title": "État",
"type": "string" "type": "string"
}, },
"fonction": { "fonction": {
@ -284,17 +307,17 @@
"items": { "items": {
"type": "string" "type": "string"
}, },
"title": "Integrations facultatives", "title": "Intégrations facultatives",
"type": "array" "type": "array"
}, },
"memoire": { "memoire": {
"description": "Vide = derive des empreintes des roles.", "description": "Vide = derive des empreintes des roles.",
"title": "Memoire (Mo)", "title": "Mémoire (Mo)",
"type": "integer" "type": "integer"
}, },
"noeud": { "noeud": {
"description": "Surcharge le defaut de `make config`.", "description": "Surcharge le defaut de `make config`.",
"title": "Noeud Proxmox", "title": "Nœud Proxmox",
"type": "string" "type": "string"
}, },
"stockage": { "stockage": {
@ -309,6 +332,10 @@
"type": "object" "type": "object"
}, },
"title": "Serveurs (VM)", "title": "Serveurs (VM)",
"x-clef": {
"description": "Le nom court de la VM. Le rang final derive l'adresse.",
"title": "Nom d'hôte"
},
"x-fichier": "plan/serveurs.yml", "x-fichier": "plan/serveurs.yml",
"x-racine": "serveurs" "x-racine": "serveurs"
}, },
@ -322,7 +349,7 @@
"x-source-valeurs": "groupes_operationnels" "x-source-valeurs": "groupes_operationnels"
}, },
"hote": { "hote": {
"title": "Hote", "title": "Hôte",
"type": "string", "type": "string",
"x-source-valeurs": "serveurs" "x-source-valeurs": "serveurs"
}, },
@ -342,6 +369,10 @@
"type": "object" "type": "object"
}, },
"title": "Serveurs de bases de donnees", "title": "Serveurs de bases de donnees",
"x-clef": {
"description": "Nom du serveur de bases, ex. `pg-principal`.",
"title": "Nom"
},
"x-fichier": "plan/bases-donnees.yml", "x-fichier": "plan/bases-donnees.yml",
"x-racine": "serveurs_bd" "x-racine": "serveurs_bd"
} }

View file

@ -1,5 +1,5 @@
--- ---
# Ecrit par `make wiki-publier`, lu par la preuve P60. Ne pas editer a la main. # Ecrit par `make wiki-publier`, lu par la preuve P60. Ne pas editer a la main.
remote: ssh://git@eregion.chezlepro.ca:2222/Alliance-Boreale/Set-OPS-Public.wiki.git remote: ssh://git@eregion.chezlepro.ca:2222/Alliance-Boreale/Set-OPS-Public.wiki.git
source: e2935ed source: 0eaceb1
date: 2026-09-08 date: 2026-09-08

View file

@ -9,7 +9,7 @@ main, et personne ne s'en apercoit avant de buter dessus.
Ce script rend le trou VISIBLE et mesurable. Il croise : Ce script rend le trou VISIBLE et mesurable. Il croise :
- les champs REELLEMENT presents dans les plans (instance courante + tous les - les champs REELLEMENT presents dans les plans (instance courante + tous les
modeles decouverts par scripts/modeles.py) ; modeles decouverts par scripts/modeles.py) ;
- `CHAMPS_ECRITS_PAR_GUI` de scripts/inventory_gui.py, qui declare ce que les - `champs_ecrits_par_gui()` de scripts/inventory_gui.py, qui declare ce que les
fonctions de sauvegarde ecrivent. fonctions de sauvegarde ecrivent.
Deux sens de verification : Deux sens de verification :
@ -32,7 +32,7 @@ from pathlib import Path
import yaml import yaml
from inventory_gui import CHAMPS_ECRITS_PAR_GUI from inventory_gui import REGISTRES_GENERES, champs_ecrits_par_gui
from modeles import decouvrir as decouvrir_modeles from modeles import decouvrir as decouvrir_modeles
from inventory_rules import instance_courante, instance_courante # noqa: E402 from inventory_rules import instance_courante, instance_courante # noqa: E402
@ -88,11 +88,18 @@ def declaration_derive() -> list[str]:
"""Champs declares au GUI mais introuvables dans son source (declaration qui ment).""" """Champs declares au GUI mais introuvables dans son source (declaration qui ment)."""
src = SOURCE_GUI.read_text(encoding="utf-8") src = SOURCE_GUI.read_text(encoding="utf-8")
# On coupe le tableau lui-meme : sa presence ne prouve rien. # On coupe le tableau lui-meme : sa presence ne prouve rien.
debut = src.find("CHAMPS_ECRITS_PAR_GUI") debut = src.find("champs_ecrits_par_gui()")
fin = src.find("\n}\n", debut) fin = src.find("\n}\n", debut)
ailleurs = src[:debut] + src[fin:] if debut != -1 and fin != -1 else src ailleurs = src[:debut] + src[fin:] if debut != -1 and fin != -1 else src
absents = [] absents = []
for registre, champs in CHAMPS_ECRITS_PAR_GUI.items(): for registre, champs in champs_ecrits_par_gui().items():
# UN REGISTRE GENERE N'A PLUS SES CHAMPS DANS LE SOURCE, et c'est le but : ils
# vivent dans le schema, et le formulaire en derive. Chercher `secret` dans le JS
# ferait crier cette garde sur precisement le progres qu'elle devrait constater.
# Pour ces registres-la, c'est P61 qui tient la promesse — elle verifie que le
# schema decrit tout ce que les plans contiennent.
if registre in REGISTRES_GENERES:
continue
for champ in sorted(champs): for champ in sorted(champs):
if champ not in ailleurs: if champ not in ailleurs:
absents.append(f"{registre}.{champ}") absents.append(f"{registre}.{champ}")
@ -112,7 +119,7 @@ def main() -> int:
if args.commande == "lister": if args.commande == "lister":
for registre in sorted(utilises): for registre in sorted(utilises):
couverts = CHAMPS_ECRITS_PAR_GUI.get(registre, set()) couverts = champs_ecrits_par_gui().get(registre, set())
print(f"\n== {registre} ==") print(f"\n== {registre} ==")
for champ in sorted(utilises[registre]): for champ in sorted(utilises[registre]):
etat = "GUI" if champ in couverts else "-- hors GUI --" etat = "GUI" if champ in couverts else "-- hors GUI --"
@ -125,7 +132,7 @@ def main() -> int:
for registre, champs in utilises.items(): for registre, champs in utilises.items():
if registre in tolere: if registre in tolere:
continue continue
couverts = CHAMPS_ECRITS_PAR_GUI.get(registre, set()) couverts = champs_ecrits_par_gui().get(registre, set())
for champ in sorted(set(champs) - couverts): for champ in sorted(set(champs) - couverts):
trous.append(f"{registre}.{champ} (vu dans : " trous.append(f"{registre}.{champ} (vu dans : "
f"{', '.join(sorted(champs[champ]))})") f"{', '.join(sorted(champs[champ]))})")
@ -138,7 +145,7 @@ def main() -> int:
for t in trous: for t in trous:
print(f" - {t}", file=sys.stderr) print(f" - {t}", file=sys.stderr)
if derive: if derive:
print(f"erreur: {len(derive)} champ(s) declare(s) dans CHAMPS_ECRITS_PAR_GUI mais " print(f"erreur: {len(derive)} champ(s) declare(s) dans champs_ecrits_par_gui() mais "
f"introuvable(s) dans le source du GUI : {', '.join(derive)}", file=sys.stderr) f"introuvable(s) dans le source du GUI : {', '.join(derive)}", file=sys.stderr)
if trous or derive: if trous or derive:

View file

@ -464,9 +464,29 @@ def inventaire_api(path: Path) -> dict:
"integrations_disponibles": integrations_disponibles(), "integrations_disponibles": integrations_disponibles(),
"integrations_universelles": integrations_universelles(RACINE), "integrations_universelles": integrations_universelles(RACINE),
"liens_acceptes": catalogue_liens(RACINE), "liens_acceptes": catalogue_liens(RACINE),
# LE SCHEMA VOYAGE AVEC LES DONNEES. Le navigateur genere ses formulaires depuis
# lui plutot que de les porter en dur : un champ ajoute au plan apparait a l'ecran
# sans toucher au JS. Genere par `make schema`, garde par P61.
"schema": schema_du_plan(),
} }
def schema_du_plan() -> dict:
"""Le JSON Schema des registres, tel que `make schema` l'a ecrit.
Lu depuis le fichier plutot que recalcule : c'est l'artefact VERSIONNE qui fait foi,
et P61 refuse qu'il soit perime. Recalculer ici laisserait l'ecran afficher une forme
que personne n'a relue.
"""
f = RACINE / "docs" / "audit" / "schema-plan.json"
if not f.is_file():
return {}
try:
return json.loads(f.read_text(encoding="utf-8")).get("registres", {})
except (OSError, json.JSONDecodeError):
return {}
def _lire_yaml_dict(path: Path) -> dict: def _lire_yaml_dict(path: Path) -> dict:
if not path.exists(): if not path.exists():
return {} return {}
@ -533,20 +553,46 @@ def intrants_courants() -> dict:
# A TENIR A JOUR avec sauvegarderServeurs / sauvegarderApplications / # A TENIR A JOUR avec sauvegarderServeurs / sauvegarderApplications /
# sauvegarderBases / sauvegarderDomaines. Le verificateur controle aussi que chaque # sauvegarderBases / sauvegarderDomaines. Le verificateur controle aussi que chaque
# champ declare ici apparait bien dans le source du GUI (garde anti-derive). # champ declare ici apparait bien dans le source du GUI (garde anti-derive).
CHAMPS_ECRITS_PAR_GUI = { # CE QUE LE GUI SAIT ECRIRE — ET D'OU IL LE TIENT (2026-09-08).
#
# Cette table etait tenue A LA MAIN : quelqu'un devait penser a l'allonger quand un champ
# arrivait au plan, et P19 confrontait la liste au reel. Une copie gardee reste une copie.
#
# `bases_donnees` en est SORTIE la premiere : son formulaire est desormais GENERE depuis
# `docs/audit/schema-plan.json` (`make schema`), donc la liste s'en deduit au lieu d'etre
# recopiee. Les registres restants gardent leur declaration manuelle jusqu'a ce que leur
# formulaire passe au generateur — on ne bascule pas six vues d'un coup.
#
# P19 lit `champs_ecrits_par_gui()` et non plus cette constante : le jour ou la table sera
# vide, elle gardera un mecanisme au lieu d'une liste.
CHAMPS_ECRITS_A_LA_MAIN = {
"serveurs": {"fonction", "etat", "noeud", "stockage", "disque", "memoire", "serveurs": {"fonction", "etat", "noeud", "stockage", "disque", "memoire",
"coeurs", "integrations"}, "coeurs", "integrations"},
"applications": {"groupe", "hote", "port", "requiert", "expose", "liens", "websocket"}, "applications": {"groupe", "hote", "port", "requiert", "expose", "liens", "websocket"},
"bases_donnees": {"serveur", "base", "proprietaire", "secret", "consommateur",
"portee", "usage"},
"serveurs_bd": {"type", "hote", "port", "groupe"}, "serveurs_bd": {"type", "hote", "port", "groupe"},
"domaines_publics": {"autorite", "edge", "secondaires", "dnssec", "mail"}, "domaines_publics": {"autorite", "edge", "secondaires", "dnssec", "mail"},
# nomenclature : AUCUN champ. Le GUI la LIT (liste des fonctions) sans jamais # nomenclature : AUCUN champ ecrit a la main. Le GUI la LIT (liste des fonctions) sans
# l'ecrire — ajouter une fonction exige d'editer le YAML. Trou connu et assume ; # jamais l'ecrire — ajouter une fonction exige d'editer le YAML. Trou connu, que le
# `couverture_gui.py` le signale tant qu'il dure. # passage au generateur fermera par construction.
"nomenclature": set(), "nomenclature": set(),
} }
# Les registres dont le formulaire est GENERE : leurs champs viennent du schema.
REGISTRES_GENERES = {"bases_donnees"}
def champs_ecrits_par_gui() -> dict:
"""Ce que le GUI sait ecrire : declare a la main, plus derive du schema.
Une seule fonction repond a la question, pour que P19 n'ait pas a savoir lequel des
deux mecanismes couvre quel registre.
"""
resultat = {nom: set(champs) for nom, champs in CHAMPS_ECRITS_A_LA_MAIN.items()}
for nom, reg in (schema_du_plan() or {}).items():
if nom in REGISTRES_GENERES:
resultat[nom] = set((reg.get("entite") or {}).get("properties") or {})
return resultat
def integrations_disponibles() -> list: def integrations_disponibles() -> list:
"""Roles client_* SELECTIONNABLES : ceux qui restent un choix. """Roles client_* SELECTIONNABLES : ceux qui restent un choix.
@ -1967,6 +2013,82 @@ HTML = r"""<!doctype html>
applicationsModifie = false; applicationsModifie = false;
} }
// ---- FORMULAIRES GENERES DEPUIS LE SCHEMA -------------------------------------
//
// Le schema (docs/audit/schema-plan.json, `make schema`) decrit la FORME des
// registres ; ces trois fonctions la rendent. Un champ ajoute au plan apparait donc
// a l'ecran sans qu'on touche a ce fichier — c'est tout l'objet.
//
// Ce qu'elles ne font PAS : valider. La coherence reste aux `valider_*` du serveur,
// qui refusent la sauvegarde. Un formulaire genere ne saurait pas dire qu'un
// consommateur designe une application inexistante.
function sourceDeValeurs(nom) {
// Les listes fermees, resolues a l'execution. C'est ce qui rend l'hote fantome
// IMPOSSIBLE A SAISIR, au lieu d'etre refuse apres coup.
if (nom === 'serveurs') return hotes.map(h => h.nom).filter(Boolean);
if (nom === 'applications') return applications.map(x => x.id).filter(Boolean);
if (nom === 'groupes_operationnels') return groupes;
if (nom === 'serveurs_bd') return bdServeurs.map(x => x.nom).filter(Boolean);
if (nom === 'nomenclature.fonctions') return Object.keys((data.nomenclature || {}).fonctions || {});
return [];
}
function champDepuisSchema(cle, spec, valeur, entite, surChangement) {
const titre = echapper(spec.title || cle);
const aide = spec.description ? ` title="${echapper(spec.description)}"` : '';
const req = (spec['x-requis'] ? ' <span class="dsn">requis</span>' : '');
const appel = (expr) => `${surChangement}('${cle}', ${expr})`;
// Liste fermee : soit fixe (enum), soit alimentee a l'execution, soit dependante
// d'un autre champ de la meme entite.
let options = null;
if (spec.enum) options = spec.enum;
else if (spec['x-source-valeurs']) options = sourceDeValeurs(spec['x-source-valeurs']);
else if (spec['x-source-selon']) {
const s = spec['x-source-selon'];
const pivot = (entite || {})[s.champ];
options = sourceDeValeurs((s.cas || {})[pivot] || s.defaut);
}
if (options) {
const opts = ['<option value="">—</option>'].concat(options.map(v =>
`<option value="${echapper(v)}"${String(v) === String(valeur == null ? '' : valeur) ? ' selected' : ''}>${echapper(v)}</option>`)).join('');
return `<label class="champ"${aide}><span>${titre}${req}</span><select onchange="${appel('this.value')}">${opts}</select></label>`;
}
if (spec.type === 'boolean') {
return `<label class="champ"${aide}><span>${titre}${req}</span><input type="checkbox"${valeur ? ' checked' : ''} onchange="${appel('this.checked')}"></label>`;
}
if (spec.type === 'integer') {
return `<label class="champ"${aide}><span>${titre}${req}</span><input type="number" value="${echapper(valeur == null ? '' : valeur)}" oninput="${appel('this.value')}"></label>`;
}
if (spec.type === 'array') {
// Une liste de textes se saisit separee par des virgules : le YAML reste une
// liste, la saisie reste lisible.
const v = Array.isArray(valeur) ? valeur.join(', ') : '';
return `<label class="champ"${aide}><span>${titre}${req}</span><input value="${echapper(v)}" placeholder="a, b" oninput="${appel('this.value.split(\',\').map(x=>x.trim()).filter(Boolean)')}"></label>`;
}
return `<label class="champ"${aide}><span>${titre}${req}</span><input value="${echapper(valeur == null ? '' : valeur)}" oninput="${appel('this.value')}"></label>`;
}
function formulaireDepuisSchema(nomRegistre, entite, surChangement, cleCourante, surChangementCle) {
const reg = ((data.schema || {})[nomRegistre]) || null;
if (!reg) return '<div class="vide">Aucun schéma pour ce registre — lancer <code>make schema</code>.</div>';
const props = (reg.entite || {}).properties || {};
const requis = new Set((reg.entite || {}).required || []);
const morceaux = [];
if (reg['x-clef'] && surChangementCle) {
const k = reg['x-clef'];
morceaux.push(`<label class="champ" title="${echapper(k.description || '')}"><span>${echapper(k.title)}</span>` +
`<input value="${echapper(cleCourante == null ? '' : cleCourante)}" oninput="(${surChangementCle})(this.value)"></label>`);
}
Object.keys(props).forEach(cle => {
const spec = Object.assign({}, props[cle], {'x-requis': requis.has(cle)});
morceaux.push(champDepuisSchema(cle, spec, (entite || {})[cle], entite, surChangement));
});
return `<div class="grille">${morceaux.join('')}</div>`;
}
function dsnBase(a) { function dsnBase(a) {
const s = bdServeurs.find(x => x.nom === a.serveur); const s = bdServeurs.find(x => x.nom === a.serveur);
if (!s) return '(serveur inconnu : ' + echapper(a.serveur || '—') + ')'; if (!s) return '(serveur inconnu : ' + echapper(a.serveur || '—') + ')';
@ -2048,22 +2170,15 @@ HTML = r"""<!doctype html>
} else { } else {
const a = bdApplis[info.i]; const a = bdApplis[info.i];
if (!a) { cible.innerHTML = '<div class="detail"><div class="vide">Introuvable.</div></div>'; return; } if (!a) { cible.innerHTML = '<div class="detail"><div class="vide">Introuvable.</div></div>'; return; }
const source = a.portee === 'hote' ? hotes.map(h => h.nom).filter(Boolean) : a.portee === 'application' ? applications.map(x => x.id) : groupes; // FORMULAIRE GENERE DEPUIS LE SCHEMA (2026-09-08) — plus aucun champ en dur.
const optConso = '<option value="">—</option>' + source.map(v => `<option value="${echapper(v)}" ${v === a.consommateur ? 'selected' : ''}>${echapper(v)}</option>`).join(''); // Les huit `<label>` qui vivaient ici sont derives de docs/audit/schema-plan.json :
const optPortee = ['groupe', 'application', 'hote'].map(p => `<option value="${p}" ${p === (a.portee || 'groupe') ? 'selected' : ''}>${p}</option>`).join(''); // libelles, types, listes fermees, et la source CONDITIONNELLE du consommateur
const optServeur = '<option value="">—</option>' + bdServeurs.map(x => `<option value="${echapper(x.nom)}" ${x.nom === a.serveur ? 'selected' : ''}>${echapper(x.nom)}</option>`).join(''); // (elle depend de la portee) que le schema declare desormais au lieu de la laisser
// vivre dans ce fichier. Un champ ajoute au registre apparait sans toucher au JS,
// et P61 refuse qu'un champ du plan manque au schema.
cible.innerHTML = `<article class="detail"> cible.innerHTML = `<article class="detail">
<div class="detail-tete"><div class="detail-nom"><span class="detail-nom-txt">${echapper(a.cle || '(sans id)')}</span></div><span class="grandit"></span><button type="button" class="danger" title="Retirer" onclick="retirerBaseBd()">✕</button></div> <div class="detail-tete"><div class="detail-nom"><span class="detail-nom-txt">${echapper(a.cle || '(sans id)')}</span></div><span class="grandit"></span><button type="button" class="danger" title="Retirer" onclick="retirerBaseBd()">✕</button></div>
<div class="grille"> ${formulaireDepuisSchema('bases_donnees', a, 'definirBaseBd', a.cle, "definirBaseBd.bind(null,'cle')")}
<label class="champ"><span>Identifiant</span><input value="${echapper(a.cle)}" placeholder="bd_forgejo" oninput="definirBaseBd('cle', this.value)"></label>
<label class="champ"><span>Portée</span><select onchange="definirBaseBd('portee', this.value)">${optPortee}</select></label>
<label class="champ"><span>Consommateur</span><select onchange="definirBaseBd('consommateur', this.value)">${optConso}</select></label>
<label class="champ"><span>Serveur</span><select onchange="definirBaseBd('serveur', this.value)">${optServeur}</select></label>
<label class="champ"><span>Base</span><input value="${echapper(a.base)}" placeholder="forgejo" oninput="definirBaseBd('base', this.value)"></label>
<label class="champ"><span>Propriétaire</span><input value="${echapper(a.proprietaire)}" oninput="definirBaseBd('proprietaire', this.value)"></label>
<label class="champ"><span>Usage</span><input value="${echapper(a.usage)}" placeholder="principale" oninput="definirBaseBd('usage', this.value)"></label>
<label class="champ"><span>Secret (Vault)</span><input value="${echapper(a.secret)}" placeholder="vault_bd_app" oninput="definirBaseBd('secret', this.value)"></label>
</div>
<div class="dsn mono" style="margin-top:8px">${echapper(dsnBase(a))}</div> <div class="dsn mono" style="margin-top:8px">${echapper(dsnBase(a))}</div>
${(() => { ${(() => {
const srv = bdServeurs.find(x => x.nom === a.serveur); const srv = bdServeurs.find(x => x.nom === a.serveur);
@ -2080,7 +2195,20 @@ HTML = r"""<!doctype html>
const sd = {}; const sd = {};
bdServeurs.forEach(s => { if (String(s.nom).trim()) sd[s.nom] = {type: s.type, hote: s.hote, port: parseInt(s.port, 10) || s.port, ...(s.groupe ? {groupe: s.groupe} : {})}; }); bdServeurs.forEach(s => { if (String(s.nom).trim()) sd[s.nom] = {type: s.type, hote: s.hote, port: parseInt(s.port, 10) || s.port, ...(s.groupe ? {groupe: s.groupe} : {})}; });
const bd = {}; const bd = {};
bdApplis.forEach(a => { if (String(a.cle).trim()) bd[a.cle] = {serveur: a.serveur, base: a.base, proprietaire: a.proprietaire, secret: a.secret, consommateur: a.consommateur, portee: a.portee || 'groupe', usage: a.usage || 'principale'}; }); // SAUVEGARDE DERIVEE DU SCHEMA (2026-09-08). Les sept champs etaient enumeres ici
// en dur : un champ ajoute au registre serait apparu au formulaire genere et
// aurait disparu SILENCIEUSEMENT a l'enregistrement — le pire des deux mondes.
// Les valeurs par defaut viennent du schema (`default`), plus du JS.
const propsBd = (((data.schema || {}).bases_donnees || {}).entite || {}).properties || {};
bdApplis.forEach(a => {
if (!String(a.cle).trim()) return;
const e = {};
Object.keys(propsBd).forEach(cle => {
const v = a[cle];
e[cle] = (v === undefined || v === '' || v === null) ? (propsBd[cle].default ?? v) : v;
});
bd[a.cle] = e;
});
const rep = await fetch('/api/bases', { const rep = await fetch('/api/bases', {
method: 'POST', headers: {'Content-Type': 'application/json', 'X-Jeton': JETON}, body: JSON.stringify({serveurs_bd: sd, bases_donnees: bd}) method: 'POST', headers: {'Content-Type': 'application/json', 'X-Jeton': JETON}, body: JSON.stringify({serveurs_bd: sd, bases_donnees: bd})
}); });

View file

@ -60,25 +60,26 @@ REGISTRES: dict = {
"titre": "Serveurs (VM)", "titre": "Serveurs (VM)",
"fichier": "plan/serveurs.yml", "fichier": "plan/serveurs.yml",
"racine": "serveurs", "racine": "serveurs",
"clef": {"libelle": "Nom d'hôte", "aide": "Le nom court de la VM. Le rang final derive l'adresse."},
"champs": { "champs": {
"fonction": {"type": "string", "requis": True, "fonction": {"type": "string", "requis": True,
"libelle": "Fonction", "libelle": "Fonction",
"aide": "Determine VMID, IP, VLAN et passerelle. Doit exister dans la nomenclature.", "aide": "Determine VMID, IP, VLAN et passerelle. Doit exister dans la nomenclature.",
"source_valeurs": "nomenclature.fonctions"}, "source_valeurs": "nomenclature.fonctions"},
"etat": {"type": "string", "enum": sorted(ETATS_SERVEUR), "etat": {"type": "string", "enum": sorted(ETATS_SERVEUR),
"libelle": "Etat", "libelle": "État",
"aide": "`planifie` = decrit mais pas deploye ; `actif` = joignable par Ansible."}, "aide": "`planifie` = decrit mais pas deploye ; `actif` = joignable par Ansible."},
"integrations": {"type": "array", "items": {"type": "string"}, "integrations": {"type": "array", "items": {"type": "string"},
"libelle": "Integrations facultatives", "libelle": "Intégrations facultatives",
"aide": "Seulement les facultatives. Les universelles viennent du role et sont refusees ici."}, "aide": "Seulement les facultatives. Les universelles viennent du role et sont refusees ici."},
"noeud": {"type": "string", "libelle": "Noeud Proxmox", "noeud": {"type": "string", "libelle": "Nœud Proxmox",
"aide": "Surcharge le defaut de `make config`."}, "aide": "Surcharge le defaut de `make config`."},
"stockage": {"type": "string", "libelle": "Stockage", "aide": "Surcharge le defaut."}, "stockage": {"type": "string", "libelle": "Stockage", "aide": "Surcharge le defaut."},
"disque": {"type": "string", "libelle": "Disque", "disque": {"type": "string", "libelle": "Disque",
"aide": "Ex. `32G`. Vide = derive des empreintes des roles."}, "aide": "Ex. `32G`. Vide = derive des empreintes des roles."},
"memoire": {"type": "integer", "libelle": "Memoire (Mo)", "memoire": {"type": "integer", "libelle": "Mémoire (Mo)",
"aide": "Vide = derive des empreintes des roles."}, "aide": "Vide = derive des empreintes des roles."},
"coeurs": {"type": "integer", "libelle": "Coeurs", "coeurs": {"type": "integer", "libelle": "Cœurs",
"aide": "Vide = derive des empreintes des roles."}, "aide": "Vide = derive des empreintes des roles."},
}, },
}, },
@ -86,11 +87,12 @@ REGISTRES: dict = {
"titre": "Applications", "titre": "Applications",
"fichier": "plan/applications.yml", "fichier": "plan/applications.yml",
"racine": "applications", "racine": "applications",
"clef": {"libelle": "Identifiant", "aide": "Nom de l'application dans le plan."},
"champs": { "champs": {
"groupe": {"type": "string", "requis": True, "libelle": "Groupe (role)", "groupe": {"type": "string", "requis": True, "libelle": "Groupe (rôle)",
"aide": "La capacite appliquee. Doit avoir un playbook homonyme.", "aide": "La capacite appliquee. Doit avoir un playbook homonyme.",
"source_valeurs": "groupes_operationnels"}, "source_valeurs": "groupes_operationnels"},
"hote": {"type": "string", "requis": True, "libelle": "Hote", "hote": {"type": "string", "requis": True, "libelle": "Hôte",
"aide": "Une VM declaree au plan. Un hote inconnu est un « hote fantome » (P06).", "aide": "Une VM declaree au plan. Un hote inconnu est un « hote fantome » (P06).",
"source_valeurs": "serveurs"}, "source_valeurs": "serveurs"},
"port": {"type": "integer", "libelle": "Port"}, "port": {"type": "integer", "libelle": "Port"},
@ -111,26 +113,38 @@ REGISTRES: dict = {
"titre": "Bases de donnees", "titre": "Bases de donnees",
"fichier": "plan/bases-donnees.yml", "fichier": "plan/bases-donnees.yml",
"racine": "bases_donnees", "racine": "bases_donnees",
"clef": {"libelle": "Identifiant", "aide": "Nom de l'entree au registre, ex. `bd_forgejo`."},
"champs": { "champs": {
"serveur": {"type": "string", "requis": True, "libelle": "Serveur de BD", "serveur": {"type": "string", "requis": True, "libelle": "Serveur de BD",
"source_valeurs": "serveurs_bd"}, "source_valeurs": "serveurs_bd"},
"base": {"type": "string", "requis": True, "libelle": "Base"}, "base": {"type": "string", "requis": True, "libelle": "Base"},
"proprietaire": {"type": "string", "requis": True, "libelle": "Proprietaire"}, "proprietaire": {"type": "string", "requis": True, "libelle": "Propriétaire"},
"secret": {"type": "string", "requis": True, "libelle": "Secret (voute)", "secret": {"type": "string", "requis": True, "libelle": "Secret (Vault)",
"aide": "NOM d'une variable Vault, jamais une valeur. Le secret ne quitte pas le role."}, "aide": "NOM d'une variable Vault, jamais une valeur. Le secret ne quitte pas le role."},
"consommateur": {"type": "string", "requis": True, "libelle": "Consommateur"}, # SOURCE CONDITIONNELLE : ce qu'on peut consommer depend de la PORTEE.
"portee": {"type": "string", "enum": sorted(PORTEES_BD), "libelle": "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."}, "aide": "Comment le consommateur est designe : par application, par groupe ou par hote."},
"usage": {"type": "string", "libelle": "Usage"}, "usage": {"type": "string", "libelle": "Usage", "defaut": "principale"},
}, },
}, },
"serveurs_bd": { "serveurs_bd": {
"titre": "Serveurs de bases de donnees", "titre": "Serveurs de bases de donnees",
"fichier": "plan/bases-donnees.yml", "fichier": "plan/bases-donnees.yml",
"racine": "serveurs_bd", "racine": "serveurs_bd",
"clef": {"libelle": "Nom", "aide": "Nom du serveur de bases, ex. `pg-principal`."},
"champs": { "champs": {
"type": {"type": "string", "requis": True, "libelle": "Moteur"}, "type": {"type": "string", "requis": True, "libelle": "Moteur"},
"hote": {"type": "string", "requis": True, "libelle": "Hote", "source_valeurs": "serveurs"}, "hote": {"type": "string", "requis": True, "libelle": "Hôte", "source_valeurs": "serveurs"},
"port": {"type": "integer", "libelle": "Port"}, "port": {"type": "integer", "libelle": "Port"},
"groupe": {"type": "string", "libelle": "Groupe", "source_valeurs": "groupes_operationnels"}, "groupe": {"type": "string", "libelle": "Groupe", "source_valeurs": "groupes_operationnels"},
}, },
@ -139,8 +153,9 @@ REGISTRES: dict = {
"titre": "Domaines publics", "titre": "Domaines publics",
"fichier": "plan/domaines.yml", "fichier": "plan/domaines.yml",
"racine": "domaines_publics", "racine": "domaines_publics",
"clef": {"libelle": "Domaine", "aide": "Le nom public, ex. `chezlepro.ca`."},
"champs": { "champs": {
"autorite": {"type": "string", "enum": sorted(AUTORITES_DNS), "libelle": "Autorite DNS"}, "autorite": {"type": "string", "enum": sorted(AUTORITES_DNS), "libelle": "Autorité DNS"},
"edge": {"type": "string", "libelle": "Edge", "source_valeurs": "serveurs"}, "edge": {"type": "string", "libelle": "Edge", "source_valeurs": "serveurs"},
"secondaires": {"type": "array", "items": {"type": "string"}, "libelle": "Secondaires"}, "secondaires": {"type": "array", "items": {"type": "string"}, "libelle": "Secondaires"},
"dnssec": {"type": "boolean", "libelle": "DNSSEC"}, "dnssec": {"type": "boolean", "libelle": "DNSSEC"},
@ -162,28 +177,28 @@ REGISTRES: dict = {
# tous les plans et absents du schema — donc un formulaire genere n'aurait # 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 # jamais su les editer, exactement le trou que la nomenclature avait deja
# dans `CHAMPS_ECRITS_PAR_GUI` (zero champ declare). # dans `CHAMPS_ECRITS_PAR_GUI` (zero champ declare).
"categories": {"type": "object", "libelle": "Categories (zones)", "categories": {"type": "object", "libelle": "Catégories (zones)",
"aide": "Une zone de securite par cle. Le numero derive le 3e octet et le VLAN.", "aide": "Une zone de securite par cle. Le numero derive le 3e octet et le VLAN.",
"entree": { "entree": {
"libelle": {"type": "string", "requis": True, "libelle": {"type": "string", "requis": True,
"libelle": "Libelle", "libelle": "Libellé",
"aide": "Nom lisible de la zone (Frontiere, Identite...)."}}}, "aide": "Nom lisible de la zone (Frontiere, Identite...)."}}},
"fonctions": {"type": "object", "libelle": "Fonctions", "fonctions": {"type": "object", "libelle": "Fonctions",
"aide": "categorie + service par fonction. VMID et IP en derivent.", "aide": "categorie + service par fonction. VMID et IP en derivent.",
"entree": { "entree": {
"categorie": {"type": "integer", "requis": True, "categorie": {"type": "integer", "requis": True,
"libelle": "Categorie", "libelle": "Catégorie",
"aide": "La zone. Fixe le 3e octet (15 + categorie) et le VLAN."}, "aide": "La zone. Fixe le 3e octet (15 + categorie) et le VLAN."},
"service": {"type": "integer", "requis": True, "service": {"type": "integer", "requis": True,
"libelle": "Service", "libelle": "Service",
"aide": "Fixe le bloc d'adresses de l'hote dans la zone."}}}, "aide": "Fixe le bloc d'adresses de l'hote dans la zone."}}},
"reservations": {"type": "object", "libelle": "Reservations", "reservations": {"type": "object", "libelle": "Réservations",
"aide": "Adresses soustraites a la derivation dans chaque zone.", "aide": "Adresses soustraites a la derivation dans chaque zone.",
"entree": { "entree": {
"passerelle": {"type": "integer", "libelle": "Passerelle", "passerelle": {"type": "integer", "libelle": "Passerelle",
"aide": "Dernier octet de la passerelle. P23 refuse un SVI qui s'en ecarte."}, "aide": "Dernier octet de la passerelle. P23 refuse un SVI qui s'en ecarte."},
"reserve_min": {"type": "integer", "libelle": "Reserve (min)"}, "reserve_min": {"type": "integer", "libelle": "Réserve (min)"},
"reserve_max": {"type": "integer", "libelle": "Reserve (max)"}}}, "reserve_max": {"type": "integer", "libelle": "Réserve (max)"}}},
}, },
}, },
} }
@ -198,7 +213,7 @@ def construire() -> dict:
p = {"type": d["type"], "title": d["libelle"]} p = {"type": d["type"], "title": d["libelle"]}
for cle_src, cle_dst in (("aide", "description"), ("enum", "enum"), for cle_src, cle_dst in (("aide", "description"), ("enum", "enum"),
("items", "items"), ("minimum", "minimum"), ("items", "items"), ("minimum", "minimum"),
("maximum", "maximum")): ("maximum", "maximum"), ("defaut", "default")):
if cle_src in d: if cle_src in d:
p[cle_dst] = d[cle_src] p[cle_dst] = d[cle_src]
if "entree" in d: if "entree" in d:
@ -216,6 +231,10 @@ def construire() -> dict:
"additionalProperties": False} "additionalProperties": False}
if sous_requis: if sous_requis:
p["additionalProperties"]["required"] = sorted(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: if "source_valeurs" in d:
# Extension hors JSON Schema : dit au formulaire d'offrir une LISTE # Extension hors JSON Schema : dit au formulaire d'offrir une LISTE
# fermee, alimentee a l'execution. C'est ce qui rend l'hote fantome # fermee, alimentee a l'execution. C'est ce qui rend l'hote fantome
@ -230,6 +249,11 @@ def construire() -> dict:
entite["required"] = sorted(requis) entite["required"] = sorted(requis)
defs[nom] = {"title": reg["titre"], "x-fichier": reg["fichier"], defs[nom] = {"title": reg["titre"], "x-fichier": reg["fichier"],
"x-racine": reg["racine"], "entite": entite} "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 { return {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Registres du plan Set-OPS", "title": "Registres du plan Set-OPS",