2026-07-03 05:29:24 -04:00
|
|
|
|
# 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.
|
|
|
|
|
|
|
2026-07-03 08:49:04 -04:00
|
|
|
|
## 5. Les bases : binding **côté base**, déjà en place (constat 2026-07-03)
|
2026-07-03 05:29:24 -04:00
|
|
|
|
|
2026-07-03 08:49:04 -04:00
|
|
|
|
> **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`.
|
2026-07-03 05:29:24 -04:00
|
|
|
|
|
2026-07-03 08:49:04 -04:00
|
|
|
|
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 = <hôte du serveur_bd, depuis le registre>
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
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.
|
2026-07-03 05:29:24 -04:00
|
|
|
|
|
|
|
|
|
|
## 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)
|
|
|
|
|
|
|
2026-07-22 21:32:42 -04:00
|
|
|
|
- **éditeur de bindings par application** — ✅ **fait** (2026-07-22). Section « Liens
|
|
|
|
|
|
(bindings) » de l'inspecteur d'application : une ligne par lien, `rôle → cible`, les deux
|
|
|
|
|
|
en listes déroulantes. Les **rôles proposés sont exactement ceux que le rôle porteur
|
|
|
|
|
|
déclare accepter** (`roles/<groupe>/meta/liens.yml`, exposé par l'API sous
|
|
|
|
|
|
`liens_acceptes`) ; les cibles sont les autres applications du plan. Chaque ligne affiche
|
|
|
|
|
|
les **variables injectées** par le lien. Changer le groupe d'une application vide les
|
|
|
|
|
|
rôles de lien devenus inacceptés.
|
|
|
|
|
|
- **graphe des liens** — *reste à faire*. 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).
|
2026-07-03 05:29:24 -04:00
|
|
|
|
|
|
|
|
|
|
## 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
|
|
|
|
|
|
|
2026-07-03 08:49:04 -04:00
|
|
|
|
- **Phase 0** : cette note + décision. ✅ (direction : liens côté app pour app→app)
|
2026-07-03 05:29:24 -04:00
|
|
|
|
- **Phase 1** : résolveur de liens dans `instancier.py` + `meta/liens.yml` des rôles mail ;
|
2026-07-03 08:49:04 -04:00
|
|
|
|
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).
|
2026-07-22 21:32:42 -04:00
|
|
|
|
- **Phase 4** : vue GUI des liens + graphe. 🟡 **éditeur fait** (2026-07-22, cf. §7) ;
|
|
|
|
|
|
**graphe des liens reste à faire**.
|
2026-07-03 05:29:24 -04:00
|
|
|
|
|
|
|
|
|
|
## 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.
|
2026-07-04 11:39:40 -04:00
|
|
|
|
|
|
|
|
|
|
## 11. Taxonomie des liaisons : niveau × modalité (ajout 2026-07-04)
|
|
|
|
|
|
|
|
|
|
|
|
« Liaison » est le concept-chapeau : toute relation déclarative que le moteur câble. Elle se
|
|
|
|
|
|
classe sur **deux axes indépendants**.
|
|
|
|
|
|
|
|
|
|
|
|
### Axe 1 — le niveau (le sous-type nommé)
|
|
|
|
|
|
- **`liens`** (dans `applications.yml`) : liaisons **app → app / base / domaine / annuaire**,
|
|
|
|
|
|
résolues en **config** (host_vars, lookups registre).
|
|
|
|
|
|
- **`intégrations`** (dans `serveurs.yml`) : liaisons **nœud → service de flotte** (PKI, sauvegarde,
|
|
|
|
|
|
métriques, journaux, MTA, DNS, annuaire), résolues en **appartenance de groupe + un agent client**.
|
|
|
|
|
|
|
|
|
|
|
|
### Axe 2 — la modalité (requise vs optionnelle) — **orthogonale au niveau**
|
|
|
|
|
|
- **Requise (constitutive)** : sans elle, l'entité **ne peut pas se concrétiser**. Son absence est
|
|
|
|
|
|
une **erreur** → le moteur doit **refuser d'instancier** (fail-fast, message clair).
|
|
|
|
|
|
- **Optionnelle (élective)** : un **choix** de l'opérateur, activé au cas par cas. Son absence est
|
|
|
|
|
|
**légitime** → jamais d'erreur.
|
|
|
|
|
|
|
|
|
|
|
|
La modalité est une propriété de **chaque** liaison, pas du sous-type. Grille :
|
|
|
|
|
|
|
|
|
|
|
|
| | **Requise** | **Optionnelle** |
|
|
|
|
|
|
|---|---|---|
|
|
|
|
|
|
| **App** (`liens`) | keycloak → sa base PostgreSQL | forgejo → SSO (auth locale possible) |
|
|
|
|
|
|
| **Nœud** (`intégrations`) | client_pki sur un nœud qui sert LDAPS | client_journal → Loki |
|
|
|
|
|
|
|
|
|
|
|
|
### État actuel — le requis est déjà appliqué, mais **implicitement**
|
|
|
|
|
|
- `resoudre_base` **assert** l'entrée BD ; le registre **exige** `serveur:` sur une base ;
|
|
|
|
|
|
`docs/dependances-groupes.yml` déclare les dépendances dures ; `bases_donnees.py verifier` valide.
|
|
|
|
|
|
- L'optionnel = les **listes opt-in** (`integrations:`, `expose:`).
|
|
|
|
|
|
- **Rien ne déclare** « ce lien est requis / celui-ci est électif » — c'est déduit d'*où* il est écrit.
|
|
|
|
|
|
|
|
|
|
|
|
### Raffinement proposé (incrémental, compatible plan-de-contrôle-gelé)
|
|
|
|
|
|
Rendre la modalité **explicite** : chaque liaison porte (ou dérive) un `requis: true|false`.
|
|
|
|
|
|
- **requise absente** → refus d'instancier, message actionnable (renforce « exploitable sans IA ») ;
|
|
|
|
|
|
- **optionnelle absente** → choix assumé, silence.
|
|
|
|
|
|
|
|
|
|
|
|
La validation du requis existe déjà à moitié ; il s'agit de l'**uniformiser et de la nommer**, pas
|
|
|
|
|
|
de refondre. Exemple de nature : les agents `client_metrique` / `client_journal` / `client_smtp`
|
|
|
|
|
|
sont des liaisons **nœud × optionnelle** — d'où leur forme d'**intégration opt-in**, activée
|
|
|
|
|
|
sciemment par nœud (superviser *ce* nœud est un choix, pas une dépendance intrinsèque).
|