GUI : la vue Nomenclature, et deux fautes que mes bancs ne voyaient pas
Some checks are pending
verifier / verifier (push) Waiting to run

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
sa vue, et son formulaire est GENERE depuis le schema. Deuxieme registre
sur six. couverture_gui verifier passe : les 28 champs des plans reels
sont editables.

Elle n est pas un registre comme les autres : elle decrit la REGLE dont
VMID, VLAN, adresse et passerelle se derivent. Chaque fonction montre ce
qu elle derive et les VM qui la portent ; l index est montre mais pas
editable, parce qu il est alloue par le site ; valider_nomenclature
refuse de retirer une fonction encore portee, ou de designer une zone
non declaree.

DEUX FAUTES, ET POURQUOI MES BANCS NE LES VOYAIENT PAS.

Le formulaire des bases, livre la veille, etait casse dans un navigateur.
Il lisait data.schema, or il n existe aucun data global : c est une const
locale de charger(). ReferenceError a l ouverture, et zone morte dans
sauvegarderBases. Je l avais eprouve sous node EN LUI PASSANT data : le
banc reproduisait la fonction, pas sa portee. D ou test_rendu_gui.py, qui
charge le JS entier dans un DOM simule et dessine les douze vues, avec son
controle negatif.

Le schema decrivait reservations comme une table de zones ; le fichier
reel est un bloc plat. P61 comparait des NOMS aplatis, donc ne voyait
rien. Elle compare desormais aussi la FORME.

ECRIRE SANS DEPLACER UN COMMENTAIRE. _fusion_chirurgicale remplace le
bloc entier des qu une valeur change : quinze entrees compactes devenaient
42 lignes, et le commentaire du poste d exploitation se retrouvait 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 les
tables ligne a ligne ; le diff fait trois lignes.

Au passage : sort_keys triait le schema, donc l ordre des cases a l ecran
(reserve_max avant reserve_min) ; et _ecrire_index_nomenclature ecrivait
encore par write_text, oubliee au passage des ecritures atomiques.

LIMITE : deux registres sur six sont generes, et je n ai toujours pas
ouvert cette page dans un navigateur.

make prouver : CONFORME, 60 OK, 0 echec, 1 saute.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
This commit is contained in:
Daniel Allaire 2026-09-08 17:08:45 -04:00
parent 34b7ec3886
commit 88033f37b8
10 changed files with 1273 additions and 298 deletions

View file

@ -1,5 +1,83 @@
# CHANGELOG — Set-OPS # 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 ## 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, **61 preuves (P01-P61, dont une conditionnelle). `make prouver` : CONFORME, 60 OK, 0 echec,

View file

@ -14,7 +14,7 @@
| # | Preuve | Affirmations | Statut | Detail | | # | Preuve | Affirmations | Statut | Detail |
|---|---|---|---|---| |---|---|---|---|---|
| P01 | Lint (ansible-lint) | AFF-006 | ✅ OK |  | | 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. | | 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 | | | P04 | Groupes <-> playbooks homonymes | AFF-008 | ✅ OK | |
| P05 | Dependances causales de groupes | AFF-009, AFF-084 | ✅ 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. | | P58 | Habilitations : chaque service dit a quel GROUPE, et par quoi | — | ✅ OK | 8 habilitation(s) declarees, toutes nommant un groupe, un mecanisme connu et une raison ; les `role-realm` sont projetees. |
| P59 | Enumerations annoncees : le nombre correspond a ce qui suit | — | ✅ OK | 2 enumeration(s) annoncee(s) correspondent a ce qu'elles annoncent (formes non ambigues seulement). | | P59 | Enumerations annoncees : le nombre correspond a ce qui suit | — | ✅ OK | 2 enumeration(s) annoncee(s) correspondent a ce qu'elles annoncent (formes non ambigues seulement). |
| P60 | Wiki publie : la forge sert ce que le depot dit | AFF-002 | ✅ OK | Le wiki publie correspond au depot : `wiki/` n'a pas bouge depuis `0eaceb1` (publie le 2026-09-08). | | P60 | Wiki publie : la forge sert ce que le depot dit | AFF-002 | ✅ OK | Le wiki publie correspond au depot : `wiki/` n'a pas bouge depuis `0eaceb1` (publie le 2026-09-08). |
| P61 | Schema du plan : il decrit tout ce que les plans contiennent | AFF-033 | ✅ OK | Le schema decrit 42 champ(s) sur 6 registres, et couvre tout ce que les plans reels contiennent ; applications:1, domaines_publics:1, nomenclature:9, serveurs:3 | | P61 | Schema du plan : il decrit tout ce que les plans contiennent | AFF-033 | ✅ OK | Le schema decrit 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 ## Couverture des affirmations ✅ du registre

View file

