Compare commits

..

No commits in common. "c0ea0c8e710d3eb21460cb7421ce6dc16dd7ed01" and "00a8dc52836f93d39cbae38840626fcdae95a2ee" have entirely different histories.

11 changed files with 88 additions and 598 deletions

View file

@ -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 62 preuves de `make prouver` : elles lisent le dépôt, sans le
C'est aussi ce que font les 61 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.**

View file

@ -1,75 +1,5 @@
# 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.**

View file

@ -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 (61 OK · 0 echec · 1 saute)
- **Verdict** : ✅ CONFORME (60 OK · 0 echec · 1 saute)
## Preuves
| # | Preuve | Affirmations | Statut | Detail |
|---|---|---|---|---|
| P01 | Lint (ansible-lint) | AFF-006 | ✅ OK |  |
| P02 | Tests unitaires (inventaire, raser, ecritures du plan, rendu du GUI) | — | ✅ OK | OK |
| P02 | Tests unitaires (inventaire, raser, ecriture atomique, rendu du GUI, nomenclature) | — | ✅ OK | OK |
| P03 | Diff-vide du plan — TOUTES les instances | AFF-001, AFF-004, AFF-030, AFF-031, AFF-032 | ✅ OK | 4 instance(s) verifiee(s) — OPS-Chezlepro-lab, OPS-Chezlepro, OPS-Technolibre, OPS-Patient0 : plan et inventaire applique coincident. |
| P04 | Groupes <-> playbooks homonymes | AFF-008 | ✅ OK | |
| P05 | Dependances causales de groupes | AFF-009, AFF-084 | ✅ OK | |
@ -69,12 +69,11 @@
| 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 (62 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 (61 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 `935a64a` (publie le 2026-09-08). |
| 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). |
| 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 |
## Couverture des affirmations ✅ du registre

View file

@ -113,25 +113,7 @@
"title": "Liens (bindings)",
"description": "Roles acceptes par le role porteur (meta/liens.yml).",
"items": {
"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"
]
"type": "object"
}
},
"websocket": {
@ -276,13 +258,11 @@
"edge": {
"type": "string",
"title": "Edge",
"description": "Le GROUPE Ansible qui sert cette zone (ex. serveur_nginx).",
"x-source-valeurs": "groupes_edge"
"x-source-valeurs": "serveurs"
},
"secondaires": {
"type": "array",
"title": "Secondaires",
"description": "Serveurs DNS secondaires de la zone.",
"items": {
"type": "string"
}
@ -291,43 +271,13 @@
"type": "boolean",
"title": "DNSSEC"
},
"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"
]
}
"mail": {
"type": "boolean",
"title": "Courriel",
"description": "La zone porte des enregistrements de messagerie."
}
},
"additionalProperties": false,
"required": [
"autorite",
"edge"
]
"additionalProperties": false
},
"x-clef": {
"title": "Domaine",

View file

@ -1,5 +1,5 @@
---
# Ecrit par `make wiki-publier`, lu par la preuve P60. Ne pas editer a la main.
remote: ssh://git@eregion.chezlepro.ca:2222/Alliance-Boreale/Set-OPS-Public.wiki.git
source: 935a64a
source: 88033f3
date: 2026-09-08

View file

@ -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 62 preuves (dont une conditionnelle, sautée sans la clé de la voûte). Elles sont toutes **statiques** : elles lisent le
`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
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.

View file

@ -569,6 +569,8 @@ 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.
@ -576,7 +578,7 @@ CHAMPS_ECRITS_A_LA_MAIN = {
}
# Les registres dont le formulaire est GENERE : leurs champs viennent du schema.
REGISTRES_GENERES = {"bases_donnees", "nomenclature", "serveurs_bd", "domaines_publics"}
REGISTRES_GENERES = {"bases_donnees", "nomenclature"}
def champs_ecrits_par_gui() -> dict:
@ -595,9 +597,10 @@ def champs_ecrits_par_gui() -> dict:
vus = set()
for nom, spec in (props or {}).items():
vus.add(nom)
for conteneur in (spec, spec.get("additionalProperties"), spec.get("items")):
if isinstance(conteneur, dict) and isinstance(conteneur.get("properties"), dict):
vus |= _noms(conteneur["properties"])
for sous in (spec.get("properties"), (spec.get("additionalProperties") or {}).get("properties")
if isinstance(spec.get("additionalProperties"), dict) else None):
if isinstance(sous, dict):
vus |= _noms(sous)
return vus
resultat = {nom: set(champs) for nom, champs in CHAMPS_ECRITS_A_LA_MAIN.items()}
@ -1060,44 +1063,6 @@ 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"
@ -1106,10 +1071,13 @@ def ecrire_bases(path: Path, registre: dict) -> None:
"# 'secret' nomme une variable Ansible Vault (jamais le mot de passe).\n"
"---\n"
)
_ecrire_registre(path, entete, {
contenu = {
"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:
@ -1121,7 +1089,10 @@ def ecrire_applications(path: Path, registre: dict) -> None:
"# les roles acceptes sont ceux de roles/<groupe>/meta/liens.yml.\n"
"---\n"
)
_ecrire_registre(path, entete, {"applications": registre.get("applications", {}) or {}})
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)
def ecrire_serveurs(path: Path, registre: dict) -> None:
@ -1131,7 +1102,10 @@ def ecrire_serveurs(path: Path, registre: dict) -> None:
"# VMID/IP/VLAN/passerelle sont DERIVES de la fonction via instance/plan/nomenclature.yml.\n"
"---\n"
)
_ecrire_registre(path, entete, {"serveurs": registre.get("serveurs", {}) or {}})
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)
def ecrire_domaines(path: Path, registre: dict) -> None:
@ -1141,7 +1115,10 @@ def ecrire_domaines(path: Path, registre: dict) -> None:
"# autorite : primaire-cache | auto-heberge | delegue (label descriptif).\n"
"---\n"
)
_ecrire_registre(path, entete, {"domaines_publics": registre.get("domaines_publics", {}) or {}})
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)
def _extraire_echec(ligne: str, tache: str) -> dict | None:
@ -2128,25 +2105,23 @@ HTML = r"""<!doctype html>
}
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]) => Object.assign({nom}, x));
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 || ''}));
domainesModifie = false;
}
function marquerDomainesModifie() { domainesModifie = true; majIndicateurSauvegarde(); }
function choisirDomaine(i) { selDomaine = i; dessiner(); }
function definirDomaine(champ, v) {
const d = domaines[selDomaine]; if (!d) return;
poserChemin(d, champ, v); marquerDomainesModifie();
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 dessinerDetail();
else if (champ === 'autorite' || champ === 'edge') dessinerDetail();
}
function ajouterDomaine() {
const edgeDefaut = groupes.includes('serveur_nginx') ? 'serveur_nginx' : (groupes[0] || '');
domaines.unshift({nom: '', autorite: 'auto-heberge', edge: edgeDefaut, dnssec: false, secondaires: []});
domaines.unshift({nom: '', autorite: 'auto-heberge', edge: edgeDefaut, dnssec: false, secondaires: '', mail: ''});
selDomaine = 0; marquerDomainesModifie(); dessiner();
const c = document.querySelector('#detail .grille input'); if (c) c.focus();
}
@ -2397,6 +2372,10 @@ HTML = r"""<!doctype html>
return;
}
const d = domaines[selDomaine];
const optAut = AUTORITES_DNS.map(a => `<option value="${a}" ${a === d.autorite ? 'selected' : ''}>${a}</option>`).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 => `<option value="${echapper(g)}" ${g === d.edge ? 'selected' : ''}>${echapper(g)}</option>`).join('');
const fqdns = expositionsSous(d.nom);
const liens = fqdns.length
? `<div class="ch-bases">${fqdns.map(f => `<div class="ch-base"><span class="mono">${echapper(f)}</span></div>`).join('')}</div>`
@ -2405,28 +2384,24 @@ HTML = r"""<!doctype html>
<div class="detail-tete"><div class="detail-nom"><span class="detail-nom-txt">${echapper(d.nom || '(sans nom)')}</span></div>
<span class="grandit"></span>
<button type="button" class="danger" title="Retirer ce domaine" onclick="retirerDomaine()">✕</button></div>
${formulaireDepuisSchema('domaines_publics', d, 'definirDomaine', d.nom, "definirDomaine.bind(null,'nom')")}
<div class="grille">
<label class="champ" style="grid-column:1/-1"><span>Zone DNS</span><input value="${echapper(d.nom)}" placeholder="exemple.ca" oninput="definirDomaine('nom', this.value)"></label>
<label class="champ"><span>Autorité</span><select onchange="definirDomaine('autorite', this.value)">${optAut}</select></label>
<label class="champ"><span>Edge</span><select onchange="definirDomaine('edge', this.value)">${optEdge}</select></label>
<label class="champ"><span>DNSSEC</span><select onchange="definirDomaine('dnssec', this.value === 'oui')"><option value="non" ${d.dnssec ? '' : 'selected'}>non</option><option value="oui" ${d.dnssec ? 'selected' : ''}>oui</option></select></label>
<label class="champ"><span>Mail (optionnel)</span><input value="${echapper(d.mail)}" placeholder="—" oninput="definirDomaine('mail', this.value)"></label>
<label class="champ" style="grid-column:1/-1"><span>Secondaires (DNS)</span><input value="${echapper(d.secondaires)}" placeholder="ns2.exemple.ca, …" oninput="definirDomaine('secondaires', this.value)"></label>
</div>
<div class="section-tete" style="margin:12px 2px 4px">Expositions sous cette zone</div>
${liens}
</article>`;
}
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 = {};
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;
});
const o = {autorite: d.autorite, edge: d.edge, secondaires: listeDepuisTexte(d.secondaires), dnssec: !!d.dnssec};
if (String(d.mail).trim()) o.mail = d.mail;
dp[d.nom] = o;
});
const rep = await fetch('/api/domaines', {
@ -2469,10 +2444,6 @@ HTML = r"""<!doctype html>
// 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 || '?')}));
@ -2527,9 +2498,6 @@ HTML = r"""<!doctype html>
if (spec.type === 'integer') {
return `<label class="champ"${aide}><span>${titre}${req}</span><input type="number" value="${echapper(valeur == null ? '' : valeur)}" oninput="${appel('this.value')}"></label>`;
}
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.
@ -2557,50 +2525,6 @@ HTML = r"""<!doctype html>
return `<div class="grille">${morceaux.join('')}</div>`;
}
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 `<div class="ch-base" style="display:block">
<div style="display:flex;align-items:center;gap:6px">
<span class="dsn">#${i + 1}</span><span class="grandit"></span>
<button type="button" class="danger" title="Retirer"
onclick="${surChangement}('${cle}', ${retrait})">✕</button></div>
${sousFormulaireDepuisSchema(items, e, cle + '.' + i, surChangement)}</div>`;
}).join('');
return `<div style="grid-column:1/-1">
<div class="section-tete">${echapper(spec.title || cle)} <span class="compte">${arr.length}</span>
<button type="button" class="fantome" style="margin-left:auto"
onclick="${surChangement}('${cle}', ${ajout})">+ Ajouter</button></div>
${arr.length ? lignes : '<div class="ch-stub">aucune entrée</div>'}</div>`;
}
function sousFormulaireDepuisSchema(specEntree, entree, prefixe, surChangement) {
// Une ligne de table (`entree` au schema) : les memes cases qu'une entite, mais
// sous une clef pointee, pour que le setter sache ou poser la valeur.
@ -2669,11 +2593,19 @@ HTML = r"""<!doctype html>
if (info.kind === 'srv') {
const s = bdServeurs[info.i];
if (!s) { cible.innerHTML = '<div class="detail"><div class="vide">Introuvable.</div></div>'; return; }
const optHote = '<option value="">—</option>' + hotes.map(h => h.nom).filter(Boolean).map(n => `<option value="${echapper(n)}" ${n === s.hote ? 'selected' : ''}>${echapper(n)}</option>`).join('');
const optGroupe = '<option value="">—</option>' + groupes.map(g => `<option value="${echapper(g)}" ${g === s.groupe ? 'selected' : ''}>${echapper(g)}</option>`).join('');
const ici = bdApplis.filter(a => a.serveur === s.nom);
const iciHtml = ici.length ? `<div class="ch-bases">${ici.map(a => `<div class="ch-base"><span class="badge-bd">${echapper(a.portee || 'groupe')}</span> <span class="mono">${echapper(a.base || '?')}</span> (${echapper(a.cle)})</div>`).join('')}</div>` : '<div class="ch-stub">aucune base sur ce serveur</div>';
cible.innerHTML = `<article class="detail">
<div class="detail-tete"><div class="detail-nom"><span class="detail-nom-txt">${echapper(s.nom || '(sans nom)')}</span></div><span class="grandit"></span><span class="badge-bd">${echapper(s.type || 'bd')}</span><button type="button" class="danger" title="Retirer" onclick="retirerServeurBd()">✕</button></div>
${formulaireDepuisSchema('serveurs_bd', s, 'definirServeurBd', s.nom, "definirServeurBd.bind(null,'nom')")}
<div class="grille">
<label class="champ"><span>Nom</span><input value="${echapper(s.nom)}" placeholder="pg-principal" oninput="definirServeurBd('nom', this.value)"></label>
<label class="champ"><span>Type</span><input value="${echapper(s.type)}" placeholder="postgres" oninput="definirServeurBd('type', this.value)"></label>
<label class="champ"><span>Hôte (VM)</span><select onchange="definirServeurBd('hote', this.value)">${optHote}</select></label>
<label class="champ"><span>Port</span><input type="number" value="${echapper(s.port)}" oninput="definirServeurBd('port', this.value)"></label>
<label class="champ"><span>Groupe Ansible</span><select onchange="definirServeurBd('groupe', this.value)">${optGroupe}</select></label>
</div>
<div class="section-tete" style="margin:12px 2px 2px">Bases hébergées ici</div>${iciHtml}
${(() => {
const hS = hotes.find(h => h.nom === s.hote);
@ -2708,17 +2640,7 @@ HTML = r"""<!doctype html>
async function sauvegarderBases() {
const sd = {};
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;
});
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 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

View file

@ -296,13 +296,9 @@ def preuve_schema_du_plan() -> tuple[bool, str]:
vus = set()
for nom, p in (props or {}).items():
vus.add(nom)
# 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"])
sous = p.get("additionalProperties")
if isinstance(sous, dict):
vus |= _noms(sous.get("properties"))
return vus
decrit = {nom: _noms(reg["entite"]["properties"])
@ -396,101 +392,6 @@ 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.
@ -2874,7 +2775,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, ecritures du plan, rendu du GUI)", "refs": [],
{"id": "P02", "titre": "Tests unitaires (inventaire, raser, ecriture atomique, rendu du GUI, nomenclature)", "refs": [],
"cmds": [[sys.executable, "scripts/tests/test_inventory_host.py"],
[sys.executable, "scripts/tests/test_raser.py"],
[sys.executable, "scripts/tests/test_ecriture_atomique.py"],
@ -2883,10 +2784,7 @@ 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"],
# 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"]]},
[sys.executable, "scripts/tests/test_nomenclature_ecriture.py"]]},
{"id": "P03", "titre": "Diff-vide du plan — TOUTES les instances", "refs": ["AFF-001", "AFF-004", "AFF-030", "AFF-031", "AFF-032"],
"func": preuve_diff_vide_toutes_instances},
{"id": "P04", "titre": "Groupes <-> playbooks homonymes", "refs": ["AFF-008"],
@ -3009,8 +2907,6 @@ 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": [],

View file

@ -102,17 +102,9 @@ REGISTRES: dict = {
"requiert": {"type": "array", "items": {"type": "string"},
"libelle": "Requiert",
"aide": "Dependance applicative. Indicative : l'ordre de deploiement vient des couches."},
# `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."}}},
"liens": {"type": "array", "items": {"type": "object"},
"libelle": "Liens (bindings)",
"aide": "Roles acceptes par le role porteur (meta/liens.yml)."},
"websocket": {"type": "boolean", "libelle": "WebSocket",
"aide": "L'edge doit relayer la mise a niveau de connexion."},
},
@ -163,33 +155,12 @@ REGISTRES: dict = {
"racine": "domaines_publics",
"clef": {"libelle": "Domaine", "aide": "Le nom public, ex. `chezlepro.ca`."},
"champs": {
"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."},
"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"},
"dnssec": {"type": "boolean", "libelle": "DNSSEC"},
# `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"}}},
"mail": {"type": "boolean", "libelle": "Courriel",
"aide": "La zone porte des enregistrements de messagerie."},
},
},
"nomenclature": {
@ -277,25 +248,6 @@ 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 ».

View file

@ -1,151 +0,0 @@
#!/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()

View file

@ -40,20 +40,12 @@ 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, 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**.
**Les formulaires sont GÉNÉRÉS.** Depuis le 2026-09-08, les vues **Bases** et **Nomenclature** ne
portent plus de formulaire écrit à la main : elles le construisent depuis
`docs/audit/schema-plan.json`, lui-même dérivé des constantes du moteur (`make schema`). Un champ
ajouté au plan apparaît donc à l'écran **sans qu'on touche à l'interface**, et la preuve **P61**
refuse qu'un champ des plans réels manque au schéma — ou que le schéma décrive une **forme** que
les plans n'ont pas.
**La flotte, dans la vue Réseau.** Voir toutes les instances, **basculer** (« Activer »), **créer**
une instance depuis un modèle, repérer une **collision** d'index. Cf.