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>
13 KiB
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-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 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 à 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).
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: 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 :
- 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é (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
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 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.ymldes rôles mail ; migrer les liens mail ; régression DIFF VIDE. ✅ (2026-07-03 :mailstore+miltermigré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_applicationsdé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
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).