# Bindings — conception des relations entre applications, bases, serveurs et domaines > Note de conception, 2026-07-02. Décision d'architecture à valider avant implémentation. > Direction retenue : **liens déclarés côté application (le consommateur déclare ses besoins)**. ## 1. Problème Les relations **service → service** ne sont nulle part dans le plan : elles sont codées en dur, éparpillées dans des `group_vars` / `defaults`. Exemples réels, produits à la main pour la messagerie : - `serveur_postfix_mailstore_hote: "infra-mail-01.lab.chezlepro.internal"` (Postfix → Dovecot) ; - `serveur_postfix_rspamd_milter: true` (Postfix → rspamd) ; - `id-ldap-01` codé en dur dans les `defaults` (Postfix / Dovecot → OpenLDAP). Conséquence : **la topologie n'est pas déclarative**. Déplacer Dovecot sur un autre hôte oblige à éditer des variables à la main. **Cela échoue à l'épreuve de la portabilité multi-tenant** — pourtant au cœur de la mission. ## 2. Ce qui existe déjà (partiel) Le concept est à moitié né, mais éclaté et non câblé : - **Bases** (`plan/bases-donnees.yml`) : `consommateur` + `portee` (groupe|hote|application) + `secret` (Vault) + `usage` + `proprietaire`. Un vrai binding base → consommateur, mais vide et non injecté dans l'inventaire. - **Domaines** (`plan/domaines.yml`) : un domaine se lie à un `edge` ; `scripts/domaines.py` sait déjà lister les **expositions déclarées par les applications** — mais aucune app n'en déclare. - **Applications** (`plan/applications.yml`) : seulement `groupe` + `hote`. Aucun lien app → app. - **Rôles** : portent déjà une méta auto-descriptive (`meta/empreinte.yml`). ## 3. Modèle proposé **Un binding est une arête typée et dirigée**, d'un **consommateur** (une application) vers une **cible** (une autre application, une base, ou un domaine public). `scripts/instancier.py` le **résout en variables Ansible**. On déclare la *relation* ; l'outil dérive le *câblage*. ### 3.1 Où vivent les liens : côté application (décision) Le consommateur déclare ses besoins, dans `plan/applications.yml` : ```yaml applications: postfix: groupe: serveur_postfix hote: edge-mta-01 liens: - vers: dovecot # autre application role: mailstore # nature du lien - vers: rspamd role: milter - vers: openldap role: annuaire ``` Rationale : **localité et lisibilité** (on lit une app et on voit de quoi elle dépend), et symétrie avec le déploiement (on pousse une app → ses liens sont là). Les entités cibles restent définies dans leur propre registre (`bases-donnees.yml`, `domaines.yml`) ; seules les **arêtes** vivent sur le consommateur. ### 3.2 Les rôles décrivent les liens qu'ils acceptent Chaque rôle Ansible porte un `meta/liens.yml` (comme `meta/empreinte.yml`) qui dit quels liens il accepte et **quelles variables ils remplissent** : ```yaml # roles/serveur_postfix/meta/liens.yml --- setops_liens: accepte: mailstore: cible: application injecte_sur: consommateur variables: serveur_postfix_mailstore_hote: "{cible.fqdn}" milter: cible: application injecte_sur: consommateur variables: serveur_postfix_rspamd_milter: true annuaire: cible: application injecte_sur: consommateur variables: serveur_postfix_ldap_serveur: "ldaps://{cible.fqdn}:636" ``` Le **moteur reste générique** ; la connaissance « lien → variables » reste **dans le rôle**. ### 3.3 Attributs de la cible (substitution) Les gabarits de variables utilisent les attributs résolus de la cible : | Placeholder | Application | Base | Domaine | |---|---|---|---| | `{cible.hote}` | nom de VM | hôte de la base | — | | `{cible.fqdn}` | FQDN interne dérivé | FQDN interne | — | | `{cible.ip}` | IP dérivée | IP | — | | `{cible.port}` | — | port | — | | `{cible.secret}` | — | nom de la variable Vault | — | | `{cible.base}` | — | nom de la base | — | | `{cible.domaine}` | — | — | nom public | | `{cible.edge}` | — | — | edge qui sert | Le **FQDN interne est dérivé de la nomenclature** (`hote` + `domaine_interne`) — jamais codé en dur. C'est ce qui rend la topologie portable. ### 3.4 Algorithme de résolution (instancier) Pour chaque application portant des `liens`, pour chaque lien `{vers, role}` : 1. résoudre `vers` → entité cible (app / base / domaine) et ses attributs ; 2. charger le `meta/liens.yml` du rôle du **consommateur** ; trouver `accepte[role]` ; 3. valider que le genre de la cible == `cible` attendue ; 4. substituer les attributs dans chaque gabarit de variable ; 5. injecter selon `injecte_sur` : - `consommateur` → `host_vars` de l'hôte du consommateur (défaut) ; - `cible` / `edge` → `host_vars` de l'hôte cible / de l'edge (cas exposition). ## 4. Les domaines : un genre de lien distinct À considérer **avec** les bindings, sans les confondre : - **FQDN interne** (service → service) : dérivé de la nomenclature. Pas de `domaines.yml`. - **Domaine public** (exposition) : espace d'adressage + edge. Modélisé comme un lien `role: exposition` d'une app vers un domaine de `domaines.yml`. Sa résolution écrit **sur l'edge** (`injecte_sur: edge`) : un vhost/upstream nginx `git.chezlepro.ca → forgejo-fqdn:port`. ```yaml applications: forgejo: groupe: serveur_forgejo hote: git-01 liens: - vers: git.chezlepro.ca # domaine public (domaines.yml) role: exposition - vers: postgresql-forgejo # base (bases-donnees.yml) role: base ``` `domaines.yml` reste le **registre** des domaines publics (autorité, DNSSEC, secondaires, edge) ; le lien app ↔ domaine relie enfin app + domaine + edge, aujourd'hui séparés. ## 5. Les bases : binding **côté base**, déjà en place (constat 2026-07-03) > **Correction du modèle après inspection du code réel.** La §3.1 tranchait « tout lien côté > app ». Mais le binding app→base **existe déjà et fonctionne**, par un mécanisme *différent* de > celui des liens app→app. Il **ne faut pas le dupliquer** dans `instancier`. Réalité : quatre rôles (`serveur_postgresql`, `serveur_forgejo`, `serveur_keycloak`, `serveur_icinga`) résolvent leur base **au déploiement, dans le rôle**, depuis le registre `plan/bases-donnees.yml` : ```yaml - include_vars: bases-donnees.yml # charge le registre - set_fact: entree = bases_donnees | selectattr(consommateur == mon_groupe) # filtre côté base - assert: entree + secret présents - set_fact: db_password = "{{ lookup('vars', entree.secret) }}" # déréférence le secret par NOM - set_fact: db_host = ``` Le binding est donc **côté base** (`consommateur` + `portee`), **résolu à l'exécution dans le rôle**. C'est **délibérément conservé**, pour deux raisons : 1. **le secret ne quitte jamais le rôle** — `lookup('vars', entree.secret)` déréférence la variable Vault *par son nom* au moment du déploiement ; instancier n'a jamais à toucher un secret ni à l'écrire dans l'inventaire ; 2. c'est **déjà utilisé et cohérent** sur 4 rôles ; le refaire en app-side/instancier serait de la duplication à risque, sans gain. **Conséquence sur la §3.1** : la direction « côté app » vaut pour les liens **app→app** (mail, annuaire, milter…) résolus par instancier. Les liens **app→base** restent **côté base**, résolus dans le rôle. Deux directions, **assumées**, chacune selon la nature de la cible et la sensibilité du secret. La §3.1 est donc raffinée, pas contredite. Reste comme valeur réelle (non bloquant) : **factoriser** le bloc de résolution copié-collé dans les 4 rôles en un include partagé (ex. `roles/_resoudre_base/`). À faire **avec l'épreuve de Keycloak** (qui utilise ce mécanisme), pour valider le DRY en le déployant. Cf. §9. ## 6. Règles de validation - tout `vers` résout vers **exactement une** entité (sinon erreur ; désambiguïser par registre) ; - tout `role` est **accepté** par le rôle du consommateur (`meta/liens.yml`) ; - le genre de la cible correspond à `cible` ; - pour une base, le `secret` nommé existe dans la voûte ; - cycles tolérés s'ils sont légitimes (ex. mTLS mutuel), signalés sinon. ## 7. Interface (GUI) - vue « Liens » / éditeur de bindings par application ; - **graphe des liens** — visualisation naturelle (le GUI a déjà un graphe d'inventaire), très parlante pour la **démo de portabilité** (déplacer un nœud, voir les arêtes suivre). ## 8. Preuve de migration (les 3 liens mail) Premier cas concret, à faire en régression (les mêmes variables doivent être générées) : | Avant (codé en dur) | Après (déclaratif) | |---|---| | `serveur_postfix_mailstore_hote` (group_var) | `postfix.liens: [mailstore → dovecot]` | | `serveur_postfix_rspamd_milter` (group_var) | `postfix.liens: [milter → rspamd]` | | `id-ldap-01` (defaults) | `postfix.liens: [annuaire → openldap]`, `dovecot.liens: [annuaire → openldap]` | ## 9. Phasage - **Phase 0** : cette note + décision. ✅ (direction : liens côté app pour app→app) - **Phase 1** : résolveur de liens dans `instancier.py` + `meta/liens.yml` des rôles mail ; migrer les liens mail ; régression DIFF VIDE. ✅ (2026-07-03 : `mailstore` + `milter` migrés) - **Phase 2** : bases. ✅ **déjà en place** — binding **côté base** (registre + résolution en rôle, 4 rôles), cf. §5. Ne PAS dupliquer dans instancier. **Reste (reporté à la session Keycloak)** : factoriser le bloc de résolution copié-collé en un include partagé (DRY), validé en déployant Keycloak. - **Phase 3** : exposition / domaines (vhosts nginx dérivés des liens ; machinerie `expose` / `expositions_des_applications` déjà présente). - **Phase 4** : vue GUI des liens + graphe. ## 10. Décisions ouvertes - désambiguïsation de `vers` entre registres (app / base / domaine) ; - `host_vars` du consommateur vs `group_vars` du groupe (défaut : host_vars, plus précis) ; - format exact des gabarits (`{cible.fqdn}`) et jeu d'attributs figés ; - compat ascendante du `consommateur` des bases pendant la migration.