diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c16cf4..1ccfa27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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é diff --git a/roles/serveur_postfix/meta/liens.yml b/roles/serveur_postfix/meta/liens.yml new file mode 100644 index 0000000..779269b --- /dev/null +++ b/roles/serveur_postfix/meta/liens.yml @@ -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 diff --git a/scripts/instancier.py b/scripts/instancier.py index e96cbc4..b8f027e 100644 --- a/scripts/instancier.py +++ b/scripts/instancier.py @@ -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//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 [])