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 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-06-23 13:38:30 -04:00
parent 92762d3c38
commit abd6395a13
12 changed files with 460 additions and 40 deletions

View file

@ -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://<IP interne de la cible>:<port>` 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é

View file

@ -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

View file

@ -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

23
docs/applications.yml Normal file
View file

@ -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 :
# <id> : 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=<id> + 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

View file

@ -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 : <type>://<proprietaire>:<secret>@<hote>:<port>/<base>
# 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 : <type>://<proprietaire>:<secret>@<hote>:<port>/<base>
# 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

View file

@ -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,
}

View file

@ -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

View file

@ -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

91
scripts/applications.py Normal file
View file

@ -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())

View file

@ -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:

View file

@ -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"""<!doctype html>
<div class="vue-bascule">
<button type="button" id="btn-vue-cartes" class="on" onclick="setVue('cartes')">Inventaire</button>
<button type="button" id="btn-vue-chaine" onclick="setVue('chaine')">Chaîne</button>
<button type="button" id="btn-vue-applications" onclick="setVue('applications')">Applications</button>
<button type="button" id="btn-vue-bases" onclick="setVue('bases')">Bases</button>
</div>
<span class="recherche"><input id="filtre" type="search" placeholder="Filtrer…" oninput="filtrer(this.value)"></span>
@ -588,6 +607,8 @@ HTML = r"""<!doctype html>
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"""<!doctype html>
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"""<!doctype html>
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"""<!doctype html>
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 = '<div class="vide"><b>Inventaire vide</b>Ajoutez un hôte pour commencer.</div>'; return; }
@ -787,23 +811,47 @@ HTML = r"""<!doctype html>
</div>`).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 ? `<div class="ch-bases">${liste.map(a => `
<div class="ch-base"><span class="badge-bd">${echapper(etiquette(a))}</span> <span class="mono">${echapper(dsnBase(a))}</span></div>`).join('')}</div>` : '';
}
function renduChaine(hote) {
const grps = hote.groupes || [];
if (!grps.length) return '<div class="vide" style="padding:18px">Aucun groupe opérationnel.</div>';
return grps.map(g => {
const apps = applications.filter(a => a.hote === hote.nom);
if (!grps.length && !apps.length) return '<div class="vide" style="padding:18px">Aucun groupe opérationnel ni application.</div>';
const grpHtml = grps.map(g => {
const c = chaine[g] || {roles: [], stub: true};
const corps = (c.roles && c.roles.length)
? `<div class="ch-roles">${c.roles.map(r => `<span class="tag">${echapper(r)}</span>`).join('')}</div>`
: '<div class="ch-stub">stub — aucun rôle rattaché</div>';
const bg = bdApplis.filter(a => a.consommateur === g);
const basesHtml = bg.length ? `<div class="ch-bases">${bg.map(a => `
<div class="ch-base"><span class="badge-bd">BD ${echapper(a.usage || '')}</span> <span class="mono">${echapper(dsnBase(a))}</span></div>`).join('')}</div>` : '';
const bg = bdApplis.filter(a => (a.portee || 'groupe') === 'groupe' && a.consommateur === g);
return `<div class="ch-grp">
<div class="ch-grp-tete"><span>${echapper(g)}</span><span class="ch-fleche"></span><span class="mono">${echapper(g)}.yml</span></div>
${corps}
${basesHtml}
${lignesDsn(bg, a => 'BD partagée ' + (a.usage || ''))}
</div>`;
}).join('');
const appHtml = apps.map(app => {
const dsns = basesDeApp(app, hote.nom);
const bd = dsns.length ? lignesDsn(dsns, a => (a.portee || 'groupe') + ' · ' + (a.usage || ''))
: '<div class="ch-stub">aucune base liée</div>';
return `<div class="ch-grp">
<div class="ch-grp-tete"><span class="badge-bd">app</span><span>${echapper(app.id)}</span><span class="ch-fleche"></span><span class="mono">${echapper(app.groupe)}</span></div>
${bd}
</div>`;
}).join('');
const appSection = apps.length ? `<div class="section-tete" style="margin:6px 2px 2px">Applications sur cet hôte <span class="compte">${apps.length}</span></div>${appHtml}` : '';
return grpHtml + appSection;
}
function dessinerArbre() {
@ -828,10 +876,16 @@ HTML = r"""<!doctype html>
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"""<!doctype html>
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"""<!doctype html>
<label class="champ"><span>Groupe Ansible</span><input value="${echapper(s.groupe)}" placeholder="serveurs_postgresql" oninput="definirServeurBd(${i}, 'groupe', this.value)"></label>
<button type="button" class="danger" title="Retirer" onclick="retirerServeurBd(${i})"></button>
</div>`).join('') : '<div class="vide" style="padding:14px">Aucun serveur de BD.</div>';
const optConso = (sel) => '<option value="">—</option>' + groupes.map(g => `<option value="${echapper(g)}" ${g === sel ? 'selected' : ''}>${echapper(g)}</option>`).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) => '<option value="">—</option>' + sourceConso(portee).map(v => `<option value="${echapper(v)}" ${v === sel ? 'selected' : ''}>${echapper(v)}</option>`).join('');
const optPortee = (sel) => ['groupe', 'application', 'hote'].map(p => `<option value="${p}" ${p === sel ? 'selected' : ''}>${p}</option>`).join('');
const applis = bdApplis.length ? bdApplis.map((a, i) => `
<div class="base-ligne">
<label class="champ"><span>Identifiant</span><input value="${echapper(a.cle)}" oninput="definirBaseBd(${i}, 'cle', this.value)"></label>
<label class="champ"><span>Consommateur</span><select onchange="definirBaseBd(${i}, 'consommateur', this.value)">${optConso(a.consommateur)}</select></label>
<label class="champ"><span>Portée</span><select onchange="definirBaseBd(${i}, 'portee', this.value)">${optPortee(a.portee || 'groupe')}</select></label>
<label class="champ"><span>Consommateur</span><select onchange="definirBaseBd(${i}, 'consommateur', this.value)">${optConso(a.portee || 'groupe', a.consommateur)}</select></label>
<label class="champ"><span>Usage</span><input value="${echapper(a.usage)}" placeholder="principale" oninput="definirBaseBd(${i}, 'usage', this.value)"></label>
<label class="champ"><span>Serveur</span><select onchange="definirBaseBd(${i}, 'serveur', this.value)">
${bdServeurs.map(s => `<option value="${echapper(s.nom)}" ${s.nom === a.serveur ? 'selected' : ''}>${echapper(s.nom)}</option>`).join('')}
@ -874,7 +933,7 @@ HTML = r"""<!doctype html>
<label class="champ"><span>Propriétaire</span><input value="${echapper(a.proprietaire)}" oninput="definirBaseBd(${i}, 'proprietaire', this.value)"></label>
<label class="champ"><span>Secret (Vault)</span><input value="${echapper(a.secret)}" placeholder="vault_bd_app" oninput="definirBaseBd(${i}, 'secret', this.value)"></label>
<button type="button" class="danger" title="Retirer" onclick="retirerBaseBd(${i})"></button>
<div class="dsn mono">${echapper(a.consommateur || '')} · ${echapper(dsnBase(a))}</div>
<div class="dsn mono">${echapper(a.portee || 'groupe')} · ${echapper(a.consommateur || '')} · ${echapper(dsnBase(a))}</div>
</div>`).join('') : '<div class="vide" style="padding:14px">Aucune base applicative.</div>';
cible.innerHTML = `
<div class="section-grille">
@ -892,7 +951,7 @@ HTML = r"""<!doctype html>
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"""<!doctype html>
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 => `<option value="${echapper(g)}" ${g === sel ? 'selected' : ''}>${echapper(g)}</option>`).join('');
const optHote = (sel) => '<option value="">—</option>' + hotes.map(h => h.nom).filter(Boolean).map(n => `<option value="${echapper(n)}" ${n === sel ? 'selected' : ''}>${echapper(n)}</option>`).join('');
const lignes = applications.length ? applications.map((a, i) => {
const dsns = basesDeApp(a, a.hote);
const resume = dsns.length ? dsns.map(b => `<span class="mono">${echapper((b.portee || 'groupe'))}:${echapper(dsnBase(b))}</span>`).join('<br>') : '<span class="ch-stub">aucune base liée</span>';
return `<div class="base-ligne">
<label class="champ"><span>Identifiant</span><input value="${echapper(a.id)}" placeholder="boutique" oninput="definirApplication(${i}, 'id', this.value)"></label>
<label class="champ"><span>Groupe</span><select onchange="definirApplication(${i}, 'groupe', this.value)">${optGroupe(a.groupe)}</select></label>
<label class="champ"><span>Hôte</span><select onchange="definirApplication(${i}, 'hote', this.value)">${optHote(a.hote)}</select></label>
<button type="button" class="danger" title="Retirer" onclick="retirerApplication(${i})"></button>
<div class="dsn">${resume}</div>
</div>`;
}).join('') : '<div class="vide" style="padding:14px">Aucune application. Une VM peut en porter plusieurs.</div>';
cible.innerHTML = `
<div class="section-grille">
<div class="section-tete">Applications <span class="compte">${applications.length}</span><button type="button" class="fantome" style="margin-left:auto" onclick="ajouterApplication()">+ Application</button></div>
<div class="bases-liste">${lignes}</div>
</div>
<div style="margin:4px 2px 20px"><button type="button" class="primaire ${applicationsModifie ? 'attention' : ''}" id="btn-sauver-applications" onclick="sauvegarderApplications()">Sauvegarder les applications${applicationsModifie ? '' : ''}</button></div>`;
}
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":

View file

@ -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")