From 935a64a1bf361f455c6fee80d7631a4af6990a80 Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Tue, 8 Sep 2026 17:54:49 -0400 Subject: [PATCH] plan : sauvegarder n emportait plus quarante lignes de commentaire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit En voulant generer deux formulaires de plus, j ai trouve pire que ce que je cherchais. CE QUI ETAIT DEJA LA. Les quatre ecrivains de registre ecrasaient le fichier au safe_dump. Mesure sur les fichiers reels : domaines.yml 6->3, applications.yml 27->5, serveurs.yml 18->3. 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, et celle qui dit dans quel ordre les deux roles du runner s appliquent. C etait l incident du 2026-08-18, jamais corrige pour les registres du plan. Les quatre passent par _ecrire_registre : aller-retour a vide identique a l octet, sur les quatre fichiers. TROIS ECARTS DE SCHEMA, trouves en confrontant le schema aux VALIDATEURS et non aux seuls plans : - edge designe un GROUPE, pas un hote. Le schema disait serveurs : un formulaire genere aurait offert une valeur qu aucun hote ne reconnait, donc aucun SAN, donc la panne du 2026-08-25 reintroduite ; - exposition, entierement valide par le moteur, manquait au schema ; - liens etait items: {type: object} — une liste d objets sans forme. Et mail, offert par la vue Domaines depuis sa creation, decrit ici comme un booleen, saisi la-bas comme du texte, lu par rien : retire. P62 garde tout ca. Elle separe l entite du reste mecaniquement : un validateur lit son entite par des variables LOCALES, les autres registres par ses PARAMETRES. Controle negatif rejoue. LES FORMULAIRES. Serveurs de BD et Domaines sont generes, chargement et sauvegarde compris. Quatre registres sur six. Le generateur a appris la liste d objets. LIMITE : restent serveurs et applications, les deux plus gros ; et je n ai toujours pas ouvert ces pages dans un navigateur. make prouver : CONFORME, 61 OK, 0 echec, 1 saute (62 preuves). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q --- AGENTS.md | 2 +- CHANGELOG.md | 70 ++++++++++ docs/audit/preuve-2026-09-08.md | 9 +- docs/audit/schema-plan.json | 64 ++++++++- docs/devis-services.md | 2 +- scripts/inventory_gui.py | 186 ++++++++++++++++++-------- scripts/prouver.py | 114 +++++++++++++++- scripts/schema_plan.py | 64 +++++++-- scripts/tests/test_ecriture_plan.py | 151 +++++++++++++++++++++ wiki/Le-GUI-console-d-exploitation.md | 20 ++- 10 files changed, 596 insertions(+), 86 deletions(-) create mode 100644 scripts/tests/test_ecriture_plan.py 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.