Set-OPS-Public/docs/bindings-conception.md
Daniel Allaire 5bc3bceac1
Some checks failed
verifier / verifier (push) Has been cancelled
documentation : la tournee des 74 documents, parce qu un balayage ne lit pas
La revision a commence par un balayage par motifs — chemins morts, cibles make
absentes, comptes derives. Il a trouve une trentaine d ecarts et rate presque
tout le reste : un motif ne voit que ce qui s exprime en motif.

make hote-planifier en est l exemple. La cible EXISTE, donc le controle passait
au vert. C est une cible depreciee qui refuse et sort en 2, recommandee par
AGENTS.md, et qui contredit la REGLE D OR du meme fichier trois ecrans plus
haut. Il fallait lire pour la voir.

74 documents lus un par un. 66 corriges, 8 exacts.

CE QUI ETAIT FRANCHEMENT FAUX

AGENTS.md, la source d autorite, annoncait la flotte pas encore executee contre
des VM reelles. Elle a ete rasee et remontee depuis zero trois fois.
ecosysteme-chezlepro.md, le document montre a un client, portait la meme
phrase : il se sous-vendait gravement.

courriel-conception.md s ouvrait sur aucun role n est encore ecrit, au-dessus de
son propre paragraphe 1 qui les nomme. autorisation.md se terminait sur rien n
est construit alors qu il rapporte des mesures datees du role en fonctionnement.
hebergeur-exploitation.md disait rien n est fait d un depot qui existe.
filiation-emancipation.md se contredisait a deux ecrans de distance.

DES MODELES DECRITS D APRES UN MONDE ANTERIEUR

Le resolveur : cinq documents decrivaient un Unbound par VM en opt-in, trois le
donnaient en exemple d integration FACULTATIVE — il est universel depuis le
2026-08-24. L adressage de nomenclature-vm.md : reseau unique, VLAN 11-15, VMID
a cinq chiffres. Le nommage SDN de sdn-evpn.md contre le code : c est le wiki
qui avait raison.

CE QUI CASSE AU PREMIER ESSAI

Le nom du gabarit dore etait faux a quatre endroits, dont la procedure qui le
FABRIQUE et le critere R2 de l epreuve d operateur independant.
preparer-un-site-hebergeur.md avertissait qu une VM faite a la main serait
detruite : raser derive du plan, il ne la detruira jamais — le risque est l
inverse. Un mot de passe d essai en clair dans un depot public.

DEUX PREUVES ETENDUES, ET UNE QUI SE TROMPAIT ELLE-MEME

P57 couvre les groupes : elle a signale aussitot 29 groupes annonces au-dessus d
un tableau qui en cite 40. P29 confronte le tableau de authentification.md aux
declarations reelles : 12 annonces, 21 reels.

Et P57 imposait un chiffre faux — 56 preuves alors que le depot en porte 57, la
conditionnelle vivant hors de tout comptage. Un garde-fou qui fait respecter une
erreur ajoute l assurance a l erreur.

CE QUI RESTE, ET QU AUCUNE PREUVE NE TIENT

Deux comptes trouves a la main. Et une lacune reelle : rien ne garde les
meta/acces.yml — ni qu un service web-sso en porte un, ni que le groupe qu il
nomme existe. P29 tient les positions d authentification, personne ne tient les
habilitations.

make prouver : CONFORME, 56 OK, 0 echec, 1 saute. 0 lien mort.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-06 16:18:23 -04:00

16 KiB
Raw Permalink Blame History

Bindings — conception des relations entre applications, bases, serveurs et domaines

Pour qui : le mainteneur qui ajoute une relation service → service.

Note de conception, 2026-07-02. Direction retenue : liens déclarés côté application (le consommateur déclare ses besoins).

