Set-OPS-Public/docs/bindings-conception.md
Daniel Allaire 7e190e0a57 Trois preuves qui regardent au-dela d'une seule instance + champ liens/websocket au GUI
Le harnais ne verifiait qu'UNE instance et le seul modele socle. Tout ce qui vit
a cote du moteur echappait au controle. Trois preuves ferment ces angles morts :

- P17 (scripts/modeles.py) : TOUS les modeles valident, pas seulement socle.
  SETOPS_MODELES=../Set-OPS-Modeles inclut les modeles assembles prives. A trouve
  6 modeles invalides sur 7 (corriges dans Set-OPS-Modeles).
- P18 (scripts/voute.py) : le gabarit vault.yml.example couvre EXACTEMENT les secrets
  que le plan exige (bases + roles actifs + group_vars). Ne dechiffre jamais la vraie
  voute : compare des noms.
- P19 (scripts/couverture_gui.py) : tout champ present dans un plan reel est editable
  par le GUI. A trouve applications.websocket (comble). Nomenclature toleree (trou connu).

GUI :
- champ « Liens (bindings) » dans l'inspecteur d'application : role -> cible en listes
  deroulantes, les roles proposes = ceux que le role porteur accepte (meta/liens.yml).
  Comble un manque : les bindings ne se declaraient qu'en editant le YAML a la main.
- champ « WebSocket » (Collabora).
- CHAMPS_ECRITS_PAR_GUI : declaration de ce que le GUI sait ecrire, verifiee par P19.

Garde-fou de fond : valider_applications refuse une application posee sur un hote non
declare (l'hote fantome exact qu'integral portait). Cable partout + POST du GUI.

liens_acceptes()/catalogue_liens() dans inventory_rules : source unique partagee par le
validateur, le GUI et instancier.py (dont la copie locale est retiree).

Valide : make verifier rc=0, CONFORME 19/19, ansible-lint 0 echec, 7 modeles valident,
DIFF VIDE, node --check du GUI OK. Piece justificative : docs/audit/preuve-2026-07-22.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 21:32:42 -04:00

270 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 = <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.
## 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)
- **é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).
## 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. 🟡 **éditeur fait** (2026-07-22, cf. §7) ;
**graphe des liens reste à faire**.
## 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.
## 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).