@ -1,381 +1,382 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$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.", "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": { "registres": {
"applications": { "serveurs": {
"title": "Serveurs (VM)",
"x-fichier": "plan/serveurs.yml",
"x-racine": "serveurs",
"entite": { "entite": {
"additionalProperties": false, "type": "object",
"properties": { "properties": {
"expose": { "fonction": {
"description": "FQDN publies par l'edge. Derivent le vhost, les SAN et le plancher /etc/hosts.", "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": { "items": {
"type": "string" "type": "string"
}, }
"title": "Exposition publique",
"type": "array"
}, },
"groupe": { "noeud": {
"description": "La capacite appliquee. Doit avoir un playbook homonyme.",
"title": "Groupe (rôle)",
"type": "string", "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" "x-source-valeurs": "groupes_operationnels"
}, },
"hote": { "hote": {
"description": "Une VM declaree au plan. Un hote inconnu est un « hote fantome » (P06).",
"title": "Hôte",
"type": "string", "type": "string",
"title": "Hôte",
"description": "Une VM declaree au plan. Un hote inconnu est un « hote fantome » (P06).",
"x-source-valeurs": "serveurs" "x-source-valeurs": "serveurs"
}, },
"liens": {
"description": "Roles acceptes par le role porteur (meta/liens.yml).",
"items": {
"type": "object"
},
"title": "Liens (bindings)",
"type": "array"
},
"port": { "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": { "requiert": {
"type": "array",
"title": "Requiert",
"description": "Dependance applicative. Indicative : l'ordre de deploiement vient des couches.", "description": "Dependance applicative. Indicative : l'ordre de deploiement vient des couches.",
"items": { "items": {
"type": "string" "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": { "websocket": {
"description": "L'edge doit relayer la mise a niveau de connexion.", "type": "boolean",
"title": "WebSocket", "title": "WebSocket",
"type": "boolean" "description": "L'edge doit relayer la mise a niveau de connexion."
} }
}, },
"additionalProperties": false,
"required": [ "required": [
"groupe", "groupe",
"hote" "hote"
], ]
"type": "object"
}, },
"title": "Applications",
"x-clef": { "x-clef": {
"description": "Nom de l'application dans le plan.", "title": "Identifiant",
"title": "Identifiant" "description": "Nom de l'application dans le plan."
}, }
"x-fichier": "plan/applications.yml",
"x-racine": "applications"
}, },
"bases_donnees": { "bases_donnees": {
"title": "Bases de donnees",
"x-fichier": "plan/bases-donnees.yml",
"x-racine": "bases_donnees",
"entite": { "entite": {
"additionalProperties": false, "type": "object",
"properties": { "properties": {
"serveur": {
"type": "string",
"title": "Serveur de BD",
"x-source-valeurs": "serveurs_bd"
},
"base": { "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": { "consommateur": {
"description": "Qui utilise cette base. La liste depend de la portee.",
"title": "Consommateur",
"type": "string", "type": "string",
"title": "Consommateur",
"description": "Qui utilise cette base. La liste depend de la portee.",
"x-source-selon": { "x-source-selon": {
"cas": {
"application": "applications",
"hote": "serveurs"
},
"champ": "portee", "champ": "portee",
"cas": {
"hote": "serveurs",
"application": "applications"
},
"defaut": "groupes_operationnels" "defaut": "groupes_operationnels"
} }
}, },
"portee": { "portee": {
"default": "groupe", "type": "string",
"title": "Portée",
"description": "Comment le consommateur est designe : par application, par groupe ou par hote.", "description": "Comment le consommateur est designe : par application, par groupe ou par hote.",
"enum": [ "enum": [
"application", "application",
"groupe", "groupe",
"hote" "hote"
], ],
"title": "Portée", "default": "groupe"
"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"
}, },
"usage": { "usage": {
"default": "principale", "type": "string",
"title": "Usage", "title": "Usage",
"type": "string" "default": "principale"
} }
}, },
"additionalProperties": false,
"required": [ "required": [
"base", "base",
"consommateur", "consommateur",
"proprietaire", "proprietaire",
"secret", "secret",
"serveur" "serveur"
], ]
"type": "object"
}, },
"title": "Bases de donnees",
"x-clef": { "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-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": { "domaines_publics": {
"title": "Domaines publics",
"x-fichier": "plan/domaines.yml",
"x-racine": "domaines_publics",
"entite": { "entite": {
"additionalProperties": false, "type": "object",
"properties": { "properties": {
"autorite": { "autorite": {
"type": "string",
"title": "Autorité DNS",
"enum": [ "enum": [
"auto-heberge", "auto-heberge",
"delegue", "delegue",
"primaire-cache" "primaire-cache"
], ]
"title": "Autorité DNS",
"type": "string"
},
"dnssec": {
"title": "DNSSEC",
"type": "boolean"
}, },
"edge": { "edge": {
"title": "Edge",
"type": "string", "type": "string",
"title": "Edge",
"x-source-valeurs": "serveurs" "x-source-valeurs": "serveurs"
}, },
"mail": {
"description": "La zone porte des enregistrements de messagerie.",
"title": "Courriel",
"type": "boolean"
},
"secondaires": { "secondaires": {
"type": "array",
"title": "Secondaires",
"items": { "items": {
"type": "string" "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": { "x-clef": {
"description": "Le nom public, ex. `chezlepro.ca`.", "title": "Domaine",
"title": "Domaine" "description": "Le nom public, ex. `chezlepro.ca`."
}, }
"x-fichier": "plan/domaines.yml",
"x-racine": "domaines_publics"
}, },
"nomenclature": { "nomenclature": {
"title": "Nomenclature",
"x-fichier": "plan/nomenclature.yml",
"x-racine": null,
"entite": { "entite": {
"additionalProperties": false, "type": "object",
"properties": { "properties": {
"categories": { "index": {
"additionalProperties": { "type": "integer",
"additionalProperties": false, "title": "Index (seed)",
"properties": { "description": "LE seul champ d'adressage. Tout en derive ; P20 refuse d'en stocker un autre.",
"libelle": { "minimum": 0,
"description": "Nom lisible de la zone (Frontiere, Identite...).", "maximum": 255
"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"
}, },
"cidr_hote": { "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": {
"additionalProperties": false, "type": "object",
"properties": { "properties": {
"categorie": { "libelle": {
"description": "La zone. Fixe le 3e octet (15 + categorie) et le VLAN.", "type": "string",
"title": "Catégorie", "title": "Libellé",
"type": "integer" "description": "Nom lisible de la zone (Frontiere, Identite...)."
},
"service": {
"description": "Fixe le bloc d'adresses de l'hote dans la zone.",
"title": "Service",
"type": "integer"
} }
}, },
"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": [ "required": [
"categorie", "categorie",
"service" "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": { "reservations": {
"additionalProperties": { "type": "object",
"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.",
"title": "Réservations", "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": [ "required": [
"index" "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"
} }

View file

@ -99,7 +99,7 @@ c'est le feu vert pour appliquer.
### Via le GUI — `make inventaire-ui` ### 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 | | 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 | | **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é) | | **Bases** *(éditable)* | serveurs de BD et bases (portée + consommateur), DSN affiché (secret masqué) |
| **Domaines** *(éditable)* | zones publiques vs internes, autorité · edge · DNSSEC, expositions | | **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 | | **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) | | **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 | | **Inventaire** *(lecture seule)* | l'inventaire **généré** — vue d'ensemble des hôtes |

View file

@ -571,14 +571,14 @@ CHAMPS_ECRITS_A_LA_MAIN = {
"applications": {"groupe", "hote", "port", "requiert", "expose", "liens", "websocket"}, "applications": {"groupe", "hote", "port", "requiert", "expose", "liens", "websocket"},
"serveurs_bd": {"type", "hote", "port", "groupe"}, "serveurs_bd": {"type", "hote", "port", "groupe"},
"domaines_publics": {"autorite", "edge", "secondaires", "dnssec", "mail"}, "domaines_publics": {"autorite", "edge", "secondaires", "dnssec", "mail"},
# nomenclature : AUCUN champ ecrit a la main. Le GUI la LIT (liste des fonctions) sans # nomenclature : plus AUCUN champ a la main non plus — elle est passee au generateur
# jamais l'ecrire — ajouter une fonction exige d'editer le YAML. Trou connu, que le # le 2026-09-08, et c'etait le dernier registre que le GUI ne savait pas ecrire du
# passage au generateur fermera par construction. # tout. Ajouter une fonction n'exige plus d'editer le YAML.
"nomenclature": set(), "nomenclature": set(),
} }
# Les registres dont le formulaire est GENERE : leurs champs viennent du schema. # 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: 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 Une seule fonction repond a la question, pour que P19 n'ait pas a savoir lequel des
deux mecanismes couvre quel registre. 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()} resultat = {nom: set(champs) for nom, champs in CHAMPS_ECRITS_A_LA_MAIN.items()}
for nom, reg in (schema_du_plan() or {}).items(): for nom, reg in (schema_du_plan() or {}).items():
if nom in REGISTRES_GENERES: 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 return resultat
@ -660,7 +676,100 @@ def _rendre_cle(cle: str, valeur: object) -> list[str]:
return texte.rstrip("\n").split("\n") 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. """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 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) vues.add(cle)
if cle in actuel and actuel[cle] == valeurs[cle]: if cle in actuel and actuel[cle] == valeurs[cle]:
sortie.extend(lignes[i:fin + 1]) # inchangee : pas un octet touche 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: else:
internes = [l for l in lignes[i + 1:fin + 1] if l.strip().startswith("#")] internes = [l for l in lignes[i + 1:fin + 1] if l.strip().startswith("#")]
rendu = _rendre_cle(cle, valeurs[cle]) 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") 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: def _ecrire_index_nomenclature(index: int) -> None:
"""Met a jour `index:` dans plan/nomenclature.yml SANS reformater le reste. """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) nouveau, remplace = re.subn(r"(?m)^index:.*$", f"index: {index}", texte)
if not remplace: if not remplace:
nouveau = re.sub(r"(?m)^(---\n)", rf"\g<1>index: {index}\n", texte, count=1) or texte 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: def ecrire_bases(path: Path, registre: dict) -> None:
@ -1375,6 +1557,7 @@ HTML = r"""<!doctype html>
<button type="button" id="btn-vue-applications" onclick="setVue('applications')">Applications</button> <button type="button" id="btn-vue-applications" onclick="setVue('applications')">Applications</button>
<button type="button" id="btn-vue-bases" onclick="setVue('bases')">Bases</button> <button type="button" id="btn-vue-bases" onclick="setVue('bases')">Bases</button>
<button type="button" id="btn-vue-domaines" onclick="setVue('domaines')">Domaines</button> <button type="button" id="btn-vue-domaines" onclick="setVue('domaines')">Domaines</button>
<button type="button" id="btn-vue-nomenclature" onclick="setVue('nomenclature')">Nomenclature</button>
<button type="button" id="btn-vue-integrations" onclick="setVue('integrations')">Intégrations</button> <button type="button" id="btn-vue-integrations" onclick="setVue('integrations')">Intégrations</button>
<button type="button" id="btn-vue-flux" onclick="setVue('flux')">Flux</button> <button type="button" id="btn-vue-flux" onclick="setVue('flux')">Flux</button>
<button type="button" id="btn-vue-couches" onclick="setVue('couches')">Couches</button> <button type="button" id="btn-vue-couches" onclick="setVue('couches')">Couches</button>
@ -1437,6 +1620,13 @@ HTML = r"""<!doctype html>
let groupes = []; let groupes = [];
let dependances = {}; let dependances = {};
let nomenclature = {}; 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 chaine = {};
let hotes = []; let hotes = [];
let estProduction = false; let estProduction = false;
@ -1453,6 +1643,9 @@ HTML = r"""<!doctype html>
let hoteOpCourant = ''; // nom de l'hôte dont les boutons Vérifier/Déployer sont affichés let hoteOpCourant = ''; // nom de l'hôte dont les boutons Vérifier/Déployer sont affichés
let applications = []; let applications = [];
let applicationsModifie = false; let applicationsModifie = false;
let nomEdit = {}; // nomenclature en cours d'édition (copie de travail)
let selNom = null; // 'cat:<clef>' ou 'fon:<clef>' — l'entrée sélectionnée
let nomModifie = false;
let domaines = []; // registre des domaines (éditable) let domaines = []; // registre des domaines (éditable)
let selDomaine = null; // index du domaine sélectionné let selDomaine = null; // index du domaine sélectionné
let domainesModifie = false; let domainesModifie = false;
@ -1510,6 +1703,7 @@ HTML = r"""<!doctype html>
groupes = data.groupes; groupes = data.groupes;
dependances = data.dependances || {}; dependances = data.dependances || {};
nomenclature = data.nomenclature || {}; nomenclature = data.nomenclature || {};
schemaPlan = data.schema || {};
chaine = data.chaine || {}; chaine = data.chaine || {};
fluxRegistre = data.flux || []; fluxRegistre = data.flux || [];
couchesRegistre = data.couches || []; couchesRegistre = data.couches || [];
@ -1520,6 +1714,7 @@ HTML = r"""<!doctype html>
chargerBases(data); chargerBases(data);
chargerApplications(data); chargerApplications(data);
chargerDomaines(data); chargerDomaines(data);
chargerNomEdit(data);
chargerServeurs(data); chargerServeurs(data);
hotes = data.hotes; hotes = data.hotes;
estProduction = !!data.production; estProduction = !!data.production;
@ -1561,11 +1756,12 @@ HTML = r"""<!doctype html>
function majIndicateurSauvegarde() { function majIndicateurSauvegarde() {
const b = document.getElementById('btn-sauver'); const b = document.getElementById('btn-sauver');
if (!b) return; 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) const sale = ((vuePrincipale === 'serveurs' || vuePrincipale === 'integrations') && serveursModifie)
|| (vuePrincipale === 'applications' && applicationsModifie) || (vuePrincipale === 'applications' && applicationsModifie)
|| (vuePrincipale === 'bases' && basesModifie) || (vuePrincipale === 'bases' && basesModifie)
|| (vuePrincipale === 'domaines' && domainesModifie); || (vuePrincipale === 'domaines' && domainesModifie)
|| (vuePrincipale === 'nomenclature' && nomModifie);
b.classList.toggle('attention', sale); b.classList.toggle('attention', sale);
b.textContent = sale ? 'Sauvegarder •' : 'Sauvegarder'; b.textContent = sale ? 'Sauvegarder •' : 'Sauvegarder';
b.disabled = !editable; b.disabled = !editable;
@ -1626,6 +1822,7 @@ HTML = r"""<!doctype html>
if (vuePrincipale === 'applications') { dessinerApplications(); return; } if (vuePrincipale === 'applications') { dessinerApplications(); return; }
if (vuePrincipale === 'bases') { dessinerBases(); return; } if (vuePrincipale === 'bases') { dessinerBases(); return; }
if (vuePrincipale === 'domaines') { dessinerDomaines(); return; } if (vuePrincipale === 'domaines') { dessinerDomaines(); return; }
if (vuePrincipale === 'nomenclature') { dessinerNomenclature(); return; }
if (vuePrincipale === 'integrations') { dessinerIntegrations(); return; } if (vuePrincipale === 'integrations') { dessinerIntegrations(); return; }
if (vuePrincipale === 'flux') { dessinerFlux(); return; } if (vuePrincipale === 'flux') { dessinerFlux(); return; }
if (vuePrincipale === 'couches') { dessinerCouches(); return; } if (vuePrincipale === 'couches') { dessinerCouches(); return; }
@ -1942,6 +2139,219 @@ HTML = r"""<!doctype html>
<div class="carte-ip">${echapper(d.autorite || '—')}</div> <div class="carte-ip">${echapper(d.autorite || '—')}</div>
</button>`; </button>`;
} }
// ---- 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 `<button type="button" class="carte${sel}" onclick="choisirNom('cat:${echapper(cle)}')">
<div class="carte-nom">${echapper(c.libelle || '(sans libellé)')}</div>
<div class="carte-ip">zone ${echapper(cle)} · ${n} fonction(s)</div></button>`;
}
function carteFonction(nom) {
const sel = selNom === 'fon:' + nom ? ' on' : '';
const d = deriveDe(nom);
const nb = vmsPortant(nom).length;
return `<button type="button" class="carte${sel}" onclick="choisirNom('fon:${echapper(nom)}')">
<div class="carte-nom">${echapper(nom)}</div>
<div class="carte-ip">${d ? echapper(d.libelle) + ' · VLAN ' + d.vlan : '<zone inconnue>'} · ${nb} VM</div></button>`;
}
function dessinerNomenclature() {
const cible = document.getElementById('grilles');
const reg = schemaPlan.nomenclature;
if (!reg) { cible.innerHTML = '<div class="vide" style="padding:14px">Aucun schéma — lancer <code>make schema</code>.</div>'; 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 = `<label class="champ" title="${echapper((props.index || {}).description || '')}">
<span>${echapper((props.index || {}).title || 'Index')}</span>
<input value="${echapper(idx)}" disabled></label>
<div class="ch-stub" style="grid-column:1/-1">L'index est <b>alloué par le site</b>, pas décidé ici
(<code>make underlay</code> refuse tout écart). Il s'édite dans le panneau <b>Intrants</b>.</div>`;
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 = `
<div class="section-grille">
<div class="section-tete">Modèle d'adressage</div>
<div class="grille">${seed}${cidr}${resv}</div>
</div>
<div class="section-grille">
<div class="section-tete">Zones de sécurité <span class="compte">${zones.length}</span>
<button type="button" class="fantome" style="margin-left:auto" onclick="ajouterZone()">+ Zone</button></div>
${zones.length ? `<div class="grille-cartes">${zones.map(carteZone).join('')}</div>`
: '<div class="vide" style="padding:14px">Aucune zone. Sans zone déclarée, aucune fonction ne dérive d\'adresse.</div>'}
</div>
<div class="section-grille">
<div class="section-tete">Fonctions <span class="compte">${fons.length}</span>
<button type="button" class="fantome" style="margin-left:auto" onclick="ajouterFonction()">+ Fonction</button></div>
${fons.length ? `<div class="grille-cartes">${fons.map(carteFonction).join('')}</div>`
: '<div class="vide" style="padding:14px">Aucune fonction déclarée.</div>'}
</div>`;
}
function inspecteurNomenclature() {
const cible = document.getElementById('detail');
const info = selNomInfo();
const reg = schemaPlan.nomenclature || {};
const props = (reg.entite || {}).properties || {};
if (!info) {
cible.innerHTML = '<div class="detail"><div class="vide"><b>Nomenclature</b>'
+ 'Le modèle dont tout l\'adressage dérive. Choisis une zone ou une fonction.</div></div>';
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 = `<div class="section-tete" style="margin:12px 2px 4px">Ce que cette fonction dérive</div>`
+ (d ? `<div class="intr-ro">Zone <b>${echapper(d.libelle)}</b> · VLAN <b>${d.vlan}</b>
· sous-réseau <b>${echapper(d.base3)}.0/${echapper(nomEdit.cidr_hote || 24)}</b>
· bloc d'hôtes <b>.${d.bloc}x</b></div>`
: '<div class="intr-ro">Zone inconnue — aucune adresse ne se dérive.</div>')
+ `<div class="section-tete" style="margin:12px 2px 4px">VM portant cette fonction <span class="compte">${vms.length}</span></div>`
+ (vms.length ? `<div class="ch-bases">${vms.map(h => `<div class="ch-base"><span class="mono">${echapper(h.nom)}</span></div>`).join('')}</div>`
: '<div class="ch-stub">aucune — cette fonction peut être retirée</div>');
} else {
const fs = fonctionsDansZone(info.cle);
consequence = `<div class="section-tete" style="margin:12px 2px 4px">Fonctions dans cette zone <span class="compte">${fs.length}</span></div>`
+ (fs.length ? `<div class="ch-bases">${fs.map(f => `<div class="ch-base"><span class="mono">${echapper(f)}</span></div>`).join('')}</div>`
: '<div class="ch-stub">aucune — cette zone peut être retirée</div>');
}
const libelleCle = info.kind === 'cat' ? 'Numéro de zone' : 'Nom de la fonction';
cible.innerHTML = `<article class="detail">
<div class="detail-tete"><div class="detail-nom"><span class="detail-nom-txt">${echapper(info.cle)}</span></div>
<span class="grandit"></span>
<button type="button" class="danger" title="Retirer" onclick="retirerNom()">✕</button></div>
<div class="grille">
<label class="champ" style="grid-column:1/-1"><span>${libelleCle}</span>
<input value="${echapper(info.cle)}" onchange="renommerNom('${table}', '${echapper(info.cle)}', this.value)"></label>
</div>
${corps}
${consequence}
</article>`;
}
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() { function dessinerDomaines() {
const cible = document.getElementById('grilles'); const cible = document.getElementById('grilles');
const tuiles = domaines.length const tuiles = domaines.length
@ -2031,6 +2441,13 @@ HTML = r"""<!doctype html>
if (nom === 'groupes_operationnels') return groupes; if (nom === 'groupes_operationnels') return groupes;
if (nom === 'serveurs_bd') return bdServeurs.map(x => x.nom).filter(Boolean); if (nom === 'serveurs_bd') return bdServeurs.map(x => x.nom).filter(Boolean);
if (nom === 'nomenclature.fonctions') return Object.keys((data.nomenclature || {}).fonctions || {}); 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 []; return [];
} }
@ -2055,10 +2472,26 @@ HTML = r"""<!doctype html>
} }
if (options) { if (options) {
const opts = ['<option value="">—</option>'].concat(options.map(v => // Une option est soit une valeur nue, soit {v: valeur ecrite, t: texte affiche}.
`<option value="${echapper(v)}"${String(v) === String(valeur == null ? '' : valeur) ? ' selected' : ''}>${echapper(v)}</option>`)).join(''); const opts = ['<option value="">—</option>'].concat(options.map(o => {
const v = (o && typeof o === 'object') ? o.v : o;
const txt = (o && typeof o === 'object') ? o.t : o;
return `<option value="${echapper(v)}"${String(v) === String(valeur == null ? '' : valeur) ? ' selected' : ''}>${echapper(txt)}</option>`;
})).join('');
return `<label class="champ"${aide}><span>${titre}${req}</span><select onchange="${appel('this.value')}">${opts}</select></label>`; return `<label class="champ"${aide}><span>${titre}${req}</span><select onchange="${appel('this.value')}">${opts}</select></label>`;
} }
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 `<div style="grid-column:1/-1"${aide}><div class="section-tete">${titre}</div>`
+ `<div class="grille">${cases}</div></div>`;
}
if (spec.type === 'boolean') { if (spec.type === 'boolean') {
return `<label class="champ"${aide}><span>${titre}${req}</span><input type="checkbox"${valeur ? ' checked' : ''} onchange="${appel('this.checked')}"></label>`; return `<label class="champ"${aide}><span>${titre}${req}</span><input type="checkbox"${valeur ? ' checked' : ''} onchange="${appel('this.checked')}"></label>`;
} }
@ -2075,7 +2508,7 @@ HTML = r"""<!doctype html>
} }
function formulaireDepuisSchema(nomRegistre, entite, surChangement, cleCourante, surChangementCle) { function formulaireDepuisSchema(nomRegistre, entite, surChangement, cleCourante, surChangementCle) {
const reg = ((data.schema || {})[nomRegistre]) || null; const reg = schemaPlan[nomRegistre] || null;
if (!reg) return '<div class="vide">Aucun schéma pour ce registre — lancer <code>make schema</code>.</div>'; if (!reg) return '<div class="vide">Aucun schéma pour ce registre — lancer <code>make schema</code>.</div>';
const props = (reg.entite || {}).properties || {}; const props = (reg.entite || {}).properties || {};
const requis = new Set((reg.entite || {}).required || []); const requis = new Set((reg.entite || {}).required || []);
@ -2092,6 +2525,17 @@ HTML = r"""<!doctype html>
return `<div class="grille">${morceaux.join('')}</div>`; return `<div class="grille">${morceaux.join('')}</div>`;
} }
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 `<div class="grille">${cases}</div>`;
}
function dsnBase(a) { function dsnBase(a) {
const s = bdServeurs.find(x => x.nom === a.serveur); const s = bdServeurs.find(x => x.nom === a.serveur);
if (!s) return '(serveur inconnu : ' + echapper(a.serveur || '—') + ')'; if (!s) return '(serveur inconnu : ' + echapper(a.serveur || '—') + ')';
@ -2202,7 +2646,7 @@ HTML = r"""<!doctype html>
// en dur : un champ ajoute au registre serait apparu au formulaire genere et // en dur : un champ ajoute au registre serait apparu au formulaire genere et
// aurait disparu SILENCIEUSEMENT a l'enregistrement — le pire des deux mondes. // aurait disparu SILENCIEUSEMENT a l'enregistrement — le pire des deux mondes.
// Les valeurs par defaut viennent du schema (`default`), plus du JS. // 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 => { bdApplis.forEach(a => {
if (!String(a.cle).trim()) return; if (!String(a.cle).trim()) return;
const e = {}; const e = {};
@ -2617,6 +3061,7 @@ HTML = r"""<!doctype html>
if (vuePrincipale === 'applications') return inspecteurApplication(); if (vuePrincipale === 'applications') return inspecteurApplication();
if (vuePrincipale === 'bases') return inspecteurBase(); if (vuePrincipale === 'bases') return inspecteurBase();
if (vuePrincipale === 'domaines') return inspecteurDomaine(); if (vuePrincipale === 'domaines') return inspecteurDomaine();
if (vuePrincipale === 'nomenclature') return inspecteurNomenclature();
return inspecteurServeur(); return inspecteurServeur();
} }
@ -2898,6 +3343,7 @@ HTML = r"""<!doctype html>
else if (vuePrincipale === 'applications') sauvegarderApplications(); else if (vuePrincipale === 'applications') sauvegarderApplications();
else if (vuePrincipale === 'bases') sauvegarderBases(); else if (vuePrincipale === 'bases') sauvegarderBases();
else if (vuePrincipale === 'domaines') sauvegarderDomaines(); else if (vuePrincipale === 'domaines') sauvegarderDomaines();
else if (vuePrincipale === 'nomenclature') sauvegarderNomenclature();
else message('Vue en lecture seule (Flux / Couches) — édite dans Serveurs / Applications / Bases / Domaines.'); 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)) valider_serveurs(donnees, charger_nomenclature(FICHIER_NOMENCLATURE))
ecrire_serveurs(FICHIER_SERVEURS, donnees) ecrire_serveurs(FICHIER_SERVEURS, donnees)
self.repondre_json(200, inventaire_api(self.inventaire)) 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": elif chemin == "/api/domaines":
valider_domaines(donnees) valider_domaines(donnees)
ecrire_domaines(FICHIER_DOMAINES, donnees) ecrire_domaines(FICHIER_DOMAINES, donnees)

View file

@ -308,6 +308,61 @@ def preuve_schema_du_plan() -> tuple[bool, str]:
import couverture_gui as _cg import couverture_gui as _cg
observes = {nom: set(champs) for nom, champs in _cg.champs_utilises().items()} 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 = [], [] trous, inutilises = [], []
for nom, champs in observes.items(): for nom, champs in observes.items():
manquants = sorted(champs - decrit.get(nom, set())) manquants = sorted(champs - decrit.get(nom, set()))
@ -318,6 +373,12 @@ def preuve_schema_du_plan() -> tuple[bool, str]:
if jamais: if jamais:
inutilises.append(f"{nom}:{len(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: if trous:
return False, ("Le schema ne decrit pas tout ce que les plans contiennent :\n - " return False, ("Le schema ne decrit pas tout ce que les plans contiennent :\n - "
+ "\n - ".join(trous) + "\n - ".join(trous)
@ -326,8 +387,9 @@ def preuve_schema_du_plan() -> tuple[bool, str]:
n = sum(len(c) for c in decrit.values()) n = sum(len(c) for c in decrit.values())
suffixe = (f" ; {', '.join(inutilises)} champ(s) decrits et inutilises dans les plans " suffixe = (f" ; {', '.join(inutilises)} champ(s) decrits et inutilises dans les plans "
f"d'aujourd'hui (legitime)") if inutilises else "" f"d'aujourd'hui (legitime)") if inutilises else ""
return True, (f"Le schema decrit {n} champ(s) sur {len(decrit)} registres, et couvre " return True, (f"Le schema decrit {n} champ(s) sur {len(decrit)} registres ; il couvre "
f"tout ce que les plans reels contiennent{suffixe}.") 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]: def preuve_wiki_publie_a_jour() -> tuple[bool, str]:
@ -2713,10 +2775,16 @@ def preuve_documentation_outillage() -> tuple[bool, str]:
PREUVES: list[dict] = [ PREUVES: list[dict] = [
{"id": "P01", "titre": "Lint (ansible-lint)", "refs": ["AFF-006"], {"id": "P01", "titre": "Lint (ansible-lint)", "refs": ["AFF-006"],
"cmds": [["ansible-lint", "-q"]]}, "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"], "cmds": [[sys.executable, "scripts/tests/test_inventory_host.py"],
[sys.executable, "scripts/tests/test_raser.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"], {"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}, "func": preuve_diff_vide_toutes_instances},
{"id": "P04", "titre": "Groupes <-> playbooks homonymes", "refs": ["AFF-008"], {"id": "P04", "titre": "Groupes <-> playbooks homonymes", "refs": ["AFF-008"],

View file

@ -171,7 +171,8 @@ REGISTRES: dict = {
"index": {"type": "integer", "requis": True, "libelle": "Index (seed)", "index": {"type": "integer", "requis": True, "libelle": "Index (seed)",
"minimum": 0, "maximum": 255, "minimum": 0, "maximum": 255,
"aide": "LE seul champ d'adressage. Tout en derive ; P20 refuse d'en stocker un autre."}, "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 ». # LES TROIS TABLES IMBRIQUEES SONT DECRITES, PAS RESUMEES EN « object ».
# C'est P61 qui l'a impose : `categorie` et `service` etaient presents dans # 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 # tous les plans et absents du schema — donc un formulaire genere n'aurait
@ -188,17 +189,28 @@ REGISTRES: dict = {
"entree": { "entree": {
"categorie": {"type": "integer", "requis": True, "categorie": {"type": "integer", "requis": True,
"libelle": "Catégorie", "libelle": "Catégorie",
"source_valeurs": "nomenclature.categories",
"aide": "La zone. Fixe le 3e octet (15 + categorie) et le VLAN."}, "aide": "La zone. Fixe le 3e octet (15 + categorie) et le VLAN."},
"service": {"type": "integer", "requis": True, "service": {"type": "integer", "requis": True,
"libelle": "Service", "libelle": "Service",
"aide": "Fixe le bloc d'adresses de l'hote dans la zone."}}}, "aide": "Fixe le bloc d'adresses de l'hote dans la zone."}}},
# 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", "reservations": {"type": "object", "libelle": "Réservations",
"aide": "Adresses soustraites a la derivation dans chaque zone.", "aide": "Adresses soustraites a la derivation dans la zone.",
"entree": { "sous_champs": {
"passerelle": {"type": "integer", "libelle": "Passerelle", "passerelle": {"type": "integer", "libelle": "Passerelle",
"aide": "Dernier octet de la passerelle. P23 refuse un SVI qui s'en ecarte."}, "aide": "Dernier octet de la passerelle. P23 refuse un SVI qui s'en ecarte."},
"reserve_min": {"type": "integer", "libelle": "Réserve (min)"}, "reserve_min": {"type": "integer", "libelle": "Réserve (min)",
"reserve_max": {"type": "integer", "libelle": "Réserve (max)"}}}, "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"]} q = {"type": sd["type"], "title": sd["libelle"]}
if "aide" in sd: if "aide" in sd:
q["description"] = sd["aide"] 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 sous[sc] = q
if sd.get("requis"): if sd.get("requis"):
sous_requis.append(sc) sous_requis.append(sc)
@ -231,6 +248,21 @@ def construire() -> dict:
"additionalProperties": False} "additionalProperties": False}
if sous_requis: if sous_requis:
p["additionalProperties"]["required"] = sorted(sous_requis) p["additionalProperties"]["required"] = sorted(sous_requis)
if "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: if "source_selon" in d:
# Meme intention que `x-source-valeurs`, mais la liste change selon la # Meme intention que `x-source-valeurs`, mais la liste change selon la
# valeur d'un autre champ de la meme entite. # 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", ap.add_argument("--verifier", action="store_true",
help="compare sans ecrire ; code 1 si le fichier est perime") help="compare sans ecrire ; code 1 si le fichier est perime")
args = ap.parse_args(argv) 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: if args.verifier:
actuel = CIBLE.read_text(encoding="utf-8") if CIBLE.is_file() else "" actuel = CIBLE.read_text(encoding="utf-8") if CIBLE.is_file() else ""
if actuel != rendu: if actuel != rendu:

View file

@ -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()

View file

@ -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"<script>(.*?)</script>", html, re.S)
assert blocs, "aucun bloc <script>"
cls.js = BANC + "\n".join(blocs) + APRES
def _jouer(self, js: str, payload: dict) -> list:
with tempfile.NamedTemporaryFile("w", suffix=".js", delete=False, encoding="utf-8") as f:
f.write(js)
chemin = f.name
try:
r = subprocess.run(["node", chemin], capture_output=True, text=True, timeout=120,
env={**__import__("os").environ, "PAYLOAD": json.dumps(payload)})
finally:
Path(chemin).unlink(missing_ok=True)
marque = "__RESULTAT__"
for ligne in r.stdout.splitlines():
if ligne.startswith(marque):
return json.loads(ligne[len(marque):])
self.fail(f"le banc n'a rien rendu.\nstdout: {r.stdout[-2000:]}\nstderr: {r.stderr[-2000:]}")
def test_toutes_les_vues_rendent(self):
"""La vraie reponse de l'API, les neuf vues, zero exception."""
payload = g.inventaire_api(g.INVENTAIRE_DEFAUT)
erreurs = self._jouer(self.js, payload)
self.assertEqual(erreurs, [], "des vues ont leve :\n - " + "\n - ".join(erreurs))
def test_controle_negatif_reference_absente(self):
"""LE CONTROLE. Une lecture d'identifiant inexistant DOIT faire echouer le banc.
Sans lui, ce test passerait aussi bien sur un banc qui n'execute rien. On rejoue
exactement la faute du 2026-09-08 : le formulaire des bases lit un global qui
n'existe pas.
"""
casse = self.js.replace("const reg = schemaPlan[nomRegistre] || null;",
"const reg = (donneesQuiNexistentPas.schema || {})[nomRegistre] || null;")
self.assertNotEqual(casse, self.js, "le motif du controle negatif n'existe plus")
payload = g.inventaire_api(g.INVENTAIRE_DEFAUT)
erreurs = self._jouer(casse, payload)
self.assertTrue(any("donneesQuiNexistentPas" in e for e in erreurs),
f"le banc n'a pas vu la reference absente : {erreurs}")
def test_le_schema_est_bien_servi(self):
"""Le formulaire ne peut rien generer si l'API n'envoie pas le schema."""
payload = g.inventaire_api(g.INVENTAIRE_DEFAUT)
self.assertIn("schema", payload, "l'API ne sert pas le schema du plan")
self.assertIn("nomenclature", payload["schema"])
if __name__ == "__main__":
unittest.main()

View file

@ -26,7 +26,7 @@ Une bonne console d'exploitation obéit à trois règles :
| Vues **éditables** (le plan) | Vues **dérivées** (lecture seule) | | Vues **éditables** (le plan) | Vues **dérivées** (lecture seule) |
|---|---| |---|---|
| Serveurs · **Intégrations** · Applications · Bases · Domaines · **Intrants** | Flux · Couches · **Réseau** (flotte + devis) | | Serveurs · **Intégrations** · Applications · Bases · Domaines · **Nomenclature** · **Intrants** | Flux · Couches · **Réseau** (flotte + devis) |
Le flux d'exploitation, de bout en bout : Le flux d'exploitation, de bout en bout :
@ -40,6 +40,13 @@ Le flux d'exploitation, de bout en bout :
propose que des **hôtes réels** : impossible de pointer vers un hôte fantôme. La console couvre le propose que des **hôtes réels** : impossible de pointer vers un hôte fantôme. La console couvre le
schéma du plan — et la preuve **P19** le vérifie. schéma du plan — et la preuve **P19** le vérifie.
**Les formulaires sont GÉNÉRÉS.** Depuis le 2026-09-08, les vues **Bases** et **Nomenclature** ne
portent plus de formulaire écrit à la main : elles le construisent depuis
`docs/audit/schema-plan.json`, lui-même dérivé des constantes du moteur (`make schema`). Un champ
ajouté au plan apparaît donc à l'écran **sans qu'on touche à l'interface**, et la preuve **P61**
refuse qu'un champ des plans réels manque au schéma — ou que le schéma décrive une **forme** que
les plans n'ont pas.
**La flotte, dans la vue Réseau.** Voir toutes les instances, **basculer** (« Activer »), **créer** **La flotte, dans la vue Réseau.** Voir toutes les instances, **basculer** (« Activer »), **créer**
une instance depuis un modèle, repérer une **collision** d'index. Cf. une instance depuis un modèle, repérer une **collision** d'index. Cf.
[Multi-instance & fédération](Multi-instance-et-fédération). [Multi-instance & fédération](Multi-instance-et-fédération).
@ -54,6 +61,12 @@ VMID/IP/VLAN **dérivées** du seed ; formulaire **Identité** = le seul endroit
![Vue Serveurs, annotée](img/Set-OPS-Serveurs-annote.svg) ![Vue Serveurs, annotée](img/Set-OPS-Serveurs-annote.svg)
**Nomenclature** *(éditable)* — le **modèle** dont tout l'adressage dérive : zones de sécurité,
fonctions (catégorie · service), réservations. Chaque fonction montre **ce qu'elle dérive** — zone,
VLAN, sous-réseau, bloc d'hôtes — et les VM qui la portent ; on ne retire pas une fonction encore
portée. L'`index` y est **affiché mais pas éditable** : il est *alloué par le site*, pas décidé par
le tenant (cf. l'en-tête de `plan/nomenclature.yml`), et s'édite depuis le panneau **Intrants**.
**Applications** *(éditable)* — catalogue des applis, éditeur (rôle · hôte · port · FQDN exposé), **Applications** *(éditable)* — catalogue des applis, éditeur (rôle · hôte · port · FQDN exposé),
relations déclaratives (requiert · liens · bases), dépendances causales. relations déclaratives (requiert · liens · bases), dépendances causales.