bindings Phase 1 : résolveur de liens dans instancier (relations déclaratives)

Une application déclare ses liens ([{vers, role}]) dans applications.yml ;
chaque rôle décrit les liens acceptés dans meta/liens.yml. instancier
résout la cible (FQDN interne dérivé de la nomenclature) et injecte les
variables en host_vars du consommateur.

Migration prouvée : les liens mail Postfix→Dovecot (mailstore) et
Postfix→rspamd (milter) passent de group_vars codés en dur à des liens
déclaratifs — make instancier donne DIFF VIDE (mêmes vars générées).

Cf. docs/bindings-conception.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-07-03 08:33:30 -04:00
parent 4454e1e18b
commit d95b26c243
3 changed files with 120 additions and 0 deletions

View file

@ -1,5 +1,20 @@
# CHANGELOG — Set-OPS
## 2026-07-03
### Ajouté
- **Bindings — Phase 1 : résolveur de liens dans `instancier.py` (relations service→service
déclaratives).** Une application déclare ses `liens: [{vers, role}]` dans
`plan/applications.yml` ; chaque rôle décrit les liens qu'il accepte dans `meta/liens.yml`
(`setops_liens.accepte`, comme `meta/empreinte.yml`). `instancier` résout la cible (FQDN interne
**dérivé de la nomenclature** + `domaine_interne`), substitue les gabarits (`{cible.fqdn}`,
`{cible.hote}`, `{cible.ip}`) et **injecte les variables en host_vars du consommateur**.
Validation : rôle accepteur, cible existante, genre attendu. **Migration prouvée** : les liens
mail Postfix→Dovecot (`mailstore`) et Postfix→rspamd (`milter`) passent de group_vars codés en
dur à des liens déclaratifs — `make instancier` donne **DIFF VIDE** (mêmes variables générées),
puis les group_vars sont retirés. La topologie mail devient déclarative et portable.
Cf. `docs/bindings-conception.md`. Suite : bases (Phase 2), exposition/domaines (Phase 3), GUI (Phase 4).
## 2026-07-02
### Décidé

View file

@ -0,0 +1,17 @@
---
# Liens (bindings) acceptes par serveur_postfix — resolus par scripts/instancier.py.
# Le consommateur declare le lien dans plan/applications.yml (liens: [{vers, role}]) ;
# la cible est resolue (FQDN interne derive) et injectee en host_vars du consommateur.
# Placeholders disponibles : {cible.hote} {cible.fqdn} {cible.ip}. Cf. docs/bindings-conception.md.
setops_liens:
accepte:
mailstore: # ou remettre le courrier (Dovecot LMTP)
cible: application
injecte_sur: consommateur
variables:
serveur_postfix_mailstore_hote: "{cible.fqdn}"
milter: # filtre antispam + DKIM (rspamd), co-localise
cible: application
injecte_sur: consommateur
variables:
serveur_postfix_rspamd_milter: true

View file

