From 7e190e0a5793c84fc63b739c412ac2a2579949f9 Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Wed, 22 Jul 2026 21:32:42 -0400 Subject: [PATCH] Trois preuves qui regardent au-dela d'une seule instance + champ liens/websocket au GUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 72 +++++++++++++++ docs/audit/README.md | 9 ++ docs/audit/preuve-2026-07-22.md | 55 ++++++++++++ docs/bindings-conception.md | 16 +++- scripts/applications.py | 11 ++- scripts/couverture_gui.py | 152 +++++++++++++++++++++++++++++++ scripts/instancier.py | 11 +-- scripts/inventory_gui.py | 102 ++++++++++++++++++++- scripts/inventory_rules.py | 70 ++++++++++++++- scripts/modeles.py | 122 +++++++++++++++++++++++++ scripts/prouver.py | 11 ++- scripts/voute.py | 155 ++++++++++++++++++++++++++++++++ 12 files changed, 763 insertions(+), 23 deletions(-) create mode 100644 docs/audit/preuve-2026-07-22.md create mode 100644 scripts/couverture_gui.py create mode 100644 scripts/modeles.py create mode 100644 scripts/voute.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 928505a..f3dbeac 100644 --- a/CHANGELOG.md +++ b/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//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é diff --git a/docs/audit/README.md b/docs/audit/README.md index 3f3e08e..d5b7a3a 100644 --- a/docs/audit/README.md +++ b/docs/audit/README.md @@ -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, diff --git a/docs/audit/preuve-2026-07-22.md b/docs/audit/preuve-2026-07-22.md new file mode 100644 index 0000000..f85ef9a --- /dev/null +++ b/docs/audit/preuve-2026-07-22.md @@ -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._ diff --git a/docs/bindings-conception.md b/docs/bindings-conception.md index a10760c..221e210 100644 --- a/docs/bindings-conception.md +++ b/docs/bindings-conception.md @@ -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//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 diff --git a/scripts/applications.py b/scripts/applications.py index c9fbab2..27b33ff 100644 --- a/scripts/applications.py +++ b/scripts/applications.py @@ -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: diff --git a/scripts/couverture_gui.py b/scripts/couverture_gui.py new file mode 100644 index 0000000..2f1efe5 --- /dev/null +++ b/scripts/couverture_gui.py @@ -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()) diff --git a/scripts/instancier.py b/scripts/instancier.py index d625615..857bee8 100644 --- a/scripts/instancier.py +++ b/scripts/instancier.py @@ -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( diff --git a/scripts/inventory_gui.py b/scripts/inventory_gui.py index b82932c..39afa71 100644 --- a/scripts/inventory_gui.py +++ b/scripts/inventory_gui.py @@ -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//meta/liens.yml.\n" "---\n" ) with path.open("w", encoding="utf-8") as fichier: @@ -1029,6 +1058,7 @@ HTML = r""" 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""" 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""" 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""" 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""" 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 `
Le rôle « ${echapper(a.groupe || '—')} » n'accepte aucun lien + (aucun roles/${echapper(a.groupe || '?')}/meta/liens.yml).
`; + } + 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 = '' + cibles.map(c => + ``).join(''); + const optR = '' + roles.map(r => + ``).join(''); + const inconnue = l.vers && !cibles.includes(l.vers); + return `
+ + + + ${inconnue ? `cible « ${echapper(l.vers)} » inconnue` : ''} + ${vars.length ? `${echapper(vars.join(', '))}` : ''} + + +
`; + }).join(''); + return `${lignes || '
aucun lien déclaré
'} +
`; + } + function carteApp(a, i) { const exp = listeDepuisTexte(a.expose); return `