diff --git a/docs/audit/preuve-2026-09-08.md b/docs/audit/preuve-2026-09-08.md index 96f068d..2c331f2 100644 --- a/docs/audit/preuve-2026-09-08.md +++ b/docs/audit/preuve-2026-09-08.md @@ -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). | | 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). | -| 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 | ## Couverture des affirmations ✅ du registre diff --git a/docs/audit/schema-plan.json b/docs/audit/schema-plan.json index 28a2cd4..b075aaf 100644 --- a/docs/audit/schema-plan.json +++ b/docs/audit/schema-plan.json @@ -16,13 +16,13 @@ }, "groupe": { "description": "La capacite appliquee. Doit avoir un playbook homonyme.", - "title": "Groupe (role)", + "title": "Groupe (rôle)", "type": "string", "x-source-valeurs": "groupes_operationnels" }, "hote": { "description": "Une VM declaree au plan. Un hote inconnu est un « hote fantome » (P06).", - "title": "Hote", + "title": "Hôte", "type": "string", "x-source-valeurs": "serveurs" }, @@ -59,6 +59,10 @@ "type": "object" }, "title": "Applications", + "x-clef": { + "description": "Nom de l'application dans le plan.", + "title": "Identifiant" + }, "x-fichier": "plan/applications.yml", "x-racine": "applications" }, @@ -71,26 +75,36 @@ "type": "string" }, "consommateur": { + "description": "Qui utilise cette base. La liste depend de la portee.", "title": "Consommateur", - "type": "string" + "type": "string", + "x-source-selon": { + "cas": { + "application": "applications", + "hote": "serveurs" + }, + "champ": "portee", + "defaut": "groupes_operationnels" + } }, "portee": { + "default": "groupe", "description": "Comment le consommateur est designe : par application, par groupe ou par hote.", "enum": [ "application", "groupe", "hote" ], - "title": "Portee", + "title": "Portée", "type": "string" }, "proprietaire": { - "title": "Proprietaire", + "title": "Propriétaire", "type": "string" }, "secret": { "description": "NOM d'une variable Vault, jamais une valeur. Le secret ne quitte pas le role.", - "title": "Secret (voute)", + "title": "Secret (Vault)", "type": "string" }, "serveur": { @@ -99,6 +113,7 @@ "x-source-valeurs": "serveurs_bd" }, "usage": { + "default": "principale", "title": "Usage", "type": "string" } @@ -113,6 +128,10 @@ "type": "object" }, "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-racine": "bases_donnees" }, @@ -126,7 +145,7 @@ "delegue", "primaire-cache" ], - "title": "Autorite DNS", + "title": "Autorité DNS", "type": "string" }, "dnssec": { @@ -154,6 +173,10 @@ "type": "object" }, "title": "Domaines publics", + "x-clef": { + "description": "Le nom public, ex. `chezlepro.ca`.", + "title": "Domaine" + }, "x-fichier": "plan/domaines.yml", "x-racine": "domaines_publics" }, @@ -167,7 +190,7 @@ "properties": { "libelle": { "description": "Nom lisible de la zone (Frontiere, Identite...).", - "title": "Libelle", + "title": "Libellé", "type": "string" } }, @@ -177,7 +200,7 @@ "type": "object" }, "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" }, "cidr_hote": { @@ -190,7 +213,7 @@ "properties": { "categorie": { "description": "La zone. Fixe le 3e octet (15 + categorie) et le VLAN.", - "title": "Categorie", + "title": "Catégorie", "type": "integer" }, "service": { @@ -226,18 +249,18 @@ "type": "integer" }, "reserve_max": { - "title": "Reserve (max)", + "title": "Réserve (max)", "type": "integer" }, "reserve_min": { - "title": "Reserve (min)", + "title": "Réserve (min)", "type": "integer" } }, "type": "object" }, "description": "Adresses soustraites a la derivation dans chaque zone.", - "title": "Reservations", + "title": "Réservations", "type": "object" } }, @@ -256,7 +279,7 @@ "properties": { "coeurs": { "description": "Vide = derive des empreintes des roles.", - "title": "Coeurs", + "title": "Cœurs", "type": "integer" }, "disque": { @@ -270,7 +293,7 @@ "actif", "planifie" ], - "title": "Etat", + "title": "État", "type": "string" }, "fonction": { @@ -284,17 +307,17 @@ "items": { "type": "string" }, - "title": "Integrations facultatives", + "title": "Intégrations facultatives", "type": "array" }, "memoire": { "description": "Vide = derive des empreintes des roles.", - "title": "Memoire (Mo)", + "title": "Mémoire (Mo)", "type": "integer" }, "noeud": { "description": "Surcharge le defaut de `make config`.", - "title": "Noeud Proxmox", + "title": "Nœud Proxmox", "type": "string" }, "stockage": { @@ -309,6 +332,10 @@ "type": "object" }, "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-racine": "serveurs" }, @@ -322,7 +349,7 @@ "x-source-valeurs": "groupes_operationnels" }, "hote": { - "title": "Hote", + "title": "Hôte", "type": "string", "x-source-valeurs": "serveurs" }, @@ -342,6 +369,10 @@ "type": "object" }, "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-racine": "serveurs_bd" } diff --git a/docs/audit/wiki-publie.yml b/docs/audit/wiki-publie.yml index 36d1e0b..e3bcb34 100644 --- a/docs/audit/wiki-publie.yml +++ b/docs/audit/wiki-publie.yml @@ -1,5 +1,5 @@ --- # 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 -source: e2935ed +source: 0eaceb1 date: 2026-09-08 diff --git a/scripts/couverture_gui.py b/scripts/couverture_gui.py index 15ff324..5327e54 100644 --- a/scripts/couverture_gui.py +++ b/scripts/couverture_gui.py @@ -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 : - les champs REELLEMENT presents dans les plans (instance courante + tous les 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. Deux sens de verification : @@ -32,7 +32,7 @@ from pathlib import Path 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 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).""" src = SOURCE_GUI.read_text(encoding="utf-8") # 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) ailleurs = src[:debut] + src[fin:] if debut != -1 and fin != -1 else src 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): if champ not in ailleurs: absents.append(f"{registre}.{champ}") @@ -112,7 +119,7 @@ def main() -> int: if args.commande == "lister": 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} ==") for champ in sorted(utilises[registre]): etat = "GUI" if champ in couverts else "-- hors GUI --" @@ -125,7 +132,7 @@ def main() -> int: for registre, champs in utilises.items(): if registre in tolere: continue - couverts = CHAMPS_ECRITS_PAR_GUI.get(registre, set()) + couverts = champs_ecrits_par_gui().get(registre, set()) for champ in sorted(set(champs) - couverts): trous.append(f"{registre}.{champ} (vu dans : " f"{', '.join(sorted(champs[champ]))})") @@ -138,7 +145,7 @@ def main() -> int: for t in trous: print(f" - {t}", file=sys.stderr) 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) if trous or derive: diff --git a/scripts/inventory_gui.py b/scripts/inventory_gui.py index 1cd0605..795b9bc 100644 --- a/scripts/inventory_gui.py +++ b/scripts/inventory_gui.py @@ -464,9 +464,29 @@ def inventaire_api(path: Path) -> dict: "integrations_disponibles": integrations_disponibles(), "integrations_universelles": integrations_universelles(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: if not path.exists(): return {} @@ -533,20 +553,46 @@ def intrants_courants() -> dict: # A TENIR A JOUR avec sauvegarderServeurs / sauvegarderApplications / # sauvegarderBases / sauvegarderDomaines. Le verificateur controle aussi que chaque # 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", "coeurs", "integrations"}, "applications": {"groupe", "hote", "port", "requiert", "expose", "liens", "websocket"}, - "bases_donnees": {"serveur", "base", "proprietaire", "secret", "consommateur", - "portee", "usage"}, "serveurs_bd": {"type", "hote", "port", "groupe"}, "domaines_publics": {"autorite", "edge", "secondaires", "dnssec", "mail"}, - # nomenclature : AUCUN champ. Le GUI la LIT (liste des fonctions) sans jamais - # l'ecrire — ajouter une fonction exige d'editer le YAML. Trou connu et assume ; - # `couverture_gui.py` le signale tant qu'il dure. + # nomenclature : AUCUN champ ecrit a la main. Le GUI la LIT (liste des fonctions) sans + # jamais l'ecrire — ajouter une fonction exige d'editer le YAML. Trou connu, que le + # passage au generateur fermera par construction. "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: """Roles client_* SELECTIONNABLES : ceux qui restent un choix. @@ -1967,6 +2013,82 @@ HTML = r""" 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'] ? ' requis' : ''); + 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 = [''].concat(options.map(v => + ``)).join(''); + return ``; + } + if (spec.type === 'boolean') { + return ``; + } + if (spec.type === 'integer') { + return ``; + } + 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 ``; + } + return ``; + } + + function formulaireDepuisSchema(nomRegistre, entite, surChangement, cleCourante, surChangementCle) { + const reg = ((data.schema || {})[nomRegistre]) || null; + if (!reg) return '
make schema.