Statut, revu le 2026-09-06 : ce n'est plus « à valider avant implémentation ». Le mécanisme est construit et déployé — le §9 en donne le phasage, et les §1 et §2 ci-dessous décrivent l'état de départ de juillet, conservé parce qu'il explique pourquoi le modèle est ce qu'il est. Ils ne décrivent pas le dépôt d'aujourd'hui : voir l'encadré au §2. Ce qui reste ouvert est nommé au §7 (le graphe des liens) et au §10.

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 existait déjà, en juillet 2026 (état de départ)

Le concept était à moitié né, é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).

Aucune de ces quatre lignes n'est encore vraie (mesuré le 2026-09-06). Le registre des bases porte 4 bases et un serveur, tous consommés ; 6 applications déclarent un expose ; postfix déclare 3 liens app→app. La section est gardée au passé parce qu'elle est le problème que le reste du document résout — la lire au présent donnerait l'impression que rien n'a bougé.

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 :
    • 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.
applications:
  forgejo:
    groupe: serveur_forgejo
    hote: forge-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é : les rôles consommateurs résolvent leur base au déploiement, dans le rôle, depuis le registre plan/bases-donnees.yml. Ils sont cinq aujourd'hui — serveur_forgejo, serveur_icinga, serveur_icingaweb2, serveur_keycloak, serveur_nextcloud — et passent tous par roles/resoudre_base (cf. le paragraphe qui suit). serveur_postgresql figurait dans cette liste jusqu'au 2026-09-06 : il n'y a pas sa place. Il lit bien le registre, mais pour créer les bases et leurs comptes — il est le serveur, pas un consommateur. :

- 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é. Fait. Le rôle utilitaire roles/resoudre_base/ porte la résolution, et cinq rôles consommateurs l'incluent (serveur_forgejo, serveur_icinga, serveur_icingaweb2, serveur_keycloak, serveur_nextcloud). Le DRY a bien été validé en le déployant, comme prévu — l'épreuve de Keycloak. 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 liens mail) — faite, mais pas comme prévu

Premier cas concret. Deux des trois lignes sont passées par les liens, la troisième non — et l'écart est instructif, il fixe la frontière entre les deux mécanismes.

Avant (codé en dur) Après Par quel mécanisme
serveur_postfix_mailstore_hote (group_var) postfix.liens: [mailstore → dovecot] lien ✅ (2026-07-03)
serveur_postfix_rspamd_milter (group_var) postfix.liens: [milter → rspamd] lien ✅ (2026-07-03)
id-ldap-01 (defaults) connexion LDAP dérivée du domaine_interne rôle utilitaire resoudre_annuaire

Pourquoi l'annuaire n'est pas un lien. Ce document a annoncé jusqu'au 2026-09-06 postfix.liens: [annuaire → openldap] et l'équivalent pour Dovecot. Ni l'un ni l'autre n'existe : roles/serveur_postfix/meta/liens.yml n'accepte que mailstore et milter. L'annuaire est traité comme les bases (§5) — par un rôle utilitaire que quatre consommateurs incluent (serveur_dovecot, serveur_postfix, serveur_keycloak, serveur_icingaweb2, plus amorcage_acces), parce qu'il n'y a qu'un annuaire par écosystème et que sa connexion se dérive entièrement du domaine_interne : il n'y a pas de choix de topologie à déclarer, donc pas d'arête à porter dans le plan. Un lien exprime un choix ; ici il n'y en a pas.

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. ✅ complète — binding côté base (registre + résolution en rôle), cf. §5. Ne PAS dupliquer dans instancier. La factorisation qui restait est faite : roles/resoudre_base/, inclus par cinq rôles consommateurs.
  • Phase 3 : exposition / domaines. ✅ — 6 applications déclarent un expose (keycloak, forgejo, grafana, oauth2_proxy, nextcloud, collabora), et serveur_nginx en dérive vhost, SAN et enregistrement A (expositions_des_applications). make expositions-plan vérifie ensuite que chacune répond réellement.
  • 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).