From abd6395a13b3f46d8ccfca0045b3b8dbdbed05c6 Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Tue, 23 Jun 2026 13:38:30 -0400 Subject: [PATCH] Faire de l'application une entite de premiere classe (BD multi-portee) Une VM peut porter plusieurs applications ; c'est l'application, pas l'hote, a laquelle une base se lie via son DSN. - docs/applications.yml : nouveau registre application -> groupe, hote. - docs/bases-donnees.yml : champ 'portee' (application|groupe|hote, defaut groupe). Une application A (groupe G, hote H) recoit les bases ou (portee=application ET consommateur=A) OU (portee=groupe ET consommateur=G) OU (portee=hote ET consommateur=H). Plusieurs DSN par application. - inventory_rules.py : charger/valider_applications, applications_de_hote, bases_de_application, PORTEES_BD ; valider_bases croise portee=application avec le registre des applications. - CLI : scripts/applications.py + bases_donnees.py --portee ; cibles make applications / applications-verifier ; integre a inventaire-verifier. - GUI : vue Applications (CRUD), portee + consommateur dynamique dans Bases, vue Chaine par application (DSN resolus par hote). - filter_plugins/registres.py : meme regle de resolution exposee a Ansible (source unique). Playbooks web_dorsaux/web_frontaux iterent sur les applications de l'hote (scaffold ; deploiement applicatif s'y insere). Services en grappe (keycloak/icingadb/forgejo) restent en portee groupe. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 6 + Makefile | 9 +- ansible.cfg | 1 + docs/applications.yml | 23 ++++ docs/bases-donnees.yml | 22 +++- filter_plugins/registres.py | 32 +++++ playbooks/groupes/serveurs_web_dorsaux.yml | 30 ++++- playbooks/groupes/serveurs_web_frontaux.yml | 30 ++++- scripts/applications.py | 91 +++++++++++++ scripts/bases_donnees.py | 31 +++-- scripts/inventory_gui.py | 138 +++++++++++++++++--- scripts/inventory_rules.py | 87 +++++++++++- 12 files changed, 460 insertions(+), 40 deletions(-) create mode 100644 docs/applications.yml create mode 100644 filter_plugins/registres.py create mode 100644 scripts/applications.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1985989..f4c3dcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # CHANGELOG — Set-OPS +## 2026-06-23 + +### Ajouté +- **Application = entité de première classe** (une VM peut porter plusieurs applications). Nouveau registre `docs/applications.yml` (`application → groupe, hote`). Les bases se lient via `consommateur` + **`portee`** (`application` | `groupe` | `hote`, défaut `groupe`) dans `docs/bases-donnees.yml` : une application `A` (groupe `G`, hôte `H`) reçoit les bases où `(portee=application ET consommateur=A) OU (portee=groupe ET consommateur=G) OU (portee=hote ET consommateur=H)` — plusieurs DSN par application. Résolution + validation partagées dans `inventory_rules.py` (`charger_applications`, `valider_applications`, `applications_de_hote`, `bases_de_application`, `PORTEES_BD` ; `valider_bases` croise désormais `portee=application` avec le registre des applications). CLI `scripts/applications.py` (`lister`/`verifier`/`ajouter`/`retirer`) + `scripts/bases_donnees.py --portee` ; cibles `make applications` / `applications-verifier` ; validation intégrée dans `make inventaire-verifier`. GUI : vue **Applications** (CRUD), sélecteur de **portée** + consommateur dynamique (groupe/hôte/application) dans la vue Bases, et **vue Chaîne par application** (DSN résolus par hôte). Filter plugin `filter_plugins/registres.py` exposant la **même** règle de résolution à Ansible (source unique) ; les playbooks `serveurs_web_dorsaux` / `serveurs_web_frontaux` itèrent désormais sur les applications de l'hôte et résolvent leurs bases (scaffold ; le déploiement applicatif réel s'y insère). Les services en grappe (keycloak/icingadb/forgejo) restent en `portee: groupe` (inchangés). +- Vhosts `serveurs_nginx` **dérivés du registre `docs/domaines.yml`** (bloc `exposition`) : pour chaque exposition `web` ciblant cet edge, un vhost est généré (`expositions.conf.j2`) avec amont = `amont` explicite, sinon `http://:` résolu depuis l'inventaire ; une exposition non résolvable (cible sans hôte actif, ou sans port) est listée mais non publiée. Variables `serveurs_nginx_groupe` (edge ciblé) et `serveurs_nginx_publier_expositions` (bascule). Les sites déclarés à la main continuent de coexister. Ajout du `port: 3000` à l'exposition `forge`. + ## 2026-06-22 ### Ajouté diff --git a/Makefile b/Makefile index a0993f0..820c85d 100644 --- a/Makefile +++ b/Makefile @@ -370,10 +370,11 @@ inventaire-verifier: ansible-runtime ansible-inventory -i $(INVENTAIRE_PRODUCTION) --list > /dev/null python3 scripts/inventory_host.py --inventaire $(INVENTAIRE_PRODUCTION) verifier-playbooks --dossier-playbooks $(DOSSIER_PLAYBOOKS_GROUPES) python3 scripts/inventory_host.py --inventaire $(INVENTAIRE_PRODUCTION) --dependances $(FICHIER_DEPENDANCES) verifier-dependances --dossier-playbooks $(DOSSIER_PLAYBOOKS_GROUPES) + python3 scripts/applications.py verifier python3 scripts/bases_donnees.py verifier python3 scripts/domaines.py verifier -.PHONY: bases bases-verifier domaines domaines-verifier +.PHONY: bases bases-verifier domaines domaines-verifier applications applications-verifier bases: python3 scripts/bases_donnees.py lister @@ -386,6 +387,12 @@ domaines: domaines-verifier: python3 scripts/domaines.py verifier +applications: + python3 scripts/applications.py lister + +applications-verifier: + python3 scripts/applications.py verifier + inventaire-lister: ansible-runtime ansible-inventory -i $(FICHIER_INVENTAIRE) --list diff --git a/ansible.cfg b/ansible.cfg index eed9b79..0d86b4c 100644 --- a/ansible.cfg +++ b/ansible.cfg @@ -1,6 +1,7 @@ [defaults] inventory = inventories/lab/hosts.yml roles_path = roles +filter_plugins = filter_plugins interpreter_python = auto_silent host_key_checking = False retry_files_enabled = False diff --git a/docs/applications.yml b/docs/applications.yml new file mode 100644 index 0000000..1375c71 --- /dev/null +++ b/docs/applications.yml @@ -0,0 +1,23 @@ +--- +# Registre des applications Set-OPS — l'application est l'entite PIVOT. +# +# Une VM (hote) peut porter PLUSIEURS applications. L'application, pas l'hote, +# est ce a quoi une base de donnees se lie (via son DSN). C'est aussi l'entite +# que l'exposition DNS pourra cibler a terme. +# +# Chaque application : +# : identifiant unique (minuscule, court) ex. boutique +# groupe : groupe operationnel / role dont elle releve ex. serveurs_web_dorsaux +# hote : hote de l'inventaire ou elle s'execute ex. web-dorsal-01 +# +# Une base se lie a une application via consommateur= + portee=application +# dans docs/bases-donnees.yml. Une application recoit aussi les bases de portee +# 'groupe' de son groupe (bases partagees). + +applications: {} +# boutique: +# groupe: serveurs_web_dorsaux +# hote: web-dorsal-01 +# blog: +# groupe: serveurs_web_dorsaux +# hote: web-dorsal-01 # meme VM que boutique : 2 applications sur 1 hote diff --git a/docs/bases-donnees.yml b/docs/bases-donnees.yml index 26377d6..92498d6 100644 --- a/docs/bases-donnees.yml +++ b/docs/bases-donnees.yml @@ -1,15 +1,24 @@ --- # Registre des bases de donnees applicatives Set-OPS. # -# Trois niveaux : +# Niveaux : # serveurs_bd : les serveurs de base de donnees (un ou plusieurs), nommes. # "groupe" relie un serveur nomme au groupe Ansible qui l'heberge. # bases_donnees: une base = un identifiant unique -> serveur, base, proprietaire, secret. -# consommateur : le groupe applicatif qui UTILISE la base (le binding appli <-> base). -# Une appli peut avoir plusieurs bases (meme consommateur, "usage" different). +# consommateur : QUI utilise la base. Interprete selon "portee" : +# portee=application : consommateur = id d'une application (docs/applications.yml) +# portee=groupe : consommateur = groupe operationnel (base PARTAGEE) +# portee=hote : consommateur = hote de l'inventaire +# Defaut : groupe (retrocompatible). +# usage : etiquette libre (principale, partagee, ...). Une appli peut avoir +# plusieurs bases (portee/usage differents). # -# La chaine de connexion se derive : ://:@:/ -# C'est elle, plus le couple (consommateur, base), qui fait le lien appli <-> BD. +# Une application A (groupe G, hote H) recoit les bases ou : +# (portee=application ET consommateur=A) OU (portee=groupe ET consommateur=G) +# OU (portee=hote ET consommateur=H). Plusieurs DSN par application sont possibles. +# +# La chaine de connexion (DSN) se derive : ://:@:/ +# C'est le DSN qui lie la bonne instanciation d'application a sa base. # # "secret" nomme une variable Ansible Vault (jamais le mot de passe en clair). @@ -27,6 +36,7 @@ bases_donnees: proprietaire: keycloak secret: vault_bd_keycloak consommateur: serveurs_keycloak + portee: groupe usage: principale icingadb: serveur: pg-principal @@ -34,6 +44,7 @@ bases_donnees: proprietaire: icingadb secret: vault_bd_icingadb consommateur: serveurs_icinga + portee: groupe usage: principale forgejo: serveur: pg-principal @@ -41,4 +52,5 @@ bases_donnees: proprietaire: forgejo secret: vault_bd_forgejo consommateur: serveurs_forgejo + portee: groupe usage: principale diff --git a/filter_plugins/registres.py b/filter_plugins/registres.py new file mode 100644 index 0000000..449a8ed --- /dev/null +++ b/filter_plugins/registres.py @@ -0,0 +1,32 @@ +"""Filtres Ansible exposant les regles de resolution des registres Set-OPS. + +Reutilise scripts/inventory_rules.py : la regle de liaison application <-> base +(et la derivation du DSN) vit une SEULE fois, en Python. Le CLI, le GUI (qui en +est le miroir) et Ansible passent tous par la meme logique. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) + +from inventory_rules import ( # noqa: E402 + applications_de_hote, + bases_de_application, + bases_du_groupe, + chaine_connexion, +) + + +class FilterModule: + """Filtres Set-OPS pour les playbooks par application.""" + + def filters(self) -> dict: + return { + "bases_de_application": bases_de_application, + "applications_de_hote": applications_de_hote, + "bases_du_groupe": bases_du_groupe, + "chaine_connexion": chaine_connexion, + } diff --git a/playbooks/groupes/serveurs_web_dorsaux.yml b/playbooks/groupes/serveurs_web_dorsaux.yml index e93423b..693167c 100644 --- a/playbooks/groupes/serveurs_web_dorsaux.yml +++ b/playbooks/groupes/serveurs_web_dorsaux.yml @@ -12,6 +12,32 @@ fail_msg: "Ce playbook est prévu pour Debian." tasks: - - name: Indiquer que le rôle dorsal web reste à définir + # Couche application : une VM dorsale peut porter PLUSIEURS applications. + # On itere donc sur les applications mappees a cet hote (docs/applications.yml), + # chacune resolvant ses bases via son DSN (regle partagee, filter plugin). + - name: Charger les registres applications et bases + ansible.builtin.include_vars: + file: "{{ item }}" + loop: + - "{{ playbook_dir }}/../../docs/applications.yml" + - "{{ playbook_dir }}/../../docs/bases-donnees.yml" + + - name: Determiner les applications instanciees sur cet hote + ansible.builtin.set_fact: + applications_hote: "{{ {'applications': applications | default({})} | applications_de_hote(inventory_hostname) }}" + registre_bd: "{{ {'serveurs_bd': serveurs_bd | default({}), 'bases_donnees': bases_donnees | default({})} }}" + + - name: Afficher la chaine application -> bases (scaffold ; le deploiement reel s'insere ici) ansible.builtin.debug: - msg: "Aucun rôle n'est encore associé au groupe serveurs_web_dorsaux (couche application web : API, traitement)." + msg: >- + {{ item.id }} [{{ item.groupe }}] -> + {{ (registre_bd | bases_de_application(item.id, item.groupe, inventory_hostname)) + | map(attribute='cle') | list }} + loop: "{{ applications_hote }}" + loop_control: + label: "{{ item.id }}" + + - name: Avertir si l'hote ne porte aucune application + ansible.builtin.debug: + msg: "Aucune application declaree sur cet hote (docs/applications.yml). Couche dorsale : API, traitement." + when: applications_hote | length == 0 diff --git a/playbooks/groupes/serveurs_web_frontaux.yml b/playbooks/groupes/serveurs_web_frontaux.yml index 006b9af..f73a92c 100644 --- a/playbooks/groupes/serveurs_web_frontaux.yml +++ b/playbooks/groupes/serveurs_web_frontaux.yml @@ -12,6 +12,32 @@ fail_msg: "Ce playbook est prévu pour Debian." tasks: - - name: Indiquer que le rôle frontal web reste à définir + # Couche presentation : une VM frontale peut porter PLUSIEURS applications. + # On itere sur les applications mappees a cet hote (docs/applications.yml), + # chacune resolvant ses bases via son DSN (regle partagee, filter plugin). + - name: Charger les registres applications et bases + ansible.builtin.include_vars: + file: "{{ item }}" + loop: + - "{{ playbook_dir }}/../../docs/applications.yml" + - "{{ playbook_dir }}/../../docs/bases-donnees.yml" + + - name: Determiner les applications instanciees sur cet hote + ansible.builtin.set_fact: + applications_hote: "{{ {'applications': applications | default({})} | applications_de_hote(inventory_hostname) }}" + registre_bd: "{{ {'serveurs_bd': serveurs_bd | default({}), 'bases_donnees': bases_donnees | default({})} }}" + + - name: Afficher la chaine application -> bases (scaffold ; le deploiement reel s'insere ici) ansible.builtin.debug: - msg: "Aucun rôle n'est encore associé au groupe serveurs_web_frontaux (couche présentation web, derrière serveurs_nginx)." + msg: >- + {{ item.id }} [{{ item.groupe }}] -> + {{ (registre_bd | bases_de_application(item.id, item.groupe, inventory_hostname)) + | map(attribute='cle') | list }} + loop: "{{ applications_hote }}" + loop_control: + label: "{{ item.id }}" + + - name: Avertir si l'hote ne porte aucune application + ansible.builtin.debug: + msg: "Aucune application declaree sur cet hote (docs/applications.yml). Couche presentation, derriere serveurs_nginx." + when: applications_hote | length == 0 diff --git a/scripts/applications.py b/scripts/applications.py new file mode 100644 index 0000000..a48d374 --- /dev/null +++ b/scripts/applications.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Lit et valide le registre des applications Set-OPS (docs/applications.yml).""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +import yaml + +from inventory_rules import ( + bases_de_application, + chaine_connexion, + charger_applications, + charger_bases_donnees, + valider_applications, +) + +RACINE = Path(__file__).resolve().parents[1] +FICHIER = RACINE / "docs/applications.yml" +FICHIER_BASES = RACINE / "docs/bases-donnees.yml" + + +def ecrire(registre: dict) -> None: + entete = ( + "# 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" + "---\n" + ) + with FICHIER.open("w", encoding="utf-8") as fichier: + fichier.write(entete) + yaml.safe_dump({"applications": registre.get("applications", {}) or {}}, + fichier, default_flow_style=False, sort_keys=False, allow_unicode=True) + + +def lister(registre: dict, bases: dict) -> None: + apps = registre.get("applications", {}) + if not apps: + print("Aucune application declaree.") + return + for nom, app in apps.items(): + print(f"{nom} [groupe {app.get('groupe', '?')}, hote {app.get('hote', '?')}]") + for entree in bases_de_application(bases, application=nom, + groupe=app.get("groupe"), hote=app.get("hote")): + base = entree["base"] + portee = base.get("portee", "groupe") + print(f" DSN ({portee}/{base.get('usage', '-')}): {chaine_connexion(base, entree['serveur'])}") + + +def main() -> int: + parser = argparse.ArgumentParser(description="Registre des applications Set-OPS.") + sub = parser.add_subparsers(dest="commande", required=True) + sub.add_parser("lister", help="Affiche les applications et leurs DSN resolus.") + sub.add_parser("verifier", help="Valide la coherence du registre.") + + pa = sub.add_parser("ajouter", help="Ajoute ou met a jour une application.") + pa.add_argument("--id", required=True) + pa.add_argument("--groupe", required=True) + pa.add_argument("--hote", required=True) + + pr = sub.add_parser("retirer", help="Retire une application.") + pr.add_argument("--id", required=True) + + args = parser.parse_args() + try: + registre = charger_applications(FICHIER) + if args.commande == "lister": + lister(registre, charger_bases_donnees(FICHIER_BASES)) + elif args.commande == "verifier": + valider_applications(registre) + print("Registre des applications valide.") + elif args.commande == "ajouter": + registre["applications"][args.id] = {"groupe": args.groupe, "hote": args.hote} + valider_applications(registre) + ecrire(registre) + print(f"Application '{args.id}' enregistree.") + elif args.commande == "retirer": + registre["applications"].pop(args.id, None) + valider_applications(registre) + ecrire(registre) + print(f"Application '{args.id}' retiree.") + except Exception as exc: + print(f"erreur: {exc}", file=sys.stderr) + return 2 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/bases_donnees.py b/scripts/bases_donnees.py index 2a22b27..626a45e 100644 --- a/scripts/bases_donnees.py +++ b/scripts/bases_donnees.py @@ -9,17 +9,24 @@ from pathlib import Path import yaml -from inventory_rules import chaine_connexion, charger_bases_donnees, valider_bases +from inventory_rules import ( + chaine_connexion, + charger_applications, + charger_bases_donnees, + valider_bases, +) RACINE = Path(__file__).resolve().parents[1] FICHIER = RACINE / "docs/bases-donnees.yml" +FICHIER_APPLICATIONS = RACINE / "docs/applications.yml" def ecrire(registre: dict) -> None: entete = ( "# Registre des bases de donnees Set-OPS (serveurs_bd + bases_donnees).\n" "# Edite par make inventaire-ui ou scripts/bases_donnees.py.\n" - "# Le champ 'secret' nomme une variable Ansible Vault (jamais le mot de passe).\n" + "# 'portee' (application|groupe|hote) interprete 'consommateur' ; defaut groupe.\n" + "# 'secret' nomme une variable Ansible Vault (jamais le mot de passe).\n" "---\n" ) contenu = { @@ -40,8 +47,9 @@ def lister(registre: dict) -> None: for cle, base in registre.get("bases_donnees", {}).items(): srv = serveurs.get(base.get("serveur"), {}) conso = base.get("consommateur", "?") + portee = base.get("portee", "groupe") usage = base.get("usage", "-") - print(f" {cle} <- {conso} ({usage}): {chaine_connexion(base, srv)} [secret {base.get('secret')}]") + print(f" {cle} <- {conso} [{portee}/{usage}]: {chaine_connexion(base, srv)} [secret {base.get('secret')}]") def main() -> int: @@ -63,7 +71,9 @@ def main() -> int: pb.add_argument("--base", required=True) pb.add_argument("--proprietaire", required=True) pb.add_argument("--secret", required=True) - pb.add_argument("--consommateur", required=True, help="Groupe applicatif qui utilise la base.") + pb.add_argument("--consommateur", required=True, + help="Qui utilise la base : groupe, hote ou id d'application (selon --portee).") + pb.add_argument("--portee", default="groupe", choices=["groupe", "hote", "application"]) pb.add_argument("--usage", default="principale") pr = sub.add_parser("retirer-base", help="Retire une base applicative.") @@ -74,36 +84,37 @@ def main() -> int: args = parser.parse_args() try: registre = charger_bases_donnees(FICHIER) + applications = charger_applications(FICHIER_APPLICATIONS) if args.commande == "lister": lister(registre) elif args.commande == "verifier": - valider_bases(registre) + valider_bases(registre, applications) print("Registre des bases valide.") elif args.commande == "ajouter-serveur": registre["serveurs_bd"][args.nom] = { "type": args.type, "hote": args.hote, "port": args.port, **({"groupe": args.groupe} if args.groupe else {}), } - valider_bases(registre) + valider_bases(registre, applications) ecrire(registre) print(f"Serveur de BD '{args.nom}' enregistre.") elif args.commande == "ajouter-base": registre["bases_donnees"][args.cle] = { "serveur": args.serveur, "base": args.base, "proprietaire": args.proprietaire, "secret": args.secret, - "consommateur": args.consommateur, "usage": args.usage, + "consommateur": args.consommateur, "portee": args.portee, "usage": args.usage, } - valider_bases(registre) + valider_bases(registre, applications) ecrire(registre) print(f"Base '{args.cle}' enregistree.") elif args.commande == "retirer-base": registre["bases_donnees"].pop(args.cle, None) - valider_bases(registre) + valider_bases(registre, applications) ecrire(registre) print(f"Base '{args.cle}' retiree.") elif args.commande == "retirer-serveur": registre["serveurs_bd"].pop(args.nom, None) - valider_bases(registre) + valider_bases(registre, applications) ecrire(registre) print(f"Serveur de BD '{args.nom}' retire.") except Exception as exc: diff --git a/scripts/inventory_gui.py b/scripts/inventory_gui.py index aa1d6c0..f119e0d 100644 --- a/scripts/inventory_gui.py +++ b/scripts/inventory_gui.py @@ -22,11 +22,13 @@ from inventory_rules import ( GROUPE_HOTES_PLANIFIES, GROUPES_ETAT_HOTE, chaine_connexion, + charger_applications, charger_bases_donnees, charger_dependances, charger_nomenclature, est_groupe_operationnel, groupes_operationnels_connus, + valider_applications, valider_bases, ) @@ -37,6 +39,7 @@ DOSSIER_PLAYBOOKS_GROUPES = RACINE / "playbooks/groupes" FICHIER_DEPENDANCES = RACINE / "docs/dependances-groupes.yml" FICHIER_NOMENCLATURE = RACINE / "docs/nomenclature.yml" FICHIER_BASES = RACINE / "docs/bases-donnees.yml" +FICHIER_APPLICATIONS = RACINE / "docs/applications.yml" # Garde-fous des executions (verifier / deployer) depuis l'interface. JETON = secrets.token_urlsafe(18) @@ -175,6 +178,7 @@ def inventaire_api(path: Path) -> dict: "nomenclature": charger_nomenclature(FICHIER_NOMENCLATURE), "chaine": roles_des_groupes(), "bases": charger_bases_donnees(FICHIER_BASES), + "applications": charger_applications(FICHIER_APPLICATIONS), "hotes": liste_hotes(data), } @@ -183,7 +187,8 @@ def ecrire_bases(path: Path, registre: dict) -> None: entete = ( "# Registre des bases de donnees Set-OPS (serveurs_bd + bases_donnees).\n" "# Edite par make inventaire-ui ou scripts/bases_donnees.py.\n" - "# Le champ 'secret' nomme une variable Ansible Vault (jamais le mot de passe).\n" + "# 'portee' (application|groupe|hote) interprete 'consommateur' ; defaut groupe.\n" + "# 'secret' nomme une variable Ansible Vault (jamais le mot de passe).\n" "---\n" ) contenu = { @@ -195,6 +200,19 @@ def ecrire_bases(path: Path, registre: dict) -> None: yaml.safe_dump(contenu, fichier, default_flow_style=False, sort_keys=False, allow_unicode=True) +def ecrire_applications(path: Path, registre: dict) -> None: + entete = ( + "# 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" + "---\n" + ) + with path.open("w", encoding="utf-8") as fichier: + fichier.write(entete) + yaml.safe_dump({"applications": registre.get("applications", {}) or {}}, + fichier, default_flow_style=False, sort_keys=False, allow_unicode=True) + + def donnees_hote(hote: dict) -> dict: variables: dict = {} for cle, var, typ in CHAMPS_PROVISION: @@ -533,6 +551,7 @@ HTML = r"""
+
@@ -588,6 +607,8 @@ HTML = r""" let bdServeurs = []; let bdApplis = []; let basesModifie = false; + let applications = []; + let applicationsModifie = false; let selection = -1; let selectionNom = null; let ongletActif = 'reseau'; @@ -631,6 +652,7 @@ HTML = r""" nomenclature = data.nomenclature || {}; chaine = data.chaine || {}; chargerBases(data); + chargerApplications(data); hotes = data.hotes; estProduction = !!data.production; modifie = false; verifie = {}; @@ -767,6 +789,7 @@ HTML = r""" vuePrincipale = v; document.getElementById('btn-vue-cartes').classList.toggle('on', v === 'cartes'); document.getElementById('btn-vue-chaine').classList.toggle('on', v === 'chaine'); + document.getElementById('btn-vue-applications').classList.toggle('on', v === 'applications'); document.getElementById('btn-vue-bases').classList.toggle('on', v === 'bases'); dessinerGrilles(); } @@ -774,6 +797,7 @@ HTML = r""" function dessinerGrilles() { document.getElementById('chips').style.display = vuePrincipale === 'cartes' ? '' : 'none'; if (vuePrincipale === 'chaine') { dessinerArbre(); return; } + if (vuePrincipale === 'applications') { dessinerApplications(); return; } if (vuePrincipale === 'bases') { dessinerBases(); return; } const cible = document.getElementById('grilles'); if (!hotes.length) { cible.innerHTML = '
Inventaire videAjoutez un hôte pour commencer.
'; return; } @@ -787,23 +811,47 @@ HTML = r""" `).join(''); } + function basesDeApp(app, hoteNom) { + return bdApplis.filter(a => { + const p = a.portee || 'groupe'; + return (p === 'application' && a.consommateur === app.id) + || (p === 'groupe' && a.consommateur === app.groupe) + || (p === 'hote' && a.consommateur === hoteNom); + }); + } + + function lignesDsn(liste, etiquette) { + return liste.length ? `
${liste.map(a => ` +
${echapper(etiquette(a))} ${echapper(dsnBase(a))}
`).join('')}
` : ''; + } + function renduChaine(hote) { const grps = hote.groupes || []; - if (!grps.length) return '
Aucun groupe opérationnel.
'; - return grps.map(g => { + const apps = applications.filter(a => a.hote === hote.nom); + if (!grps.length && !apps.length) return '
Aucun groupe opérationnel ni application.
'; + const grpHtml = grps.map(g => { const c = chaine[g] || {roles: [], stub: true}; const corps = (c.roles && c.roles.length) ? `
${c.roles.map(r => `${echapper(r)}`).join('')}
` : '
stub — aucun rôle rattaché
'; - const bg = bdApplis.filter(a => a.consommateur === g); - const basesHtml = bg.length ? `
${bg.map(a => ` -
BD ${echapper(a.usage || '')} ${echapper(dsnBase(a))}
`).join('')}
` : ''; + const bg = bdApplis.filter(a => (a.portee || 'groupe') === 'groupe' && a.consommateur === g); return `
${echapper(g)}${echapper(g)}.yml
${corps} - ${basesHtml} + ${lignesDsn(bg, a => 'BD partagée ' + (a.usage || ''))}
`; }).join(''); + const appHtml = apps.map(app => { + const dsns = basesDeApp(app, hote.nom); + const bd = dsns.length ? lignesDsn(dsns, a => (a.portee || 'groupe') + ' · ' + (a.usage || '')) + : '
aucune base liée
'; + return `
+
app${echapper(app.id)}${echapper(app.groupe)}
+ ${bd} +
`; + }).join(''); + const appSection = apps.length ? `
Applications sur cet hôte ${apps.length}
${appHtml}` : ''; + return grpHtml + appSection; } function dessinerArbre() { @@ -828,10 +876,16 @@ HTML = r""" nom, type: s.type || 'postgres', hote: s.hote || '', port: s.port || 5432, groupe: s.groupe || ''})); bdApplis = Object.entries(b.bases_donnees || {}).map(([cle, x]) => ({ cle, serveur: x.serveur || '', base: x.base || '', proprietaire: x.proprietaire || '', secret: x.secret || '', - consommateur: x.consommateur || '', usage: x.usage || 'principale'})); + consommateur: x.consommateur || '', portee: x.portee || 'groupe', usage: x.usage || 'principale'})); basesModifie = false; } + function chargerApplications(data) { + const a = (data.applications || {}).applications || {}; + applications = Object.entries(a).map(([id, x]) => ({id, groupe: x.groupe || '', hote: x.hote || ''})); + applicationsModifie = false; + } + function dsnBase(a) { const s = bdServeurs.find(x => x.nom === a.serveur); if (!s) return '(serveur inconnu : ' + echapper(a.serveur || '—') + ')'; @@ -844,10 +898,10 @@ HTML = r""" if (b) { b.classList.toggle('attention', true); b.textContent = 'Sauvegarder les bases •'; } } function definirServeurBd(i, champ, v) { bdServeurs[i][champ] = v; marquerBasesModifie(); } - function definirBaseBd(i, champ, v) { bdApplis[i][champ] = v; marquerBasesModifie(); if (champ === 'serveur') dessinerBases(); } + function definirBaseBd(i, champ, v) { bdApplis[i][champ] = v; marquerBasesModifie(); if (champ === 'serveur' || champ === 'portee') dessinerBases(); } function ajouterServeurBd() { bdServeurs.push({nom: '', type: 'postgres', hote: '', port: 5432, groupe: ''}); marquerBasesModifie(); dessinerBases(); } function retirerServeurBd(i) { bdServeurs.splice(i, 1); marquerBasesModifie(); dessinerBases(); } - function ajouterBaseBd() { bdApplis.push({cle: '', serveur: (bdServeurs[0] || {}).nom || '', base: '', proprietaire: '', secret: '', consommateur: '', usage: 'principale'}); marquerBasesModifie(); dessinerBases(); } + function ajouterBaseBd() { bdApplis.push({cle: '', serveur: (bdServeurs[0] || {}).nom || '', base: '', proprietaire: '', secret: '', consommateur: '', portee: 'groupe', usage: 'principale'}); marquerBasesModifie(); dessinerBases(); } function retirerBaseBd(i) { bdApplis.splice(i, 1); marquerBasesModifie(); dessinerBases(); } function dessinerBases() { @@ -861,11 +915,16 @@ HTML = r""" `).join('') : '
Aucun serveur de BD.
'; - const optConso = (sel) => '' + groupes.map(g => ``).join(''); + const sourceConso = (portee) => portee === 'hote' ? hotes.map(h => h.nom).filter(Boolean) + : portee === 'application' ? applications.map(a => a.id) + : groupes; + const optConso = (portee, sel) => '' + sourceConso(portee).map(v => ``).join(''); + const optPortee = (sel) => ['groupe', 'application', 'hote'].map(p => ``).join(''); const applis = bdApplis.length ? bdApplis.map((a, i) => `
- + + -
${echapper(a.consommateur || '—')} · ${echapper(dsnBase(a))}
+
${echapper(a.portee || 'groupe')} · ${echapper(a.consommateur || '—')} · ${echapper(dsnBase(a))}
`).join('') : '
Aucune base applicative.
'; cible.innerHTML = `
@@ -892,7 +951,7 @@ HTML = r""" 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 bd = {}; - bdApplis.forEach(a => { if (String(a.cle).trim()) bd[a.cle] = {serveur: a.serveur, base: a.base, proprietaire: a.proprietaire, secret: a.secret, consommateur: a.consommateur, usage: a.usage || 'principale'}; }); + bdApplis.forEach(a => { if (String(a.cle).trim()) bd[a.cle] = {serveur: a.serveur, base: a.base, proprietaire: a.proprietaire, secret: a.secret, consommateur: a.consommateur, portee: a.portee || 'groupe', usage: a.usage || 'principale'}; }); const rep = await fetch('/api/bases', { method: 'POST', headers: {'Content-Type': 'application/json', 'X-Jeton': JETON}, body: JSON.stringify({serveurs_bd: sd, bases_donnees: bd}) }); @@ -903,6 +962,51 @@ HTML = r""" message('Bases de données sauvegardées.', 'ok'); } + function marquerApplicationsModifie() { + applicationsModifie = true; + const b = document.getElementById('btn-sauver-applications'); + if (b) { b.classList.add('attention'); b.textContent = 'Sauvegarder les applications •'; } + } + function definirApplication(i, champ, v) { applications[i][champ] = v; marquerApplicationsModifie(); if (champ === 'hote' || champ === 'groupe') dessinerApplications(); } + function ajouterApplication() { applications.push({id: '', groupe: (groupes[0] || ''), hote: ''}); marquerApplicationsModifie(); dessinerApplications(); } + function retirerApplication(i) { applications.splice(i, 1); marquerApplicationsModifie(); dessinerApplications(); } + + function dessinerApplications() { + const cible = document.getElementById('grilles'); + const optGroupe = (sel) => groupes.map(g => ``).join(''); + const optHote = (sel) => '' + hotes.map(h => h.nom).filter(Boolean).map(n => ``).join(''); + const lignes = applications.length ? applications.map((a, i) => { + const dsns = basesDeApp(a, a.hote); + const resume = dsns.length ? dsns.map(b => `${echapper((b.portee || 'groupe'))}:${echapper(dsnBase(b))}`).join('
') : 'aucune base liée'; + return `
+ + + + +
${resume}
+
`; + }).join('') : '
Aucune application. Une VM peut en porter plusieurs.
'; + cible.innerHTML = ` +
+
Applications ${applications.length}
+
${lignes}
+
+
`; + } + + async function sauvegarderApplications() { + const apps = {}; + applications.forEach(a => { if (String(a.id).trim()) apps[a.id] = {groupe: a.groupe, hote: a.hote}; }); + const rep = await fetch('/api/applications', { + method: 'POST', headers: {'Content-Type': 'application/json', 'X-Jeton': JETON}, body: JSON.stringify({applications: apps}) + }); + const data = await rep.json(); + if (!rep.ok) { message(data.erreur || 'Sauvegarde refusée.', 'erreur'); return; } + chargerApplications(data); + dessinerApplications(); + message('Applications sauvegardées.', 'ok'); + } + function dessinerDependances() { const cible = document.getElementById('dependances'); const noms = Object.keys(dependances).sort(); @@ -1323,9 +1427,13 @@ class Gestionnaire(BaseHTTPRequestHandler): VERIF_OK.clear() self.repondre_json(200, inventaire_api(self.inventaire)) elif chemin == "/api/bases": - valider_bases(donnees) + valider_bases(donnees, charger_applications(FICHIER_APPLICATIONS)) ecrire_bases(FICHIER_BASES, donnees) self.repondre_json(200, inventaire_api(self.inventaire)) + elif chemin == "/api/applications": + valider_applications(donnees) + ecrire_applications(FICHIER_APPLICATIONS, donnees) + self.repondre_json(200, inventaire_api(self.inventaire)) elif chemin == "/api/verifier": self.executer_flux(str(donnees.get("hote", "")), "verifier", donnees.get("vault")) elif chemin == "/api/deployer": diff --git a/scripts/inventory_rules.py b/scripts/inventory_rules.py index 1cf1b9c..31fa1ae 100644 --- a/scripts/inventory_rules.py +++ b/scripts/inventory_rules.py @@ -37,10 +37,19 @@ def charger_bases_donnees(path: Path | None) -> dict: return data -def valider_bases(registre: dict) -> None: - """Valide la coherence du registre des bases de donnees.""" +PORTEES_BD = {"groupe", "hote", "application"} + + +def valider_bases(registre: dict, applications: dict | None = None) -> None: + """Valide la coherence du registre des bases de donnees. + + Si `applications` (le registre des applications) est fourni, les bases de + portee 'application' sont verifiees : leur consommateur doit etre une + application declaree. + """ serveurs = registre.get("serveurs_bd") or {} bases = registre.get("bases_donnees") or {} + apps = (applications or {}).get("applications") or {} if not isinstance(serveurs, dict) or not isinstance(bases, dict): raise ValueError("serveurs_bd et bases_donnees doivent etre des tables.") for nom, srv in serveurs.items(): @@ -58,22 +67,90 @@ def valider_bases(registre: dict) -> None: raise ValueError(f"Base '{cle}': champ '{champ}' requis.") if base["serveur"] not in serveurs: raise ValueError(f"Base '{cle}': serveur '{base['serveur']}' inconnu.") + portee = str(base.get("portee", "groupe")).strip() + if portee not in PORTEES_BD: + attendu = ", ".join(sorted(PORTEES_BD)) + raise ValueError(f"Base '{cle}': portee '{portee}' inconnue (attendu: {attendu}).") + if portee == "groupe" and not est_groupe_operationnel(base["consommateur"]): + raise ValueError(f"Base '{cle}': portee groupe mais consommateur '{base['consommateur']}' non operationnel.") + if portee == "application" and apps and base["consommateur"] not in apps: + raise ValueError(f"Base '{cle}': application consommatrice '{base['consommateur']}' inconnue (docs/applications.yml).") couple = f"{base['serveur']}/{base['base']}" if couple in noms_bases: raise ValueError(f"Base en double sur un meme serveur: {couple}") noms_bases.add(couple) +def _base_resolue(cle: str, base: dict, serveurs: dict) -> dict: + return {"cle": cle, "base": base, "serveur": serveurs.get(base.get("serveur"), {})} + + def bases_du_groupe(registre: dict, groupe: str) -> list[dict]: - """Bases consommees par un groupe applicatif, avec leur serveur resolu.""" + """Bases de portee 'groupe' consommees par un groupe applicatif.""" serveurs = registre.get("serveurs_bd") or {} resultat = [] for cle, base in (registre.get("bases_donnees") or {}).items(): - if base.get("consommateur") == groupe: - resultat.append({"cle": cle, "base": base, "serveur": serveurs.get(base.get("serveur"), {})}) + if str(base.get("portee", "groupe")) == "groupe" and base.get("consommateur") == groupe: + resultat.append(_base_resolue(cle, base, serveurs)) return resultat +def bases_de_application(registre: dict, application: str | None = None, + groupe: str | None = None, hote: str | None = None) -> list[dict]: + """Bases vues par une application instanciee, toutes portees confondues. + + Une application `A` (du groupe `G`, sur l'hote `H`) recoit les bases ou : + (portee=application ET consommateur=A) OU (portee=groupe ET consommateur=G) + OU (portee=hote ET consommateur=H). + """ + serveurs = registre.get("serveurs_bd") or {} + resultat = [] + for cle, base in (registre.get("bases_donnees") or {}).items(): + portee = str(base.get("portee", "groupe")) + conso = base.get("consommateur") + if ((portee == "application" and application is not None and conso == application) + or (portee == "groupe" and groupe is not None and conso == groupe) + or (portee == "hote" and hote is not None and conso == hote)): + resultat.append(_base_resolue(cle, base, serveurs)) + return resultat + + +def charger_applications(path: Path | None) -> dict: + """Charge le registre des applications (applications -> groupe, hote).""" + if not path or not path.exists(): + return {"applications": {}} + with path.open("r", encoding="utf-8") as fichier: + data = yaml.safe_load(fichier) or {} + if not isinstance(data, dict): + raise ValueError(f"{path} ne contient pas une table YAML.") + data.setdefault("applications", {}) + return data + + +def valider_applications(registre: dict) -> None: + """Valide la coherence du registre des applications.""" + apps = registre.get("applications") or {} + if not isinstance(apps, dict): + raise ValueError("applications doit etre une table.") + for nom, app in apps.items(): + if not isinstance(app, dict): + raise ValueError(f"Application '{nom}': table attendue.") + for champ in ("groupe", "hote"): + if not str(app.get(champ, "")).strip(): + raise ValueError(f"Application '{nom}': champ '{champ}' requis.") + if not est_groupe_operationnel(app["groupe"]): + raise ValueError(f"Application '{nom}': groupe '{app['groupe']}' non operationnel.") + + +def applications_de_hote(registre: dict, hote: str) -> list[dict]: + """Applications instanciees sur un hote donne (plusieurs possibles).""" + return [ + {"id": nom, **app} + for nom, app in (registre.get("applications") or {}).items() + if app.get("hote") == hote + ] + + def chaine_connexion(entree: dict, serveur: dict, secret: str = "***") -> str: """Derive une chaine de connexion (mot de passe masque par defaut).""" type_bd = (serveur or {}).get("type", "postgres")