diff --git a/CHANGELOG.md b/CHANGELOG.md index 41a2bac..98e2f6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,66 @@ # CHANGELOG — Set-OPS +## 2026-09-08 (4) — Les SIX registres ont un formulaire genere + +**62 preuves. `make prouver` : CONFORME, 61 OK, 0 echec, 1 saute.** +`CHAMPS_ECRITS_A_LA_MAIN` est **vide** : plus un seul champ recopie a la main. + +### L'epreuve qui compte + +Ouvrir chaque vue et enregistrer **sans rien toucher** doit renvoyer au serveur +exactement le plan qu'on vient de lire. C'est ce qui separe un formulaire genere d'un +formulaire qui a l'air genere : un champ visible a l'ecran et perdu en silence a +l'enregistrement serait le pire des deux mondes. + + /api/serveurs 14 entite(s) IDENTIQUE + /api/applications 25 entite(s) IDENTIQUE + /api/domaines 2 entite(s) IDENTIQUE + /api/bases 4 entite(s) IDENTIQUE + +`test_rendu_gui.py` le mesure desormais a chaque `make prouver`. + +### Trois defauts trouves en chemin + +**Le formulaire annoncait des defauts inventes.** « 2048 » pour la memoire, « 2 » pour les +coeurs, « 16G » pour le disque. Il n'existe aucun defaut fixe : `deriver_ressources` +calcule la taille depuis les ROLES que l'hote porte — 1024 Mo et 1 coeur pour +`infra-pki-01`, 5632 et 4 pour `collab-01`. Un repere faux est pire qu'aucun : il fait +croire qu'on connait la valeur. Le schema nomme maintenant le champ derive +(`x-defaut-derive`), et le formulaire affiche la valeur REELLE de cet hote. De meme, +l'option vide d'un `${opts}`; + return ``; } if (spec.type === 'object' && spec.properties) { // BLOC FIXE (`sous_champs` au schema) : ses cases une fois, sans « ajouter ». @@ -2522,10 +2542,20 @@ HTML = r""" + `
${cases}
`; } if (spec.type === 'boolean') { - return ``; + return ``; + } + // Repere de saisie : la valeur REELLEMENT derivee pour cette entite, lue dans + // l'inventaire genere. Le formulaire ecrit a la main annoncait « 2048 » et « 2 » — + // des constantes inventees, alors que `deriver_ressources` calcule depuis les roles + // portes. Un repere faux fait croire qu'on connait la valeur. + const derive = spec['x-defaut-derive']; + let repere = ''; + if (derive && entite && entite.nom) { + const h = hotes.find(x => x.nom === entite.nom); + if (h && h[derive] != null && h[derive] !== '') repere = ` placeholder="dérivé : ${echapper(h[derive])}"`; } if (spec.type === 'integer') { - return ``; + return ``; } if (spec.type === 'array' && (spec.items || {}).properties) { return listeDepuisSchema(cle, spec, valeur, surChangement); @@ -2534,12 +2564,12 @@ HTML = r""" // 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 ``; } - return ``; + return ``; } - function formulaireDepuisSchema(nomRegistre, entite, surChangement, cleCourante, surChangementCle) { + function formulaireDepuisSchema(nomRegistre, entite, surChangement, cleCourante, surChangementCle, nomClef) { const reg = schemaPlan[nomRegistre] || null; if (!reg) return '
Aucun schéma pour ce registre — lancer make schema.
'; const props = (reg.entite || {}).properties || {}; @@ -2548,7 +2578,7 @@ HTML = r""" if (reg['x-clef'] && surChangementCle) { const k = reg['x-clef']; morceaux.push(``); + ``); } Object.keys(props).forEach(cle => { const spec = Object.assign({}, props[cle], {'x-requis': requis.has(cle)}); @@ -2748,7 +2778,7 @@ HTML = r""" function choisirApp(i) { selApp = i; dessiner(); } function definirApplication(champ, v) { const a = applications[selApp]; if (!a) return; - a[champ] = v; marquerApplicationsModifie(); + poserChemin(a, champ, v); marquerApplicationsModifie(); if (champ === 'id') { const t = document.querySelector('#tuile-app-' + selApp + ' .carte-nom'); if (t) { t.textContent = v || '(sans id)'; t.className = 'carte-nom' + (v ? '' : ' sans'); } } else if (champ === 'groupe') { const ok = Object.keys(liensAcceptes[a.groupe] || {}); @@ -2841,8 +2871,6 @@ HTML = r""" return; } const a = applications[selApp]; - const optGroupe = groupes.map(g => ``).join(''); - const optHote = '' + hotes.map(h => h.nom).filter(Boolean).map(n => ``).join(''); const dsns = basesDeApp(a, a.hote); const liens = dsns.length ? `
${dsns.map(b => `
${echapper(b.portee || 'groupe')} ${echapper(dsnBase(b))}
`).join('')}
` @@ -2851,15 +2879,7 @@ HTML = r"""
${echapper(a.id || '(sans id)')}
-
- - - - - - - -
+ ${formulaireDepuisSchema('applications', a, 'definirApplication', a.id, "definirApplication.bind(null,'id')", 'id')}
Liens (bindings) ${(a.liens || []).length}
${blocLiens(a)} @@ -2876,21 +2896,41 @@ HTML = r""" `; } - function listeDepuisTexte(v) { return String(v || '').split(',').map(s => s.trim()).filter(Boolean); } + function listeDepuisTexte(v) { + // Elle accepte desormais une VRAIE liste autant qu'un texte separe par des virgules. + // Les chargeurs aplatissaient `expose`, `requiert` et `integrations` en chaine ; + // le formulaire genere, lui, attend le type que le schema declare. Plutot que de + // toucher aux douze sites d'appel, la conversion vit ici, en un seul endroit. + if (Array.isArray(v)) return v.map(x => String(x).trim()).filter(Boolean); + return String(v || '').split(',').map(s => s.trim()).filter(Boolean); + } async function sauvegarderApplications() { const apps = {}; + const propsA = ((schemaPlan.applications || {}).entite || {}).properties || {}; applications.forEach(a => { if (!String(a.id).trim()) return; - const o = {groupe: a.groupe, hote: a.hote}; - if (String(a.port).trim()) o.port = parseInt(a.port, 10) || a.port; - const req = listeDepuisTexte(a.requiert); if (req.length) o.requiert = req; - const exp = listeDepuisTexte(a.expose); if (exp.length) o.expose = exp; - if (a.websocket) o.websocket = true; - const lns = (a.liens || []) - .filter(l => String(l.vers).trim() && String(l.role).trim()) - .map(l => ({vers: l.vers, role: l.role})); - if (lns.length) o.liens = lns; + const o = {}; + Object.keys(propsA).forEach(cle => { + const spec = propsA[cle]; + if (cle === 'liens') { + // Un lien incomplet n'est pas un lien : il ferait echouer le validateur du + // moteur a la sauvegarde suivante, sur une entree que personne n'a voulue. + const lns = (a.liens || []).filter(l => String(l.vers || '').trim() && String(l.role || '').trim()) + .map(l => ({vers: l.vers, role: l.role})); + if (lns.length) o.liens = lns; + return; + } + if (spec.type === 'array') { const l = listeDepuisTexte(a[cle]); if (l.length) o[cle] = l; return; } + if (spec.type === 'boolean') { if (a[cle]) o[cle] = true; return; } + const v = a[cle]; + if (v === undefined || v === null || String(v).trim() === '') return; + o[cle] = (spec.type === 'integer') ? (parseInt(v, 10) || v) : v; + }); + // `groupe` et `hote` sont REQUIS : on les ecrit meme vides, pour que le + // validateur du moteur refuse clairement plutot que de recevoir un objet muet. + if (o.groupe === undefined) o.groupe = a.groupe || ''; + if (o.hote === undefined) o.hote = a.hote || ''; apps[a.id] = o; }); const rep = await fetch('/api/applications', { @@ -2904,7 +2944,7 @@ HTML = r""" } function chargerServeurs(data) { - serveurs = (data.serveurs || []).map(s => ({...s, integrations: (s.integrations || []).join(', ')})); + serveurs = (data.serveurs || []).map(s => ({...s, integrations: s.integrations || []})); serveursModifie = false; } @@ -2913,7 +2953,7 @@ HTML = r""" function choisirServeur(i) { selServeur = i; dessiner(); } function definirServeur(champ, v) { const s = serveurs[selServeur]; if (!s) return; - s[champ] = v; marquerServeursModifie(); + poserChemin(s, champ, v); marquerServeursModifie(); if (champ === 'nom') { const t = document.querySelector('#tuile-srv-' + selServeur + ' .carte-nom'); if (t) { t.textContent = v || '(sans nom)'; t.className = 'carte-nom' + (v ? '' : ' sans'); } } else if (champ === 'fonction' || champ === 'etat') dessiner(); } @@ -2921,7 +2961,7 @@ HTML = r""" const s = serveurs[selServeur]; if (!s) return; const set = new Set(listeDepuisTexte(s.integrations)); if (actif) set.add(role); else set.delete(role); - s.integrations = Array.from(set).join(', '); marquerServeursModifie(); + s.integrations = Array.from(set); marquerServeursModifie(); } function ajouterServeur() { serveurs.unshift({nom: '', fonction: (Object.keys(nomenclature.fonctions || {})[0] || ''), etat: 'planifie', noeud: '', stockage: '', disque: '', memoire: '', coeurs: '', integrations: '', statut: 'absent', vmid: '', adresse_ip: '', vlan: ''}); @@ -3032,15 +3072,8 @@ HTML = r"""
${tuile(s.vmid, 'VMID')}${tuile(s.adresse_ip, 'IP')}${tuile(s.vlan, 'VLAN')}
Plan : ${echapper(s.etat)}  ·  Réel : ${sondes[s.nom] === true ? ' vivante' + (s.adresse_ip ? ' (' + echapper(s.adresse_ip) + ')' : '') : sondes[s.nom] === false ? ' injoignable' : ' non sondé'}
Identité — saisie du plan (surcharge les défauts)
+ ${formulaireDepuisSchema('serveurs', s, 'definirServeur', s.nom, "definirServeur.bind(null,'nom')", 'nom')}
- - - - - - - -
Applications ici
${appsHtml} @@ -3061,25 +3094,40 @@ HTML = r""" return true; } function validerChampServeur(el) { - const ok = serveurValeurValide(el.getAttribute('data-v'), el.value); + const ok = serveurValeurValide(el.getAttribute('data-champ'), el.value); el.classList.toggle('invalide', !ok); return ok; } async function sauvegarderServeurs() { - const invalides = Array.from(document.querySelectorAll('#detail [data-v]')).filter(el => !validerChampServeur(el)); + const champs = Array.from(document.querySelectorAll('#detail [data-champ]')); + const invalides = champs.filter(el => !validerChampServeur(el)); + // Un controle qui n'inspecte AUCUN champ n'est pas un controle : si le formulaire + // cesse un jour de marquer ses contröles, on le dit plutot que de laisser passer. + if (!champs.length) { message('Formulaire non marqué : sauvegarde refusée.', 'erreur'); return; } if (invalides.length) { message('Corrige les ' + invalides.length + ' champ(s) en rouge avant de sauvegarder.', 'erreur'); invalides[0].focus(); return; } const srv = {}; + const propsS = ((schemaPlan.serveurs || {}).entite || {}).properties || {}; serveurs.forEach(s => { if (!String(s.nom).trim()) return; - const o = {fonction: s.fonction, etat: s.etat || 'planifie'}; - ['noeud', 'stockage', 'disque'].forEach(k => { if (String(s[k] || '').trim()) o[k] = s[k]; }); - ['memoire', 'coeurs'].forEach(k => { if (String(s[k] || '').trim()) o[k] = parseInt(s[k], 10) || s[k]; }); - const integ = listeDepuisTexte(s.integrations); if (integ.length) o.integrations = integ; + const o = {}; + Object.keys(propsS).forEach(cle => { + const spec = propsS[cle]; + if (spec.type === 'array') { + const l = listeDepuisTexte(s[cle]); if (l.length) o[cle] = l; return; + } + const v = s[cle]; + if (v === undefined || v === null || String(v).trim() === '') return; + o[cle] = (spec.type === 'integer') ? (parseInt(v, 10) || v) : v; + }); + // `etat` a un defaut METIER, pas un defaut de schema : un serveur sans etat est + // planifie, jamais actif. Le declarer ici plutot qu'au schema evite de le poser + // par ecrit dans chaque plan. + if (!o.etat) o.etat = 'planifie'; srv[s.nom] = o; }); const rep = await fetch('/api/serveurs', { diff --git a/scripts/schema_plan.py b/scripts/schema_plan.py index 4ca6b88..6bcce87 100644 --- a/scripts/schema_plan.py +++ b/scripts/schema_plan.py @@ -69,17 +69,36 @@ REGISTRES: dict = { "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", + "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", "aide": "Surcharge le defaut."}, + "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."}, }, }, @@ -105,7 +124,11 @@ REGISTRES: dict = { # `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)", + # EDITEUR PROPRE lui aussi : il contraint le ROLE du lien a ceux que le + # groupe porteur accepte (`roles//meta/liens.yml`) et montre les + # variables injectees. Le generateur, lui, offrirait un champ libre — il + # saurait moins que l'editeur qu'il remplacerait. + "liens": {"type": "array", "libelle": "Liens (bindings)", "editeur": "liens", "aide": "Roles acceptes par le role porteur (meta/liens.yml).", "entrees": { "vers": {"type": "string", "requis": True, "libelle": "Vers", @@ -315,6 +338,11 @@ def construire() -> dict: # 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 diff --git a/scripts/tests/test_rendu_gui.py b/scripts/tests/test_rendu_gui.py index d3dfc89..7b78c2c 100644 --- a/scripts/tests/test_rendu_gui.py +++ b/scripts/tests/test_rendu_gui.py @@ -51,7 +51,23 @@ Elem.prototype.setAttribute = function () {}; Elem.prototype.getAttribute = function () { return ''; }; Elem.prototype.addEventListener = function () {}; Elem.prototype.querySelector = function () { return null; }; -Elem.prototype.querySelectorAll = function () { return []; }; +// `querySelectorAll('[data-champ]')` rend vraiment les controles du dernier rendu : le +// GUI s'en sert pour valider avant d'enregistrer, et un stub qui rend toujours [] ferait +// passer cette validation sur ZERO champ — le banc mesurerait alors moins que rien. +Elem.prototype.querySelectorAll = function (sel) { + if (!/data-champ/.test(String(sel))) { return []; } + var html = this.innerHTML || ''; + var out = [], re = /<(input|select)([^>]*?)data-champ="([^"]*)"([^>]*)>/g, m; + while ((m = re.exec(html)) !== null) { + var attrs = m[2] + m[4]; + var v = /\svalue="([^"]*)"/.exec(attrs); + out.push({__champ: m[3], value: v ? v[1] : '', + getAttribute: function (n) { return n === 'data-champ' ? this.__champ : null; }, + classList: {add: function () {}, remove: function () {}, toggle: function () {}}, + focus: function () {}}); + } + return out; +}; Elem.prototype.focus = function () {}; Elem.prototype.remove = function () {}; Elem.prototype.closest = function () { return null; }; @@ -62,7 +78,14 @@ var document = { getElementById: function (id) { return __elems[id] || (__elems[id] = new Elem(id)); }, createElement: function () { return new Elem('cree'); }, querySelector: function () { return null; }, - querySelectorAll: function () { return []; }, + // `#detail [data-champ]` : le GUI interroge le DOCUMENT, pas l'element. Un stub qui + // rendait [] ici faisait croire que le formulaire n'etait pas marque — et la + // sauvegarde des serveurs refusait, silencieusement, dans le banc seul. + querySelectorAll: function (sel) { + var m = /^#([\w-]+)\s+(.+)$/.exec(String(sel)); + if (m && __elems[m[1]]) { return __elems[m[1]].querySelectorAll(m[2]); } + return []; + }, addEventListener: function () {}, body: new Elem('body'), documentElement: new Elem('html'), @@ -77,10 +100,15 @@ function EventSource() { this.addEventListener = function () {}; this.close = fu function alert() {} function confirm() { return false; } function prompt() { return null; } var localStorage = {getItem: function () { return null; }, setItem: function () {}, removeItem: function () {}}; var __PAYLOAD__ = JSON.parse(process.env.PAYLOAD); +var __postsCaptures = {}; // `fetch` rend la VRAIE reponse de /api/inventaire ; les autres routes rendent un objet // vide plutot que de jeter, pour que les sondes de fond ne masquent pas l'erreur cherchee. -function fetch(url) { - var corps = String(url).indexOf('/api/inventaire') === 0 ? __PAYLOAD__ : {}; +function fetch(url, opts) { + if (opts && opts.method === 'POST') { __postsCaptures[String(url)] = JSON.parse(opts.body); } + // Un POST rend la MEME charge que l'inventaire : le GUI recharge apres sauvegarde, et + // lui rendre {} le ferait repartir d'un plan vide — un faux vert, pas un test. + var corps = (String(url).indexOf('/api/inventaire') === 0 || (opts && opts.method === 'POST')) + ? __PAYLOAD__ : {}; return Promise.resolve({ok: true, status: 200, json: function () { return Promise.resolve(corps); }, text: function () { return Promise.resolve(''); }}); } @@ -166,6 +194,47 @@ class TestRenduGui(unittest.TestCase): self.assertTrue(any("donneesQuiNexistentPas" in e for e in erreurs), f"le banc n'a pas vu la reference absente : {erreurs}") + def test_charger_puis_sauvegarder_ne_change_pas_le_plan(self): + """L'EPREUVE DECISIVE : ouvrir une vue et enregistrer sans rien toucher. + + Les six formulaires sont GENERES, et leur sauvegarde l'est aussi : les champs + ecrits sont ceux que le schema declare. C'est ce qui evite le pire des deux + mondes — un champ visible a l'ecran et perdu en silence a l'enregistrement. + + Encore faut-il le mesurer. On charge la vraie reponse de l'API, on ouvre chaque + vue, on appelle sa sauvegarde, on intercepte le POST, et on compare la charge au + plan REEL lu par le moteur. Aucune entite perdue, aucune en trop, aucun champ + different : sinon un simple aller-retour appauvrirait le plan. + """ + js = self.js.replace(APRES, r""" + charger() + .then(function () { setVue('serveurs'); choisirServeur(0); return sauvegarderServeurs(); }) + .then(function () { setVue('applications'); if (applications.length) ouvrirApp(applications[0].id); return sauvegarderApplications(); }) + .then(function () { setVue('domaines'); if (domaines.length) choisirDomaine(0); return sauvegarderDomaines(); }) + .then(function () { setVue('bases'); return sauvegarderBases(); }) + .then(function () { console.log('__RESULTAT__' + JSON.stringify(__postsCaptures)); }) + .catch(function (e) { console.log('__RESULTAT__' + JSON.stringify({erreur: String(e && e.stack || e)})); }); + """) + payload = g.inventaire_api(g.INVENTAIRE_DEFAUT) + posts = self._jouer(js, payload) + self.assertNotIn("erreur", posts, posts.get("erreur", "")) + + attendu = { + "/api/serveurs": (g.charger_serveurs(g.FICHIER_SERVEURS), "serveurs"), + "/api/applications": (g.charger_applications(g.FICHIER_APPLICATIONS), "applications"), + "/api/domaines": (g.charger_domaines(g.FICHIER_DOMAINES), "domaines_publics"), + "/api/bases": (g.charger_bases_donnees(g.FICHIER_BASES), "bases_donnees"), + } + for url, (plan, racine) in attendu.items(): + with self.subTest(route=url): + envoye = (posts.get(url) or {}).get(racine) + self.assertIsNotNone(envoye, f"{url} n'a pas ete appele — la sauvegarde a-t-elle refuse ?") + reel = plan.get(racine) or {} + self.assertEqual(sorted(envoye), sorted(reel), "entites perdues ou ajoutees") + for cle in sorted(reel): + self.assertEqual(envoye[cle], reel[cle], + f"{racine}.{cle} : l'aller-retour a change l'entite") + def test_le_schema_est_bien_servi(self): """Le formulaire ne peut rien generer si l'API n'envoie pas le schema.""" payload = g.inventaire_api(g.INVENTAIRE_DEFAUT) diff --git a/scripts/verifier_gui.py b/scripts/verifier_gui.py index 49254b1..096bea8 100644 --- a/scripts/verifier_gui.py +++ b/scripts/verifier_gui.py @@ -20,6 +20,60 @@ sys.path.insert(0, str(Path(__file__).resolve().parent)) import inventory_gui as g # noqa: E402 +# Les noms qui RESSEMBLENT a un global sans en etre un. `data` est le cas fondateur : +# chaque `charger*`/`sauvegarder*` en declare un LOCAL, si bien que le nom se lit partout +# comme s'il etait disponible — et ne l'est pas. +NON_GLOBALES = ("data",) + +# Une fonction de premier niveau du