@ -62,10 +62,95 @@ PLACEMENT = [("noeud", "proxmox_noeud"), ("stockage", "proxmox_stockage"),
("coeurs", "proxmox_coeurs")]
# --- Bindings (liens) : relations declaratives entre applications -----------------
# Une application declare ses 'liens' ([{vers, role}]) dans plan/applications.yml ;
# chaque role decrit les liens qu'il accepte dans roles/<groupe>/meta/liens.yml
# (setops_liens.accepte). instancier resout la cible (FQDN interne derive) et injecte
# les variables en host_vars du consommateur. Cf. docs/bindings-conception.md.
def _domaine_interne() -> str:
"""Lit domaine_interne dans group_vars/all de l'instance (keystone, non chiffre)."""
base = INVENTAIRE.parent / "group_vars" / "all"
if base.is_dir():
for fichier in sorted(base.glob("*.yml")):
try:
data = yaml.safe_load(fichier.read_text(encoding="utf-8")) or {}
except yaml.YAMLError:
continue
if isinstance(data, dict) and str(data.get("domaine_interne", "")).strip():
return str(data["domaine_interne"]).strip()
return ""
def _liens_acceptes(groupe: str) -> dict:
"""meta/liens.yml d'un role -> setops_liens.accepte (liens que le role accepte)."""
fichier = ROLES / groupe / "meta" / "liens.yml"
if not fichier.is_file():
return {}
data = yaml.safe_load(fichier.read_text(encoding="utf-8")) or {}
return ((data.get("setops_liens") or {}).get("accepte")) or {}
def _attributs_cible(vers: str, apps: dict, serveurs: dict, nomenclature: dict, domaine: str) -> dict | None:
"""Resout une cible de lien en attributs substituables. Phase 1 : applications."""
if vers in apps:
hote = apps[vers].get("hote")
srv = serveurs.get(hote, {})
_, seq = fonction_seq(str(hote))
d = deriver_nomenclature(str(srv.get("fonction", "")), seq, nomenclature) or {}
return {
"genre": "application",
"hote": hote,
"fqdn": f"{hote}.{domaine}" if domaine else str(hote),
"ip": d.get("adresse_ip"),
}
return None
def _substituer(valeur, attrs: dict):
"""Remplace {cible.X} dans une chaine ; laisse bool/int tels quels."""
if not isinstance(valeur, str):
return valeur
for cle, val in attrs.items():
valeur = valeur.replace("{cible." + cle + "}", str(val))
return valeur
def resoudre_liens(apps: dict, serveurs: dict, nomenclature: dict) -> dict:
"""Derive {hote: {var: valeur}} depuis les 'liens' des applications (bindings)."""
domaine = _domaine_interne()
injections: dict = {}
for nom_app, app in apps.items():
accepte = None
for lien in (app.get("liens") or []):
vers, role = lien.get("vers"), lien.get("role")
if accepte is None:
accepte = _liens_acceptes(str(app.get("groupe", "")))
spec = accepte.get(role)
if not spec:
raise ValueError(
f"application '{nom_app}': lien role '{role}' non accepte par "
f"'{app.get('groupe')}' (roles/{app.get('groupe')}/meta/liens.yml).")
attrs = _attributs_cible(str(vers), apps, serveurs, nomenclature, domaine)
if attrs is None:
raise ValueError(f"application '{nom_app}': cible de lien '{vers}' introuvable.")
attendu = spec.get("cible")
if attendu and attendu != attrs["genre"]:
raise ValueError(
f"application '{nom_app}': lien '{role}' attend une cible '{attendu}', "
f"'{vers}' est '{attrs['genre']}'.")
sur_conso = spec.get("injecte_sur", "consommateur") == "consommateur"
cible_hote = app.get("hote") if sur_conso else attrs["hote"]
for var, gabarit in (spec.get("variables") or {}).items():
injections.setdefault(cible_hote, {})[var] = _substituer(gabarit, attrs)
return injections
def generer() -> dict:
serveurs = charger_serveurs(FICHIER_SERVEURS).get("serveurs", {})
apps = charger_applications(FICHIER_APPLICATIONS).get("applications", {})
nomenclature = charger_nomenclature(FICHIER_NOMENCLATURE)
injections = resoudre_liens(apps, serveurs, nomenclature)
services_par_hote: dict = {}
for app in apps.values():
@ -96,6 +181,9 @@ def generer() -> dict:
hostvars.setdefault("proxmox_coeurs", ressources["coeurs"])
hostvars.setdefault("proxmox_memoire", ressources["memoire"])
hostvars.setdefault("proxmox_disque_taille", ressources["disque"])
# Variables derivees des liens (bindings) declares par les applications.
for var, val in injections.get(nom, {}).items():
hostvars[var] = val
etat = "hotes_actifs" if srv.get("etat") == "actif" else "hotes_planifies"
children[etat]["hosts"][nom] = hostvars
groupes = set(GROUPES_SOCLE) | services_par_hote.get(nom, set()) | set(srv.get("integrations") or [])