Compare commits
2 commits
00a8dc5283
...
c0ea0c8e71
| Author | SHA1 | Date | |
|---|---|---|---|
| c0ea0c8e71 | |||
| 935a64a1bf |
11 changed files with 598 additions and 88 deletions
|
|
@ -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.**
|
||||
|
||||
|
|
|
|||
70
CHANGELOG.md
70
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.**
|
||||
|
|
|
|||
|
|
@ -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 | [0m[0m |
|
||||
| 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 |
|
||||
| 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). |
|
||||
|
||||
## Couverture des affirmations ✅ du registre
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: 88033f3
|
||||
source: 935a64a
|
||||
date: 2026-09-08
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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/<groupe>/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"""<!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]) => ({
|
||||
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"""<!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>`
|
||||
|
|
@ -2384,24 +2405,28 @@ 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>
|
||||
<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>
|
||||
${formulaireDepuisSchema('domaines_publics', d, 'definirDomaine', d.nom, "definirDomaine.bind(null,'nom')")}
|
||||
<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 = {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"""<!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 || '?')}));
|
||||
|
|
@ -2498,6 +2527,9 @@ 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.
|
||||
|
|
@ -2525,6 +2557,50 @@ 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.
|
||||
|
|
@ -2593,19 +2669,11 @@ 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>
|
||||
<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>
|
||||
${formulaireDepuisSchema('serveurs_bd', s, 'definirServeurBd', s.nom, "definirServeurBd.bind(null,'nom')")}
|
||||
<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);
|
||||
|
|
@ -2640,7 +2708,17 @@ HTML = r"""<!doctype html>
|
|||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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": [],
|
||||
|
|
|
|||
|
|
@ -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 ».
|
||||
|
|
|
|||
151
scripts/tests/test_ecriture_plan.py
Normal file
151
scripts/tests/test_ecriture_plan.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue