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
16 KiB
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-01codé en dur dans lesdefaults(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 à unedge;scripts/domaines.pysait déjà lister les expositions déclarées par les applications — mais aucune app n'en déclare. - Applications (
plan/applications.yml) : seulementgroupe+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;postfixdé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} :
- résoudre
vers→ entité cible (app / base / domaine) et ses attributs ; - charger le
meta/liens.ymldu rôle du consommateur ; trouveraccepte[role]; - valider que le genre de la cible ==
cibleattendue ; - substituer les attributs dans chaque gabarit de variable ;
- injecter selon
injecte_sur:consommateur→host_varsde l'hôte du consommateur (défaut) ;cible/edge→host_varsde 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: expositiond'une app vers un domaine dedomaines.yml. Sa résolution écrit sur l'edge (injecte_sur: edge) : un vhost/upstream nginxgit.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 :
- 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 ; - 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
versrésout vers exactement une entité (sinon erreur ; désambiguïser par registre) ; - tout
roleest accepté par le rôle du consommateur (meta/liens.yml) ; - le genre de la cible correspond à
cible; - pour une base, le
secretnommé 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 sousliens_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.ymldes rôles mail ; migrer les liens mail ; régression DIFF VIDE. ✅ (2026-07-03 :mailstore+miltermigré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), etserveur_nginxen dérive vhost, SAN et enregistrement A (expositions_des_applications).make expositions-planvé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
versentre registres (app / base / domaine) ; host_varsdu consommateur vsgroup_varsdu groupe (défaut : host_vars, plus précis) ;- format exact des gabarits (
{cible.fqdn}) et jeu d'attributs figés ; - compat ascendante du
consommateurdes 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(dansapplications.yml) : liaisons app → app / base / domaine / annuaire, résolues en config (host_vars, lookups registre).intégrations(dansserveurs.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_baseassert l'entrée BD ; le registre exigeserveur:sur une base ;docs/dependances-groupes.ymldéclare les dépendances dures ;bases_donnees.py verifiervalide.- 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).