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

13 KiB
Raw Permalink Blame History

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 :

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 :

# 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 :
    • consommateurhost_vars de l'hôte du consommateur (défaut) ;
    • cible / edgehost_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.
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 :

- 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ôlelookup('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 liensreste à 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' 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).