From 33b1933a6df78dcb3949f454c532f49dd081baeb Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Mon, 22 Jun 2026 19:44:05 -0400 Subject: [PATCH] Rendre explicites les bindings appli<->BD (consommateur, multi-BD) Modele: - Chaque base porte un "consommateur" (le groupe applicatif qui l'utilise) et un "usage" (principale/cache...). Une application peut avoir plusieurs bases. - Le binding appli<->base n'est plus une convention de nom mais un champ gere. Regles partagees (inventory_rules): consommateur requis a la validation; helper bases_du_groupe(registre, groupe). GUI (make inventaire-ui): - Vue "Bases": menu deroulant Consommateur + champ Usage par base -> on fait les connexions ici; plusieurs bases par appli supportees. - Vue "Chaine" detaillee: chaque groupe affiche ses roles ET ses bases avec leur chaine de connexion (appli -> base -> serveur de BD -> hote). CLI (bases_donnees.py): ajouter-base --consommateur --usage; lister affiche "base <- consommateur (usage): DSN". Roles serveurs_keycloak / serveurs_forgejo / serveurs_icinga: resolution de leur base par consommateur (groupe), plus de cle codee en dur. Valide: ansible-lint (roles), node --check (JS), round-trips GUI et CLI, multi-BD verifie (serveurs_web_dorsaux -> 2 bases), make inventaire-verifier. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 1 + docs/bases-donnees.yml | 29 +++++++++++------------ roles/serveurs_forgejo/defaults/main.yml | 4 ++-- roles/serveurs_forgejo/tasks/main.yml | 10 +++++--- roles/serveurs_icinga/defaults/main.yml | 4 ++-- roles/serveurs_icinga/tasks/main.yml | 10 +++++--- roles/serveurs_keycloak/defaults/main.yml | 4 ++-- roles/serveurs_keycloak/tasks/main.yml | 10 +++++--- scripts/bases_donnees.py | 7 +++++- scripts/inventory_gui.py | 21 ++++++++++++---- scripts/inventory_rules.py | 12 +++++++++- 11 files changed, 75 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d43cd4d..7f18be5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,7 @@ - variables de production DNS et runbook `docs/dns-interne.md`. ### Modifié +- Bindings appli↔BD explicites et **plusieurs bases par application** : chaque base porte un `consommateur` (le groupe applicatif qui l'utilise) et un `usage` (principale/cache…). La GUI (vue « Bases » : menu déroulant **Consommateur** + champ **Usage**) et la CLI (`ajouter-base --consommateur --usage`) gèrent le lien ; la vue **Chaîne devient détaillée** (chaque groupe affiche ses rôles ET ses bases avec leur chaîne de connexion). Rôles `serveurs_keycloak`/`serveurs_forgejo`/`serveurs_icinga` migrés pour résoudre leur base par consommateur (plus de clé codée en dur). - Registre `docs/bases-donnees.yml` passé à un **modèle multi-serveurs** : section `serveurs_bd` (serveurs de BD nommés — `type`/`hôte`/`port`/`groupe`) que chaque base référence. La **chaîne de connexion dérivée** devient le lien appli↔BD ; l'hôte n'est plus codé en dur. Rôles `serveurs_postgresql` (filtre par serveur nommé relié à son groupe) et `serveurs_keycloak`/`serveurs_forgejo`/`serveurs_icinga` (hôte/port lus depuis le registre) migrés. - Robustesse du serveur `make inventaire-ui` : `repondre()` avale proprement les connexions fermées par le client (`BrokenPipeError`/`ConnectionResetError`) au lieu de déverser une trace dans la console ; ajout d'un favicon (204) pour éviter le 404 répété du navigateur. - `make deployer` et `make verifier-deploiement` détectent un `group_vars` de production chiffré (Ansible Vault) et demandent le mot de passe **une seule fois** (réutilisé pour tous les playbooks et sous-vérifications via fichier temporaire `0600`, nettoyé en fin de run). Refus propre si vault chiffré détecté en exécution **non-interactive** ; la GUI ferme `stdin` du sous-processus et fournit le mot de passe via la modale, donc elle ne bloque jamais. Ferme l'asymétrie de gestion des secrets entre terminal et GUI. diff --git a/docs/bases-donnees.yml b/docs/bases-donnees.yml index f771e89..26377d6 100644 --- a/docs/bases-donnees.yml +++ b/docs/bases-donnees.yml @@ -1,24 +1,17 @@ --- # Registre des bases de donnees applicatives Set-OPS. # -# Patron : 1 appli -> 1 base nommee -> 1 compte proprietaire -> 1 chaine de connexion. -# -# Deux niveaux : +# Trois niveaux : # serveurs_bd : les serveurs de base de donnees (un ou plusieurs), nommes. -# Le champ "groupe" relie un serveur nomme au groupe Ansible qui -# l'heberge (le role serveur cree les bases dont le serveur lui correspond). -# bases_donnees: une entree par appli, qui REFERENCE un serveur_bd nomme. +# "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). # -# La chaine de connexion se DERIVE : -# ://:@:/ -# Avec plusieurs serveurs de BD, c'est cette chaine (et le serveur nomme) qui fait -# le lien appli <-> base ; l'hote n'est plus code en dur dans les roles applicatifs. +# La chaine de connexion se derive : ://:@:/ +# C'est elle, plus le couple (consommateur, base), qui fait le lien appli <-> BD. # -# Le champ "secret" nomme la variable Ansible Vault contenant le mot de passe -# (jamais le mot de passe en clair). La GUI/CLI ne voient jamais le secret reel. -# -# Deux consommateurs : serveurs_postgresql (cree base + compte) et le role applicatif -# (construit sa connexion). Secret partage, source unique. +# "secret" nomme une variable Ansible Vault (jamais le mot de passe en clair). serveurs_bd: pg-principal: @@ -33,13 +26,19 @@ bases_donnees: base: keycloak proprietaire: keycloak secret: vault_bd_keycloak + consommateur: serveurs_keycloak + usage: principale icingadb: serveur: pg-principal base: icingadb proprietaire: icingadb secret: vault_bd_icingadb + consommateur: serveurs_icinga + usage: principale forgejo: serveur: pg-principal base: forgejo proprietaire: forgejo secret: vault_bd_forgejo + consommateur: serveurs_forgejo + usage: principale diff --git a/roles/serveurs_forgejo/defaults/main.yml b/roles/serveurs_forgejo/defaults/main.yml index 538e6ed..79cf4b9 100644 --- a/roles/serveurs_forgejo/defaults/main.yml +++ b/roles/serveurs_forgejo/defaults/main.yml @@ -15,8 +15,8 @@ serveurs_forgejo_service: "forgejo" serveurs_forgejo_hostname: "forge.chezlepro.internal" serveurs_forgejo_http_port: 3000 -# Base de donnees (registre docs/bases-donnees.yml). -serveurs_forgejo_db_cle: "forgejo" +# Base de donnees : resolue depuis le registre par le groupe consommateur. +serveurs_forgejo_groupe: "serveurs_forgejo" serveurs_forgejo_db_host: "10.1.13.11" # data-01 # Relais courriel. diff --git a/roles/serveurs_forgejo/tasks/main.yml b/roles/serveurs_forgejo/tasks/main.yml index 555fd38..dae0a6a 100644 --- a/roles/serveurs_forgejo/tasks/main.yml +++ b/roles/serveurs_forgejo/tasks/main.yml @@ -11,16 +11,20 @@ ansible.builtin.include_vars: file: "{{ role_path }}/../../docs/bases-donnees.yml" -- name: Resoudre l'entree de base Forgejo +- name: Resoudre l'entree de base Forgejo (par consommateur) ansible.builtin.set_fact: - serveurs_forgejo_entree: "{{ (bases_donnees | default({}))[serveurs_forgejo_db_cle] | default({}) }}" + serveurs_forgejo_entree: >- + {{ ((bases_donnees | default({})) | dict2items + | selectattr('value.consommateur', 'defined') + | selectattr('value.consommateur', 'equalto', serveurs_forgejo_groupe) + | map(attribute='value') | list | first) | default({}) }} - name: Exiger l'entree de registre et son secret ansible.builtin.assert: that: - serveurs_forgejo_entree.base is defined - serveurs_forgejo_entree.secret is defined - fail_msg: "Entree '{{ serveurs_forgejo_db_cle }}' absente de docs/bases-donnees.yml." + fail_msg: "Aucune base avec consommateur '{{ serveurs_forgejo_groupe }}' dans docs/bases-donnees.yml." - name: Resoudre le mot de passe de base (Vault) ansible.builtin.set_fact: diff --git a/roles/serveurs_icinga/defaults/main.yml b/roles/serveurs_icinga/defaults/main.yml index 11795ee..5094049 100644 --- a/roles/serveurs_icinga/defaults/main.yml +++ b/roles/serveurs_icinga/defaults/main.yml @@ -16,8 +16,8 @@ serveurs_icinga_depot_source: >- deb [signed-by=/usr/share/keyrings/icinga-archive-keyring.gpg] https://packages.icinga.com/debian icinga-{{ ansible_distribution_release }} main -# Base Icinga DB : entree du registre docs/bases-donnees.yml. -serveurs_icinga_db_cle: "icingadb" +# Base Icinga DB : resolue depuis le registre par le groupe consommateur. +serveurs_icinga_groupe: "serveurs_icinga" serveurs_icinga_db_host: "10.1.13.11" # data-01 (serveurs_postgresql) # Redis dedie a Icinga DB (paquet icingadb-redis). diff --git a/roles/serveurs_icinga/tasks/main.yml b/roles/serveurs_icinga/tasks/main.yml index 567afd7..b670ee5 100644 --- a/roles/serveurs_icinga/tasks/main.yml +++ b/roles/serveurs_icinga/tasks/main.yml @@ -26,16 +26,20 @@ ansible.builtin.include_vars: file: "{{ role_path }}/../../docs/bases-donnees.yml" -- name: Resoudre l'entree de base Icinga DB +- name: Resoudre l'entree de base Icinga DB (par consommateur) ansible.builtin.set_fact: - serveurs_icinga_entree: "{{ (bases_donnees | default({}))[serveurs_icinga_db_cle] | default({}) }}" + serveurs_icinga_entree: >- + {{ ((bases_donnees | default({})) | dict2items + | selectattr('value.consommateur', 'defined') + | selectattr('value.consommateur', 'equalto', serveurs_icinga_groupe) + | map(attribute='value') | list | first) | default({}) }} - name: Exiger l'entree de registre et son secret ansible.builtin.assert: that: - serveurs_icinga_entree.base is defined - serveurs_icinga_entree.secret is defined - fail_msg: "Entree '{{ serveurs_icinga_db_cle }}' absente de docs/bases-donnees.yml." + fail_msg: "Aucune base avec consommateur '{{ serveurs_icinga_groupe }}' dans docs/bases-donnees.yml." - name: Resoudre le mot de passe de base (Vault) ansible.builtin.set_fact: diff --git a/roles/serveurs_keycloak/defaults/main.yml b/roles/serveurs_keycloak/defaults/main.yml index 12c1da6..5476b28 100644 --- a/roles/serveurs_keycloak/defaults/main.yml +++ b/roles/serveurs_keycloak/defaults/main.yml @@ -11,8 +11,8 @@ serveurs_keycloak_service: "keycloak" # Publication (derriere serveurs_nginx, TLS termine a l'edge). serveurs_keycloak_hostname: "keycloak.chezlepro.internal" -# Base de donnees : entree du registre docs/bases-donnees.yml. -serveurs_keycloak_db_cle: "keycloak" +# Base de donnees : resolue depuis le registre par le groupe consommateur. +serveurs_keycloak_groupe: "serveurs_keycloak" # IP interne du serveur PostgreSQL (data-01, cf. nomenclature). serveurs_keycloak_db_host: "10.1.13.11" diff --git a/roles/serveurs_keycloak/tasks/main.yml b/roles/serveurs_keycloak/tasks/main.yml index 948f883..2ad7813 100644 --- a/roles/serveurs_keycloak/tasks/main.yml +++ b/roles/serveurs_keycloak/tasks/main.yml @@ -9,9 +9,13 @@ ansible.builtin.include_vars: file: "{{ role_path }}/../../docs/bases-donnees.yml" -- name: Resoudre l'entree de base de donnees Keycloak +- name: Resoudre l'entree de base de donnees Keycloak (par consommateur) ansible.builtin.set_fact: - serveurs_keycloak_entree: "{{ (bases_donnees | default({}))[serveurs_keycloak_db_cle] | default({}) }}" + serveurs_keycloak_entree: >- + {{ ((bases_donnees | default({})) | dict2items + | selectattr('value.consommateur', 'defined') + | selectattr('value.consommateur', 'equalto', serveurs_keycloak_groupe) + | map(attribute='value') | list | first) | default({}) }} - name: Exiger une entree de registre et son secret ansible.builtin.assert: @@ -19,7 +23,7 @@ - serveurs_keycloak_entree.base is defined - serveurs_keycloak_entree.proprietaire is defined - serveurs_keycloak_entree.secret is defined - fail_msg: "Entree '{{ serveurs_keycloak_db_cle }}' absente de docs/bases-donnees.yml." + fail_msg: "Aucune base avec consommateur '{{ serveurs_keycloak_groupe }}' dans docs/bases-donnees.yml." - name: Resoudre le mot de passe de base de donnees (Vault) ansible.builtin.set_fact: diff --git a/scripts/bases_donnees.py b/scripts/bases_donnees.py index ccf9b6c..2a22b27 100644 --- a/scripts/bases_donnees.py +++ b/scripts/bases_donnees.py @@ -39,7 +39,9 @@ def lister(registre: dict) -> None: print("Bases applicatives:") for cle, base in registre.get("bases_donnees", {}).items(): srv = serveurs.get(base.get("serveur"), {}) - print(f" {cle}: {chaine_connexion(base, srv)} [secret {base.get('secret')}]") + conso = base.get("consommateur", "?") + usage = base.get("usage", "-") + print(f" {cle} <- {conso} ({usage}): {chaine_connexion(base, srv)} [secret {base.get('secret')}]") def main() -> int: @@ -61,6 +63,8 @@ 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("--usage", default="principale") pr = sub.add_parser("retirer-base", help="Retire une base applicative.") pr.add_argument("--cle", required=True) @@ -87,6 +91,7 @@ def main() -> int: registre["bases_donnees"][args.cle] = { "serveur": args.serveur, "base": args.base, "proprietaire": args.proprietaire, "secret": args.secret, + "consommateur": args.consommateur, "usage": args.usage, } valider_bases(registre) ecrire(registre) diff --git a/scripts/inventory_gui.py b/scripts/inventory_gui.py index 82da003..aa1d6c0 100644 --- a/scripts/inventory_gui.py +++ b/scripts/inventory_gui.py @@ -498,6 +498,9 @@ HTML = r""" .base-ligne { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)) auto; gap: 9px 11px; align-items: end; background: var(--surface); border: 1px solid var(--ligne); border-radius: var(--rayon-sm); padding: 11px 12px; box-shadow: var(--ombre); } .base-ligne .danger { align-self: end; } .base-ligne .dsn { grid-column: 1 / -1; color: var(--muted); font-size: 11.5px; padding-top: 2px; overflow-wrap: anywhere; } + .ch-bases { display: grid; gap: 4px; margin: 6px 0 0 23px; } + .ch-base { font-size: 11.5px; color: var(--muted); overflow-wrap: anywhere; } + .badge-bd { display: inline-block; font-size: 10px; font-weight: 800; padding: 1px 6px; border-radius: 999px; background: color-mix(in srgb, var(--bleu) 18%, transparent); color: var(--bleu); } .modale { position: fixed; inset: 0; z-index: 50; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,.55); } .modale[hidden] { display: none; } @@ -792,9 +795,13 @@ HTML = r""" 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('')}
` : ''; return `
${echapper(g)}${echapper(g)}.yml
${corps} + ${basesHtml}
`; }).join(''); } @@ -820,7 +827,8 @@ HTML = r""" bdServeurs = Object.entries(b.serveurs_bd || {}).map(([nom, s]) => ({ 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 || ''})); + cle, serveur: x.serveur || '', base: x.base || '', proprietaire: x.proprietaire || '', secret: x.secret || '', + consommateur: x.consommateur || '', usage: x.usage || 'principale'})); basesModifie = false; } @@ -839,7 +847,7 @@ HTML = r""" function definirBaseBd(i, champ, v) { bdApplis[i][champ] = v; marquerBasesModifie(); if (champ === 'serveur') 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: ''}); marquerBasesModifie(); dessinerBases(); } + function ajouterBaseBd() { bdApplis.push({cle: '', serveur: (bdServeurs[0] || {}).nom || '', base: '', proprietaire: '', secret: '', consommateur: '', usage: 'principale'}); marquerBasesModifie(); dessinerBases(); } function retirerBaseBd(i) { bdApplis.splice(i, 1); marquerBasesModifie(); dessinerBases(); } function dessinerBases() { @@ -853,9 +861,12 @@ HTML = r""" `).join('') : '
Aucun serveur de BD.
'; + const optConso = (sel) => '' + groupes.map(g => ``).join(''); const applis = bdApplis.length ? bdApplis.map((a, i) => `
- + + + @@ -863,7 +874,7 @@ HTML = r""" -
${echapper(dsnBase(a))}
+
${echapper(a.consommateur || '—')} · ${echapper(dsnBase(a))}
`).join('') : '
Aucune base applicative.
'; cible.innerHTML = `
@@ -881,7 +892,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}; }); + 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'}; }); 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}) }); diff --git a/scripts/inventory_rules.py b/scripts/inventory_rules.py index bc49b75..6b10663 100644 --- a/scripts/inventory_rules.py +++ b/scripts/inventory_rules.py @@ -53,7 +53,7 @@ def valider_bases(registre: dict) -> None: for cle, base in bases.items(): if not isinstance(base, dict): raise ValueError(f"Base '{cle}': table attendue.") - for champ in ("serveur", "base", "proprietaire", "secret"): + for champ in ("serveur", "base", "proprietaire", "secret", "consommateur"): if not str(base.get(champ, "")).strip(): raise ValueError(f"Base '{cle}': champ '{champ}' requis.") if base["serveur"] not in serveurs: @@ -64,6 +64,16 @@ def valider_bases(registre: dict) -> None: noms_bases.add(couple) +def bases_du_groupe(registre: dict, groupe: str) -> list[dict]: + """Bases consommees par un groupe applicatif, avec leur serveur resolu.""" + 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"), {})}) + return resultat + + 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")