diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e59012..9179659 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,83 @@ # CHANGELOG — Set-OPS +## 2026-09-08 (2) — La vue Nomenclature, et deux fautes que mes bancs ne pouvaient pas voir + +**61 preuves. `make prouver` : CONFORME, 60 OK, 0 echec, 1 saute.** +`couverture_gui verifier` : les 28 champs des plans reels sont editables — le dernier trou +est ferme. + +### La vue + +La nomenclature etait le seul registre que le GUI ne savait pas ecrire DU TOUT : ajouter une +fonction exigeait d'ouvrir le YAML. Elle a maintenant sa vue, et son formulaire est GENERE +depuis le schema — deuxieme registre sur six. + +Elle n'est pas un registre comme les autres : elle ne decrit pas des objets, elle decrit la +REGLE dont VMID, VLAN, adresse et passerelle se derivent. D'ou trois partis pris : + +- chaque fonction affiche **ce qu'elle derive** (zone, VLAN, sous-reseau, bloc d'hotes) et + les VM qui la portent — sans ca, changer un chiffre est un geste a l'aveugle ; +- `index` est montre mais PAS editable ici : le fichier dit lui-meme qu'il est RECU du site + et non decide par le tenant ; +- `valider_nomenclature` refuse de retirer une fonction encore portee par une VM, ou de + designer une zone non declaree — `deriver_nomenclature` rendrait `None` EN SILENCE. + +### Les deux fautes, et pourquoi mes bancs ne les voyaient pas + +**1. Le formulaire des bases, livre la veille, etait casse dans un navigateur.** +Il lisait `data.schema` — or il n'existe aucun `data` global dans cette page : c'est une +const locale de `charger()`. `ReferenceError` a l'ouverture. Pire dans `sauvegarderBases`, +ou `const data` est declare PLUS BAS dans la meme fonction : zone morte temporelle, +l'enregistrement jetait avant meme d'envoyer. + +Je l'avais « eprouve » sous node — en PASSANT `data` a la fonction. Le banc reproduisait la +fonction, pas sa PORTEE. `verifier_gui.py`, lui, ne verifie que la syntaxe. + +Remede : un global `schemaPlan`, et surtout `scripts/tests/test_rendu_gui.py`, qui charge le +JS entier dans un DOM simule, appelle `charger()` sur la VRAIE reponse de l'API, et dessine +les douze vues. Son controle negatif remet une reference absente et exige que le banc la +voie. + +**2. Le schema decrivait `reservations` comme une table de zones.** Le fichier reel est un +bloc plat, et `underlay.py` lit `reservations.passerelle` a plat. P61 ne voyait rien : elle +comparait des NOMS aplatis, et `passerelle` existe des deux cotes — a des profondeurs +differentes. Un formulaire genere depuis cette description aurait offert « ajouter une zone » +et ecrit une forme que le moteur ne lit pas. + +P61 compare desormais aussi la FORME : scalaire, bloc a clefs fixes, table a clefs libres. +Controle negatif rejoue, elle echoue. + +### Ecrire dans la nomenclature sans deplacer un commentaire + +Le fichier porte vingt-deux lignes qui disent pourquoi `index` est recu, et laquelle des +fonctions est le poste d'exploitation. `_fusion_chirurgicale` les gardait toutes — mais elle +remplace le BLOC entier des qu'une valeur change. Mesure : ajouter une fonction transformait +quinze entrees compactes en quarante-deux lignes, et HISSAIT le commentaire du poste +d'exploitation en tete du bloc, ou il affirmait que `collab` etait le poste d'exploitation. + +Un commentaire deplace n'est pas laid : il est FAUX. + +`_fusion_table` edite donc les tables LIGNE A LIGNE. Le diff d'un changement reel fait +desormais trois lignes, le style compact survit, et le commentaire garde son voisin. +`scripts/tests/test_nomenclature_ecriture.py` le fige, son controle negatif compris. + +### Au passage + +`sort_keys=True` triait le schema genere alphabetiquement — donc l'ordre des cases a +l'ecran. `reserve_max` passait avant `reserve_min`. L'ordre de `REGISTRES` est délibéré et +un dict Python litteral est deja stable : le tri ne servait a rien et desservait les deux +formulaires. + +Et `_ecrire_index_nomenclature` ecrivait encore par `write_text` : oubliee au passage des +ecritures atomiques du 2026-09-06. Une coupure y laissait le seed a moitie ecrit, +c'est-a-dire tout l'adressage de l'ecosysteme. + +### La limite, dite franchement + +**DEUX registres sur six sont generes.** Les quatre autres formulaires restent ecrits a la +main. Et je n'ai toujours pas ouvert cette page dans un navigateur : le banc prouve qu'elle +rend sans lever, pas qu'elle est lisible. + ## 2026-09-08 — La forme des registres cesse d'etre recopiee : elle est derivee **61 preuves (P01-P61, dont une conditionnelle). `make prouver` : CONFORME, 60 OK, 0 echec, diff --git a/docs/audit/preuve-2026-09-08.md b/docs/audit/preuve-2026-09-08.md index 2c331f2..242e7f9 100644 --- a/docs/audit/preuve-2026-09-08.md +++ b/docs/audit/preuve-2026-09-08.md @@ -14,7 +14,7 @@ | # | Preuve | Affirmations | Statut | Detail | |---|---|---|---|---| | P01 | Lint (ansible-lint) | AFF-006 | ✅ OK |  | -| P02 | Tests unitaires (inventaire, raser, ecriture atomique) | — | ✅ OK | >>> l'ecriture du plan est atomique, eprouvee contre son propre controle. | +| P02 | Tests unitaires (inventaire, raser, ecriture atomique, rendu du GUI, nomenclature) | — | ✅ OK | OK | | P03 | Diff-vide du plan — TOUTES les instances | AFF-001, AFF-004, AFF-030, AFF-031, AFF-032 | ✅ OK | 4 instance(s) verifiee(s) — OPS-Chezlepro-lab, OPS-Chezlepro, OPS-Technolibre, OPS-Patient0 : plan et inventaire applique coincident. | | P04 | Groupes <-> playbooks homonymes | AFF-008 | ✅ OK | | | P05 | Dependances causales de groupes | AFF-009, AFF-084 | ✅ OK | | @@ -73,7 +73,7 @@ | 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 `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 39 champ(s) sur 6 registres ; il couvre tout ce que les plans reels contiennent, et la FORME de chaque champ (scalaire / objet / table) corresp | ## Couverture des affirmations ✅ du registre diff --git a/docs/audit/schema-plan.json b/docs/audit/schema-plan.json index b075aaf..8a0bd46 100644 --- a/docs/audit/schema-plan.json +++ b/docs/audit/schema-plan.json @@ -1,381 +1,382 @@ { "$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": { - "applications": { + "serveurs": { + "title": "Serveurs (VM)", + "x-fichier": "plan/serveurs.yml", + "x-racine": "serveurs", "entite": { - "additionalProperties": false, + "type": "object", "properties": { - "expose": { - "description": "FQDN publies par l'edge. Derivent le vhost, les SAN et le plancher /etc/hosts.", + "fonction": { + "type": "string", + "title": "Fonction", + "description": "Determine VMID, IP, VLAN et passerelle. Doit exister dans la nomenclature.", + "x-source-valeurs": "nomenclature.fonctions" + }, + "etat": { + "type": "string", + "title": "État", + "description": "`planifie` = decrit mais pas deploye ; `actif` = joignable par Ansible.", + "enum": [ + "actif", + "planifie" + ] + }, + "integrations": { + "type": "array", + "title": "Intégrations facultatives", + "description": "Seulement les facultatives. Les universelles viennent du role et sont refusees ici.", "items": { "type": "string" - }, - "title": "Exposition publique", - "type": "array" + } }, - "groupe": { - "description": "La capacite appliquee. Doit avoir un playbook homonyme.", - "title": "Groupe (rôle)", + "noeud": { "type": "string", + "title": "Nœud Proxmox", + "description": "Surcharge le defaut de `make config`." + }, + "stockage": { + "type": "string", + "title": "Stockage", + "description": "Surcharge le defaut." + }, + "disque": { + "type": "string", + "title": "Disque", + "description": "Ex. `32G`. Vide = derive des empreintes des roles." + }, + "memoire": { + "type": "integer", + "title": "Mémoire (Mo)", + "description": "Vide = derive des empreintes des roles." + }, + "coeurs": { + "type": "integer", + "title": "Cœurs", + "description": "Vide = derive des empreintes des roles." + } + }, + "additionalProperties": false, + "required": [ + "fonction" + ] + }, + "x-clef": { + "title": "Nom d'hôte", + "description": "Le nom court de la VM. Le rang final derive l'adresse." + } + }, + "applications": { + "title": "Applications", + "x-fichier": "plan/applications.yml", + "x-racine": "applications", + "entite": { + "type": "object", + "properties": { + "groupe": { + "type": "string", + "title": "Groupe (rôle)", + "description": "La capacite appliquee. Doit avoir un playbook homonyme.", "x-source-valeurs": "groupes_operationnels" }, "hote": { - "description": "Une VM declaree au plan. Un hote inconnu est un « hote fantome » (P06).", - "title": "Hôte", "type": "string", + "title": "Hôte", + "description": "Une VM declaree au plan. Un hote inconnu est un « hote fantome » (P06).", "x-source-valeurs": "serveurs" }, - "liens": { - "description": "Roles acceptes par le role porteur (meta/liens.yml).", - "items": { - "type": "object" - }, - "title": "Liens (bindings)", - "type": "array" - }, "port": { - "title": "Port", - "type": "integer" + "type": "integer", + "title": "Port" + }, + "expose": { + "type": "array", + "title": "Exposition publique", + "description": "FQDN publies par l'edge. Derivent le vhost, les SAN et le plancher /etc/hosts.", + "items": { + "type": "string" + } }, "requiert": { + "type": "array", + "title": "Requiert", "description": "Dependance applicative. Indicative : l'ordre de deploiement vient des couches.", "items": { "type": "string" - }, - "title": "Requiert", - "type": "array" + } + }, + "liens": { + "type": "array", + "title": "Liens (bindings)", + "description": "Roles acceptes par le role porteur (meta/liens.yml).", + "items": { + "type": "object" + } }, "websocket": { - "description": "L'edge doit relayer la mise a niveau de connexion.", + "type": "boolean", "title": "WebSocket", - "type": "boolean" + "description": "L'edge doit relayer la mise a niveau de connexion." } }, + "additionalProperties": false, "required": [ "groupe", "hote" - ], - "type": "object" + ] }, - "title": "Applications", "x-clef": { - "description": "Nom de l'application dans le plan.", - "title": "Identifiant" - }, - "x-fichier": "plan/applications.yml", - "x-racine": "applications" + "title": "Identifiant", + "description": "Nom de l'application dans le plan." + } }, "bases_donnees": { + "title": "Bases de donnees", + "x-fichier": "plan/bases-donnees.yml", + "x-racine": "bases_donnees", "entite": { - "additionalProperties": false, + "type": "object", "properties": { + "serveur": { + "type": "string", + "title": "Serveur de BD", + "x-source-valeurs": "serveurs_bd" + }, "base": { - "title": "Base", - "type": "string" + "type": "string", + "title": "Base" + }, + "proprietaire": { + "type": "string", + "title": "Propriétaire" + }, + "secret": { + "type": "string", + "title": "Secret (Vault)", + "description": "NOM d'une variable Vault, jamais une valeur. Le secret ne quitte pas le role." }, "consommateur": { - "description": "Qui utilise cette base. La liste depend de la portee.", - "title": "Consommateur", "type": "string", + "title": "Consommateur", + "description": "Qui utilise cette base. La liste depend de la portee.", "x-source-selon": { - "cas": { - "application": "applications", - "hote": "serveurs" - }, "champ": "portee", + "cas": { + "hote": "serveurs", + "application": "applications" + }, "defaut": "groupes_operationnels" } }, "portee": { - "default": "groupe", + "type": "string", + "title": "Portée", "description": "Comment le consommateur est designe : par application, par groupe ou par hote.", "enum": [ "application", "groupe", "hote" ], - "title": "Portée", - "type": "string" - }, - "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 (Vault)", - "type": "string" - }, - "serveur": { - "title": "Serveur de BD", - "type": "string", - "x-source-valeurs": "serveurs_bd" + "default": "groupe" }, "usage": { - "default": "principale", + "type": "string", "title": "Usage", - "type": "string" + "default": "principale" } }, + "additionalProperties": false, "required": [ "base", "consommateur", "proprietaire", "secret", "serveur" - ], - "type": "object" + ] }, - "title": "Bases de donnees", "x-clef": { - "description": "Nom de l'entree au registre, ex. `bd_forgejo`.", - "title": "Identifiant" - }, + "title": "Identifiant", + "description": "Nom de l'entree au registre, ex. `bd_forgejo`." + } + }, + "serveurs_bd": { + "title": "Serveurs de bases de donnees", "x-fichier": "plan/bases-donnees.yml", - "x-racine": "bases_donnees" + "x-racine": "serveurs_bd", + "entite": { + "type": "object", + "properties": { + "type": { + "type": "string", + "title": "Moteur" + }, + "hote": { + "type": "string", + "title": "Hôte", + "x-source-valeurs": "serveurs" + }, + "port": { + "type": "integer", + "title": "Port" + }, + "groupe": { + "type": "string", + "title": "Groupe", + "x-source-valeurs": "groupes_operationnels" + } + }, + "additionalProperties": false, + "required": [ + "hote", + "type" + ] + }, + "x-clef": { + "title": "Nom", + "description": "Nom du serveur de bases, ex. `pg-principal`." + } }, "domaines_publics": { + "title": "Domaines publics", + "x-fichier": "plan/domaines.yml", + "x-racine": "domaines_publics", "entite": { - "additionalProperties": false, + "type": "object", "properties": { "autorite": { + "type": "string", + "title": "Autorité DNS", "enum": [ "auto-heberge", "delegue", "primaire-cache" - ], - "title": "Autorité DNS", - "type": "string" - }, - "dnssec": { - "title": "DNSSEC", - "type": "boolean" + ] }, "edge": { - "title": "Edge", "type": "string", + "title": "Edge", "x-source-valeurs": "serveurs" }, - "mail": { - "description": "La zone porte des enregistrements de messagerie.", - "title": "Courriel", - "type": "boolean" - }, "secondaires": { + "type": "array", + "title": "Secondaires", "items": { "type": "string" - }, - "title": "Secondaires", - "type": "array" + } + }, + "dnssec": { + "type": "boolean", + "title": "DNSSEC" + }, + "mail": { + "type": "boolean", + "title": "Courriel", + "description": "La zone porte des enregistrements de messagerie." } }, - "type": "object" + "additionalProperties": false }, - "title": "Domaines publics", "x-clef": { - "description": "Le nom public, ex. `chezlepro.ca`.", - "title": "Domaine" - }, - "x-fichier": "plan/domaines.yml", - "x-racine": "domaines_publics" + "title": "Domaine", + "description": "Le nom public, ex. `chezlepro.ca`." + } }, "nomenclature": { + "title": "Nomenclature", + "x-fichier": "plan/nomenclature.yml", + "x-racine": null, "entite": { - "additionalProperties": false, + "type": "object", "properties": { - "categories": { - "additionalProperties": { - "additionalProperties": false, - "properties": { - "libelle": { - "description": "Nom lisible de la zone (Frontiere, Identite...).", - "title": "Libellé", - "type": "string" - } - }, - "required": [ - "libelle" - ], - "type": "object" - }, - "description": "Une zone de securite par cle. Le numero derive le 3e octet et le VLAN.", - "title": "Catégories (zones)", - "type": "object" + "index": { + "type": "integer", + "title": "Index (seed)", + "description": "LE seul champ d'adressage. Tout en derive ; P20 refuse d'en stocker un autre.", + "minimum": 0, + "maximum": 255 }, "cidr_hote": { - "title": "CIDR d'hote", - "type": "integer" + "type": "integer", + "title": "CIDR d'hôte", + "description": "Masque des sous-reseaux de zone. 24 = 254 hotes par zone." }, - "fonctions": { + "categories": { + "type": "object", + "title": "Catégories (zones)", + "description": "Une zone de securite par cle. Le numero derive le 3e octet et le VLAN.", "additionalProperties": { - "additionalProperties": false, + "type": "object", "properties": { - "categorie": { - "description": "La zone. Fixe le 3e octet (15 + categorie) et le VLAN.", - "title": "Catégorie", - "type": "integer" - }, - "service": { - "description": "Fixe le bloc d'adresses de l'hote dans la zone.", - "title": "Service", - "type": "integer" + "libelle": { + "type": "string", + "title": "Libellé", + "description": "Nom lisible de la zone (Frontiere, Identite...)." } }, + "additionalProperties": false, + "required": [ + "libelle" + ] + } + }, + "fonctions": { + "type": "object", + "title": "Fonctions", + "description": "categorie + service par fonction. VMID et IP en derivent.", + "additionalProperties": { + "type": "object", + "properties": { + "categorie": { + "type": "integer", + "title": "Catégorie", + "description": "La zone. Fixe le 3e octet (15 + categorie) et le VLAN.", + "x-source-valeurs": "nomenclature.categories" + }, + "service": { + "type": "integer", + "title": "Service", + "description": "Fixe le bloc d'adresses de l'hote dans la zone." + } + }, + "additionalProperties": false, "required": [ "categorie", "service" - ], - "type": "object" - }, - "description": "categorie + service par fonction. VMID et IP en derivent.", - "title": "Fonctions", - "type": "object" - }, - "index": { - "description": "LE seul champ d'adressage. Tout en derive ; P20 refuse d'en stocker un autre.", - "maximum": 255, - "minimum": 0, - "title": "Index (seed)", - "type": "integer" + ] + } }, "reservations": { - "additionalProperties": { - "additionalProperties": false, - "properties": { - "passerelle": { - "description": "Dernier octet de la passerelle. P23 refuse un SVI qui s'en ecarte.", - "title": "Passerelle", - "type": "integer" - }, - "reserve_max": { - "title": "Réserve (max)", - "type": "integer" - }, - "reserve_min": { - "title": "Réserve (min)", - "type": "integer" - } - }, - "type": "object" - }, - "description": "Adresses soustraites a la derivation dans chaque zone.", + "type": "object", "title": "Réservations", - "type": "object" + "description": "Adresses soustraites a la derivation dans la zone.", + "properties": { + "passerelle": { + "type": "integer", + "title": "Passerelle", + "description": "Dernier octet de la passerelle. P23 refuse un SVI qui s'en ecarte." + }, + "reserve_min": { + "type": "integer", + "title": "Réserve (min)", + "description": "Premier octet soustrait a la derivation." + }, + "reserve_max": { + "type": "integer", + "title": "Réserve (max)", + "description": "Dernier octet soustrait a la derivation." + } + }, + "additionalProperties": false } }, + "additionalProperties": false, "required": [ "index" - ], - "type": "object" - }, - "title": "Nomenclature", - "x-fichier": "plan/nomenclature.yml", - "x-racine": null - }, - "serveurs": { - "entite": { - "additionalProperties": false, - "properties": { - "coeurs": { - "description": "Vide = derive des empreintes des roles.", - "title": "Cœurs", - "type": "integer" - }, - "disque": { - "description": "Ex. `32G`. Vide = derive des empreintes des roles.", - "title": "Disque", - "type": "string" - }, - "etat": { - "description": "`planifie` = decrit mais pas deploye ; `actif` = joignable par Ansible.", - "enum": [ - "actif", - "planifie" - ], - "title": "État", - "type": "string" - }, - "fonction": { - "description": "Determine VMID, IP, VLAN et passerelle. Doit exister dans la nomenclature.", - "title": "Fonction", - "type": "string", - "x-source-valeurs": "nomenclature.fonctions" - }, - "integrations": { - "description": "Seulement les facultatives. Les universelles viennent du role et sont refusees ici.", - "items": { - "type": "string" - }, - "title": "Intégrations facultatives", - "type": "array" - }, - "memoire": { - "description": "Vide = derive des empreintes des roles.", - "title": "Mémoire (Mo)", - "type": "integer" - }, - "noeud": { - "description": "Surcharge le defaut de `make config`.", - "title": "Nœud Proxmox", - "type": "string" - }, - "stockage": { - "description": "Surcharge le defaut.", - "title": "Stockage", - "type": "string" - } - }, - "required": [ - "fonction" - ], - "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" - }, - "serveurs_bd": { - "entite": { - "additionalProperties": false, - "properties": { - "groupe": { - "title": "Groupe", - "type": "string", - "x-source-valeurs": "groupes_operationnels" - }, - "hote": { - "title": "Hôte", - "type": "string", - "x-source-valeurs": "serveurs" - }, - "port": { - "title": "Port", - "type": "integer" - }, - "type": { - "title": "Moteur", - "type": "string" - } - }, - "required": [ - "hote", - "type" - ], - "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" + ] + } } - }, - "title": "Registres du plan Set-OPS" + } } diff --git a/docs/plan-et-generation.md b/docs/plan-et-generation.md index c9d38e3..907ba6d 100644 --- a/docs/plan-et-generation.md +++ b/docs/plan-et-generation.md @@ -99,7 +99,7 @@ c'est le feu vert pour appliquer. ### Via le GUI — `make inventaire-ui` -**Onze vues**, éditables ou dérivées *(la table n'en listait que cinq)* : +**Douze vues**, éditables ou dérivées : | Vue | Rôle | | --- | --- | @@ -107,6 +107,7 @@ c'est le feu vert pour appliquer. | **Applications** *(éditable)* | groupe/hôte/port/requiert/expose, et les **liens** acceptés par le rôle | | **Bases** *(éditable)* | serveurs de BD et bases (portée + consommateur), DSN affiché (secret masqué) | | **Domaines** *(éditable)* | zones publiques vs internes, autorité · edge · DNSSEC, expositions | +| **Nomenclature** *(éditable)* | le modèle dont tout l'adressage dérive : zones, fonctions (catégorie · service), réservations. Chaque fonction affiche **ce qu'elle dérive** (VLAN, sous-réseau, bloc d'hôtes) et les VM qui la portent. L'`index` y est **montré, pas éditable** : il est alloué par le site | | **Intégrations** *(éditable)* | la matrice serveurs × intégrations ; les universelles en ✓ non décochables | | **Intrants** *(éditable)* | les intrants de base — identité, Proxmox, fabric, et la liste de rappel des secrets (lecture seule) | | **Inventaire** *(lecture seule)* | l'inventaire **généré** — vue d'ensemble des hôtes | diff --git a/scripts/inventory_gui.py b/scripts/inventory_gui.py index 1e83946..6a819eb 100644 --- a/scripts/inventory_gui.py +++ b/scripts/inventory_gui.py @@ -571,14 +571,14 @@ CHAMPS_ECRITS_A_LA_MAIN = { "applications": {"groupe", "hote", "port", "requiert", "expose", "liens", "websocket"}, "serveurs_bd": {"type", "hote", "port", "groupe"}, "domaines_publics": {"autorite", "edge", "secondaires", "dnssec", "mail"}, - # 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 : plus AUCUN champ a la main non plus — elle est passee au generateur + # le 2026-09-08, et c'etait le dernier registre que le GUI ne savait pas ecrire du + # tout. Ajouter une fonction n'exige plus d'editer le YAML. "nomenclature": set(), } # Les registres dont le formulaire est GENERE : leurs champs viennent du schema. -REGISTRES_GENERES = {"bases_donnees"} +REGISTRES_GENERES = {"bases_donnees", "nomenclature"} def champs_ecrits_par_gui() -> dict: @@ -587,10 +587,26 @@ def champs_ecrits_par_gui() -> dict: Une seule fonction repond a la question, pour que P19 n'ait pas a savoir lequel des deux mecanismes couvre quel registre. """ + def _noms(props: dict) -> set: + """Les champs decrits, TABLES ET BLOCS IMBRIQUES COMPRIS. + + Les plans portent `categorie` et `service` a plat, sous la table `fonctions`. + Ne rendre que les proprietes de premier niveau ferait dire au GUI qu'il ne sait + pas les ecrire, alors que le formulaire genere les edite. + """ + vus = set() + for nom, spec in (props or {}).items(): + vus.add(nom) + for sous in (spec.get("properties"), (spec.get("additionalProperties") or {}).get("properties") + if isinstance(spec.get("additionalProperties"), dict) else None): + if isinstance(sous, dict): + vus |= _noms(sous) + return vus + 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 {}) + resultat[nom] = _noms((reg.get("entite") or {}).get("properties") or {}) return resultat @@ -660,7 +676,100 @@ def _rendre_cle(cle: str, valeur: object) -> list[str]: return texte.rstrip("\n").split("\n") -def _fusion_chirurgicale(texte: str, valeurs: dict) -> str: +_ENTREE_TABLE = re.compile(r"^(\s+)([^\s#:][^:]*):(\s*)(.*)$") + + +def _fusion_table(bloc: list[str], entrees: dict) -> list[str]: + """Met a jour une table clef->mapping LIGNE A LIGNE, sans la reecrire. + + POURQUOI PAS `_fusion_chirurgicale` SEULE (mesure du 2026-09-08). Elle remplace le + BLOC entier des qu'une valeur change. Sur `fonctions:` de la nomenclature, ajouter + une seule entree a produit deux degats mesures : + + - les quinze entrees compactes `{ categorie: 6, service: 2 }` sont devenues + quarante-deux lignes — le fichier cesse d'etre une table lisible d'un coup d'oeil ; + - le commentaire « LE POSTE D'EXPLOITATION », qui coiffait `ops:`, s'est retrouve + en TETE du bloc. Un commentaire deplace ne se contente pas d'etre laid : il + explique desormais la mauvaise ligne. Il affirmait que `collab` etait le poste + d'exploitation. + + Ici on ne touche qu'aux LIGNES concernees. Une entree inchangee n'est pas relue, un + commentaire garde son voisin, et le style compact survit parce qu'on ne le refait pas. + """ + tete, corps = bloc[0], bloc[1:] + sortie, vues = [tete], set() + flow, colonne = 0, 0 + i = 0 + while i < len(corps): + ligne = corps[i] + m = _ENTREE_TABLE.match(ligne) + if not m: + sortie.append(ligne) + i += 1 + continue + indent, cle, ecart, reste = m.group(1), m.group(2).strip(), m.group(3), m.group(4) + # L'entree s'etend sur les lignes PLUS indentees qui suivent immediatement. + fin = i + j = i + 1 + while j < len(corps): + suite = corps[j] + if not suite.strip() or suite.strip().startswith("#"): + break + creux = len(suite) - len(suite.lstrip()) + if creux <= len(indent): + break + fin = j + j += 1 + vues.add(cle) + if reste.startswith("{"): + flow += 1 + colonne = max(colonne, len(indent) + len(cle) + 1 + len(ecart)) + try: + ancien = (yaml.safe_load("\n".join(l[len(indent):] for l in corps[i:fin + 1])) or {}).get(cle) + except yaml.YAMLError: + ancien = object() # illisible : on la considere changee + if cle not in entrees: + i = fin + 1 # entree retiree : ses lignes disparaissent, pas ses voisines + continue + if ancien == entrees[cle]: + sortie.extend(corps[i:fin + 1]) + elif reste.startswith("{"): + sortie.append(f"{indent}{cle}:{ecart}{_rendre_flow(entrees[cle])}") + else: + # L'entree tenait sur plusieurs lignes : on la refait en bloc, indentee + # comme ses voisines. + sortie.extend(indent + l for l in _rendre_cle(cle, entrees[cle])) + i = fin + 1 + # Les entrees NEUVES vont a la fin du bloc, dans le style dominant. + neuves = [c for c in entrees if c not in vues] + if neuves: + indent = " " + for ligne in corps: + m = _ENTREE_TABLE.match(ligne) + if m: + indent = m.group(1) + break + while sortie and not sortie[-1].strip(): + sortie.pop() + for cle in neuves: + if flow: + ecart = " " * max(1, colonne - len(indent) - len(str(cle)) - 1) + sortie.append(f"{indent}{cle}:{ecart}{_rendre_flow(entrees[cle])}") + else: + sortie.extend(indent + l for l in _rendre_cle(cle, entrees[cle])) + return sortie + + +def _rendre_flow(valeur: object) -> str: + """La forme compacte `{ a: 1, b: 2 }` — celle que le fichier employait deja.""" + rendu = yaml.safe_dump(valeur, default_flow_style=True, allow_unicode=True, + sort_keys=False, width=10 ** 6).strip() + if rendu.startswith("{") and rendu.endswith("}"): + rendu = "{ " + rendu[1:-1].strip() + " }" + return rendu + + +def _fusion_chirurgicale(texte: str, valeurs: dict, tables: set | None = None) -> str: """Pose les valeurs dans le texte existant SANS reformater ce qui n'a pas change. POURQUOI (mesure du 2026-08-18). Ces fichiers portent la memoire ECRITE des @@ -714,6 +823,9 @@ def _fusion_chirurgicale(texte: str, valeurs: dict) -> str: vues.add(cle) if cle in actuel and actuel[cle] == valeurs[cle]: sortie.extend(lignes[i:fin + 1]) # inchangee : pas un octet touche + elif cle in (tables or set()) and isinstance(valeurs[cle], dict): + # Table clef->mapping : on edite les lignes concernees, pas le bloc. + sortie.extend(_fusion_table(lignes[i:fin + 1], valeurs[cle])) else: internes = [l for l in lignes[i + 1:fin + 1] if l.strip().startswith("#")] rendu = _rendre_cle(cle, valeurs[cle]) @@ -867,6 +979,72 @@ def _ecrire_intrants_fabric(valeurs: dict) -> None: FICHIER_UNDERLAY.write_text(texte, encoding="utf-8") +def valider_nomenclature(donnees: dict, serveurs: dict) -> None: + """Refuse une nomenclature qui casserait la derivation, AVANT d'ecrire. + + La nomenclature n'est pas un registre comme les autres : elle ne decrit pas des + objets, elle decrit la REGLE dont VMID, VLAN, adresse et passerelle se derivent. + Une fonction retiree ne laisse pas un trou local — elle prive de tout adressage + chaque VM qui la porte, et `deriver_nomenclature` rend `None` SANS RIEN DIRE. + + Trois refus, dans l'ordre du degat : + - une fonction encore portee par une VM ne se retire pas ; + - une fonction ne peut designer qu'une zone declaree ; + - `index` ne s'ecrit pas ici (il est ALLOUE par le site, cf. l'entete du fichier). + """ + categories = donnees.get("categories") or {} + fonctions = donnees.get("fonctions") or {} + if not isinstance(categories, dict) or not isinstance(fonctions, dict): + raise ValueError("`categories` et `fonctions` doivent etre des tables.") + + connues = {str(c) for c in categories} + for nom, f in fonctions.items(): + if not isinstance(f, dict): + raise ValueError(f"Fonction '{nom}': entree invalide.") + cat = f.get("categorie") + if cat in (None, ""): + raise ValueError(f"Fonction '{nom}': 'categorie' requise.") + if str(cat) not in connues: + raise ValueError(f"Fonction '{nom}': zone {cat} non declaree dans `categories` " + f"— la VM n'aurait ni VLAN ni adresse.") + if f.get("service") in (None, ""): + raise ValueError(f"Fonction '{nom}': 'service' requis.") + + portees = {} + for nom_vm, srv in (serveurs or {}).items(): + if isinstance(srv, dict) and srv.get("fonction"): + portees.setdefault(str(srv["fonction"]), []).append(nom_vm) + for fonction, vms in sorted(portees.items()): + if fonction not in fonctions: + raise ValueError(f"Fonction '{fonction}' retiree, mais encore portee par " + f"{len(vms)} VM ({', '.join(sorted(vms)[:4])}) — " + f"deplacer ces VM d'abord.") + + zones_utilisees = {str(f.get("categorie")) for f in fonctions.values() if isinstance(f, dict)} + for zone in sorted(zones_utilisees - connues): + raise ValueError(f"Zone {zone} utilisee mais non declaree.") + + +def ecrire_nomenclature(path: Path, donnees: dict) -> None: + """Ecrit la nomenclature SANS effacer ses commentaires. + + POURQUOI PAS `safe_dump`. Ce fichier porte dix-neuf lignes qui disent pourquoi + `index` EST RECU ET PAS DECIDE, et pourquoi le modele des zones est commun a toute + la flotte. Un `safe_dump` les emporterait toutes — c'est exactement l'incident du + 2026-08-18 (94 lignes de commentaire perdues en un enregistrement). On repasse donc + par `_fusion_chirurgicale`, qui pose les valeurs dans le texte existant. + + `index` n'est jamais du lot : il s'ecrit par `_ecrire_index_nomenclature`, depuis le + panneau Intrants, parce qu'il vient du site et non de cette vue. + """ + texte = path.read_text(encoding="utf-8") if path.exists() else "---\n" + valeurs = {cle: donnees[cle] for cle in ("cidr_hote", "reservations", "categories", "fonctions") + if cle in donnees} + with ecriture_atomique(path) as fichier: + fichier.write(_fusion_chirurgicale(texte, valeurs, + tables={"categories", "fonctions"})) + + def _ecrire_index_nomenclature(index: int) -> None: """Met a jour `index:` dans plan/nomenclature.yml SANS reformater le reste. @@ -878,7 +1056,11 @@ def _ecrire_index_nomenclature(index: int) -> None: nouveau, remplace = re.subn(r"(?m)^index:.*$", f"index: {index}", texte) if not remplace: nouveau = re.sub(r"(?m)^(---\n)", rf"\g<1>index: {index}\n", texte, count=1) or texte - path.write_text(nouveau, encoding="utf-8") + # Atomique comme les autres : cette ecriture-ci avait ete oubliee au passage du + # 2026-09-06, et une coupure ici laisserait le seed a moitie ecrit — c'est-a-dire + # tout l'adressage de l'ecosysteme. + with ecriture_atomique(path) as fichier: + fichier.write(nouveau) def ecrire_bases(path: Path, registre: dict) -> None: @@ -1375,6 +1557,7 @@ HTML = r""" + @@ -1437,6 +1620,13 @@ HTML = r""" let groupes = []; let dependances = {}; let nomenclature = {}; + // LE SCHEMA DES REGISTRES, a portee globale. Les formulaires generes le lisaient dans + // `data.schema` — or il n'existe AUCUN `data` global ici : c'est une const locale de + // `charger()`. Le rendu levait donc `ReferenceError` dans un navigateur, et + // `sauvegarderBases` pire encore (zone morte : `const data` y est declare plus bas, + // dans la meme fonction). Eprouver le rendu sous node en LUI FOURNISSANT `data` ne + // pouvait pas le voir : le banc reproduisait la fonction, pas sa portee. + let schemaPlan = {}; let chaine = {}; let hotes = []; let estProduction = false; @@ -1453,6 +1643,9 @@ HTML = r""" let hoteOpCourant = ''; // nom de l'hôte dont les boutons Vérifier/Déployer sont affichés let applications = []; let applicationsModifie = false; + let nomEdit = {}; // nomenclature en cours d'édition (copie de travail) + let selNom = null; // 'cat:' ou 'fon:' — l'entrée sélectionnée + let nomModifie = false; let domaines = []; // registre des domaines (éditable) let selDomaine = null; // index du domaine sélectionné let domainesModifie = false; @@ -1510,6 +1703,7 @@ HTML = r""" groupes = data.groupes; dependances = data.dependances || {}; nomenclature = data.nomenclature || {}; + schemaPlan = data.schema || {}; chaine = data.chaine || {}; fluxRegistre = data.flux || []; couchesRegistre = data.couches || []; @@ -1520,6 +1714,7 @@ HTML = r""" chargerBases(data); chargerApplications(data); chargerDomaines(data); + chargerNomEdit(data); chargerServeurs(data); hotes = data.hotes; estProduction = !!data.production; @@ -1561,11 +1756,12 @@ HTML = r""" function majIndicateurSauvegarde() { const b = document.getElementById('btn-sauver'); if (!b) return; - const editable = vuePrincipale === 'serveurs' || vuePrincipale === 'integrations' || vuePrincipale === 'applications' || vuePrincipale === 'bases' || vuePrincipale === 'domaines'; + const editable = vuePrincipale === 'serveurs' || vuePrincipale === 'integrations' || vuePrincipale === 'applications' || vuePrincipale === 'bases' || vuePrincipale === 'domaines' || vuePrincipale === 'nomenclature'; const sale = ((vuePrincipale === 'serveurs' || vuePrincipale === 'integrations') && serveursModifie) || (vuePrincipale === 'applications' && applicationsModifie) || (vuePrincipale === 'bases' && basesModifie) - || (vuePrincipale === 'domaines' && domainesModifie); + || (vuePrincipale === 'domaines' && domainesModifie) + || (vuePrincipale === 'nomenclature' && nomModifie); b.classList.toggle('attention', sale); b.textContent = sale ? 'Sauvegarder •' : 'Sauvegarder'; b.disabled = !editable; @@ -1626,6 +1822,7 @@ HTML = r""" if (vuePrincipale === 'applications') { dessinerApplications(); return; } if (vuePrincipale === 'bases') { dessinerBases(); return; } if (vuePrincipale === 'domaines') { dessinerDomaines(); return; } + if (vuePrincipale === 'nomenclature') { dessinerNomenclature(); return; } if (vuePrincipale === 'integrations') { dessinerIntegrations(); return; } if (vuePrincipale === 'flux') { dessinerFlux(); return; } if (vuePrincipale === 'couches') { dessinerCouches(); return; } @@ -1942,6 +2139,219 @@ HTML = r"""
${echapper(d.autorite || '—')}
`; } + // ---- VUE NOMENCLATURE --------------------------------------------------------- + // + // Le dernier registre que le GUI ne savait pas ecrire du tout (2026-09-08). Il n'est + // pas un registre comme les autres : il ne decrit pas des objets, il decrit la REGLE + // dont VMID, VLAN, adresse et passerelle se derivent. D'ou deux partis pris : + // + // - `index` est montre mais PAS editable ici. Le fichier le dit lui-meme : il est + // RECU du site, pas decide par le tenant, et `make underlay` refuse tout ecart. + // Il s'ecrit par le panneau Intrants, qui est le chemin de l'intrant recu. + // - chaque fonction affiche CE QU'ELLE DERIVE (zone, VLAN, 3e octet) et combien de + // VM la portent : sans ca, changer un chiffre ici est un geste a l'aveugle. + + function chargerNomEdit(data) { + // Copie de travail : editer `nomenclature` en place ferait suivre les indices + // derives de la vue Serveurs a des valeurs non encore enregistrees. + nomEdit = JSON.parse(JSON.stringify(data.nomenclature || {})); + nomModifie = false; + } + function marquerNomModifie() { nomModifie = true; majIndicateurSauvegarde(); } + function selNomInfo() { if (!selNom) return null; const i = selNom.indexOf(':'); return {kind: selNom.slice(0, i), cle: selNom.slice(i + 1)}; } + function choisirNom(k) { selNom = k; dessiner(); } + + function definirNom(chemin, v) { + // UN SEUL setter pour toute la profondeur : le generateur transmet une clef + // POINTEE (`reservations.passerelle`, `fonctions.idm.service`). + const parts = String(chemin).split('.'); + let o = nomEdit; + for (let i = 0; i < parts.length - 1; i++) { + if (o[parts[i]] == null || typeof o[parts[i]] !== 'object') o[parts[i]] = {}; + o = o[parts[i]]; + } + const feuille = parts[parts.length - 1]; + // Les entiers restent des ENTIERS : le plan est relu par le moteur, pas par un + // navigateur, et `categorie: "6"` ne derive pas la meme chose que `categorie: 6`. + o[feuille] = (v !== '' && v != null && String(v).match(/^-?\d+$/)) ? parseInt(v, 10) : v; + marquerNomModifie(); + if (chemin.startsWith('fonctions.') || chemin.startsWith('categories.')) dessiner(); + } + + function renommerNom(table, ancienne, nouvelle) { + nouvelle = String(nouvelle || '').trim(); + if (!nouvelle || nouvelle === ancienne) return; + const t = nomEdit[table] || {}; + if (Object.prototype.hasOwnProperty.call(t, nouvelle)) { message('« ' + nouvelle + ' » existe déjà.', 'erreur'); return; } + // On preserve l'ORDRE : le fichier est lu par des humains, et un renommage ne doit + // pas remonter la clef en tete. + const refait = {}; + Object.keys(t).forEach(k => { refait[k === ancienne ? nouvelle : k] = t[k]; }); + nomEdit[table] = refait; + selNom = (table === 'categories' ? 'cat:' : 'fon:') + nouvelle; + marquerNomModifie(); dessiner(); + } + + function ajouterZone() { + const cats = nomEdit.categories || (nomEdit.categories = {}); + // La prochaine zone LIBRE, pas « la derniere + 1 » : un trou se rebouche. + let n = 1; while (cats[n] || cats[String(n)]) n++; + cats[n] = {libelle: ''}; + selNom = 'cat:' + n; marquerNomModifie(); dessiner(); + } + function ajouterFonction() { + const f = nomEdit.fonctions || (nomEdit.fonctions = {}); + let nom = 'nouvelle', i = 2; while (f[nom]) nom = 'nouvelle-' + (i++); + const premiereZone = Object.keys(nomEdit.categories || {})[0]; + f[nom] = {categorie: premiereZone != null ? parseInt(premiereZone, 10) : 1, service: 1}; + selNom = 'fon:' + nom; marquerNomModifie(); dessiner(); + } + function retirerNom() { + const info = selNomInfo(); if (!info) return; + const table = info.kind === 'cat' ? 'categories' : 'fonctions'; + if (info.kind === 'fon' && vmsPortant(info.cle).length) { + message('« ' + info.cle + ' » est encore portée par ' + vmsPortant(info.cle).length + ' VM.', 'erreur'); + return; + } + if (info.kind === 'cat' && fonctionsDansZone(info.cle).length) { + message('Zone ' + info.cle + ' encore utilisée par ' + fonctionsDansZone(info.cle).length + ' fonction(s).', 'erreur'); + return; + } + delete (nomEdit[table] || {})[info.cle]; + selNom = null; marquerNomModifie(); dessiner(); + } + + function vmsPortant(fonction) { return hotes.filter(h => fonctionDe(h.nom).fonction === fonction); } + function fonctionsDansZone(zone) { + return Object.entries(nomEdit.fonctions || {}).filter(([, f]) => String(f.categorie) === String(zone)).map(([n]) => n); + } + function deriveDe(fonction) { + // Miroir de `inventory_rules.deriver_nomenclature`, cote ecran. Il ne DECIDE rien : + // il montre ce que le moteur derivera, pour que le chiffre saisi ait un sens visible. + const f = (nomEdit.fonctions || {})[fonction]; + if (!f) return null; + const c = (nomEdit.categories || {})[f.categorie] || (nomEdit.categories || {})[String(f.categorie)]; + const idx = (nomEdit.index != null) ? nomEdit.index : 9; + const cat = Number(f.categorie); + if (!c || isNaN(cat)) return null; + return {libelle: c.libelle || '?', vlan: 1000 + idx * 10 + cat, + base3: '10.' + idx + '.' + (15 + cat), bloc: (Number(f.service) * 10)}; + } + + function carteZone(cle) { + const c = (nomEdit.categories || {})[cle] || {}; + const sel = selNom === 'cat:' + cle ? ' on' : ''; + const n = fonctionsDansZone(cle).length; + return ``; + } + function carteFonction(nom) { + const sel = selNom === 'fon:' + nom ? ' on' : ''; + const d = deriveDe(nom); + const nb = vmsPortant(nom).length; + return ``; + } + + function dessinerNomenclature() { + const cible = document.getElementById('grilles'); + const reg = schemaPlan.nomenclature; + if (!reg) { cible.innerHTML = '
Aucun schéma — lancer make schema.
'; return; } + const props = (reg.entite || {}).properties || {}; + + // Le seed, montre et NON editable ici (il vient du site). + const idx = nomEdit.index == null ? '—' : nomEdit.index; + const seed = ` +
L'index est alloué par le site, pas décidé ici + (make underlay refuse tout écart). Il s'édite dans le panneau Intrants.
`; + const cidr = props.cidr_hote ? champDepuisSchema('cidr_hote', props.cidr_hote, nomEdit.cidr_hote, nomEdit, 'definirNom') : ''; + const resv = props.reservations ? champDepuisSchema('reservations', props.reservations, nomEdit.reservations, nomEdit, 'definirNom') : ''; + + const zones = Object.keys(nomEdit.categories || {}); + const fons = Object.keys(nomEdit.fonctions || {}); + cible.innerHTML = ` +
+
Modèle d'adressage
+
${seed}${cidr}${resv}
+
+
+
Zones de sécurité ${zones.length} +
+ ${zones.length ? `
${zones.map(carteZone).join('')}
` + : '
Aucune zone. Sans zone déclarée, aucune fonction ne dérive d\'adresse.
'} +
+
+
Fonctions ${fons.length} +
+ ${fons.length ? `
${fons.map(carteFonction).join('')}
` + : '
Aucune fonction déclarée.
'} +
`; + } + + function inspecteurNomenclature() { + const cible = document.getElementById('detail'); + const info = selNomInfo(); + const reg = schemaPlan.nomenclature || {}; + const props = (reg.entite || {}).properties || {}; + if (!info) { + cible.innerHTML = '
Nomenclature' + + 'Le modèle dont tout l\'adressage dérive. Choisis une zone ou une fonction.
'; + return; + } + const table = info.kind === 'cat' ? 'categories' : 'fonctions'; + const entree = (nomEdit[table] || {})[info.cle]; + if (!entree) { selNom = null; cible.innerHTML = ''; return; } + const specEntree = (props[table] || {}).additionalProperties || {}; + const corps = sousFormulaireDepuisSchema(specEntree, entree, table + '.' + info.cle, 'definirNom'); + + let consequence = ''; + if (info.kind === 'fon') { + const d = deriveDe(info.cle); + const vms = vmsPortant(info.cle); + consequence = `
Ce que cette fonction dérive
` + + (d ? `
Zone ${echapper(d.libelle)} · VLAN ${d.vlan} + · sous-réseau ${echapper(d.base3)}.0/${echapper(nomEdit.cidr_hote || 24)} + · bloc d'hôtes .${d.bloc}x
` + : '
Zone inconnue — aucune adresse ne se dérive.
') + + `
VM portant cette fonction ${vms.length}
` + + (vms.length ? `
${vms.map(h => `
${echapper(h.nom)}
`).join('')}
` + : '
aucune — cette fonction peut être retirée
'); + } else { + const fs = fonctionsDansZone(info.cle); + consequence = `
Fonctions dans cette zone ${fs.length}
` + + (fs.length ? `
${fs.map(f => `
${echapper(f)}
`).join('')}
` + : '
aucune — cette zone peut être retirée
'); + } + const libelleCle = info.kind === 'cat' ? 'Numéro de zone' : 'Nom de la fonction'; + cible.innerHTML = `
+
${echapper(info.cle)}
+ +
+
+ +
+ ${corps} + ${consequence} +
`; + } + + async function sauvegarderNomenclature() { + const rep = await fetch('/api/nomenclature', {method: 'POST', + headers: {'Content-Type': 'application/json', 'X-Jeton': JETON}, + body: JSON.stringify(nomEdit)}); + const data2 = await rep.json(); + if (!rep.ok) { message(data2.erreur || 'Sauvegarde refusée.', 'erreur'); return; } + schemaPlan = data2.schema || schemaPlan; + nomenclature = data2.nomenclature || nomenclature; + chargerNomEdit(data2); dessiner(); + message('Nomenclature sauvegardée.', 'ok'); + } + function dessinerDomaines() { const cible = document.getElementById('grilles'); const tuiles = domaines.length @@ -2031,6 +2441,13 @@ HTML = r""" 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 || {}); + // La zone se choisit dans les zones DECLAREES — et on la lit dans la copie en + // cours d'edition, pour qu'une zone ajoutee a l'instant soit offerte tout de suite. + // `{v, t}` : la valeur ecrite au plan reste le numero, l'ecran montre le libelle. + if (nom === 'nomenclature.categories') { + const cats = (nomEdit.categories || {}); + return Object.keys(cats).map(k => ({v: k, t: k + ' — ' + ((cats[k] || {}).libelle || '?')})); + } return []; } @@ -2055,10 +2472,26 @@ HTML = r""" } if (options) { - const opts = [''].concat(options.map(v => - ``)).join(''); + // Une option est soit une valeur nue, soit {v: valeur ecrite, t: texte affiche}. + const opts = [''].concat(options.map(o => { + const v = (o && typeof o === 'object') ? o.v : o; + const txt = (o && typeof o === 'object') ? o.t : o; + return ``; + })).join(''); return ``; } + if (spec.type === 'object' && spec.properties) { + // BLOC FIXE (`sous_champs` au schema) : ses cases une fois, sans « ajouter ». + // La clef transmise au setter est POINTEE (`reservations.passerelle`) — c'est ce + // qui permet a un seul setter de servir toute la profondeur. + const sousReq = new Set(spec.required || []); + const cases = Object.keys(spec.properties).map(sc => champDepuisSchema( + cle + '.' + sc, + Object.assign({}, spec.properties[sc], {'x-requis': sousReq.has(sc)}), + (valeur || {})[sc], valeur, surChangement)).join(''); + return `
${titre}
` + + `
${cases}
`; + } if (spec.type === 'boolean') { return ``; } @@ -2075,7 +2508,7 @@ HTML = r""" } function formulaireDepuisSchema(nomRegistre, entite, surChangement, cleCourante, surChangementCle) { - const reg = ((data.schema || {})[nomRegistre]) || null; + const reg = schemaPlan[nomRegistre] || null; if (!reg) return '
Aucun schéma pour ce registre — lancer make schema.
'; const props = (reg.entite || {}).properties || {}; const requis = new Set((reg.entite || {}).required || []); @@ -2092,6 +2525,17 @@ HTML = r""" return `
${morceaux.join('')}
`; } + function sousFormulaireDepuisSchema(specEntree, entree, prefixe, surChangement) { + // Une ligne de table (`entree` au schema) : les memes cases qu'une entite, mais + // sous une clef pointee, pour que le setter sache ou poser la valeur. + const props = (specEntree || {}).properties || {}; + const requis = new Set((specEntree || {}).required || []); + const cases = Object.keys(props).map(c => champDepuisSchema( + prefixe + '.' + c, Object.assign({}, props[c], {'x-requis': requis.has(c)}), + (entree || {})[c], entree, surChangement)).join(''); + return `
${cases}
`; + } + function dsnBase(a) { const s = bdServeurs.find(x => x.nom === a.serveur); if (!s) return '(serveur inconnu : ' + echapper(a.serveur || '—') + ')'; @@ -2202,7 +2646,7 @@ HTML = r""" // 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 || {}; + const propsBd = ((schemaPlan.bases_donnees || {}).entite || {}).properties || {}; bdApplis.forEach(a => { if (!String(a.cle).trim()) return; const e = {}; @@ -2617,6 +3061,7 @@ HTML = r""" if (vuePrincipale === 'applications') return inspecteurApplication(); if (vuePrincipale === 'bases') return inspecteurBase(); if (vuePrincipale === 'domaines') return inspecteurDomaine(); + if (vuePrincipale === 'nomenclature') return inspecteurNomenclature(); return inspecteurServeur(); } @@ -2898,6 +3343,7 @@ HTML = r""" else if (vuePrincipale === 'applications') sauvegarderApplications(); else if (vuePrincipale === 'bases') sauvegarderBases(); else if (vuePrincipale === 'domaines') sauvegarderDomaines(); + else if (vuePrincipale === 'nomenclature') sauvegarderNomenclature(); else message('Vue en lecture seule (Flux / Couches) — édite dans Serveurs / Applications / Bases / Domaines.'); } @@ -3273,6 +3719,10 @@ class Gestionnaire(BaseHTTPRequestHandler): valider_serveurs(donnees, charger_nomenclature(FICHIER_NOMENCLATURE)) ecrire_serveurs(FICHIER_SERVEURS, donnees) self.repondre_json(200, inventaire_api(self.inventaire)) + elif chemin == "/api/nomenclature": + valider_nomenclature(donnees, charger_serveurs(FICHIER_SERVEURS)) + ecrire_nomenclature(FICHIER_NOMENCLATURE, donnees) + self.repondre_json(200, inventaire_api(self.inventaire)) elif chemin == "/api/domaines": valider_domaines(donnees) ecrire_domaines(FICHIER_DOMAINES, donnees) diff --git a/scripts/prouver.py b/scripts/prouver.py index b21ecc7..7a3c382 100644 --- a/scripts/prouver.py +++ b/scripts/prouver.py @@ -308,6 +308,61 @@ def preuve_schema_du_plan() -> tuple[bool, str]: import couverture_gui as _cg observes = {nom: set(champs) for nom, champs in _cg.champs_utilises().items()} + # TROISIEME SENS, ajoute le 2026-09-08 : la FORME, pas seulement le nom. + # + # `reservations` etait decrit comme une TABLE de zones, chacune portant `passerelle`. + # Le fichier reel est un bloc PLAT, et `underlay.py` lit `reservations.passerelle` + # a plat. Les deux sens ci-dessus n'y voyaient rien : ils aplatissent les noms des + # deux cotes, et `passerelle` existe bien de part et d'autre — a des profondeurs + # differentes. Un formulaire genere depuis cette description aurait offert un bouton + # « ajouter une zone » et ecrit dans le plan une forme que le moteur ne lit pas. + # + # Un nom commun ne dit donc rien de la structure. On compare desormais trois formes : + # scalaire, objet a clefs fixes, table a clefs libres. La lecture des plans se derive + # de `x-fichier` / `x-racine` du schema lui-meme — pas d'une seconde liste a tenir. + def _forme_declaree(spec: dict) -> str: + if isinstance(spec.get("additionalProperties"), dict): + return "table" + if spec.get("type") == "object": + return "objet" + return "scalaire" + + def _forme_observee(valeur): + if isinstance(valeur, dict): + if not valeur: + return None # un dictionnaire vide ne tranche pas entre objet et table + return "table" if all(isinstance(x, dict) for x in valeur.values()) else "objet" + return "scalaire" + + # `plan_de()` et non `RACINE / "instance" / "plan"` : P41 refuse une dixieme copie de + # la resolution d'instance, et elle a refuse celle-ci en premier. + plans = [plan_de()] + sorted((RACINE / "exemples" / "modeles").glob("*/plan")) + formes = [] + for plan in plans: + for nom, reg in schema["registres"].items(): + fichier = plan / Path(reg["x-fichier"]).name + if not fichier.is_file(): + continue + try: + data = yaml.safe_load(fichier.read_text(encoding="utf-8")) or {} + except yaml.YAMLError: + continue + racine = reg["x-racine"] + # `x-racine: null` = le document EST l'entite (la nomenclature). + entites = list((data.get(racine) or {}).values()) if racine else [data] + props = reg["entite"]["properties"] + for entite in entites: + if not isinstance(entite, dict): + continue + for champ, valeur in entite.items(): + if champ not in props: + continue + vue = _forme_observee(valeur) + dite = _forme_declaree(props[champ]) + if vue and vue != dite: + formes.append(f"{nom}.{champ} : le plan en fait un {vue}, " + f"le schema un {dite} ({plan.parent.name})") + trous, inutilises = [], [] for nom, champs in observes.items(): manquants = sorted(champs - decrit.get(nom, set())) @@ -318,6 +373,12 @@ def preuve_schema_du_plan() -> tuple[bool, str]: if jamais: inutilises.append(f"{nom}:{len(jamais)}") + if formes: + return False, ("Le schema decrit une FORME que les plans n'ont pas :\n - " + + "\n - ".join(sorted(set(formes))) + + "\n (corriger REGISTRES dans scripts/schema_plan.py — `entree` " + "pour une table a clefs libres, `sous_champs` pour un bloc fixe — " + "puis `make schema`)") if trous: return False, ("Le schema ne decrit pas tout ce que les plans contiennent :\n - " + "\n - ".join(trous) @@ -326,8 +387,9 @@ def preuve_schema_du_plan() -> tuple[bool, str]: n = sum(len(c) for c in decrit.values()) suffixe = (f" ; {', '.join(inutilises)} champ(s) decrits et inutilises dans les plans " f"d'aujourd'hui (legitime)") if inutilises else "" - return True, (f"Le schema decrit {n} champ(s) sur {len(decrit)} registres, et couvre " - f"tout ce que les plans reels contiennent{suffixe}.") + return True, (f"Le schema decrit {n} champ(s) sur {len(decrit)} registres ; il couvre " + f"tout ce que les plans reels contiennent, et la FORME de chaque champ " + f"(scalaire / objet / table) correspond dans {len(plans)} plan(s){suffixe}.") def preuve_wiki_publie_a_jour() -> tuple[bool, str]: @@ -2713,10 +2775,16 @@ def preuve_documentation_outillage() -> tuple[bool, str]: PREUVES: list[dict] = [ {"id": "P01", "titre": "Lint (ansible-lint)", "refs": ["AFF-006"], "cmds": [["ansible-lint", "-q"]]}, - {"id": "P02", "titre": "Tests unitaires (inventaire, raser, ecriture atomique)", "refs": [], + {"id": "P02", "titre": "Tests unitaires (inventaire, raser, ecriture atomique, rendu du GUI, nomenclature)", "refs": [], "cmds": [[sys.executable, "scripts/tests/test_inventory_host.py"], [sys.executable, "scripts/tests/test_raser.py"], - [sys.executable, "scripts/tests/test_ecriture_atomique.py"]]}, + [sys.executable, "scripts/tests/test_ecriture_atomique.py"], + # Le rendu, et non la seule syntaxe : `verifier_gui.py` passait au vert sur + # une page qui levait `ReferenceError` a l'ouverture. + [sys.executable, "scripts/tests/test_rendu_gui.py"], + # Le fichier le plus dense en memoire de decision du depot : ecrire dedans + # ne doit deplacer aucun commentaire. + [sys.executable, "scripts/tests/test_nomenclature_ecriture.py"]]}, {"id": "P03", "titre": "Diff-vide du plan — TOUTES les instances", "refs": ["AFF-001", "AFF-004", "AFF-030", "AFF-031", "AFF-032"], "func": preuve_diff_vide_toutes_instances}, {"id": "P04", "titre": "Groupes <-> playbooks homonymes", "refs": ["AFF-008"], diff --git a/scripts/schema_plan.py b/scripts/schema_plan.py index 4761d29..b04de51 100644 --- a/scripts/schema_plan.py +++ b/scripts/schema_plan.py @@ -171,7 +171,8 @@ REGISTRES: dict = { "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'hote"}, + "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 @@ -188,17 +189,28 @@ REGISTRES: dict = { "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 chaque zone.", - "entree": { + "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)"}, - "reserve_max": {"type": "integer", "libelle": "Réserve (max)"}}}, + "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."}}}, }, }, } @@ -224,6 +236,11 @@ def construire() -> dict: 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) @@ -231,6 +248,21 @@ def construire() -> dict: "additionalProperties": False} if sous_requis: p["additionalProperties"]["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. @@ -269,7 +301,12 @@ def main(argv: list[str] | None = None) -> int: ap.add_argument("--verifier", action="store_true", help="compare sans ecrire ; code 1 si le fichier est perime") args = ap.parse_args(argv) - rendu = json.dumps(construire(), ensure_ascii=False, indent=2, sort_keys=True) + "\n" + # 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: diff --git a/scripts/tests/test_nomenclature_ecriture.py b/scripts/tests/test_nomenclature_ecriture.py new file mode 100644 index 0000000..19f8282 --- /dev/null +++ b/scripts/tests/test_nomenclature_ecriture.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +"""Ecrire la nomenclature depuis le GUI ne doit rien emporter d'autre. + +CE QUI EST EN JEU. `instance/plan/nomenclature.yml` porte vingt-deux lignes de +commentaire, et elles ne sont pas decoratives : elles disent pourquoi `index` EST RECU +ET PAS DECIDE, pourquoi le modele des zones est commun a toute la flotte, et laquelle +des fonctions est le poste d'exploitation. C'est de la memoire de decision — le genre +qu'on ne retrouve pas en relisant le code. + +TROIS DEGATS MESURES, ET LEURS GARDES : + + 1. `safe_dump` efface TOUS les commentaires (incident du 2026-08-18 : 94 lignes dans + quatre fichiers). Garde : `_fusion_chirurgicale`. + 2. Remplacer le BLOC d'une table detruit son style compact — quinze entrees d'une + ligne devenues quarante-deux. Garde : `_fusion_table`, ligne a ligne. + 3. Le meme remplacement HISSE les commentaires internes en tete du bloc. Celui du + poste d'exploitation coiffait alors `collab` : il n'etait plus laid, il etait FAUX. + Garde : le meme, et c'est le controle negatif de ce fichier qui le prouve. +""" + +from __future__ import annotations + +import shutil +import sys +import tempfile +import unittest +from pathlib import Path + +RACINE = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(RACINE / "scripts")) + +import yaml # noqa: E402 + +import inventory_gui as g # noqa: E402 + +SOURCE = RACINE / "instance" / "plan" / "nomenclature.yml" + + +class TestEcritureNomenclature(unittest.TestCase): + + def setUp(self): + self.dossier = Path(tempfile.mkdtemp(prefix="nomenclature-")) + self.fichier = self.dossier / "nomenclature.yml" + shutil.copy(SOURCE, self.fichier) + self.avant = self.fichier.read_text(encoding="utf-8") + self.plan = yaml.safe_load(self.avant) + + def tearDown(self): + shutil.rmtree(self.dossier, ignore_errors=True) + + def _ecrire(self, plan): + g.ecrire_nomenclature(self.fichier, plan) + return self.fichier.read_text(encoding="utf-8") + + @staticmethod + def _commentaires(texte): + return [l.strip() for l in texte.splitlines() if l.strip().startswith("#")] + + @staticmethod + def _ligne_apres(texte, motif): + """La premiere ligne NON commentaire qui suit le commentaire cherche.""" + lignes = texte.splitlines() + for i, l in enumerate(lignes): + if motif in l: + for suite in lignes[i + 1:]: + if not suite.strip().startswith("#"): + return suite + return None + + def test_les_commentaires_survivent(self): + plan = yaml.safe_load(self.avant) + plan["fonctions"]["essai"] = {"categorie": 6, "service": 9} + apres = self._ecrire(plan) + self.assertEqual(self._commentaires(self.avant), self._commentaires(apres), + "des commentaires ont ete perdus ou ajoutes") + + def test_le_commentaire_reste_sur_sa_ligne(self): + """LE POINT DUR. Un commentaire deplace explique la mauvaise ligne.""" + repere = "LE POSTE D'EXPLOITATION" + self.assertIn("ops:", self._ligne_apres(self.avant, repere) or "", + "le fichier source n'a plus la forme que ce test mesure") + plan = yaml.safe_load(self.avant) + plan["fonctions"]["essai"] = {"categorie": 6, "service": 9} + apres = self._ecrire(plan) + self.assertIn("ops:", self._ligne_apres(apres, repere) or "", + "le commentaire du poste d'exploitation a change de voisin") + + def test_controle_negatif_le_bloc_entier_deplace_le_commentaire(self): + """LE CONTROLE. Sans `_fusion_table`, le commentaire DOIT se detacher. + + Sans lui, le test ci-dessus passerait aussi sur une ecriture qui ne change rien. + On rejoue donc la fusion SANS declarer les tables — exactement le code d'avant. + """ + plan = yaml.safe_load(self.avant) + plan["fonctions"]["essai"] = {"categorie": 6, "service": 9} + naif = g._fusion_chirurgicale(self.avant, {"fonctions": plan["fonctions"]}) + self.assertNotIn("ops:", self._ligne_apres(naif, "LE POSTE D'EXPLOITATION") or "", + "le controle negatif ne reproduit plus le defaut : " + "_fusion_chirurgicale ne deplace plus le commentaire") + + def test_le_style_compact_survit(self): + plan = yaml.safe_load(self.avant) + plan["fonctions"]["idm"]["service"] = 7 + apres = self._ecrire(plan) + compactes = [l for l in apres.splitlines() if l.strip().startswith(("collab:", "idm:", "ops:"))] + self.assertTrue(all("{" in l for l in compactes), + f"le style compact a ete perdu : {compactes}") + self.assertIn("idm:", "".join(compactes)) + self.assertIn("service: 7", apres) + + def test_seules_les_lignes_visees_changent(self): + plan = yaml.safe_load(self.avant) + plan["fonctions"]["idm"]["service"] = 7 + apres = self._ecrire(plan) + a, b = self.avant.splitlines(), apres.splitlines() + self.assertEqual(len(a), len(b), "le nombre de lignes a change") + differentes = [i for i, (x, y) in enumerate(zip(a, b)) if x != y] + self.assertEqual(len(differentes), 1, + f"{len(differentes)} ligne(s) modifiees au lieu d'une") + + def test_index_jamais_ecrit_par_cette_vue(self): + """`index` est ALLOUE par le site : cette vue ne doit pas pouvoir le changer.""" + plan = yaml.safe_load(self.avant) + plan["index"] = 99 + apres = self._ecrire(plan) + self.assertEqual(yaml.safe_load(apres)["index"], self.plan["index"], + "la vue Nomenclature a ecrit l'index, qui vient du site") + + def test_retirer_une_fonction_portee_est_refuse(self): + serveurs = {"idm-01": {"fonction": "idm"}} + plan = yaml.safe_load(self.avant) + del plan["fonctions"]["idm"] + with self.assertRaises(ValueError) as e: + g.valider_nomenclature(plan, serveurs) + self.assertIn("idm", str(e.exception)) + + def test_zone_non_declaree_est_refusee(self): + plan = yaml.safe_load(self.avant) + plan["fonctions"]["idm"]["categorie"] = 42 + with self.assertRaises(ValueError) as e: + g.valider_nomenclature(plan, {}) + self.assertIn("42", str(e.exception)) + + def test_une_nomenclature_saine_passe(self): + """Controle du validateur : il ne refuse pas tout.""" + g.valider_nomenclature(yaml.safe_load(self.avant), g.charger_serveurs(g.FICHIER_SERVEURS)) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_rendu_gui.py b/scripts/tests/test_rendu_gui.py new file mode 100644 index 0000000..d3dfc89 --- /dev/null +++ b/scripts/tests/test_rendu_gui.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +"""Le GUI REND-IL ? Chaque vue est dessinee pour de vrai, dans un DOM simule. + +CE QUE `verifier_gui.py` NE POUVAIT PAS VOIR (2026-09-08). Il passe le JS a +`node --check` : la syntaxe, donc, et rien d'autre. Le formulaire genere des bases +lisait `data.schema` — or il n'existe aucun `data` global dans cette page ; c'est une +const locale de `charger()`. Syntaxe parfaite, `ReferenceError` a l'ouverture. Pire dans +`sauvegarderBases`, ou `const data` est declare PLUS BAS dans la meme fonction : zone +morte temporelle, l'enregistrement jetait avant meme d'envoyer. + +Le defaut a survecu a une verification sous node parce que le banc APPELAIT la fonction +en lui passant `data` — il reproduisait la fonction, pas sa PORTEE. Une garantie qu'on +n'a jamais vue echouer n'est pas une garantie. + +Ce banc-ci charge le JS entier, tel que la page le recoit, dans un DOM simule, appelle +`charger()` sur la VRAIE reponse de l'API, puis dessine CHAQUE vue. Toute reference a un +identifiant qui n'existe pas leve, et le test echoue. + +Ce qu'il ne fait PAS : juger l'apparence. Il dit que ca rend, pas que c'est beau. +""" + +from __future__ import annotations + +import json +import re +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +RACINE = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(RACINE / "scripts")) + +import inventory_gui as g # noqa: E402 + +# Un DOM juste assez reel : tout element repond, retient son innerHTML, et n'invente +# rien. Un `getElementById` qui rendrait `null` masquerait les fautes qu'on cherche. +BANC = r""" +'use strict'; +function Elem(id) { + this.id = id; this.innerHTML = ''; this.textContent = ''; this.value = ''; + this.hidden = false; this.disabled = false; this.title = ''; this.className = ''; + this.style = {}; this.dataset = {}; this.children = []; + this.classList = {add(){}, remove(){}, toggle(){}, contains(){ return false; }}; +} +Elem.prototype.appendChild = function (e) { this.children.push(e); return e; }; +Elem.prototype.removeChild = function (e) { return e; }; +Elem.prototype.setAttribute = function () {}; +Elem.prototype.getAttribute = function () { return ''; }; +Elem.prototype.addEventListener = function () {}; +Elem.prototype.querySelector = function () { return null; }; +Elem.prototype.querySelectorAll = function () { return []; }; +Elem.prototype.focus = function () {}; +Elem.prototype.remove = function () {}; +Elem.prototype.closest = function () { return null; }; +Elem.prototype.scrollIntoView = function () {}; + +var __elems = {}; +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 []; }, + addEventListener: function () {}, + body: new Elem('body'), + documentElement: new Elem('html'), +}; +var window = {addEventListener: function () {}, location: {href: '', reload: function () {}}, + matchMedia: function () { return {matches: false, addEventListener: function () {}}; }}; +var navigator = {clipboard: {writeText: function () { return Promise.resolve(); }}}; +function MutationObserver(f) { this.observe = function () {}; this.disconnect = function () {}; } +function requestAnimationFrame(f) { return 0; } +function cancelAnimationFrame() {} +function EventSource() { this.addEventListener = function () {}; this.close = function () {}; } +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); +// `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__ : {}; + return Promise.resolve({ok: true, status: 200, json: function () { return Promise.resolve(corps); }, + text: function () { return Promise.resolve(''); }}); +} +var __erreurs = []; +function __essayer(quoi, f) { try { f(); } catch (e) { __erreurs.push(quoi + ' : ' + e); } } +""" + +APRES = r""" +// --- L'EPREUVE ----------------------------------------------------------------- +charger().then(function () { + ['serveurs', 'applications', 'bases', 'domaines', 'nomenclature', + 'integrations', 'flux', 'couches', 'reseau'].forEach(function (v) { + __essayer('setVue(' + v + ')', function () { setVue(v); }); + }); + // Les vues a maitre-detail ne montrent leur formulaire QU'UNE FOIS une entree + // choisie : dessiner la grille seule ne prouve rien du formulaire genere. + __essayer('bases + selection', function () { + setVue('bases'); + if (typeof bdApplis !== 'undefined' && bdApplis.length) { choisirBase('bd:0'); } + if (typeof bdServeurs !== 'undefined' && bdServeurs.length) { choisirBase('srv:0'); } + }); + __essayer('nomenclature + selection', function () { + setVue('nomenclature'); + var f = Object.keys(nomEdit.fonctions || {}); + var c = Object.keys(nomEdit.categories || {}); + if (f.length) { choisirNom('fon:' + f[0]); } + if (c.length) { choisirNom('cat:' + c[0]); } + }); + __essayer('domaines + selection', function () { setVue('domaines'); if (domaines.length) choisirDomaine(0); }); + __essayer('applications + selection', function () { setVue('applications'); if (applications.length) ouvrirApp(applications[0].id); }); + console.log('__RESULTAT__' + JSON.stringify(__erreurs)); +}).catch(function (e) { + console.log('__RESULTAT__' + JSON.stringify(['charger() : ' + (e && e.stack || e)])); +}); +""" + + +class TestRenduGui(unittest.TestCase): + """Chaque vue du GUI se dessine sans lever.""" + + @classmethod + def setUpClass(cls): + if not shutil.which("node"): + raise unittest.SkipTest("node absent") + html = g.HTML.replace("__JETON__", "TEST") + blocs = re.findall(r"", html, re.S) + assert blocs, "aucun bloc