2026-07-03 05:29:24 -04:00
|
|
|
|
# Bindings — conception des relations entre applications, bases, serveurs et domaines
|
|
|
|
|
|
|
preuve : P34 — chaque document declare son lecteur (D-74)
La refonte de ce matin posait une convention. Une convention qu'on n'outille
pas tient tant que quelqu'un y pense : c'est le raisonnement de D-70, applique
au corpus documentaire.
Etat de depart mesure : 2 documents sur 34 declaraient leur lecteur. Les 32
autres disaient leur SUJET — ce qui avait enfoui le runbook de reprise le plus
utile du depot au §6 de autorisation.md.
Les 38 le declarent desormais, lecteur determine document par document et non
colle au gabarit : l'exploitant (devis, migration de tenant, cycle de vie,
gabarit d'or), le mainteneur (conceptions, registres, carte), le lecteur
externe (ecosysteme-chezlepro), l'agent IA (MISE-A-JOUR-CODEX-CLAUDE).
Deux exemptions DERIVEES, pas listees — un chemin en dur aurait vieilli a la
premiere page ajoutee : un document qui s'annonce genere, et un fragment sans
titre. Les 13 exemptes verifies un par un ; aucun document ecrit a la main
n'est exempte par accident. La preuve ne lit que l'EN-TETE, ce qui empeche
frontiere-opnsense.md et plan-et-generation.md — qui parlent de generation
dans leur corps — d'etre exemptes a tort.
Eprouvee dans les deux sens. Elle a echoue seule des sa premiere execution en
nommant deux documents que mon inventaire avait manques (docs/audit/). Puis
test negatif delibere : declaration retiree de meta-classe.md -> ECHEC la
nommant ; restauree -> OK.
Ce qu'elle ne teste pas : que le lecteur declare soit le BON. Ca se juge en
revue ; elle garantit qu'on a du y penser.
P01–P34. Comptes perimes corriges au passage (AGENTS.md et devis-services.md
annoncaient encore 30 preuves).
Verifie : prouver.py 0 (34 OK), plan-recette inchange.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 07:53:04 -04:00
|
|
|
|
> **Pour qui :** le **mainteneur** qui ajoute une relation service → service.
|
|
|
|
|
|
|
2026-07-03 05:29:24 -04:00
|
|
|
|
> 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).
|