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 | [0m[0m |
-| 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.
`;
}
+ 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