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)
|
## Écrire, puis relire (D-68)
|
||||||
|
|
||||||
`--syntax-check` et `ansible-lint` prouvent que le dépôt est cohérent **avec lui-même**.
|
`--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
|
moindre appel réseau. **Aucune ne demande au système déployé s'il ressemble à ce que le
|
||||||
dépôt annonce.**
|
dépôt annonce.**
|
||||||
|
|
||||||
|
|
|
||||||
70
CHANGELOG.md
70
CHANGELOG.md
|
|
@ -1,5 +1,75 @@
|
||||||
# CHANGELOG — Set-OPS
|
# 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
|
## 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.**
|
**61 preuves. `make prouver` : CONFORME, 60 OK, 0 echec, 1 saute.**
|
||||||
|
|
|
||||||
|
|
@ -7,14 +7,14 @@
|
||||||
> [`docs/audit/affirmations.md`](affirmations.md).
|
> [`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`
|
- **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
|
## Preuves
|
||||||
|
|
||||||
| # | Preuve | Affirmations | Statut | Detail |
|
| # | Preuve | Affirmations | Statut | Detail |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| P01 | Lint (ansible-lint) | AFF-006 | ✅ OK | [0m[0m |
|
| 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. |
|
| P03 | Diff-vide du plan — TOUTES les instances | AFF-001, AFF-004, AFF-030, AFF-031, AFF-032 | ✅ OK | 4 instance(s) verifiee(s) — OPS-Chezlepro-lab, OPS-Chezlepro, OPS-Technolibre, OPS-Patient0 : plan et inventaire applique coincident. |
|
||||||
| P04 | Groupes <-> playbooks homonymes | AFF-008 | ✅ OK | |
|
| P04 | Groupes <-> playbooks homonymes | AFF-008 | ✅ OK | |
|
||||||
| P05 | Dependances causales de groupes | AFF-009, AFF-084 | ✅ OK | |
|
| P05 | Dependances causales de groupes | AFF-009, AFF-084 | ✅ OK | |
|
||||||
|
|
@ -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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| P58 | Habilitations : chaque service dit a quel GROUPE, et par quoi | — | ✅ OK | 8 habilitation(s) declarees, toutes nommant un groupe, un mecanisme connu et une raison ; les `role-realm` sont projetees. |
|
||||||
| P59 | Enumerations annoncees : le nombre correspond a ce qui suit | — | ✅ OK | 2 enumeration(s) annoncee(s) correspondent a ce qu'elles annoncent (formes non ambigues seulement). |
|
| P59 | Enumerations annoncees : le nombre correspond a ce qui suit | — | ✅ OK | 2 enumeration(s) annoncee(s) correspondent a ce qu'elles annoncent (formes non ambigues seulement). |
|
||||||
| P60 | Wiki publie : la forge sert ce que le depot dit | AFF-002 | ✅ OK | Le wiki publie correspond au depot : `wiki/` n'a pas bouge depuis `88033f3` (publie le 2026-09-08). |
|
| P60 | Wiki publie : la forge sert ce que le depot dit | AFF-002 | ✅ OK | Le wiki publie correspond au depot : `wiki/` n'a pas bouge depuis `935a64a` (publie le 2026-09-08). |
|
||||||
| P61 | Schema du plan : il decrit tout ce que les plans contiennent | AFF-033 | ✅ OK | Le schema decrit 39 champ(s) sur 6 registres ; il couvre tout ce que les plans reels contiennent, et la FORME de chaque champ (scalaire / objet / table) corresp |
|
| P61 | Schema du plan : il decrit tout ce que les plans contiennent | AFF-033 | ✅ OK | Le schema decrit 47 champ(s) sur 6 registres ; il couvre tout ce que les plans reels contiennent, et la FORME de chaque champ (scalaire / objet / table) corresp |
|
||||||
|
| P62 | Schema du plan : il decrit tout ce que le MOTEUR accepte | AFF-033 | ✅ OK | Les 4 validateurs n'acceptent aucun champ que le schema ignore (applications:8, bases_donnees:4, domaines_publics:5, serveurs:3 champ(s) lus par validateur). |
|
||||||
|
|
||||||
## Couverture des affirmations ✅ du registre
|
## Couverture des affirmations ✅ du registre
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -113,7 +113,25 @@
|
||||||
"title": "Liens (bindings)",
|
"title": "Liens (bindings)",
|
||||||
"description": "Roles acceptes par le role porteur (meta/liens.yml).",
|
"description": "Roles acceptes par le role porteur (meta/liens.yml).",
|
||||||
"items": {
|
"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": {
|
"websocket": {
|
||||||
|
|
@ -258,11 +276,13 @@
|
||||||
"edge": {
|
"edge": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"title": "Edge",
|
"title": "Edge",
|
||||||
"x-source-valeurs": "serveurs"
|
"description": "Le GROUPE Ansible qui sert cette zone (ex. serveur_nginx).",
|
||||||
|
"x-source-valeurs": "groupes_edge"
|
||||||
},
|
},
|
||||||
"secondaires": {
|
"secondaires": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"title": "Secondaires",
|
"title": "Secondaires",
|
||||||
|
"description": "Serveurs DNS secondaires de la zone.",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
|
|
@ -271,13 +291,43 @@
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"title": "DNSSEC"
|
"title": "DNSSEC"
|
||||||
},
|
},
|
||||||
"mail": {
|
"exposition": {
|
||||||
"type": "boolean",
|
"type": "array",
|
||||||
"title": "Courriel",
|
"title": "Expositions declarees",
|
||||||
"description": "La zone porte des enregistrements de messagerie."
|
"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": {
|
"x-clef": {
|
||||||
"title": "Domaine",
|
"title": "Domaine",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
---
|
---
|
||||||
# Ecrit par `make wiki-publier`, lu par la preuve P60. Ne pas editer a la main.
|
# 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
|
remote: ssh://git@eregion.chezlepro.ca:2222/Alliance-Boreale/Set-OPS-Public.wiki.git
|
||||||
source: 88033f3
|
source: 935a64a
|
||||||
date: 2026-09-08
|
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
|
## 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
|
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
|
cohérent **avec lui-même** — que les handlers existent, que les intrants ont un
|
||||||
propriétaire, que rien n'est codé en dur.
|
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",
|
"serveurs": {"fonction", "etat", "noeud", "stockage", "disque", "memoire",
|
||||||
"coeurs", "integrations"},
|
"coeurs", "integrations"},
|
||||||
"applications": {"groupe", "hote", "port", "requiert", "expose", "liens", "websocket"},
|
"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
|
# 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
|
# 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.
|
# 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.
|
# 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:
|
def champs_ecrits_par_gui() -> dict:
|
||||||
|
|
@ -597,10 +595,9 @@ def champs_ecrits_par_gui() -> dict:
|
||||||
vus = set()
|
vus = set()
|
||||||
for nom, spec in (props or {}).items():
|
for nom, spec in (props or {}).items():
|
||||||
vus.add(nom)
|
vus.add(nom)
|
||||||
for sous in (spec.get("properties"), (spec.get("additionalProperties") or {}).get("properties")
|
for conteneur in (spec, spec.get("additionalProperties"), spec.get("items")):
|
||||||
if isinstance(spec.get("additionalProperties"), dict) else None):
|
if isinstance(conteneur, dict) and isinstance(conteneur.get("properties"), dict):
|
||||||
if isinstance(sous, dict):
|
vus |= _noms(conteneur["properties"])
|
||||||
vus |= _noms(sous)
|
|
||||||
return vus
|
return vus
|
||||||
|
|
||||||
resultat = {nom: set(champs) for nom, champs in CHAMPS_ECRITS_A_LA_MAIN.items()}
|
resultat = {nom: set(champs) for nom, champs in CHAMPS_ECRITS_A_LA_MAIN.items()}
|
||||||
|
|
@ -1063,6 +1060,44 @@ def _ecrire_index_nomenclature(index: int) -> None:
|
||||||
fichier.write(nouveau)
|
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:
|
def ecrire_bases(path: Path, registre: dict) -> None:
|
||||||
entete = (
|
entete = (
|
||||||
"# Registre des bases de donnees Set-OPS (serveurs_bd + bases_donnees).\n"
|
"# 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"
|
"# 'secret' nomme une variable Ansible Vault (jamais le mot de passe).\n"
|
||||||
"---\n"
|
"---\n"
|
||||||
)
|
)
|
||||||
contenu = {
|
_ecrire_registre(path, entete, {
|
||||||
"serveurs_bd": registre.get("serveurs_bd", {}) or {},
|
"serveurs_bd": registre.get("serveurs_bd", {}) or {},
|
||||||
"bases_donnees": registre.get("bases_donnees", {}) 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:
|
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"
|
"# les roles acceptes sont ceux de roles/<groupe>/meta/liens.yml.\n"
|
||||||
"---\n"
|
"---\n"
|
||||||
)
|
)
|
||||||
with ecriture_atomique(path) as fichier:
|
_ecrire_registre(path, entete, {"applications": registre.get("applications", {}) or {}})
|
||||||
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:
|
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"
|
"# VMID/IP/VLAN/passerelle sont DERIVES de la fonction via instance/plan/nomenclature.yml.\n"
|
||||||
"---\n"
|
"---\n"
|
||||||
)
|
)
|
||||||
with ecriture_atomique(path) as fichier:
|
_ecrire_registre(path, entete, {"serveurs": registre.get("serveurs", {}) or {}})
|
||||||
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:
|
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"
|
"# autorite : primaire-cache | auto-heberge | delegue (label descriptif).\n"
|
||||||
"---\n"
|
"---\n"
|
||||||
)
|
)
|
||||||
with ecriture_atomique(path) as fichier:
|
_ecrire_registre(path, entete, {"domaines_publics": registre.get("domaines_publics", {}) or {}})
|
||||||
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:
|
def _extraire_echec(ligne: str, tache: str) -> dict | None:
|
||||||
|
|
@ -2105,23 +2128,25 @@ HTML = r"""<!doctype html>
|
||||||
}
|
}
|
||||||
|
|
||||||
function chargerDomaines(data) {
|
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) || {};
|
const dp = ((data.domaines || {}).domaines_publics) || {};
|
||||||
domaines = Object.entries(dp).map(([nom, x]) => ({
|
domaines = Object.entries(dp).map(([nom, x]) => Object.assign({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;
|
domainesModifie = false;
|
||||||
}
|
}
|
||||||
function marquerDomainesModifie() { domainesModifie = true; majIndicateurSauvegarde(); }
|
function marquerDomainesModifie() { domainesModifie = true; majIndicateurSauvegarde(); }
|
||||||
function choisirDomaine(i) { selDomaine = i; dessiner(); }
|
function choisirDomaine(i) { selDomaine = i; dessiner(); }
|
||||||
function definirDomaine(champ, v) {
|
function definirDomaine(champ, v) {
|
||||||
const d = domaines[selDomaine]; if (!d) return;
|
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'); } }
|
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() {
|
function ajouterDomaine() {
|
||||||
const edgeDefaut = groupes.includes('serveur_nginx') ? 'serveur_nginx' : (groupes[0] || '');
|
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();
|
selDomaine = 0; marquerDomainesModifie(); dessiner();
|
||||||
const c = document.querySelector('#detail .grille input'); if (c) c.focus();
|
const c = document.querySelector('#detail .grille input'); if (c) c.focus();
|
||||||
}
|
}
|
||||||
|
|
@ -2372,10 +2397,6 @@ HTML = r"""<!doctype html>
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const d = domaines[selDomaine];
|
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 fqdns = expositionsSous(d.nom);
|
||||||
const liens = fqdns.length
|
const liens = fqdns.length
|
||||||
? `<div class="ch-bases">${fqdns.map(f => `<div class="ch-base"><span class="mono">${echapper(f)}</span></div>`).join('')}</div>`
|
? `<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>
|
<div class="detail-tete"><div class="detail-nom"><span class="detail-nom-txt">${echapper(d.nom || '(sans nom)')}</span></div>
|
||||||
<span class="grandit"></span>
|
<span class="grandit"></span>
|
||||||
<button type="button" class="danger" title="Retirer ce domaine" onclick="retirerDomaine()">✕</button></div>
|
<button type="button" class="danger" title="Retirer ce domaine" onclick="retirerDomaine()">✕</button></div>
|
||||||
<div class="grille">
|
${formulaireDepuisSchema('domaines_publics', d, 'definirDomaine', d.nom, "definirDomaine.bind(null,'nom')")}
|
||||||
<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>
|
<div class="section-tete" style="margin:12px 2px 4px">Expositions sous cette zone</div>
|
||||||
${liens}
|
${liens}
|
||||||
</article>`;
|
</article>`;
|
||||||
}
|
}
|
||||||
async function sauvegarderDomaines() {
|
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 dp = {};
|
||||||
|
const propsD = ((schemaPlan.domaines_publics || {}).entite || {}).properties || {};
|
||||||
domaines.forEach(d => {
|
domaines.forEach(d => {
|
||||||
if (!String(d.nom).trim()) return;
|
if (!String(d.nom).trim()) return;
|
||||||
const o = {autorite: d.autorite, edge: d.edge, secondaires: listeDepuisTexte(d.secondaires), dnssec: !!d.dnssec};
|
const o = {};
|
||||||
if (String(d.mail).trim()) o.mail = d.mail;
|
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;
|
dp[d.nom] = o;
|
||||||
});
|
});
|
||||||
const rep = await fetch('/api/domaines', {
|
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
|
// 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.
|
// 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.
|
// `{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') {
|
if (nom === 'nomenclature.categories') {
|
||||||
const cats = (nomEdit.categories || {});
|
const cats = (nomEdit.categories || {});
|
||||||
return Object.keys(cats).map(k => ({v: k, t: k + ' — ' + ((cats[k] || {}).libelle || '?')}));
|
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') {
|
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>`;
|
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') {
|
if (spec.type === 'array') {
|
||||||
// Une liste de textes se saisit separee par des virgules : le YAML reste une
|
// Une liste de textes se saisit separee par des virgules : le YAML reste une
|
||||||
// liste, la saisie reste lisible.
|
// liste, la saisie reste lisible.
|
||||||
|
|
@ -2525,6 +2557,50 @@ HTML = r"""<!doctype html>
|
||||||
return `<div class="grille">${morceaux.join('')}</div>`;
|
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) {
|
function sousFormulaireDepuisSchema(specEntree, entree, prefixe, surChangement) {
|
||||||
// Une ligne de table (`entree` au schema) : les memes cases qu'une entite, mais
|
// 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.
|
// 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') {
|
if (info.kind === 'srv') {
|
||||||
const s = bdServeurs[info.i];
|
const s = bdServeurs[info.i];
|
||||||
if (!s) { cible.innerHTML = '<div class="detail"><div class="vide">Introuvable.</div></div>'; return; }
|
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 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>';
|
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">
|
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="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">
|
${formulaireDepuisSchema('serveurs_bd', s, 'definirServeurBd', s.nom, "definirServeurBd.bind(null,'nom')")}
|
||||||
<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}
|
<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);
|
const hS = hotes.find(h => h.nom === s.hote);
|
||||||
|
|
@ -2640,7 +2708,17 @@ HTML = r"""<!doctype html>
|
||||||
|
|
||||||
async function sauvegarderBases() {
|
async function sauvegarderBases() {
|
||||||
const sd = {};
|
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 = {};
|
const bd = {};
|
||||||
// SAUVEGARDE DERIVEE DU SCHEMA (2026-09-08). Les sept champs etaient enumeres ici
|
// 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
|
// 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()
|
vus = set()
|
||||||
for nom, p in (props or {}).items():
|
for nom, p in (props or {}).items():
|
||||||
vus.add(nom)
|
vus.add(nom)
|
||||||
sous = p.get("additionalProperties")
|
# Trois imbrications possibles : table a clefs libres (`additionalProperties`),
|
||||||
if isinstance(sous, dict):
|
# bloc a clefs fixes (`properties`), liste d'objets (`items.properties`).
|
||||||
vus |= _noms(sous.get("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
|
return vus
|
||||||
|
|
||||||
decrit = {nom: _noms(reg["entite"]["properties"])
|
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}.")
|
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]:
|
def preuve_wiki_publie_a_jour() -> tuple[bool, str]:
|
||||||
"""Le wiki publie sur la forge correspond a `wiki/` dans le depot.
|
"""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] = [
|
PREUVES: list[dict] = [
|
||||||
{"id": "P01", "titre": "Lint (ansible-lint)", "refs": ["AFF-006"],
|
{"id": "P01", "titre": "Lint (ansible-lint)", "refs": ["AFF-006"],
|
||||||
"cmds": [["ansible-lint", "-q"]]},
|
"cmds": [["ansible-lint", "-q"]]},
|
||||||
{"id": "P02", "titre": "Tests unitaires (inventaire, raser, ecriture atomique, 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"],
|
"cmds": [[sys.executable, "scripts/tests/test_inventory_host.py"],
|
||||||
[sys.executable, "scripts/tests/test_raser.py"],
|
[sys.executable, "scripts/tests/test_raser.py"],
|
||||||
[sys.executable, "scripts/tests/test_ecriture_atomique.py"],
|
[sys.executable, "scripts/tests/test_ecriture_atomique.py"],
|
||||||
|
|
@ -2784,7 +2883,10 @@ PREUVES: list[dict] = [
|
||||||
[sys.executable, "scripts/tests/test_rendu_gui.py"],
|
[sys.executable, "scripts/tests/test_rendu_gui.py"],
|
||||||
# Le fichier le plus dense en memoire de decision du depot : ecrire dedans
|
# Le fichier le plus dense en memoire de decision du depot : ecrire dedans
|
||||||
# ne doit deplacer aucun commentaire.
|
# 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"],
|
{"id": "P03", "titre": "Diff-vide du plan — TOUTES les instances", "refs": ["AFF-001", "AFF-004", "AFF-030", "AFF-031", "AFF-032"],
|
||||||
"func": preuve_diff_vide_toutes_instances},
|
"func": preuve_diff_vide_toutes_instances},
|
||||||
{"id": "P04", "titre": "Groupes <-> playbooks homonymes", "refs": ["AFF-008"],
|
{"id": "P04", "titre": "Groupes <-> playbooks homonymes", "refs": ["AFF-008"],
|
||||||
|
|
@ -2907,6 +3009,8 @@ PREUVES: list[dict] = [
|
||||||
"refs": ["AFF-002"], "func": preuve_wiki_publie_a_jour},
|
"refs": ["AFF-002"], "func": preuve_wiki_publie_a_jour},
|
||||||
{"id": "P61", "titre": "Schema du plan : il decrit tout ce que les plans contiennent",
|
{"id": "P61", "titre": "Schema du plan : il decrit tout ce que les plans contiennent",
|
||||||
"refs": ["AFF-033"], "func": preuve_schema_du_plan},
|
"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": [],
|
{"id": "P43", "titre": "Frontiere : le devis voit les machines du site", "refs": [],
|
||||||
"func": preuve_devis_frontiere_du_site},
|
"func": preuve_devis_frontiere_du_site},
|
||||||
{"id": "P33", "titre": "Aucune collision de port entre roles co-localises", "refs": [],
|
{"id": "P33", "titre": "Aucune collision de port entre roles co-localises", "refs": [],
|
||||||
|
|
|
||||||
|
|
@ -102,9 +102,17 @@ REGISTRES: dict = {
|
||||||
"requiert": {"type": "array", "items": {"type": "string"},
|
"requiert": {"type": "array", "items": {"type": "string"},
|
||||||
"libelle": "Requiert",
|
"libelle": "Requiert",
|
||||||
"aide": "Dependance applicative. Indicative : l'ordre de deploiement vient des couches."},
|
"aide": "Dependance applicative. Indicative : l'ordre de deploiement vient des couches."},
|
||||||
"liens": {"type": "array", "items": {"type": "object"},
|
# `items: {type: object}` ne disait rien de la FORME : un formulaire genere
|
||||||
"libelle": "Liens (bindings)",
|
# aurait offert « ajouter un lien » sans savoir quelles cases y mettre.
|
||||||
"aide": "Roles acceptes par le role porteur (meta/liens.yml)."},
|
# `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",
|
"websocket": {"type": "boolean", "libelle": "WebSocket",
|
||||||
"aide": "L'edge doit relayer la mise a niveau de connexion."},
|
"aide": "L'edge doit relayer la mise a niveau de connexion."},
|
||||||
},
|
},
|
||||||
|
|
@ -155,12 +163,33 @@ REGISTRES: dict = {
|
||||||
"racine": "domaines_publics",
|
"racine": "domaines_publics",
|
||||||
"clef": {"libelle": "Domaine", "aide": "Le nom public, ex. `chezlepro.ca`."},
|
"clef": {"libelle": "Domaine", "aide": "Le nom public, ex. `chezlepro.ca`."},
|
||||||
"champs": {
|
"champs": {
|
||||||
"autorite": {"type": "string", "enum": sorted(AUTORITES_DNS), "libelle": "Autorité DNS"},
|
"autorite": {"type": "string", "requis": True, "enum": sorted(AUTORITES_DNS),
|
||||||
"edge": {"type": "string", "libelle": "Edge", "source_valeurs": "serveurs"},
|
"libelle": "Autorité DNS"},
|
||||||
"secondaires": {"type": "array", "items": {"type": "string"}, "libelle": "Secondaires"},
|
# 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"},
|
"dnssec": {"type": "boolean", "libelle": "DNSSEC"},
|
||||||
"mail": {"type": "boolean", "libelle": "Courriel",
|
# `valider_domaines` valide entierement `exposition`, et le schema l'ignorait.
|
||||||
"aide": "La zone porte des enregistrements de messagerie."},
|
# 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": {
|
"nomenclature": {
|
||||||
|
|
@ -248,6 +277,25 @@ def construire() -> dict:
|
||||||
"additionalProperties": False}
|
"additionalProperties": False}
|
||||||
if sous_requis:
|
if sous_requis:
|
||||||
p["additionalProperties"]["required"] = sorted(sous_requis)
|
p["additionalProperties"]["required"] = sorted(sous_requis)
|
||||||
|
if "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:
|
if "sous_champs" in d:
|
||||||
# Un OBJET a clefs FIXES (par opposition a `entree`, table ouverte). Le
|
# Un OBJET a clefs FIXES (par opposition a `entree`, table ouverte). Le
|
||||||
# formulaire genere ses cases une fois, sans bouton « ajouter ».
|
# 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
|
propose que des **hôtes réels** : impossible de pointer vers un hôte fantôme. La console couvre le
|
||||||
schéma du plan — et la preuve **P19** le vérifie.
|
schéma du plan — et la preuve **P19** le vérifie.
|
||||||
|
|
||||||
**Les formulaires sont GÉNÉRÉS.** Depuis le 2026-09-08, les vues **Bases** et **Nomenclature** ne
|
**Les formulaires sont GÉNÉRÉS.** Depuis le 2026-09-08, quatre registres sur six — **bases de
|
||||||
portent plus de formulaire écrit à la main : elles le construisent depuis
|
données**, **serveurs de BD**, **domaines** et **nomenclature** — ne portent plus de formulaire
|
||||||
`docs/audit/schema-plan.json`, lui-même dérivé des constantes du moteur (`make schema`). Un champ
|
écrit à la main : ils le construisent depuis `docs/audit/schema-plan.json`, lui-même dérivé des
|
||||||
ajouté au plan apparaît donc à l'écran **sans qu'on touche à l'interface**, et la preuve **P61**
|
constantes du moteur (`make schema`). Un champ ajouté au plan apparaît donc à l'écran **sans qu'on
|
||||||
refuse qu'un champ des plans réels manque au schéma — ou que le schéma décrive une **forme** que
|
touche à l'interface**. Deux preuves le tiennent : **P61** refuse qu'un champ des plans réels
|
||||||
les plans n'ont pas.
|
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**
|
**La flotte, dans la vue Réseau.** Voir toutes les instances, **basculer** (« Activer »), **créer**
|
||||||
une instance depuis un modèle, repérer une **collision** d'index. Cf.
|
une instance depuis un modèle, repérer une **collision** d'index. Cf.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue