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 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-06-22 19:44:05 -04:00
parent 25d0c73275
commit 33b1933a6d
11 changed files with 75 additions and 37 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -498,6 +498,9 @@ HTML = r"""<!doctype html>
.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"""<!doctype html>
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>` : '';
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}
</div>`;
}).join('');
}
@ -820,7 +827,8 @@ HTML = r"""<!doctype html>
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"""<!doctype html>
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"""<!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 applis = bdApplis.length ? bdApplis.map((a, i) => `
<div class="base-ligne">
<label class="champ"><span>Application</span><input value="${echapper(a.cle)}" oninput="definirBaseBd(${i}, 'cle', this.value)"></label>
<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>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('')}
</select></label>
@ -863,7 +874,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(dsnBase(a))}</div>
<div class="dsn mono">${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">
@ -881,7 +892,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}; });
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})
});

View file

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