diff --git a/AGENTS.md b/AGENTS.md index 008ceb1..11024ab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -188,7 +188,7 @@ Si `ansible-lint` n’est pas disponible, le signaler clairement. Ne pas invente ## Écrire, puis relire (D-68) `--syntax-check` et `ansible-lint` prouvent que le dépôt est cohérent **avec lui-même**. -C'est aussi ce que font les 61 preuves de `make prouver` : elles lisent le dépôt, sans le +C'est aussi ce que font les 62 preuves de `make prouver` : elles lisent le dépôt, sans le moindre appel réseau. **Aucune ne demande au système déployé s'il ressemble à ce que le dépôt annonce.** diff --git a/CHANGELOG.md b/CHANGELOG.md index 9179659..41a2bac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,75 @@ # CHANGELOG — Set-OPS +## 2026-09-08 (3) — Quarante lignes de memoire que chaque « Sauvegarder » detruisait + +**62 preuves (P01-P62). `make prouver` : CONFORME, 61 OK, 0 echec, 1 saute.** +Quatre registres sur six ont desormais un formulaire GENERE. + +### Ce que j'ai trouve en voulant generer deux formulaires de plus + +Avant de basculer les vues **Bases (serveurs de BD)** et **Domaines** sur le generateur, +j'ai confronte le schema a ce que les VALIDATEURS acceptent — pas seulement a ce que les +plans contiennent. Trois ecarts, et un quatrieme trouve en chemin. + +**`edge` designe un GROUPE, pas un hote.** Le schema declarait `source_valeurs: serveurs`. +`instancier` compare pourtant cette valeur aux GROUPES d'un hote (`e.get("edge") in +groupes`) pour lui deriver ses SAN de certificat. Un formulaire genere aurait offert +`web-frontal-01`, qu'aucun hote n'aurait reconnu : aucun SAN, donc un certificat correct +sur un nom que personne ne peut appeler. C'est mot pour mot la panne du 2026-08-25, qu'une +liste mal choisie aurait reintroduite. + +**`exposition` manquait au schema.** `valider_domaines` le valide entierement — une liste +de `{nom, cible, type}` — et le schema l'ignorait. Aucun plan ne s'en sert : P61 etait au +vert. Le formulaire genere n'aurait donc jamais pu offrir une fonctionnalite que le moteur +possede deja. + +**`applications.liens` etait decrit `items: {type: object}`** — une liste d'objets sans +forme. Le moteur exige `vers` et `role`. + +**`mail` faisait l'inverse** : offert par la vue Domaines depuis sa creation, decrit ici +comme un booleen, saisi la-bas comme du texte, et lu par RIEN. Retire. + +D'ou **P62** : les champs qu'un validateur lit sur l'entite qu'il valide doivent tous etre +decrits. Elle separe l'entite du reste mecaniquement — un validateur lit son entite par +des variables LOCALES, et les autres registres par ses PARAMETRES (`srv.get("fonction")` +contre `nomenclature.get("fonctions")`). Aucune liste a tenir. Controle negatif rejoue. + +### Et le degat qui etait deja la + +En verifiant que le nouveau formulaire n'abimerait pas `domaines.yml`, j'ai mesure les +quatre ecrivains de registre sur les fichiers REELS : + + domaines.yml 6 lignes de commentaire -> 3 (PERD 3) + applications.yml 27 -> 5 (PERD 22) + serveurs.yml 18 -> 3 (PERD 15) + bases-donnees.yml 4 -> 4 (intact — il n'en portait pas) + +**Quarante lignes**, detruites par n'importe quel clic sur « Sauvegarder » dans les vues +Serveurs, Applications ou Domaines. Parmi elles, celle qui explique pourquoi `backup-01` a +ete retire — *« une supervision creuse est pire qu'aucune : elle est verte »* — et celle +qui dit dans quel ORDRE les deux roles du runner s'appliquent. + +C'etait l'incident du 2026-08-18 (94 lignes perdues dans les fichiers d'intrants), **jamais +corrige pour les registres du plan** : `_fusion_chirurgicale` avait ete ecrite pour les +intrants seuls, et les quatre `ecrire_*` etaient restes au `safe_dump`. Ils passent tous +par `_ecrire_registre` maintenant. Aller-retour a vide : les quatre fichiers sont +**identiques a l'octet**. Une modification reelle ne touche que ses lignes. +`scripts/tests/test_ecriture_plan.py` le mesure sur les vrais fichiers, controle negatif +compris. + +### Les deux formulaires + +**Serveurs de BD** et **Domaines** sont generes, chargement et sauvegarde compris. Le +generateur a appris une forme de plus : la **liste d'objets** (`exposition`, `liens`), avec +son bouton d'ajout et le retrait par ligne. L'ajout passe par le meme setter que les +champs — on lui remet le tableau entier, serialise dans l'attribut — plutot que par une +fonction globale a resoudre au clic : ce qui marche sous le banc marche dans le navigateur. + +### La limite + +**Quatre registres sur six.** Restent `serveurs` et `applications`, les deux plus gros. +Et je n'ai toujours pas ouvert ces pages dans un navigateur. + ## 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.** diff --git a/docs/audit/preuve-2026-09-08.md b/docs/audit/preuve-2026-09-08.md index 56596e0..632c982 100644 --- a/docs/audit/preuve-2026-09-08.md +++ b/docs/audit/preuve-2026-09-08.md @@ -7,14 +7,14 @@ > [`docs/audit/affirmations.md`](affirmations.md). - **Instance** : `/home/danallaire/Espace Chezlepro/DépôtsSurForge/Set-OPS-public/instance` — inventaire `/home/danallaire/Espace Chezlepro/DépôtsSurForge/Set-OPS-public/instance/inventories/principal/hosts.yml` -- **Verdict** : ✅ CONFORME (60 OK · 0 echec · 1 saute) +- **Verdict** : ✅ CONFORME (61 OK · 0 echec · 1 saute) ## Preuves | # | Preuve | Affirmations | Statut | Detail | |---|---|---|---|---| | P01 | Lint (ansible-lint) | AFF-006 | ✅ OK |  | -| P02 | Tests unitaires (inventaire, raser, ecriture atomique, rendu du GUI, nomenclature) | — | ✅ OK | OK | +| P02 | Tests unitaires (inventaire, raser, ecritures du plan, rendu du GUI) | — | ✅ OK | OK | | P03 | Diff-vide du plan — TOUTES les instances | AFF-001, AFF-004, AFF-030, AFF-031, AFF-032 | ✅ OK | 4 instance(s) verifiee(s) — OPS-Chezlepro-lab, OPS-Chezlepro, OPS-Technolibre, OPS-Patient0 : plan et inventaire applique coincident. | | P04 | Groupes <-> playbooks homonymes | AFF-008 | ✅ OK | | | P05 | Dependances causales de groupes | AFF-009, AFF-084 | ✅ OK | | @@ -69,11 +69,12 @@ | P54 | L'insemination ne reclame aucun secret du tenant | — | ✅ OK | 2 couche(s) d'insemination (serveur_debian, serveur_ops), 10 role(s) applique(s), aucun secret de tenant reclame. | | P55 | La cle du SITE ne nait que sur le runner d'un tenant | — | ✅ OK | 14 hote(s) : la cle du SITE ne nait que sur 1 runner(s) de tenant, celle du tenant sur 14. | | P56 | Gabarit minimal, et rien de retire n'est perdu | — | ✅ OK | Gabarit minimal : 4 role(s), tous indispensables au premier demarrage ; 14 role(s) retire(s), tous repris par le socle ou le durcissement. | -| P57 | Comptes en prose : les chiffres du depot sur lui-meme | — | ✅ OK | Les comptes ecrits en prose correspondent a la mesure (61 preuves, 65 roles, 40 groupes). | +| P57 | Comptes en prose : les chiffres du depot sur lui-meme | — | ✅ OK | Les comptes ecrits en prose correspondent a la mesure (62 preuves, 65 roles, 40 groupes). | | P58 | Habilitations : chaque service dit a quel GROUPE, et par quoi | — | ✅ OK | 8 habilitation(s) declarees, toutes nommant un groupe, un mecanisme connu et une raison ; les `role-realm` sont projetees. | | P59 | Enumerations annoncees : le nombre correspond a ce qui suit | — | ✅ OK | 2 enumeration(s) annoncee(s) correspondent a ce qu'elles annoncent (formes non ambigues seulement). | | P60 | Wiki publie : la forge sert ce que le depot dit | AFF-002 | ✅ OK | Le wiki publie correspond au depot : `wiki/` n'a pas bouge depuis `88033f3` (publie le 2026-09-08). | -| 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 | +| P61 | Schema du plan : il decrit tout ce que les plans contiennent | AFF-033 | ✅ OK | Le schema decrit 47 champ(s) sur 6 registres ; il couvre tout ce que les plans reels contiennent, et la FORME de chaque champ (scalaire / objet / table) corresp | +| P62 | Schema du plan : il decrit tout ce que le MOTEUR accepte | AFF-033 | ✅ OK | Les 4 validateurs n'acceptent aucun champ que le schema ignore (applications:8, bases_donnees:4, domaines_publics:5, serveurs:3 champ(s) lus par validateur). | ## Couverture des affirmations ✅ du registre diff --git a/docs/audit/schema-plan.json b/docs/audit/schema-plan.json index 8a0bd46..a11cce5 100644 --- a/docs/audit/schema-plan.json +++ b/docs/audit/schema-plan.json @@ -113,7 +113,25 @@ "title": "Liens (bindings)", "description": "Roles acceptes par le role porteur (meta/liens.yml).", "items": { - "type": "object" + "type": "object", + "properties": { + "vers": { + "type": "string", + "title": "Vers", + "description": "L'application liee.", + "x-source-valeurs": "applications" + }, + "role": { + "type": "string", + "title": "Rôle", + "description": "Le role du lien, accepte par meta/liens.yml." + } + }, + "additionalProperties": false, + "required": [ + "role", + "vers" + ] } }, "websocket": { @@ -258,11 +276,13 @@ "edge": { "type": "string", "title": "Edge", - "x-source-valeurs": "serveurs" + "description": "Le GROUPE Ansible qui sert cette zone (ex. serveur_nginx).", + "x-source-valeurs": "groupes_edge" }, "secondaires": { "type": "array", "title": "Secondaires", + "description": "Serveurs DNS secondaires de la zone.", "items": { "type": "string" } @@ -271,13 +291,43 @@ "type": "boolean", "title": "DNSSEC" }, - "mail": { - "type": "boolean", - "title": "Courriel", - "description": "La zone porte des enregistrements de messagerie." + "exposition": { + "type": "array", + "title": "Expositions declarees", + "description": "FQDN publies pour cette zone, vers un groupe cible.", + "items": { + "type": "object", + "properties": { + "nom": { + "type": "string", + "title": "Nom", + "description": "Sous-domaine, ou `@` pour la zone elle-meme." + }, + "cible": { + "type": "string", + "title": "Cible", + "description": "Le groupe interne qui sert ce nom.", + "x-source-valeurs": "groupes_operationnels" + }, + "type": { + "type": "string", + "title": "Type", + "default": "web" + } + }, + "additionalProperties": false, + "required": [ + "cible", + "nom" + ] + } } }, - "additionalProperties": false + "additionalProperties": false, + "required": [ + "autorite", + "edge" + ] }, "x-clef": { "title": "Domaine", diff --git a/docs/devis-services.md b/docs/devis-services.md index 7e3b476..64cdef6 100644 --- a/docs/devis-services.md +++ b/docs/devis-services.md @@ -30,7 +30,7 @@ make placement-plan # chaque VM est-elle là où le plan la met ## Le trou qu'il comble -`scripts/prouver.py` porte 61 preuves (dont une conditionnelle, sautée sans la clé de la voûte). Elles sont toutes **statiques** : elles lisent le +`scripts/prouver.py` porte 62 preuves (dont une conditionnelle, sautée sans la clé de la voûte). Elles sont toutes **statiques** : elles lisent le dépôt. Zéro appel réseau, zéro SSH, zéro `ansible`. Elles établissent que le dépôt est cohérent **avec lui-même** — que les handlers existent, que les intrants ont un propriétaire, que rien n'est codé en dur. diff --git a/scripts/inventory_gui.py b/scripts/inventory_gui.py index 6a819eb..b0ffabb 100644 --- a/scripts/inventory_gui.py +++ b/scripts/inventory_gui.py @@ -569,8 +569,6 @@ CHAMPS_ECRITS_A_LA_MAIN = { "serveurs": {"fonction", "etat", "noeud", "stockage", "disque", "memoire", "coeurs", "integrations"}, "applications": {"groupe", "hote", "port", "requiert", "expose", "liens", "websocket"}, - "serveurs_bd": {"type", "hote", "port", "groupe"}, - "domaines_publics": {"autorite", "edge", "secondaires", "dnssec", "mail"}, # nomenclature : plus AUCUN champ a la main non plus — elle est passee au generateur # le 2026-09-08, et c'etait le dernier registre que le GUI ne savait pas ecrire du # tout. Ajouter une fonction n'exige plus d'editer le YAML. @@ -578,7 +576,7 @@ CHAMPS_ECRITS_A_LA_MAIN = { } # Les registres dont le formulaire est GENERE : leurs champs viennent du schema. -REGISTRES_GENERES = {"bases_donnees", "nomenclature"} +REGISTRES_GENERES = {"bases_donnees", "nomenclature", "serveurs_bd", "domaines_publics"} def champs_ecrits_par_gui() -> dict: @@ -597,10 +595,9 @@ def champs_ecrits_par_gui() -> dict: 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) + for conteneur in (spec, spec.get("additionalProperties"), spec.get("items")): + if isinstance(conteneur, dict) and isinstance(conteneur.get("properties"), dict): + vus |= _noms(conteneur["properties"]) return vus resultat = {nom: set(champs) for nom, champs in CHAMPS_ECRITS_A_LA_MAIN.items()} @@ -1063,6 +1060,44 @@ def _ecrire_index_nomenclature(index: int) -> None: fichier.write(nouveau) +def _ecrire_registre(path: Path, entete: str, contenu: dict) -> None: + """Ecrit un registre du plan SANS effacer les commentaires qu'il porte. + + MESURE DU 2026-09-08, sur les fichiers reels de cet ecosysteme : + + domaines.yml 6 lignes de commentaire -> 3 (PERD 3) + applications.yml 27 -> 5 (PERD 22) + serveurs.yml 18 -> 3 (PERD 15) + bases-donnees.yml 4 -> 4 (intact, il n'en a pas) + + Quarante lignes, detruites par n'importe quel « Sauvegarder » depuis les vues + Serveurs, Applications ou Domaines. Parmi elles, celle qui explique pourquoi + `backup-01` a ete RETIRE : « une supervision creuse est pire qu'aucune — elle est + verte ». Et celle qui dit dans quel ORDRE les deux roles du runner s'appliquent. + + C'est l'incident du 2026-08-18 (94 lignes perdues dans les fichiers d'intrants), + jamais corrige pour les registres du plan : `_fusion_chirurgicale` avait ete ecrite + pour les intrants seuls, et les quatre `ecrire_*` sont restes au `safe_dump`. + + Les registres du plan ont tous la meme forme — une clef racine, une table d'entites — + donc chaque clef racine est declaree comme TABLE : `_fusion_table` edite alors les + lignes concernees, une entite retiree disparait, et le reste n'est pas touche. + + Un fichier qui n'existe pas encore recoit son entete et un `safe_dump` : il n'y a + rien a preserver. + """ + if path.exists(): + texte = path.read_text(encoding="utf-8") + if texte.strip(): + with ecriture_atomique(path) as fichier: + fichier.write(_fusion_chirurgicale(texte, contenu, tables=set(contenu))) + return + with ecriture_atomique(path) as fichier: + fichier.write(entete) + yaml.safe_dump(contenu, fichier, default_flow_style=False, + sort_keys=False, allow_unicode=True) + + def ecrire_bases(path: Path, registre: dict) -> None: entete = ( "# Registre des bases de donnees Set-OPS (serveurs_bd + bases_donnees).\n" @@ -1071,13 +1106,10 @@ def ecrire_bases(path: Path, registre: dict) -> None: "# 'secret' nomme une variable Ansible Vault (jamais le mot de passe).\n" "---\n" ) - contenu = { + _ecrire_registre(path, entete, { "serveurs_bd": registre.get("serveurs_bd", {}) or {}, "bases_donnees": registre.get("bases_donnees", {}) or {}, - } - with ecriture_atomique(path) as fichier: - fichier.write(entete) - yaml.safe_dump(contenu, fichier, default_flow_style=False, sort_keys=False, allow_unicode=True) + }) def ecrire_applications(path: Path, registre: dict) -> None: @@ -1089,10 +1121,7 @@ def ecrire_applications(path: Path, registre: dict) -> None: "# les roles acceptes sont ceux de roles//meta/liens.yml.\n" "---\n" ) - with ecriture_atomique(path) as fichier: - fichier.write(entete) - yaml.safe_dump({"applications": registre.get("applications", {}) or {}}, - fichier, default_flow_style=False, sort_keys=False, allow_unicode=True) + _ecrire_registre(path, entete, {"applications": registre.get("applications", {}) or {}}) def ecrire_serveurs(path: Path, registre: dict) -> None: @@ -1102,10 +1131,7 @@ def ecrire_serveurs(path: Path, registre: dict) -> None: "# VMID/IP/VLAN/passerelle sont DERIVES de la fonction via instance/plan/nomenclature.yml.\n" "---\n" ) - with ecriture_atomique(path) as fichier: - fichier.write(entete) - yaml.safe_dump({"serveurs": registre.get("serveurs", {}) or {}}, - fichier, default_flow_style=False, sort_keys=False, allow_unicode=True) + _ecrire_registre(path, entete, {"serveurs": registre.get("serveurs", {}) or {}}) def ecrire_domaines(path: Path, registre: dict) -> None: @@ -1115,10 +1141,7 @@ def ecrire_domaines(path: Path, registre: dict) -> None: "# autorite : primaire-cache | auto-heberge | delegue (label descriptif).\n" "---\n" ) - with ecriture_atomique(path) as fichier: - fichier.write(entete) - yaml.safe_dump({"domaines_publics": registre.get("domaines_publics", {}) or {}}, - fichier, default_flow_style=False, sort_keys=False, allow_unicode=True) + _ecrire_registre(path, entete, {"domaines_publics": registre.get("domaines_publics", {}) or {}}) def _extraire_echec(ligne: str, tache: str) -> dict | None: @@ -2105,23 +2128,25 @@ HTML = r""" } function chargerDomaines(data) { + // Les valeurs restent DE LEUR TYPE (les listes restent des listes) : le formulaire + // est genere depuis le schema, qui sait deja qu'une liste se saisit par virgules. + // L'ancien chargement les aplatissait en texte, et la sauvegarde devait deviner + // comment les reconstruire. const dp = ((data.domaines || {}).domaines_publics) || {}; - domaines = Object.entries(dp).map(([nom, x]) => ({ - nom, autorite: x.autorite || 'auto-heberge', edge: x.edge || 'serveur_nginx', - dnssec: !!x.dnssec, secondaires: (x.secondaires || []).join(', '), mail: x.mail || ''})); + domaines = Object.entries(dp).map(([nom, x]) => Object.assign({nom}, x)); domainesModifie = false; } function marquerDomainesModifie() { domainesModifie = true; majIndicateurSauvegarde(); } function choisirDomaine(i) { selDomaine = i; dessiner(); } function definirDomaine(champ, v) { const d = domaines[selDomaine]; if (!d) return; - d[champ] = v; marquerDomainesModifie(); + poserChemin(d, champ, v); marquerDomainesModifie(); if (champ === 'nom') { const t = document.querySelector('#tuile-dom-' + selDomaine + ' .carte-nom'); if (t) { t.textContent = v || '(sans nom)'; t.className = 'carte-nom' + (v ? '' : ' sans'); } } - else if (champ === 'autorite' || champ === 'edge') dessinerDetail(); + else dessinerDetail(); } function ajouterDomaine() { const edgeDefaut = groupes.includes('serveur_nginx') ? 'serveur_nginx' : (groupes[0] || ''); - domaines.unshift({nom: '', autorite: 'auto-heberge', edge: edgeDefaut, dnssec: false, secondaires: '', mail: ''}); + domaines.unshift({nom: '', autorite: 'auto-heberge', edge: edgeDefaut, dnssec: false, secondaires: []}); selDomaine = 0; marquerDomainesModifie(); dessiner(); const c = document.querySelector('#detail .grille input'); if (c) c.focus(); } @@ -2372,10 +2397,6 @@ HTML = r""" return; } const d = domaines[selDomaine]; - const optAut = AUTORITES_DNS.map(a => ``).join(''); - const grpEdge = groupes.filter(g => g.startsWith('serveur_nginx')); - if (d.edge && !grpEdge.includes(d.edge)) grpEdge.push(d.edge); - const optEdge = grpEdge.map(g => ``).join(''); const fqdns = expositionsSous(d.nom); const liens = fqdns.length ? `
${fqdns.map(f => `
${echapper(f)}
`).join('')}
` @@ -2384,24 +2405,28 @@ HTML = r"""
${echapper(d.nom || '(sans nom)')}
-
- - - - - - -
+ ${formulaireDepuisSchema('domaines_publics', d, 'definirDomaine', d.nom, "definirDomaine.bind(null,'nom')")}
Expositions sous cette zone
${liens} `; } async function sauvegarderDomaines() { + // SAUVEGARDE DERIVEE DU SCHEMA : les champs ecrits sont ceux qu'il declare. Un + // champ ajoute au registre apparait au formulaire ET arrive au fichier — sans quoi + // il serait saisissable et perdu en silence, le pire des deux mondes. const dp = {}; + const propsD = ((schemaPlan.domaines_publics || {}).entite || {}).properties || {}; domaines.forEach(d => { if (!String(d.nom).trim()) return; - const o = {autorite: d.autorite, edge: d.edge, secondaires: listeDepuisTexte(d.secondaires), dnssec: !!d.dnssec}; - if (String(d.mail).trim()) o.mail = d.mail; + const o = {}; + Object.keys(propsD).forEach(cle => { + const v = d[cle]; + if (v === undefined || v === null || v === '') { + if (propsD[cle].default !== undefined) o[cle] = propsD[cle].default; + return; + } + o[cle] = (propsD[cle].type === 'boolean') ? !!v : v; + }); dp[d.nom] = o; }); const rep = await fetch('/api/domaines', { @@ -2444,6 +2469,10 @@ HTML = r""" // 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. + // `edge` designe un GROUPE Ansible : `instancier` compare cette valeur aux + // groupes d'un hote pour lui deriver ses SAN. Offrir des HOTES ici produirait un + // certificat correct sur un nom que personne ne peut appeler. + if (nom === 'groupes_edge') return groupes.filter(g => g.startsWith('serveur_nginx')); if (nom === 'nomenclature.categories') { const cats = (nomEdit.categories || {}); return Object.keys(cats).map(k => ({v: k, t: k + ' — ' + ((cats[k] || {}).libelle || '?')})); @@ -2498,6 +2527,9 @@ HTML = r""" if (spec.type === 'integer') { return ``; } + if (spec.type === 'array' && (spec.items || {}).properties) { + return listeDepuisSchema(cle, spec, valeur, surChangement); + } if (spec.type === 'array') { // Une liste de textes se saisit separee par des virgules : le YAML reste une // liste, la saisie reste lisible. @@ -2525,6 +2557,50 @@ HTML = r""" return `
${morceaux.join('')}
`; } + function poserChemin(objet, chemin, valeur) { + // `reservations.passerelle`, `exposition.0.cible` : un seul mecanisme pour toute + // la profondeur, tableaux compris. Les setters de chaque vue s'appuient dessus. + const parts = String(chemin).split('.'); + let o = objet; + for (let i = 0; i < parts.length - 1; i++) { + const suivant = parts[i + 1]; + if (o[parts[i]] == null || typeof o[parts[i]] !== 'object') { + o[parts[i]] = String(suivant).match(/^\d+$/) ? [] : {}; + } + o = o[parts[i]]; + } + o[parts[parts.length - 1]] = valeur; + } + + function listeDepuisSchema(cle, spec, valeur, surChangement) { + // UNE LISTE D'OBJETS de forme connue (`entrees` au schema) : une sous-fiche par + // element, plus un bouton d'ajout. L'ajout et le retrait passent par le MEME + // setter que les champs — on lui remet le tableau entier, serialise dans + // l'attribut. Pas de fonction globale a resoudre au clic : ce qui marche sous le + // banc marche dans le navigateur. + const items = spec.items || {}; + const arr = Array.isArray(valeur) ? valeur : []; + const defauts = {}; + Object.keys(items.properties || {}).forEach(c => { + if (items.properties[c].default !== undefined) defauts[c] = items.properties[c].default; + }); + const ajout = echapper(JSON.stringify(arr.concat([defauts]))); + const lignes = arr.map((e, i) => { + const retrait = echapper(JSON.stringify(arr.filter((_, j) => j !== i))); + return `
+
+ #${i + 1} +
+ ${sousFormulaireDepuisSchema(items, e, cle + '.' + i, surChangement)}
`; + }).join(''); + return `
+
${echapper(spec.title || cle)} ${arr.length} +
+ ${arr.length ? lignes : '
aucune entrée
'}
`; + } + 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. @@ -2593,19 +2669,11 @@ HTML = r""" if (info.kind === 'srv') { const s = bdServeurs[info.i]; if (!s) { cible.innerHTML = '
Introuvable.
'; return; } - const optHote = '' + hotes.map(h => h.nom).filter(Boolean).map(n => ``).join(''); - const optGroupe = '' + groupes.map(g => ``).join(''); const ici = bdApplis.filter(a => a.serveur === s.nom); const iciHtml = ici.length ? `
${ici.map(a => `
${echapper(a.portee || 'groupe')} ${echapper(a.base || '?')} (${echapper(a.cle)})
`).join('')}
` : '
aucune base sur ce serveur
'; cible.innerHTML = `
${echapper(s.nom || '(sans nom)')}
${echapper(s.type || 'bd')}
-
- - - - - -
+ ${formulaireDepuisSchema('serveurs_bd', s, 'definirServeurBd', s.nom, "definirServeurBd.bind(null,'nom')")}
Bases hébergées ici
${iciHtml} ${(() => { const hS = hotes.find(h => h.nom === s.hote); @@ -2640,7 +2708,17 @@ HTML = r""" async function sauvegarderBases() { const sd = {}; - bdServeurs.forEach(s => { if (String(s.nom).trim()) sd[s.nom] = {type: s.type, hote: s.hote, port: parseInt(s.port, 10) || s.port, ...(s.groupe ? {groupe: s.groupe} : {})}; }); + const propsSrv = ((schemaPlan.serveurs_bd || {}).entite || {}).properties || {}; + bdServeurs.forEach(s => { + if (!String(s.nom).trim()) return; + const o = {}; + Object.keys(propsSrv).forEach(cle => { + const v = s[cle]; + if (v === undefined || v === null || v === '') return; + o[cle] = (propsSrv[cle].type === 'integer') ? (parseInt(v, 10) || v) : v; + }); + sd[s.nom] = o; + }); const bd = {}; // SAUVEGARDE DERIVEE DU SCHEMA (2026-09-08). Les sept champs etaient enumeres ici // en dur : un champ ajoute au registre serait apparu au formulaire genere et diff --git a/scripts/prouver.py b/scripts/prouver.py index 7a3c382..d15f89b 100644 --- a/scripts/prouver.py +++ b/scripts/prouver.py @@ -296,9 +296,13 @@ def preuve_schema_du_plan() -> tuple[bool, str]: vus = set() for nom, p in (props or {}).items(): vus.add(nom) - sous = p.get("additionalProperties") - if isinstance(sous, dict): - vus |= _noms(sous.get("properties")) + # Trois imbrications possibles : table a clefs libres (`additionalProperties`), + # bloc a clefs fixes (`properties`), liste d'objets (`items.properties`). + for sous in (p.get("additionalProperties"), p.get("items")): + if isinstance(sous, dict): + vus |= _noms(sous.get("properties")) + if isinstance(p.get("properties"), dict): + vus |= _noms(p["properties"]) return vus decrit = {nom: _noms(reg["entite"]["properties"]) @@ -392,6 +396,101 @@ def preuve_schema_du_plan() -> tuple[bool, str]: f"(scalaire / objet / table) correspond dans {len(plans)} plan(s){suffixe}.") +def preuve_schema_couvre_les_validateurs() -> tuple[bool, str]: + """Le schema decrit-il tout ce que le MOTEUR accepte — pas seulement ce que les plans ont ? + + LA LACUNE QUE CETTE PREUVE COMBLE (2026-09-08). P61 confronte le schema aux plans + REELS : tout champ present quelque part doit etre decrit. C'est necessaire, et ca ne + suffit pas. Un champ que les validateurs acceptent mais qu'aucun plan n'emploie + ENCORE passe entre les mailles — et le formulaire genere depuis le schema ne saura + jamais l'offrir. L'operateur devra rouvrir le YAML pour une fonctionnalite que le + moteur possede deja. + + DEUX CAS MESURES, LE JOUR OU CETTE PREUVE A ETE ECRITE : + + - `valider_domaines` valide entierement `exposition` (une liste de `{nom, cible, + type}`) et le schema l'ignorait. Aucun plan ne s'en sert : P61 etait au vert. + - `applications.liens` etait decrit `items: {type: object}` — une liste d'objets + sans forme. Le moteur, lui, exige `vers` et `role`. Un formulaire genere aurait + offert « ajouter un lien » sans savoir quelles cases y mettre. + + COMMENT ON SEPARE L'ENTITE DU RESTE. Un validateur lit deux choses : l'entite qu'il + valide, et d'AUTRES registres passes en parametres pour la coherence croisee + (`valider_serveurs` lit `nomenclature.get("fonctions")`). Les champs de l'entite sont + donc les acces dont le destinataire est une variable LOCALE — jamais un parametre. + C'est ce qui distingue `srv.get("fonction")` de `nomenclature.get("fonctions")`, et + c'est mecanique : aucune liste a tenir. + + CE QU'ELLE NE FAIT PAS. Elle ne verifie pas l'inverse (un champ decrit qu'aucun + validateur ne lit) : `usage`, `websocket` ou `dnssec` sont consommes par les roles et + les gabarits, pas par les validateurs. Exiger la reciproque ferait crier la preuve + sur des champs parfaitement legitimes. + """ + import json as _json + schema = _json.loads((RACINE / "docs" / "audit" / "schema-plan.json").read_text(encoding="utf-8")) + + def _noms(props: dict) -> set: + vus = set() + for nom, p in (props or {}).items(): + vus.add(nom) + for sous in (p.get("additionalProperties"), p.get("items")): + if isinstance(sous, dict): + vus |= _noms(sous.get("properties")) + if isinstance(p.get("properties"), dict): + vus |= _noms(p["properties"]) + return vus + + decrit = {nom: _noms(reg["entite"]["properties"]) for nom, reg in schema["registres"].items()} + racines = {reg["x-racine"] for reg in schema["registres"].values()} | set(schema["registres"]) + + VALIDATEURS = { + "domaines_publics": "valider_domaines", + "serveurs": "valider_serveurs", + "bases_donnees": "valider_bases", + "applications": "valider_applications", + } + source = (RACINE / "scripts" / "inventory_rules.py").read_text(encoding="utf-8") + arbre = ast.parse(source) + fonctions = {n.name: n for n in ast.walk(arbre) if isinstance(n, ast.FunctionDef)} + + trous, mesures = [], [] + for registre, nom_fn in sorted(VALIDATEURS.items()): + fn = fonctions.get(nom_fn) + if fn is None: + trous.append(f"{registre} : validateur `{nom_fn}` introuvable — la preuve ne mesure plus rien") + continue + parametres = {a.arg for a in fn.args.args} | {a.arg for a in fn.args.kwonlyargs} + champs = set() + for n in ast.walk(fn): + cible = None + if (isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute) and n.func.attr == "get" + and n.args and isinstance(n.args[0], ast.Constant) + and isinstance(n.args[0].value, str)): + cible, champ = n.func.value, n.args[0].value + elif (isinstance(n, ast.Subscript) and isinstance(n.slice, ast.Constant) + and isinstance(n.slice.value, str)): + cible, champ = n.value, n.slice.value + if cible is None: + continue + # Seuls les acces sur une variable LOCALE decrivent l'entite validee. + if not isinstance(cible, ast.Name) or cible.id in parametres: + continue + champs.add(champ) + manquants = sorted(champs - decrit.get(registre, set()) - racines) + mesures.append(f"{registre}:{len(champs)}") + if manquants: + trous.append(f"{registre} : `{nom_fn}` accepte {', '.join(manquants)}, " + f"que le schema ne decrit pas") + + if trous: + return False, ("Le moteur accepte des champs que le schema ignore — le formulaire " + "genere ne pourra pas les offrir :\n - " + "\n - ".join(trous) + + "\n (ajouter le champ a REGISTRES dans scripts/schema_plan.py, " + "puis `make schema`)") + return True, (f"Les {len(VALIDATEURS)} validateurs n'acceptent aucun champ que le schema " + f"ignore ({', '.join(mesures)} champ(s) lus par validateur).") + + def preuve_wiki_publie_a_jour() -> tuple[bool, str]: """Le wiki publie sur la forge correspond a `wiki/` dans le depot. @@ -2775,7 +2874,7 @@ def preuve_documentation_outillage() -> tuple[bool, str]: PREUVES: list[dict] = [ {"id": "P01", "titre": "Lint (ansible-lint)", "refs": ["AFF-006"], "cmds": [["ansible-lint", "-q"]]}, - {"id": "P02", "titre": "Tests unitaires (inventaire, raser, ecriture atomique, rendu du GUI, nomenclature)", "refs": [], + {"id": "P02", "titre": "Tests unitaires (inventaire, raser, ecritures du plan, rendu du GUI)", "refs": [], "cmds": [[sys.executable, "scripts/tests/test_inventory_host.py"], [sys.executable, "scripts/tests/test_raser.py"], [sys.executable, "scripts/tests/test_ecriture_atomique.py"], @@ -2784,7 +2883,10 @@ PREUVES: list[dict] = [ [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"]]}, + [sys.executable, "scripts/tests/test_nomenclature_ecriture.py"], + # Les quatre registres du plan : sauvegarder ne doit emporter aucun + # commentaire. Quarante lignes etaient detruites a chaque clic. + [sys.executable, "scripts/tests/test_ecriture_plan.py"]]}, {"id": "P03", "titre": "Diff-vide du plan — TOUTES les instances", "refs": ["AFF-001", "AFF-004", "AFF-030", "AFF-031", "AFF-032"], "func": preuve_diff_vide_toutes_instances}, {"id": "P04", "titre": "Groupes <-> playbooks homonymes", "refs": ["AFF-008"], @@ -2907,6 +3009,8 @@ PREUVES: list[dict] = [ "refs": ["AFF-002"], "func": preuve_wiki_publie_a_jour}, {"id": "P61", "titre": "Schema du plan : il decrit tout ce que les plans contiennent", "refs": ["AFF-033"], "func": preuve_schema_du_plan}, + {"id": "P62", "titre": "Schema du plan : il decrit tout ce que le MOTEUR accepte", + "refs": ["AFF-033"], "func": preuve_schema_couvre_les_validateurs}, {"id": "P43", "titre": "Frontiere : le devis voit les machines du site", "refs": [], "func": preuve_devis_frontiere_du_site}, {"id": "P33", "titre": "Aucune collision de port entre roles co-localises", "refs": [], diff --git a/scripts/schema_plan.py b/scripts/schema_plan.py index b04de51..4ca6b88 100644 --- a/scripts/schema_plan.py +++ b/scripts/schema_plan.py @@ -102,9 +102,17 @@ REGISTRES: dict = { "requiert": {"type": "array", "items": {"type": "string"}, "libelle": "Requiert", "aide": "Dependance applicative. Indicative : l'ordre de deploiement vient des couches."}, - "liens": {"type": "array", "items": {"type": "object"}, - "libelle": "Liens (bindings)", - "aide": "Roles acceptes par le role porteur (meta/liens.yml)."}, + # `items: {type: object}` ne disait rien de la FORME : un formulaire genere + # aurait offert « ajouter un lien » sans savoir quelles cases y mettre. + # `valider_applications` exige `vers` et `role` — on les nomme. + "liens": {"type": "array", "libelle": "Liens (bindings)", + "aide": "Roles acceptes par le role porteur (meta/liens.yml).", + "entrees": { + "vers": {"type": "string", "requis": True, "libelle": "Vers", + "source_valeurs": "applications", + "aide": "L'application liee."}, + "role": {"type": "string", "requis": True, "libelle": "Rôle", + "aide": "Le role du lien, accepte par meta/liens.yml."}}}, "websocket": {"type": "boolean", "libelle": "WebSocket", "aide": "L'edge doit relayer la mise a niveau de connexion."}, }, @@ -155,12 +163,33 @@ REGISTRES: dict = { "racine": "domaines_publics", "clef": {"libelle": "Domaine", "aide": "Le nom public, ex. `chezlepro.ca`."}, "champs": { - "autorite": {"type": "string", "enum": sorted(AUTORITES_DNS), "libelle": "Autorité DNS"}, - "edge": {"type": "string", "libelle": "Edge", "source_valeurs": "serveurs"}, - "secondaires": {"type": "array", "items": {"type": "string"}, "libelle": "Secondaires"}, + "autorite": {"type": "string", "requis": True, "enum": sorted(AUTORITES_DNS), + "libelle": "Autorité DNS"}, + # UN GROUPE, PAS UN HOTE. `instancier` compare `edge` aux GROUPES d'un hote + # (`e.get("edge") in groupes`) pour lui derive ses SAN de certificat. Le schema + # disait « serveurs » : un formulaire genere aurait offert `web-frontal-01`, et + # aucun hote n'aurait jamais reconnu cette valeur — donc aucun SAN, donc un + # certificat correct sur un nom que personne ne peut appeler. C'est exactement + # la panne du 2026-08-25, reintroduite par le choix d'une liste. + "edge": {"type": "string", "requis": True, "libelle": "Edge", + "source_valeurs": "groupes_edge", + "aide": "Le GROUPE Ansible qui sert cette zone (ex. serveur_nginx)."}, + "secondaires": {"type": "array", "items": {"type": "string"}, "libelle": "Secondaires", + "aide": "Serveurs DNS secondaires de la zone."}, "dnssec": {"type": "boolean", "libelle": "DNSSEC"}, - "mail": {"type": "boolean", "libelle": "Courriel", - "aide": "La zone porte des enregistrements de messagerie."}, + # `valider_domaines` valide entierement `exposition`, et le schema l'ignorait. + # Un formulaire genere ne pouvait donc PAS exprimer ce que le moteur accepte. + # `mail` faisait l'inverse : offert par le GUI depuis sa creation, decrit ici + # comme un booleen, saisi la-bas comme du texte, et lu par RIEN. Retire. + "exposition": {"type": "array", "libelle": "Expositions declarees", + "aide": "FQDN publies pour cette zone, vers un groupe cible.", + "entrees": { + "nom": {"type": "string", "requis": True, "libelle": "Nom", + "aide": "Sous-domaine, ou `@` pour la zone elle-meme."}, + "cible": {"type": "string", "requis": True, "libelle": "Cible", + "source_valeurs": "groupes_operationnels", + "aide": "Le groupe interne qui sert ce nom."}, + "type": {"type": "string", "libelle": "Type", "defaut": "web"}}}, }, }, "nomenclature": { @@ -248,6 +277,25 @@ def construire() -> dict: "additionalProperties": False} if sous_requis: p["additionalProperties"]["required"] = sorted(sous_requis) + if "entrees" in d: + # Une LISTE dont chaque element a une forme connue (par opposition a + # `entree`, qui est une table a clefs libres). Le formulaire genere une + # ligne par element, avec un bouton d'ajout. + sous, sous_requis = {}, [] + for sc, sd in d["entrees"].items(): + q = {"type": sd["type"], "title": sd["libelle"]} + if "aide" in sd: + q["description"] = sd["aide"] + if "defaut" in sd: + q["default"] = sd["defaut"] + if "source_valeurs" in sd: + q["x-source-valeurs"] = sd["source_valeurs"] + sous[sc] = q + if sd.get("requis"): + sous_requis.append(sc) + p["items"] = {"type": "object", "properties": sous, "additionalProperties": False} + if sous_requis: + p["items"]["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 ». diff --git a/scripts/tests/test_ecriture_plan.py b/scripts/tests/test_ecriture_plan.py new file mode 100644 index 0000000..e6e02ad --- /dev/null +++ b/scripts/tests/test_ecriture_plan.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +"""Sauvegarder depuis le GUI ne doit emporter aucun commentaire du plan. + +CE QUI ETAIT VRAI JUSQU'AU 2026-09-08, mesure sur les fichiers reels de Chezlepro : + + domaines.yml 6 lignes de commentaire -> 3 (PERD 3) + applications.yml 27 -> 5 (PERD 22) + serveurs.yml 18 -> 3 (PERD 15) + bases-donnees.yml 4 -> 4 (intact — il n'en portait pas) + +Quarante lignes, detruites par n'importe quel clic sur « Sauvegarder » dans les vues +Serveurs, Applications ou Domaines. Parmi elles, celle qui explique pourquoi `backup-01` +a ete retire — « une supervision creuse est pire qu'aucune : elle est verte » — et celle +qui dit dans quel ORDRE les deux roles du runner s'appliquent. + +C'etait l'incident du 2026-08-18 (94 lignes perdues dans les fichiers d'intrants), jamais +corrige pour les registres du plan : `_fusion_chirurgicale` avait ete ecrite pour les +intrants seuls, et les quatre `ecrire_*` etaient restes au `safe_dump`. + +Ce banc mesure les quatre ecrivains sur les VRAIS fichiers du depot, et non sur un +gabarit invente : c'est la seule facon de constater ce que l'operateur perdrait. +""" + +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 + +# (nom, fichier source, ecrivain, chargeur, clef racine) +REGISTRES = [ + ("domaines.yml", g.FICHIER_DOMAINES, g.ecrire_domaines, g.charger_domaines, "domaines_publics"), + ("bases-donnees.yml", g.FICHIER_BASES, g.ecrire_bases, g.charger_bases_donnees, "bases_donnees"), + ("applications.yml", g.FICHIER_APPLICATIONS, g.ecrire_applications, g.charger_applications, "applications"), + ("serveurs.yml", g.FICHIER_SERVEURS, g.ecrire_serveurs, g.charger_serveurs, "serveurs"), +] + + +def commentaires(texte: str) -> list[str]: + return [l.strip() for l in texte.splitlines() if l.strip().startswith("#")] + + +class TestEcriturePlan(unittest.TestCase): + + def setUp(self): + self.dossier = Path(tempfile.mkdtemp(prefix="plan-")) + + def tearDown(self): + shutil.rmtree(self.dossier, ignore_errors=True) + + def _copie(self, nom, source): + cible = self.dossier / nom + shutil.copy(source, cible) + return cible + + def test_aller_retour_a_vide_est_identique(self): + """Ecrire ce qu'on vient de lire ne doit pas changer un octet. + + C'est la propriete forte : sans elle, toute sauvegarde produit du bruit au diff, + et le bruit finit par cacher le vrai changement. + """ + for nom, source, ecrire, charger, _ in REGISTRES: + with self.subTest(registre=nom): + cible = self._copie(nom, source) + avant = cible.read_text(encoding="utf-8") + ecrire(cible, charger(source)) + self.assertEqual(avant, cible.read_text(encoding="utf-8")) + + def test_aucun_commentaire_perdu_apres_modification(self): + for nom, source, ecrire, charger, racine in REGISTRES: + with self.subTest(registre=nom): + cible = self._copie(nom, source) + avant = cible.read_text(encoding="utf-8") + registre = charger(source) + registre[racine]["essai-banc"] = dict( + next(iter(registre[racine].values()), {}) or {}) + ecrire(cible, registre) + self.assertEqual(commentaires(avant), commentaires(cible.read_text(encoding="utf-8"))) + + def test_une_entite_ajoutee_arrive_bien(self): + for nom, source, ecrire, charger, racine in REGISTRES: + with self.subTest(registre=nom): + cible = self._copie(nom, source) + registre = charger(source) + modele = dict(next(iter(registre[racine].values()), {}) or {}) + registre[racine]["essai-banc"] = modele + ecrire(cible, registre) + relu = yaml.safe_load(cible.read_text(encoding="utf-8")) + self.assertIn("essai-banc", relu[racine]) + self.assertEqual(relu[racine]["essai-banc"], modele) + + def test_une_entite_retiree_disparait(self): + for nom, source, ecrire, charger, racine in REGISTRES: + with self.subTest(registre=nom): + cible = self._copie(nom, source) + avant = cible.read_text(encoding="utf-8") + registre = charger(source) + if len(registre[racine]) < 2: + continue + victime = sorted(registre[racine])[0] + del registre[racine][victime] + ecrire(cible, registre) + apres = cible.read_text(encoding="utf-8") + relu = yaml.safe_load(apres) + self.assertNotIn(victime, relu[racine]) + # Les autres entites restent, et les commentaires aussi : retirer une + # entite n'est pas une raison d'effacer ce qui l'entourait. + self.assertEqual(sorted(relu[racine]), sorted(registre[racine])) + self.assertEqual(commentaires(avant), commentaires(apres)) + + def test_controle_negatif_safe_dump_detruit(self): + """LE CONTROLE. L'ancienne ecriture DOIT perdre des commentaires. + + Sans lui, les tests ci-dessus passeraient sur un fichier qui n'en porte aucun — + ils ne mesureraient rien. On rejoue le `safe_dump` d'avant sur les memes fichiers + et on exige la perte. + """ + perdants = [] + for nom, source, _, charger, racine in REGISTRES: + avant = source.read_text(encoding="utf-8") + naif = "---\n" + yaml.safe_dump({racine: charger(source)[racine]}, + default_flow_style=False, sort_keys=False, + allow_unicode=True) + if len(commentaires(naif)) < len(commentaires(avant)): + perdants.append(nom) + self.assertIn("serveurs.yml", perdants, + "le controle negatif ne reproduit plus le defaut : serveurs.yml " + "ne porte-t-il plus de commentaire interne ?") + self.assertGreaterEqual(len(perdants), 3, f"seuls {perdants} perdent — banc suspect") + + def test_un_fichier_absent_recoit_son_entete(self): + """Rien a preserver : on ecrit l'entete et le contenu.""" + cible = self.dossier / "neuf.yml" + g.ecrire_domaines(cible, {"domaines_publics": {"exemple.ca": {"autorite": "delegue", + "edge": "serveur_nginx"}}}) + texte = cible.read_text(encoding="utf-8") + self.assertIn("# Registre des domaines publics", texte) + self.assertIn("exemple.ca", yaml.safe_load(texte)["domaines_publics"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/wiki/Le-GUI-console-d-exploitation.md b/wiki/Le-GUI-console-d-exploitation.md index bb5f0dc..e29c4da 100644 --- a/wiki/Le-GUI-console-d-exploitation.md +++ b/wiki/Le-GUI-console-d-exploitation.md @@ -40,12 +40,20 @@ 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 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. +**Les formulaires sont GÉNÉRÉS.** Depuis le 2026-09-08, quatre registres sur six — **bases de +données**, **serveurs de BD**, **domaines** et **nomenclature** — ne portent plus de formulaire +écrit à la main : ils 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**. Deux preuves le tiennent : **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 ; **P62** refuse +qu'un champ accepté par les *validateurs du moteur* manque au schéma — sans quoi le formulaire ne +saurait pas offrir une fonctionnalité que Set-OPS possède déjà. + +**Sauvegarder n'efface plus les commentaires du plan.** Les registres portent la mémoire écrite des +décisions — pourquoi `backup-01` a été retiré, dans quel ordre les rôles du runner s'appliquent. +Jusqu'au 2026-09-08, chaque « Sauvegarder » en détruisait **quarante lignes**. Les quatre écrivains +passent désormais par une fusion ligne à ligne : un aller-retour sans modification laisse le +fichier **identique à l'octet**. **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.