Trois preuves qui regardent au-dela d'une seule instance + champ liens/websocket au GUI
Le harnais ne verifiait qu'UNE instance et le seul modele socle. Tout ce qui vit a cote du moteur echappait au controle. Trois preuves ferment ces angles morts : - P17 (scripts/modeles.py) : TOUS les modeles valident, pas seulement socle. SETOPS_MODELES=../Set-OPS-Modeles inclut les modeles assembles prives. A trouve 6 modeles invalides sur 7 (corriges dans Set-OPS-Modeles). - P18 (scripts/voute.py) : le gabarit vault.yml.example couvre EXACTEMENT les secrets que le plan exige (bases + roles actifs + group_vars). Ne dechiffre jamais la vraie voute : compare des noms. - P19 (scripts/couverture_gui.py) : tout champ present dans un plan reel est editable par le GUI. A trouve applications.websocket (comble). Nomenclature toleree (trou connu). GUI : - champ « Liens (bindings) » dans l'inspecteur d'application : role -> cible en listes deroulantes, les roles proposes = ceux que le role porteur accepte (meta/liens.yml). Comble un manque : les bindings ne se declaraient qu'en editant le YAML a la main. - champ « WebSocket » (Collabora). - CHAMPS_ECRITS_PAR_GUI : declaration de ce que le GUI sait ecrire, verifiee par P19. Garde-fou de fond : valider_applications refuse une application posee sur un hote non declare (l'hote fantome exact qu'integral portait). Cable partout + POST du GUI. liens_acceptes()/catalogue_liens() dans inventory_rules : source unique partagee par le validateur, le GUI et instancier.py (dont la copie locale est retiree). Valide : make verifier rc=0, CONFORME 19/19, ansible-lint 0 echec, 7 modeles valident, DIFF VIDE, node --check du GUI OK. Piece justificative : docs/audit/preuve-2026-07-22.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
131a30075d
commit
7e190e0a57
12 changed files with 763 additions and 23 deletions
72
CHANGELOG.md
72
CHANGELOG.md
|
|
@ -1,5 +1,77 @@
|
|||
# CHANGELOG — Set-OPS
|
||||
|
||||
## 2026-07-22 (suite)
|
||||
|
||||
### Ajouté — trois preuves qui ferment les angles morts du harnais
|
||||
Le constat : `make prouver` ne vérifiait qu'*une* instance et le seul modèle `socle`. Tout
|
||||
ce qui vit à côté du moteur dérivait en silence — d'où l'hôte fantôme d'`integral`, les
|
||||
neuf secrets absents du gabarit Chezlepro, les champs de plan que le GUI ne sait pas écrire.
|
||||
|
||||
- **P17 — tous les modèles valident** (`scripts/modeles.py`). Rejoue les 4 validateurs de
|
||||
registres sur chaque modèle découvert (les `exemples/modeles/*` du dépôt, plus les chemins
|
||||
de `SETOPS_MODELES`, ex. le dépôt privé). **A immédiatement trouvé 6 modèles invalides
|
||||
sur 7** : cinq portaient `autorite: interne` (valeur périmée jamais propagée depuis la
|
||||
correction de `socle` en phase 5), `presence-web` manquait son domaine interne. Corrigés.
|
||||
- **P18 — gabarit de voûte complet** (`scripts/voute.py`). Confronte `vault.yml.example`
|
||||
aux secrets réellement exigés par le plan (champ `secret` des bases + références `vault_*`
|
||||
des rôles actifs et des group_vars). Ne déchiffre jamais la vraie voûte : compare des noms.
|
||||
`--strict` signale aussi les clés devenues inutiles.
|
||||
- **P19 — le GUI couvre le plan** (`scripts/couverture_gui.py`). Croise les champs présents
|
||||
dans les plans réels (instance + modèles) avec `CHAMPS_ECRITS_PAR_GUI`, nouveau tableau de
|
||||
`inventory_gui.py` déclarant ce que les fonctions de sauvegarde écrivent. Signale tout
|
||||
champ non éditable par le GUI (**a trouvé `applications.websocket`**, comblé — case à
|
||||
cocher ajoutée), et toute dérive entre le tableau et le source du GUI. La nomenclature
|
||||
reste un trou connu (lecture seule), tolérable via `--tolerer nomenclature`.
|
||||
|
||||
### Corrigé
|
||||
- **Garde-fou contre l'hôte fantôme** : `valider_applications` accepte désormais le registre
|
||||
des serveurs et **refuse une application posée sur un hôte non déclaré** — la faute exacte
|
||||
qu'`integral` portait. Câblé dans `scripts/applications.py` (les 4 appels), au POST du GUI,
|
||||
et vérifié : re-teste le bug d'origine → rejeté avec un message actionnable.
|
||||
- **6 modèles de `Set-OPS-Modeles`** : `autorite: interne` → `auto-heberge` (collaboration,
|
||||
forge, identite, integral, observabilite) ; `presence-web` gagne son domaine interne pour
|
||||
ses expositions `site.*`/`app.*`.
|
||||
- **Champ `websocket` dans le GUI** (inspecteur d'application) — Collabora en a besoin.
|
||||
|
||||
Validé : `make verifier` → **rc=0, CONFORME 19/16→19** (P01–P19), `ansible-lint` 0 échec,
|
||||
les 7 modèles valident (`SETOPS_MODELES` posé), gabarit de voûte complet (23/23), couverture
|
||||
GUI 27/27 champs (nomenclature tolérée), DIFF VIDE conservé, JS du GUI valide.
|
||||
|
||||
## 2026-07-22
|
||||
|
||||
### Ajouté
|
||||
- **Champ « Liens (bindings) » dans le GUI** (`scripts/inventory_gui.py`). L'inspecteur
|
||||
d'application porte une section dédiée : une ligne par lien, `rôle → cible`, les deux en
|
||||
listes déroulantes, avec bouton d'ajout et de retrait. Les **rôles proposés sont
|
||||
exactement ceux que le rôle porteur déclare accepter** (`roles/<groupe>/meta/liens.yml`),
|
||||
et les cibles sont les autres applications du plan. Chaque ligne affiche les **variables
|
||||
que le lien injectera**. Changer le groupe d'une application vide les rôles de lien
|
||||
devenus inacceptés. Comble un manque relevé à l'audit du GUI : les bindings ne pouvaient
|
||||
se déclarer qu'en éditant `plan/applications.yml` à la main, ce qui rendait la
|
||||
configuration du courriel (Postfix → Dovecot, Postfix → rspamd) inaccessible sans éditeur.
|
||||
- **`liens_acceptes(groupe)` et `catalogue_liens()`** dans `scripts/inventory_rules.py` —
|
||||
source **unique** de « quels liens un rôle accepte », partagée par le validateur, le GUI
|
||||
et `instancier.py`. Nouvelle clé `liens_acceptes` dans la charge utile de `/api/inventaire`.
|
||||
|
||||
### Modifié
|
||||
- **`valider_applications` valide désormais les `liens`** : liste de tables `{vers, role}`,
|
||||
champs non vides, cible connue, pas de lien vers soi-même, et **rôle accepté par le rôle
|
||||
porteur** (avec la liste des rôles acceptés dans le message d'erreur). Un rôle sans
|
||||
`meta/liens.yml` reste toléré au validateur — `instancier.py` tranche avec le même message.
|
||||
Bénéficie aussi à `make inventaire-verifier` et à `scripts/applications.py verifier`.
|
||||
- **`scripts/instancier.py`** — sa copie locale `_liens_acceptes()` est retirée au profit de
|
||||
la fonction partagée. Un seul endroit lit `meta/liens.yml`.
|
||||
- **`docs/bindings-conception.md`** — la Phase 4 passe à 🟡 : l'éditeur est fait, le
|
||||
**graphe des liens reste à faire**.
|
||||
|
||||
Validé : `make verifier` → **rc=0**, `ansible-lint` 0 échec sur 485 fichiers,
|
||||
`prouver.py --verifier` → CONFORME 16/16, `verifier_gui.py` (node --check) OK,
|
||||
**DIFF VIDE** conservé et les deux variables de binding du courriel toujours générées
|
||||
(`serveur_postfix_mailstore_hote`, `serveur_postfix_rspamd_milter`). Aller-retour
|
||||
d'écriture testé : les liens survivent au cycle chargement → sauvegarde → relecture.
|
||||
Sept garde-fous du validateur éprouvés (rôle inconnu, cible inexistante, lien vers
|
||||
soi-même, champ manquant, types invalides).
|
||||
|
||||
## 2026-07-21 (suite)
|
||||
|
||||
### Modifié
|
||||
|
|
|
|||
|
|
@ -54,6 +54,15 @@ make prouver
|
|||
| P14 | Chemins d'inventaire | aucun `instance/inventories/lab/group_vars` codé en dur. |
|
||||
| P15 | Modèle public `socle` | ses registres (domaines/serveurs/applications/bases) valident. |
|
||||
| P16 | Inventaire Ansible (voûte) | `ansible-inventory --list` — sauté sans mot de passe Vault. |
|
||||
| P17 | **Tous** les modèles d'instance | chaque modèle découvert valide (pas seulement `socle`). `SETOPS_MODELES=../Set-OPS-Modeles` inclut les modèles assemblés privés. |
|
||||
| P18 | Gabarit de voûte complet | `vault.yml.example` couvre **exactement** les secrets que le plan exige (rôles actifs + bases + group_vars). |
|
||||
| P19 | GUI couvre le plan | tout champ présent dans un plan réel est éditable par le GUI (nomenclature tolérée : trou connu). |
|
||||
|
||||
> **P17, P18, P19 ferment les angles morts du harnais** : il ne vérifiait qu'*une* instance
|
||||
> et le seul modèle `socle`. P17 aurait attrapé l'hôte fantôme d'`integral` ; P18, les neuf
|
||||
> secrets absents du gabarit Chezlepro ; P19, les champs `liens` / `websocket` que le GUI ne
|
||||
> savait pas écrire. Chacune a une CLI dédiée (`scripts/modeles.py`, `voute.py`,
|
||||
> `couverture_gui.py`) utilisable seule.
|
||||
|
||||
Le rapport relie chaque preuve aux **affirmations** qu'elle couvre (colonne « Affirmations »),
|
||||
et liste à part les **déclarations d'intention** (⚪ invérifiables localement : AFF-036,
|
||||
|
|
|
|||
55
docs/audit/preuve-2026-07-22.md
Normal file
55
docs/audit/preuve-2026-07-22.md
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
# Preuve de conformite — Set-OPS — 2026-07-22
|
||||
|
||||
> Genere par `make prouver` (`scripts/prouver.py`). **Rejouable** : relancer
|
||||
> reproduit ce rapport. Chaque preuve rejoue l'outillage existant du depot ;
|
||||
> aucune validation n'est reimplementee ici. Voir le mode d'emploi :
|
||||
> [`docs/audit/README.md`](README.md), et le registre trace :
|
||||
> [`docs/audit/affirmations.md`](affirmations.md).
|
||||
|
||||
- **Instance** : `instance` — inventaire `instance/inventories/principal/hosts.yml`
|
||||
- **Verdict** : ✅ CONFORME (19 OK · 0 echec · 0 saute)
|
||||
|
||||
## Preuves
|
||||
|
||||
| # | Preuve | Affirmations | Statut | Detail |
|
||||
|---|---|---|---|---|
|
||||
| P01 | Lint (ansible-lint) | AFF-006 | ✅ OK | |
|
||||
| P02 | Tests unitaires (inventory_host) | — | ✅ OK | 4 tests passes. |
|
||||
| P03 | Diff-vide du plan (inventaire genere) | AFF-001, AFF-004, AFF-030, AFF-031, AFF-032 | ✅ OK | DIFF VIDE : le plan reproduit exactement l'inventaire actuel. Bascule possible. |
|
||||
| P04 | Groupes <-> playbooks homonymes | AFF-008 | ✅ OK | |
|
||||
| P05 | Dependances causales de groupes | AFF-009, AFF-084 | ✅ OK | |
|
||||
| P06 | Validateurs de registres (serveurs/apps/bases/domaines) | AFF-003 | ✅ OK | Registre des domaines valide. |
|
||||
| P07 | GUI (node --check) | AFF-033 | ✅ OK | JS du GUI : syntaxe valide (node --check). |
|
||||
| P08 | Orchestration (couches + graphe) | AFF-070 | ✅ OK | Orchestration coherente : 30 groupes classes, aucun cycle, aucune arete en arriere. |
|
||||
| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 29 rôles, 63 flux, schéma + matrice OK. |
|
||||
| P10 | Handlers <-> notify | AFF-034, AFF-035 | ✅ OK | Tout notify pointe vers un handler du meme role (49 roles). |
|
||||
| P11 | Syntaxe des playbooks (--syntax-check) | AFF-083 | ✅ OK | playbook: playbooks/proxmox/cloner_vm_debian.yml |
|
||||
| P12 | Existence des runbooks cites | AFF-010, AFF-011, AFF-012, AFF-083 | ✅ OK | 17/17 runbooks/registres cites presents. |
|
||||
| P13 | Invariants structurels/doctrinaux | AFF-015, AFF-022, AFF-037, AFF-038, AFF-062 | ✅ OK | LICENSE, socle dossier, pas de couches paralleles, SSH clef-only, nftables off : OK. |
|
||||
| P14 | Pas de chemin lab/ code en dur | AFF-097 | ✅ OK | Aucun chemin instance/inventories/lab/group_vars code en dur. |
|
||||
| P15 | Modele public socle valide | AFF-022, AFF-099 | ✅ OK | Modele public socle : domaines/serveurs/applications/bases valides. |
|
||||
| P16 | Inventaire Ansible complet (--list) | AFF-030 | ✅ OK | 14 hotes, 31 groupes (inventaire dechiffre et parse). |
|
||||
| P17 | Tous les modeles d'instance valident | AFF-022, AFF-099 | ✅ OK | Les 7 modele(s) decouvert(s) valident. |
|
||||
| P18 | Gabarit de voute complet | AFF-026 | ✅ OK | Gabarit de voute complet : 23 secret(s) exige(s), tous presents. |
|
||||
| P19 | Le GUI couvre le schema du plan | AFF-002, AFF-095 | ✅ OK | GUI : les 27 champ(s) des plans reels sont editables (8 plan(s) inspecte(s)), registres toleres : nomenclature. |
|
||||
|
||||
## Couverture des affirmations ✅ du registre
|
||||
|
||||
Chaque affirmation ✅ automatisable est couverte par la preuve indiquee ci-dessus.
|
||||
Les ✅ **structurelles/doctrinales** non rejouables par une commande (ex. AFF-005
|
||||
`make`=aide, AFF-014 ciblage groupe, AFF-024 `instancier-appliquer`, AFF-051 autorite
|
||||
d'AGENTS.md, AFF-073/075 gardes `make`, AFF-090 wiki) ont ete verifiees a l'audit ;
|
||||
elles restent hors du harnais recurrent (rien d'executable a rejouer).
|
||||
|
||||
## Declarations d'intention (⚪ invérifiables localement — assumees)
|
||||
|
||||
Ces affirmations ne sont pas rejouables hors production ; elles sont **assumees**
|
||||
comme declarations d'intention, non comme preuves :
|
||||
|
||||
- **AFF-036** — « testables avec `--check` autant que possible » : verifiable seulement
|
||||
contre une flotte vivante.
|
||||
- **AFF-091** — contenu pedagogique du wiki : affirmations conceptuelles.
|
||||
- **AFF-096** — « GUI 100 % francais » : revue exhaustive des libelles rendus, non automatisee.
|
||||
- **AFF-007** — hote d'exemple `web-frontal-01` : placeholder assume.
|
||||
|
||||
_Rapport genere le 2026-07-22._
|
||||
|
|
@ -187,9 +187,16 @@ Keycloak** (qui utilise ce mécanisme), pour valider le DRY en le déployant. Cf
|
|||
|
||||
## 7. Interface (GUI)
|
||||
|
||||
- vue « Liens » / éditeur de bindings par application ;
|
||||
- **graphe des liens** — visualisation naturelle (le GUI a déjà un graphe d'inventaire), très
|
||||
parlante pour la **démo de portabilité** (déplacer un nœud, voir les arêtes suivre).
|
||||
- **éditeur de bindings par application** — ✅ **fait** (2026-07-22). Section « Liens
|
||||
(bindings) » de l'inspecteur d'application : une ligne par lien, `rôle → cible`, les deux
|
||||
en listes déroulantes. Les **rôles proposés sont exactement ceux que le rôle porteur
|
||||
déclare accepter** (`roles/<groupe>/meta/liens.yml`, exposé par l'API sous
|
||||
`liens_acceptes`) ; les cibles sont les autres applications du plan. Chaque ligne affiche
|
||||
les **variables injectées** par le lien. Changer le groupe d'une application vide les
|
||||
rôles de lien devenus inacceptés.
|
||||
- **graphe des liens** — *reste à faire*. Visualisation naturelle (le GUI a déjà un graphe
|
||||
d'inventaire), très parlante pour la **démo de portabilité** (déplacer un nœud, voir les
|
||||
arêtes suivre).
|
||||
|
||||
## 8. Preuve de migration (les 3 liens mail)
|
||||
|
||||
|
|
@ -212,7 +219,8 @@ Premier cas concret, à faire en régression (les mêmes variables doivent être
|
|||
validé en déployant Keycloak.
|
||||
- **Phase 3** : exposition / domaines (vhosts nginx dérivés des liens ; machinerie `expose` /
|
||||
`expositions_des_applications` déjà présente).
|
||||
- **Phase 4** : vue GUI des liens + graphe.
|
||||
- **Phase 4** : vue GUI des liens + graphe. 🟡 **éditeur fait** (2026-07-22, cf. §7) ;
|
||||
**graphe des liens reste à faire**.
|
||||
|
||||
## 10. Décisions ouvertes
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ from inventory_rules import (
|
|||
charger_applications,
|
||||
charger_bases_donnees,
|
||||
charger_domaines,
|
||||
charger_serveurs,
|
||||
valider_applications,
|
||||
)
|
||||
|
||||
|
|
@ -26,6 +27,7 @@ INSTANCE = Path(os.environ.get("SETOPS_INSTANCE") or (RACINE / "instance"))
|
|||
FICHIER = INSTANCE / "plan/applications.yml"
|
||||
FICHIER_BASES = INSTANCE / "plan/bases-donnees.yml"
|
||||
FICHIER_DOMAINES = INSTANCE / "plan/domaines.yml"
|
||||
FICHIER_SERVEURS = INSTANCE / "plan/serveurs.yml"
|
||||
def _inventaire(instance: Path, *noms: str) -> Path:
|
||||
forced = os.environ.get("SETOPS_INVENTAIRE")
|
||||
if forced:
|
||||
|
|
@ -132,14 +134,15 @@ def main() -> int:
|
|||
try:
|
||||
registre = charger_applications(FICHIER)
|
||||
domaines = charger_domaines(FICHIER_DOMAINES)
|
||||
serveurs = charger_serveurs(FICHIER_SERVEURS)
|
||||
if args.commande == "lister":
|
||||
lister(registre, charger_bases_donnees(FICHIER_BASES))
|
||||
elif args.commande == "verifier":
|
||||
valider_applications(registre, domaines)
|
||||
valider_applications(registre, domaines, serveurs)
|
||||
print("Registre des applications valide.")
|
||||
elif args.commande == "bootstrap":
|
||||
registre = bootstrap(registre)
|
||||
valider_applications(registre, domaines)
|
||||
valider_applications(registre, domaines, serveurs)
|
||||
ecrire(registre)
|
||||
print(f"instance/plan/applications.yml genere depuis l'inventaire ({len(registre['applications'])} applications).")
|
||||
elif args.commande == "ajouter":
|
||||
|
|
@ -151,12 +154,12 @@ def main() -> int:
|
|||
if _liste(args.expose):
|
||||
app["expose"] = _liste(args.expose)
|
||||
registre["applications"][args.id] = app
|
||||
valider_applications(registre, domaines)
|
||||
valider_applications(registre, domaines, serveurs)
|
||||
ecrire(registre)
|
||||
print(f"Application '{args.id}' enregistree.")
|
||||
elif args.commande == "retirer":
|
||||
registre["applications"].pop(args.id, None)
|
||||
valider_applications(registre, domaines)
|
||||
valider_applications(registre, domaines, serveurs)
|
||||
ecrire(registre)
|
||||
print(f"Application '{args.id}' retiree.")
|
||||
except Exception as exc:
|
||||
|
|
|
|||
152
scripts/couverture_gui.py
Normal file
152
scripts/couverture_gui.py
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Le GUI sait-il editer tout ce que les plans reels contiennent ?
|
||||
|
||||
Principe 10 : « un operateur humain exploite Set-OPS sans IA — la doc, `make` et
|
||||
le GUI suffisent ». Ce principe se dement en silence des qu'un registre accepte un
|
||||
champ que l'interface ne sait pas ecrire : l'operateur doit editer le YAML a la
|
||||
main, et personne ne s'en apercoit avant de buter dessus.
|
||||
|
||||
Ce script rend le trou VISIBLE et mesurable. Il croise :
|
||||
- les champs REELLEMENT presents dans les plans (instance courante + tous les
|
||||
modeles decouverts par scripts/modeles.py) ;
|
||||
- `CHAMPS_ECRITS_PAR_GUI` de scripts/inventory_gui.py, qui declare ce que les
|
||||
fonctions de sauvegarde ecrivent.
|
||||
|
||||
Deux sens de verification :
|
||||
1. champ dans un plan, absent du GUI -> TROU d'interface (signale) ;
|
||||
2. champ declare au GUI, introuvable dans son source -> DERIVE de la
|
||||
declaration (signale) — le tableau ment sur ce que le GUI fait.
|
||||
|
||||
Usage :
|
||||
python3 scripts/couverture_gui.py lister
|
||||
python3 scripts/couverture_gui.py verifier # rc=2 s'il reste un trou
|
||||
python3 scripts/couverture_gui.py verifier --tolerer nomenclature
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from inventory_gui import CHAMPS_ECRITS_PAR_GUI
|
||||
from modeles import decouvrir as decouvrir_modeles
|
||||
|
||||
RACINE = Path(__file__).resolve().parents[1]
|
||||
INSTANCE = Path(os.environ.get("SETOPS_INSTANCE") or (RACINE / "instance"))
|
||||
SOURCE_GUI = RACINE / "scripts" / "inventory_gui.py"
|
||||
|
||||
# (fichier du plan, cle racine, nom du registre) — les registres tables-de-tables.
|
||||
REGISTRES = [
|
||||
("serveurs.yml", "serveurs", "serveurs"),
|
||||
("applications.yml", "applications", "applications"),
|
||||
("bases-donnees.yml", "bases_donnees", "bases_donnees"),
|
||||
("bases-donnees.yml", "serveurs_bd", "serveurs_bd"),
|
||||
("domaines.yml", "domaines_publics", "domaines_publics"),
|
||||
("nomenclature.yml", "fonctions", "nomenclature"),
|
||||
]
|
||||
|
||||
|
||||
def _plans() -> list[Path]:
|
||||
"""Dossiers plan/ a inspecter : l'instance courante + tous les modeles."""
|
||||
trouves = []
|
||||
if (INSTANCE / "plan").is_dir():
|
||||
trouves.append(INSTANCE / "plan")
|
||||
trouves += [m / "plan" for m in decouvrir_modeles() if (m / "plan").is_dir()]
|
||||
return trouves
|
||||
|
||||
|
||||
def champs_utilises() -> dict[str, dict[str, set[str]]]:
|
||||
"""{registre: {champ: {origines}}} — champs vus dans les plans reels."""
|
||||
vus: dict[str, dict[str, set[str]]] = {r: {} for _, _, r in REGISTRES}
|
||||
for plan in _plans():
|
||||
etiquette = plan.parent.name
|
||||
for fichier, racine_cle, registre in REGISTRES:
|
||||
p = plan / fichier
|
||||
if not p.is_file():
|
||||
continue
|
||||
try:
|
||||
data = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
|
||||
except yaml.YAMLError:
|
||||
continue
|
||||
entrees = (data or {}).get(racine_cle) or {}
|
||||
if not isinstance(entrees, dict):
|
||||
continue
|
||||
for entree in entrees.values():
|
||||
if isinstance(entree, dict):
|
||||
for champ in entree:
|
||||
vus[registre].setdefault(str(champ), set()).add(etiquette)
|
||||
return vus
|
||||
|
||||
|
||||
def declaration_derive() -> list[str]:
|
||||
"""Champs declares au GUI mais introuvables dans son source (declaration qui ment)."""
|
||||
src = SOURCE_GUI.read_text(encoding="utf-8")
|
||||
# On coupe le tableau lui-meme : sa presence ne prouve rien.
|
||||
debut = src.find("CHAMPS_ECRITS_PAR_GUI")
|
||||
fin = src.find("\n}\n", debut)
|
||||
ailleurs = src[:debut] + src[fin:] if debut != -1 and fin != -1 else src
|
||||
absents = []
|
||||
for registre, champs in CHAMPS_ECRITS_PAR_GUI.items():
|
||||
for champ in sorted(champs):
|
||||
if champ not in ailleurs:
|
||||
absents.append(f"{registre}.{champ}")
|
||||
return absents
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Couverture du schema du plan par le GUI.")
|
||||
sub = parser.add_subparsers(dest="commande", required=True)
|
||||
sub.add_parser("lister", help="Detaille champ par champ.")
|
||||
pv = sub.add_parser("verifier", help="Echoue s'il reste un champ non editable.")
|
||||
pv.add_argument("--tolerer", action="append", default=[], metavar="REGISTRE",
|
||||
help="Registre dont les trous sont connus et assumes (repetable).")
|
||||
args = parser.parse_args()
|
||||
|
||||
utilises = champs_utilises()
|
||||
|
||||
if args.commande == "lister":
|
||||
for registre in sorted(utilises):
|
||||
couverts = CHAMPS_ECRITS_PAR_GUI.get(registre, set())
|
||||
print(f"\n== {registre} ==")
|
||||
for champ in sorted(utilises[registre]):
|
||||
etat = "GUI" if champ in couverts else "-- hors GUI --"
|
||||
print(f" {champ:<18} {etat:<16} vu dans : "
|
||||
f"{', '.join(sorted(utilises[registre][champ]))}")
|
||||
return 0
|
||||
|
||||
tolere = set(args.tolerer)
|
||||
trous: list[str] = []
|
||||
for registre, champs in utilises.items():
|
||||
if registre in tolere:
|
||||
continue
|
||||
couverts = CHAMPS_ECRITS_PAR_GUI.get(registre, set())
|
||||
for champ in sorted(set(champs) - couverts):
|
||||
trous.append(f"{registre}.{champ} (vu dans : "
|
||||
f"{', '.join(sorted(champs[champ]))})")
|
||||
|
||||
derive = declaration_derive()
|
||||
|
||||
if trous:
|
||||
print(f"erreur: {len(trous)} champ(s) present(s) dans un plan reel que le GUI "
|
||||
f"ne sait pas ecrire :", file=sys.stderr)
|
||||
for t in trous:
|
||||
print(f" - {t}", file=sys.stderr)
|
||||
if derive:
|
||||
print(f"erreur: {len(derive)} champ(s) declare(s) dans CHAMPS_ECRITS_PAR_GUI mais "
|
||||
f"introuvable(s) dans le source du GUI : {', '.join(derive)}", file=sys.stderr)
|
||||
|
||||
if trous or derive:
|
||||
return 2
|
||||
total = sum(len(c) for c in utilises.values())
|
||||
print(f"GUI : les {total} champ(s) des plans reels sont editables "
|
||||
f"({len(_plans())} plan(s) inspecte(s))"
|
||||
+ (f", registres toleres : {', '.join(sorted(tolere))}." if tolere else "."))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -34,6 +34,7 @@ from inventory_rules import (
|
|||
deriver_ressources,
|
||||
expositions_des_applications,
|
||||
fonction_seq,
|
||||
liens_acceptes,
|
||||
)
|
||||
|
||||
RACINE = Path(__file__).resolve().parents[1]
|
||||
|
|
@ -91,14 +92,6 @@ def _domaine_interne() -> str:
|
|||
return ""
|
||||
|
||||
|
||||
def _liens_acceptes(groupe: str) -> dict:
|
||||
"""meta/liens.yml d'un role -> setops_liens.accepte (liens que le role accepte)."""
|
||||
fichier = ROLES / groupe / "meta" / "liens.yml"
|
||||
if not fichier.is_file():
|
||||
return {}
|
||||
data = yaml.safe_load(fichier.read_text(encoding="utf-8")) or {}
|
||||
return ((data.get("setops_liens") or {}).get("accepte")) or {}
|
||||
|
||||
|
||||
def _attributs_cible(vers: str, apps: dict, serveurs: dict, nomenclature: dict, domaine: str) -> dict | None:
|
||||
"""Resout une cible de lien en attributs substituables. Phase 1 : applications."""
|
||||
|
|
@ -134,7 +127,7 @@ def resoudre_liens(apps: dict, serveurs: dict, nomenclature: dict) -> dict:
|
|||
for lien in (app.get("liens") or []):
|
||||
vers, role = lien.get("vers"), lien.get("role")
|
||||
if accepte is None:
|
||||
accepte = _liens_acceptes(str(app.get("groupe", "")))
|
||||
accepte = liens_acceptes(str(app.get("groupe", "")), RACINE)
|
||||
spec = accepte.get(role)
|
||||
if not spec:
|
||||
raise ValueError(
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ from inventory_rules import (
|
|||
groupes_operationnels_connus,
|
||||
reconcilier_serveur,
|
||||
valider_applications,
|
||||
catalogue_liens,
|
||||
valider_bases,
|
||||
valider_domaines,
|
||||
valider_serveurs,
|
||||
|
|
@ -355,6 +356,7 @@ def inventaire_api(path: Path) -> dict:
|
|||
"hotes": liste_hotes(data),
|
||||
"intrants": intrants_api(),
|
||||
"integrations_disponibles": integrations_disponibles(),
|
||||
"liens_acceptes": catalogue_liens(RACINE),
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -401,6 +403,31 @@ def intrants_courants() -> dict:
|
|||
return valeurs
|
||||
|
||||
|
||||
# --- Couverture du schema du plan par le GUI -------------------------------------
|
||||
#
|
||||
# Ce que les fonctions de sauvegarde JS ecrivent REELLEMENT, registre par registre.
|
||||
# Source unique verifiee par `scripts/couverture_gui.py` : tout champ present dans
|
||||
# un plan reel (instance ou modele) mais absent d'ici signale un trou de l'interface
|
||||
# — l'operateur devrait editer le YAML a la main, ce que la doctrine refuse.
|
||||
#
|
||||
# A TENIR A JOUR avec sauvegarderServeurs / sauvegarderApplications /
|
||||
# sauvegarderBases / sauvegarderDomaines. Le verificateur controle aussi que chaque
|
||||
# champ declare ici apparait bien dans le source du GUI (garde anti-derive).
|
||||
CHAMPS_ECRITS_PAR_GUI = {
|
||||
"serveurs": {"fonction", "etat", "noeud", "stockage", "disque", "memoire",
|
||||
"coeurs", "integrations"},
|
||||
"applications": {"groupe", "hote", "port", "requiert", "expose", "liens", "websocket"},
|
||||
"bases_donnees": {"serveur", "base", "proprietaire", "secret", "consommateur",
|
||||
"portee", "usage"},
|
||||
"serveurs_bd": {"type", "hote", "port", "groupe"},
|
||||
"domaines_publics": {"autorite", "edge", "secondaires", "dnssec", "mail"},
|
||||
# nomenclature : AUCUN champ. Le GUI la LIT (liste des fonctions) sans jamais
|
||||
# l'ecrire — ajouter une fonction exige d'editer le YAML. Trou connu et assume ;
|
||||
# `couverture_gui.py` le signale tant qu'il dure.
|
||||
"nomenclature": set(),
|
||||
}
|
||||
|
||||
|
||||
def integrations_disponibles() -> list:
|
||||
"""Roles client_* presents dans roles/ : source des integrations selectionnables."""
|
||||
base = RACINE / "roles"
|
||||
|
|
@ -495,6 +522,8 @@ def ecrire_applications(path: Path, registre: dict) -> None:
|
|||
"# Registre des applications Set-OPS (application = entite pivot).\n"
|
||||
"# Edite par make inventaire-ui ou scripts/applications.py.\n"
|
||||
"# Une VM peut porter plusieurs applications ; une base se lie a une application.\n"
|
||||
"# 'liens' = bindings declaratifs [{vers, role}] resolus par scripts/instancier.py ;\n"
|
||||
"# les roles acceptes sont ceux de roles/<groupe>/meta/liens.yml.\n"
|
||||
"---\n"
|
||||
)
|
||||
with path.open("w", encoding="utf-8") as fichier:
|
||||
|
|
@ -1029,6 +1058,7 @@ HTML = r"""<!doctype html>
|
|||
let modaleResolveur = null;
|
||||
let intrants = null;
|
||||
let integrationsDispo = [];
|
||||
let liensAcceptes = {};
|
||||
let sondes = {}; // état RÉEL par hôte : joignable (SSH) ou non — distinct du plan
|
||||
let chronoConsole = null;
|
||||
let consoleDebut = 0;
|
||||
|
|
@ -1076,6 +1106,7 @@ HTML = r"""<!doctype html>
|
|||
couchesRegistre = data.couches || [];
|
||||
intrants = data.intrants || null;
|
||||
integrationsDispo = data.integrations_disponibles || [];
|
||||
liensAcceptes = data.liens_acceptes || {};
|
||||
chargerBases(data);
|
||||
chargerApplications(data);
|
||||
chargerDomaines(data);
|
||||
|
|
@ -1387,7 +1418,9 @@ HTML = r"""<!doctype html>
|
|||
const a = (data.applications || {}).applications || {};
|
||||
applications = Object.entries(a).map(([id, x]) => ({
|
||||
id, groupe: x.groupe || '', hote: x.hote || '', port: x.port || '',
|
||||
requiert: (x.requiert || []).join(', '), expose: (x.expose || []).join(', ')}));
|
||||
requiert: (x.requiert || []).join(', '), expose: (x.expose || []).join(', '),
|
||||
websocket: !!x.websocket,
|
||||
liens: (x.liens || []).map(l => ({vers: l.vers || '', role: l.role || ''}))}));
|
||||
applicationsModifie = false;
|
||||
}
|
||||
|
||||
|
|
@ -1521,10 +1554,15 @@ HTML = r"""<!doctype html>
|
|||
const a = applications[selApp]; if (!a) return;
|
||||
a[champ] = v; marquerApplicationsModifie();
|
||||
if (champ === 'id') { const t = document.querySelector('#tuile-app-' + selApp + ' .carte-nom'); if (t) { t.textContent = v || '(sans id)'; t.className = 'carte-nom' + (v ? '' : ' sans'); } }
|
||||
else if (champ === 'hote' || champ === 'groupe' || champ === 'port') dessinerDetail();
|
||||
else if (champ === 'groupe') {
|
||||
const ok = Object.keys(liensAcceptes[a.groupe] || {});
|
||||
(a.liens || []).forEach(l => { if (l.role && !ok.includes(l.role)) l.role = ''; });
|
||||
dessinerDetail();
|
||||
}
|
||||
else if (champ === 'hote' || champ === 'port') dessinerDetail();
|
||||
}
|
||||
function ajouterApplication() {
|
||||
applications.unshift({id: '', groupe: (groupes[0] || ''), hote: '', port: '', requiert: '', expose: ''});
|
||||
applications.unshift({id: '', groupe: (groupes[0] || ''), hote: '', port: '', requiert: '', expose: '', websocket: false, liens: []});
|
||||
selApp = 0; marquerApplicationsModifie(); dessiner();
|
||||
const c = document.querySelector('#detail .grille input'); if (c) c.focus();
|
||||
}
|
||||
|
|
@ -1536,6 +1574,52 @@ HTML = r"""<!doctype html>
|
|||
marquerApplicationsModifie(); dessiner();
|
||||
}
|
||||
|
||||
function ajouterLien() {
|
||||
const a = applications[selApp]; if (!a) return;
|
||||
if (!a.liens) a.liens = [];
|
||||
const roles = Object.keys(liensAcceptes[a.groupe] || {});
|
||||
a.liens.push({vers: '', role: roles.length === 1 ? roles[0] : ''});
|
||||
marquerApplicationsModifie(); dessinerDetail();
|
||||
}
|
||||
function retirerLien(i) {
|
||||
const a = applications[selApp]; if (!a || !a.liens) return;
|
||||
a.liens.splice(i, 1); marquerApplicationsModifie(); dessinerDetail();
|
||||
}
|
||||
function definirLien(i, champ, v) {
|
||||
const a = applications[selApp]; if (!a || !a.liens || !a.liens[i]) return;
|
||||
a.liens[i][champ] = v; marquerApplicationsModifie();
|
||||
if (champ === 'role') dessinerDetail();
|
||||
}
|
||||
function blocLiens(a) {
|
||||
const accepte = liensAcceptes[a.groupe] || {};
|
||||
const roles = Object.keys(accepte);
|
||||
if (!roles.length) {
|
||||
return `<div class="ch-stub">Le rôle « ${echapper(a.groupe || '—')} » n'accepte aucun lien
|
||||
(aucun <span class="mono">roles/${echapper(a.groupe || '?')}/meta/liens.yml</span>).</div>`;
|
||||
}
|
||||
const cibles = applications.map(x => x.id).filter(x => x && x !== a.id);
|
||||
const lignes = (a.liens || []).map((l, i) => {
|
||||
const spec = accepte[l.role] || {};
|
||||
const vars = Object.keys(spec.variables || {});
|
||||
const optV = '<option value="">— cible —</option>' + cibles.map(c =>
|
||||
`<option value="${echapper(c)}" ${c === l.vers ? 'selected' : ''}>${echapper(c)}</option>`).join('');
|
||||
const optR = '<option value="">— rôle —</option>' + roles.map(r =>
|
||||
`<option value="${echapper(r)}" ${r === l.role ? 'selected' : ''}>${echapper(r)}</option>`).join('');
|
||||
const inconnue = l.vers && !cibles.includes(l.vers);
|
||||
return `<div class="ch-base" style="gap:8px;align-items:center">
|
||||
<select style="max-width:12rem" onchange="definirLien(${i}, 'role', this.value)">${optR}</select>
|
||||
<span class="mono">→</span>
|
||||
<select style="max-width:12rem" onchange="definirLien(${i}, 'vers', this.value)">${optV}</select>
|
||||
${inconnue ? `<span class="tag" style="color:#b45309">cible « ${echapper(l.vers)} » inconnue</span>` : ''}
|
||||
${vars.length ? `<span class="tag" title="Variables injectées sur cette application">${echapper(vars.join(', '))}</span>` : ''}
|
||||
<span class="grandit"></span>
|
||||
<button type="button" class="danger" title="Retirer ce lien" onclick="retirerLien(${i})">✕</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
return `${lignes || '<div class="ch-stub">aucun lien déclaré</div>'}
|
||||
<div style="margin-top:6px"><button type="button" class="fantome" onclick="ajouterLien()">+ Lien</button></div>`;
|
||||
}
|
||||
|
||||
function carteApp(a, i) {
|
||||
const exp = listeDepuisTexte(a.expose);
|
||||
return `<button type="button" class="carte ${i === selApp ? 'selectionnee' : ''}" id="tuile-app-${i}" onclick="choisirApp(${i})">
|
||||
|
|
@ -1576,9 +1660,13 @@ HTML = r"""<!doctype html>
|
|||
<label class="champ"><span>Groupe (rôle)</span><select onchange="definirApplication('groupe', this.value)">${optGroupe}</select></label>
|
||||
<label class="champ"><span>Hôte (VM)</span><select onchange="definirApplication('hote', this.value)">${optHote}</select></label>
|
||||
<label class="champ"><span>Port</span><input type="number" value="${echapper(a.port)}" placeholder="3000" oninput="definirApplication('port', this.value)"></label>
|
||||
<label class="champ"><span>WebSocket</span><span style="display:flex;align-items:center;gap:8px;height:2.1rem"><input type="checkbox" ${a.websocket ? 'checked' : ''} onchange="definirApplication('websocket', this.checked)"><span class="hint-dep">vhost compatible WebSocket (Collabora…)</span></span></label>
|
||||
<label class="champ" style="grid-column:1/-1"><span>Requiert (applis)</span><input value="${echapper(a.requiert)}" placeholder="pg-principal, …" oninput="definirApplication('requiert', this.value)"></label>
|
||||
<label class="champ" style="grid-column:1/-1"><span>Expose (FQDN)</span><input value="${echapper(a.expose)}" placeholder="forge.exemple.ca" oninput="definirApplication('expose', this.value)"></label>
|
||||
</div>
|
||||
<div class="section-tete" style="margin:12px 2px 4px">Liens (bindings)
|
||||
<span class="compte">${(a.liens || []).length}</span></div>
|
||||
${blocLiens(a)}
|
||||
<div class="section-tete" style="margin:12px 2px 4px">Bases consommées</div>
|
||||
${liens}
|
||||
${(() => {
|
||||
|
|
@ -1602,6 +1690,11 @@ HTML = r"""<!doctype html>
|
|||
if (String(a.port).trim()) o.port = parseInt(a.port, 10) || a.port;
|
||||
const req = listeDepuisTexte(a.requiert); if (req.length) o.requiert = req;
|
||||
const exp = listeDepuisTexte(a.expose); if (exp.length) o.expose = exp;
|
||||
if (a.websocket) o.websocket = true;
|
||||
const lns = (a.liens || [])
|
||||
.filter(l => String(l.vers).trim() && String(l.role).trim())
|
||||
.map(l => ({vers: l.vers, role: l.role}));
|
||||
if (lns.length) o.liens = lns;
|
||||
apps[a.id] = o;
|
||||
});
|
||||
const rep = await fetch('/api/applications', {
|
||||
|
|
@ -2405,7 +2498,8 @@ class Gestionnaire(BaseHTTPRequestHandler):
|
|||
ecrire_bases(FICHIER_BASES, donnees)
|
||||
self.repondre_json(200, inventaire_api(self.inventaire))
|
||||
elif chemin == "/api/applications":
|
||||
valider_applications(donnees, charger_domaines(FICHIER_DOMAINES))
|
||||
valider_applications(donnees, charger_domaines(FICHIER_DOMAINES),
|
||||
charger_serveurs(FICHIER_SERVEURS))
|
||||
ecrire_applications(FICHIER_APPLICATIONS, donnees)
|
||||
self.repondre_json(200, inventaire_api(self.inventaire))
|
||||
elif chemin == "/api/serveurs":
|
||||
|
|
|
|||
|
|
@ -14,6 +14,38 @@ GROUPE_HOTES_ACTIFS = "hotes_actifs"
|
|||
GROUPE_HOTES_PLANIFIES = "hotes_planifies"
|
||||
GROUPES_ETAT_HOTE = {GROUPE_HOTES_ACTIFS, GROUPE_HOTES_PLANIFIES}
|
||||
|
||||
RACINE_DEPOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def liens_acceptes(groupe: str, racine: Path | None = None) -> dict:
|
||||
"""Liens qu'un role accepte : roles/<groupe>/meta/liens.yml -> setops_liens.accepte.
|
||||
|
||||
Source unique partagee par le validateur, le GUI et scripts/instancier.py.
|
||||
Retourne {} si le role n'en declare aucun.
|
||||
"""
|
||||
fichier = (racine or RACINE_DEPOT) / "roles" / groupe / "meta" / "liens.yml"
|
||||
if not fichier.is_file():
|
||||
return {}
|
||||
data = yaml.safe_load(fichier.read_text(encoding="utf-8")) or {}
|
||||
return ((data.get("setops_liens") or {}).get("accepte")) or {}
|
||||
|
||||
|
||||
def catalogue_liens(racine: Path | None = None) -> dict:
|
||||
"""{groupe: {role_de_lien: {cible, injecte_sur, variables}}} pour tous les roles.
|
||||
|
||||
Alimente la liste deroulante « rôle du lien » du GUI : on ne propose que ce
|
||||
que le role porteur declare accepter.
|
||||
"""
|
||||
base = (racine or RACINE_DEPOT) / "roles"
|
||||
if not base.is_dir():
|
||||
return {}
|
||||
catalogue = {}
|
||||
for meta in sorted(base.glob("*/meta/liens.yml")):
|
||||
accepte = liens_acceptes(meta.parents[1].name, racine)
|
||||
if accepte:
|
||||
catalogue[meta.parents[1].name] = accepte
|
||||
return catalogue
|
||||
|
||||
|
||||
def est_groupe_operationnel(groupe: str) -> bool:
|
||||
return groupe.startswith(GROUPES_OPERATIONNELS_PREFIXES)
|
||||
|
|
@ -128,11 +160,17 @@ def charger_applications(path: Path | None) -> dict:
|
|||
return data
|
||||
|
||||
|
||||
def valider_applications(registre: dict, domaines: dict | None = None) -> None:
|
||||
def valider_applications(registre: dict, domaines: dict | None = None,
|
||||
serveurs: dict | None = None) -> None:
|
||||
"""Valide la coherence du registre des applications.
|
||||
|
||||
Si `domaines` (le registre des domaines publics) est fourni, chaque FQDN
|
||||
de `expose` doit avoir un domaine parent declare.
|
||||
|
||||
Si `serveurs` (le registre des serveurs) est fourni, l'hote porteur de chaque
|
||||
application doit y etre declare — c'est le garde-fou contre l'hote FANTOME
|
||||
(une application posee sur une VM qui n'existe pas). Sans lui, le plan passe
|
||||
la validation et casse au deploiement.
|
||||
"""
|
||||
apps = registre.get("applications") or {}
|
||||
doms = (domaines or {}).get("domaines_publics") or {}
|
||||
|
|
@ -144,6 +182,12 @@ def valider_applications(registre: dict, domaines: dict | None = None) -> None:
|
|||
for champ in ("groupe", "hote"):
|
||||
if not str(app.get(champ, "")).strip():
|
||||
raise ValueError(f"Application '{nom}': champ '{champ}' requis.")
|
||||
hotes_connus = (serveurs or {}).get("serveurs")
|
||||
if hotes_connus and app["hote"] not in hotes_connus:
|
||||
raise ValueError(
|
||||
f"Application '{nom}': hote '{app['hote']}' absent du registre des "
|
||||
f"serveurs (hote fantome). Declare-le dans plan/serveurs.yml, ou "
|
||||
f"deplace l'application sur un hote existant.")
|
||||
if not est_groupe_operationnel(app["groupe"]):
|
||||
raise ValueError(f"Application '{nom}': groupe '{app['groupe']}' non operationnel.")
|
||||
requiert = app.get("requiert") or []
|
||||
|
|
@ -161,6 +205,30 @@ def valider_applications(registre: dict, domaines: dict | None = None) -> None:
|
|||
port = app.get("port")
|
||||
if port is not None and not str(port).strip().isdigit():
|
||||
raise ValueError(f"Application '{nom}': 'port' doit etre un entier.")
|
||||
liens = app.get("liens") or []
|
||||
if not isinstance(liens, list):
|
||||
raise ValueError(f"Application '{nom}': 'liens' doit etre une liste.")
|
||||
accepte = None
|
||||
for lien in liens:
|
||||
if not isinstance(lien, dict):
|
||||
raise ValueError(f"Application '{nom}': chaque lien doit etre une table {{vers, role}}.")
|
||||
vers = str(lien.get("vers", "")).strip()
|
||||
role = str(lien.get("role", "")).strip()
|
||||
if not vers or not role:
|
||||
raise ValueError(f"Application '{nom}': lien incomplet (champs 'vers' et 'role' requis).")
|
||||
if vers == nom:
|
||||
raise ValueError(f"Application '{nom}': lien vers elle-meme.")
|
||||
if vers not in apps:
|
||||
raise ValueError(f"Application '{nom}': cible de lien '{vers}' inconnue.")
|
||||
if accepte is None:
|
||||
accepte = liens_acceptes(str(app.get("groupe", "")))
|
||||
# Un role sans meta/liens.yml n'accepte aucun lien : on ne bloque que
|
||||
# s'il en declare (sinon instancier.py tranchera avec le meme message).
|
||||
if accepte and role not in accepte:
|
||||
raise ValueError(
|
||||
f"Application '{nom}': role de lien '{role}' non accepte par "
|
||||
f"'{app.get('groupe')}' (roles/{app.get('groupe')}/meta/liens.yml). "
|
||||
f"Acceptes : {', '.join(sorted(accepte))}.")
|
||||
|
||||
|
||||
def applications_de_hote(registre: dict, hote: str) -> list[dict]:
|
||||
|
|
|
|||
122
scripts/modeles.py
Normal file
122
scripts/modeles.py
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Valide TOUS les modeles d'instance, pas seulement le socle public.
|
||||
|
||||
Un modele est un dossier portant un `plan/` (serveurs, applications, bases,
|
||||
domaines, nomenclature). Ce script rejoue sur chacun les MEMES validateurs que
|
||||
sur une instance reelle — il ne reimplemente aucune regle.
|
||||
|
||||
Pourquoi : les modeles assembles vivent dans un depot separe (actif commercial).
|
||||
Rien ne les verifiait, et `integral` a porte pendant des semaines une application
|
||||
posee sur un hote jamais declare. Un modele defectueux se propage a chaque
|
||||
locataire cree depuis lui.
|
||||
|
||||
Decouverte des modeles :
|
||||
- `exemples/modeles/*` du depot (le socle public) ;
|
||||
- plus les chemins de la variable d'environnement SETOPS_MODELES
|
||||
(separes par ':'). Un chemin peut designer un modele OU un dossier de
|
||||
modeles. Absent = ignore en silence (le depot prive n'est pas toujours la).
|
||||
|
||||
Usage :
|
||||
python3 scripts/modeles.py lister
|
||||
python3 scripts/modeles.py verifier
|
||||
SETOPS_MODELES=../Set-OPS-Modeles python3 scripts/modeles.py verifier
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
RACINE = Path(__file__).resolve().parents[1]
|
||||
MODELES_DEPOT = RACINE / "exemples" / "modeles"
|
||||
VALIDATEURS = ("domaines", "serveurs", "applications", "bases_donnees")
|
||||
|
||||
|
||||
def _est_modele(p: Path) -> bool:
|
||||
return p.is_dir() and (p / "plan").is_dir()
|
||||
|
||||
|
||||
def decouvrir() -> list[Path]:
|
||||
"""Modeles du depot + ceux designes par SETOPS_MODELES. Tries, dedoublonnes."""
|
||||
trouves: list[Path] = []
|
||||
if MODELES_DEPOT.is_dir():
|
||||
trouves += [p for p in sorted(MODELES_DEPOT.iterdir()) if _est_modele(p)]
|
||||
for brut in (os.environ.get("SETOPS_MODELES") or "").split(":"):
|
||||
brut = brut.strip()
|
||||
if not brut:
|
||||
continue
|
||||
chemin = Path(brut)
|
||||
if not chemin.is_absolute():
|
||||
chemin = (RACINE / chemin).resolve()
|
||||
if not chemin.is_dir():
|
||||
continue
|
||||
if _est_modele(chemin):
|
||||
trouves.append(chemin)
|
||||
else:
|
||||
trouves += [p for p in sorted(chemin.iterdir()) if _est_modele(p)]
|
||||
vus: dict[str, Path] = {}
|
||||
for p in trouves:
|
||||
vus.setdefault(str(p.resolve()), p)
|
||||
return list(vus.values())
|
||||
|
||||
|
||||
def verifier_un(modele: Path) -> list[str]:
|
||||
"""Rejoue les 4 validateurs sur un modele. Renvoie la liste des erreurs."""
|
||||
erreurs: list[str] = []
|
||||
env = {**os.environ, "SETOPS_INSTANCE": str(modele)}
|
||||
env.pop("SETOPS_INVENTAIRE", None)
|
||||
for nom in VALIDATEURS:
|
||||
r = subprocess.run([sys.executable, f"scripts/{nom}.py", "verifier"],
|
||||
cwd=RACINE, env=env, capture_output=True, text=True)
|
||||
if r.returncode != 0:
|
||||
lignes = (r.stdout + r.stderr).strip().splitlines()
|
||||
erreurs.append(f"{nom}: {lignes[-1] if lignes else 'rc!=0'}")
|
||||
return erreurs
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Validation des modeles d'instance Set-OPS.")
|
||||
sub = parser.add_subparsers(dest="commande", required=True)
|
||||
sub.add_parser("lister", help="Liste les modeles decouverts.")
|
||||
sub.add_parser("verifier", help="Valide les registres de chaque modele.")
|
||||
args = parser.parse_args()
|
||||
|
||||
modeles = decouvrir()
|
||||
if args.commande == "lister":
|
||||
if not modeles:
|
||||
print("Aucun modele decouvert.")
|
||||
return 0
|
||||
for m in modeles:
|
||||
try:
|
||||
rel = m.relative_to(RACINE)
|
||||
except ValueError:
|
||||
rel = m
|
||||
print(f"{m.name} ({rel})")
|
||||
return 0
|
||||
|
||||
if not modeles:
|
||||
print("Aucun modele decouvert : rien a verifier.")
|
||||
return 0
|
||||
|
||||
en_faute = 0
|
||||
for m in modeles:
|
||||
erreurs = verifier_un(m)
|
||||
if erreurs:
|
||||
en_faute += 1
|
||||
print(f"MODELE {m.name} : INVALIDE", file=sys.stderr)
|
||||
for e in erreurs:
|
||||
print(f" - {e}", file=sys.stderr)
|
||||
else:
|
||||
print(f"MODELE {m.name} : valide")
|
||||
if en_faute:
|
||||
print(f"erreur: {en_faute} modele(s) invalide(s) sur {len(modeles)}.", file=sys.stderr)
|
||||
return 2
|
||||
print(f"Les {len(modeles)} modele(s) decouvert(s) valident.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -240,6 +240,15 @@ PREUVES: list[dict] = [
|
|||
"func": preuve_pas_lab_code_en_dur},
|
||||
{"id": "P15", "titre": "Modele public socle valide", "refs": ["AFF-022", "AFF-099"],
|
||||
"func": preuve_modele_socle},
|
||||
{"id": "P17", "titre": "Tous les modeles d'instance valident",
|
||||
"refs": ["AFF-022", "AFF-099"],
|
||||
"cmds": [[sys.executable, "scripts/modeles.py", "verifier"]]},
|
||||
{"id": "P18", "titre": "Gabarit de voute complet", "refs": ["AFF-026"],
|
||||
"cmds": [[sys.executable, "scripts/voute.py", "verifier"]]},
|
||||
{"id": "P19", "titre": "Le GUI couvre le schema du plan",
|
||||
"refs": ["AFF-002", "AFF-095"],
|
||||
"cmds": [[sys.executable, "scripts/couverture_gui.py", "verifier",
|
||||
"--tolerer", "nomenclature"]]},
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -298,7 +307,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||
statut, detail = _executer(preuve)
|
||||
resultats.append((preuve, statut, detail))
|
||||
|
||||
# Tri : garder l'ordre d'execution (P01..P15) ; la preuve sautee a ete inseree tot.
|
||||
# Tri : garder l'ordre d'execution (P01..P19) ; la preuve sautee a ete inseree tot.
|
||||
resultats.sort(key=lambda r: r[0]["id"])
|
||||
|
||||
n_ok = sum(1 for _, s, _ in resultats if s == "OK")
|
||||
|
|
|
|||
155
scripts/voute.py
Normal file
155
scripts/voute.py
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Confronte le GABARIT de voute d'une instance aux secrets que son plan exige.
|
||||
|
||||
Le gabarit `group_vars/all/vault.yml.example` est la seule liste que l'operateur
|
||||
voit avant de creer sa voute. S'il est incomplet, le deploiement casse au milieu,
|
||||
sur une variable indefinie ; s'il est bavard, l'operateur genere des secrets morts.
|
||||
|
||||
Les secrets EXIGES sont recenses a trois endroits, sans rien deviner :
|
||||
1. `plan/bases-donnees.yml` -> le champ `secret` de chaque base ;
|
||||
2. les roles des groupes actifs du plan -> toute reference `vault_*` ;
|
||||
3. les `group_vars` de l'instance -> toute reference `vault_*`.
|
||||
|
||||
La voute REELLE (vault.yml) n'est jamais lue ni dechiffree : on ne compare que
|
||||
des NOMS, et seulement ceux du gabarit en clair.
|
||||
|
||||
Usage :
|
||||
python3 scripts/voute.py lister
|
||||
python3 scripts/voute.py verifier # rc=2 si le gabarit est incomplet
|
||||
python3 scripts/voute.py verifier --strict # rc=2 aussi si des cles sont inutiles
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from inventory_rules import charger_applications, charger_bases_donnees, charger_serveurs
|
||||
|
||||
RACINE = Path(__file__).resolve().parents[1]
|
||||
INSTANCE = Path(os.environ.get("SETOPS_INSTANCE") or (RACINE / "instance"))
|
||||
ROLES = RACINE / "roles"
|
||||
MOTIF_VAULT = re.compile(r"\b(vault_[a-z0-9_]+)")
|
||||
|
||||
# Secrets d'acces a l'hyperviseur : attendus au gabarit, mais hors motif vault_*.
|
||||
SECRETS_HORS_MOTIF = {"proxmox_api_token_id", "proxmox_api_token_secret"}
|
||||
|
||||
|
||||
def _inventaire_dir() -> Path | None:
|
||||
for nom in ("principal", "production", "lab"):
|
||||
d = INSTANCE / "inventories" / nom
|
||||
if d.is_dir():
|
||||
return d
|
||||
return None
|
||||
|
||||
|
||||
def gabarit() -> Path | None:
|
||||
d = _inventaire_dir()
|
||||
if not d:
|
||||
return None
|
||||
p = d / "group_vars" / "all" / "vault.yml.example"
|
||||
return p if p.is_file() else None
|
||||
|
||||
|
||||
def _refs_vault(chemin: Path) -> set[str]:
|
||||
try:
|
||||
return set(MOTIF_VAULT.findall(chemin.read_text(encoding="utf-8", errors="ignore")))
|
||||
except OSError:
|
||||
return set()
|
||||
|
||||
|
||||
def groupes_du_plan() -> set[str]:
|
||||
apps = charger_applications(INSTANCE / "plan/applications.yml")
|
||||
srv = charger_serveurs(INSTANCE / "plan/serveurs.yml")
|
||||
groupes = {a.get("groupe") for a in (apps.get("applications") or {}).values() if a.get("groupe")}
|
||||
for s in (srv.get("serveurs") or {}).values():
|
||||
groupes.update(s.get("integrations") or [])
|
||||
return groupes
|
||||
|
||||
|
||||
def secrets_exiges() -> dict[str, set[str]]:
|
||||
"""{secret: {origines}} — d'ou vient chaque exigence, pour un message utile."""
|
||||
exiges: dict[str, set[str]] = {}
|
||||
|
||||
def noter(nom: str, origine: str) -> None:
|
||||
exiges.setdefault(nom, set()).add(origine)
|
||||
|
||||
bases = charger_bases_donnees(INSTANCE / "plan/bases-donnees.yml")
|
||||
for cle, base in (bases.get("bases_donnees") or {}).items():
|
||||
if base.get("secret"):
|
||||
noter(str(base["secret"]), f"base '{cle}'")
|
||||
|
||||
for groupe in sorted(groupes_du_plan()):
|
||||
dossier = ROLES / groupe
|
||||
if not dossier.is_dir():
|
||||
continue
|
||||
for f in list(dossier.rglob("*.yml")) + list(dossier.rglob("*.j2")):
|
||||
for nom in _refs_vault(f):
|
||||
noter(nom, f"role {groupe}")
|
||||
|
||||
d = _inventaire_dir()
|
||||
if d and (d / "group_vars").is_dir():
|
||||
for f in (d / "group_vars").rglob("*.yml"):
|
||||
if f.name.endswith(".example"):
|
||||
continue
|
||||
for nom in _refs_vault(f):
|
||||
noter(nom, f"group_vars/{f.name}")
|
||||
|
||||
return exiges
|
||||
|
||||
|
||||
def cles_gabarit(path: Path) -> set[str]:
|
||||
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
return set(data) if isinstance(data, dict) else set()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Couverture du gabarit de voute Set-OPS.")
|
||||
sub = parser.add_subparsers(dest="commande", required=True)
|
||||
sub.add_parser("lister", help="Liste les secrets exiges et leur origine.")
|
||||
pv = sub.add_parser("verifier", help="Le gabarit couvre-t-il les secrets exiges ?")
|
||||
pv.add_argument("--strict", action="store_true",
|
||||
help="Echouer aussi si le gabarit porte des cles inutiles.")
|
||||
args = parser.parse_args()
|
||||
|
||||
g = gabarit()
|
||||
if not g:
|
||||
print("Aucun gabarit vault.yml.example : rien a verifier.")
|
||||
return 0
|
||||
|
||||
exiges = secrets_exiges()
|
||||
presentes = cles_gabarit(g)
|
||||
|
||||
if args.commande == "lister":
|
||||
for nom in sorted(exiges):
|
||||
etat = "au gabarit" if nom in presentes else "ABSENT du gabarit"
|
||||
print(f"{nom:<34} {etat:<18} <- {', '.join(sorted(exiges[nom]))}")
|
||||
return 0
|
||||
|
||||
manquants = sorted(set(exiges) - presentes)
|
||||
inutiles = sorted(presentes - set(exiges) - SECRETS_HORS_MOTIF)
|
||||
|
||||
if manquants:
|
||||
print(f"erreur: {len(manquants)} secret(s) exige(s) par le plan mais ABSENT(s) "
|
||||
f"du gabarit {g.relative_to(RACINE) if g.is_relative_to(RACINE) else g} :",
|
||||
file=sys.stderr)
|
||||
for nom in manquants:
|
||||
print(f" - {nom} <- {', '.join(sorted(exiges[nom]))}", file=sys.stderr)
|
||||
if inutiles:
|
||||
flux = sys.stderr if args.strict else sys.stdout
|
||||
print(f"{'erreur' if args.strict else 'note'}: {len(inutiles)} cle(s) du gabarit "
|
||||
f"que le plan n'exige plus : {', '.join(inutiles)}", file=flux)
|
||||
|
||||
if manquants or (inutiles and args.strict):
|
||||
return 2
|
||||
print(f"Gabarit de voute complet : {len(exiges)} secret(s) exige(s), tous presents.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Loading…
Reference in a new issue