# Certificate planning lookup for Infinito.Nexus: # - Computes certificate file paths (cert/key/ca) # - Computes effective SAN list (domains.san) # - Supports self-signed scope: "app" | "global" # # See utils/tls_common.py for shared resolution logic. from __future__ import annotations from pathlib import Path from typing import Any from ansible.errors import AnsibleError from ansible.plugins.loader import lookup_loader from ansible.plugins.lookup import LookupBase from utils.templating.jinja import render_strict from utils.tls_common import ( AVAILABLE_FLAVORS, as_str, collect_domains_for_app, override_san_list, require, resolve_enabled, resolve_le_name, resolve_mode, resolve_term, uniq_preserve, want_get, ) LE_FULLCHAIN = "fullchain.pem" LE_PRIVKEY = "privkey.pem" def _join(*parts: Any) -> str: cleaned = [str(p).strip() for p in parts if str(p).strip()] return str(Path(*cleaned)) if cleaned else "" def _require_current_play_domains_all_strict(variables: dict) -> list[str]: """ STRICT FORMAT REQUIREMENT CURRENT_PLAY_DOMAINS_ALL MUST: - exist - be list[str] - be non-empty - contain only non-empty strings No fallback. No coercion. No dict support. """ value = variables.get("CURRENT_PLAY_DOMAINS_ALL") if not isinstance(value, list): raise AnsibleError( "cert(strict): CURRENT_PLAY_DOMAINS_ALL must be of type list[str]. " f"Got {type(value).__name__}." ) cleaned: list[str] = [] for raw_item in value: if not isinstance(raw_item, str): raise AnsibleError( "cert(strict): CURRENT_PLAY_DOMAINS_ALL must contain only strings." ) item = raw_item.strip() if not item: raise AnsibleError( "cert(strict): CURRENT_PLAY_DOMAINS_ALL must not contain empty strings." ) cleaned.append(item) if not cleaned: raise AnsibleError("cert(strict): CURRENT_PLAY_DOMAINS_ALL must not be empty.") return cleaned class LookupModule(LookupBase): def run(self, terms, variables: dict | None = None, **kwargs): variables = variables or {} if not terms or len(terms) not in (1, 2): raise AnsibleError( "cert: one or two terms required: (domain|application_id[, want_path])" ) term = as_str(terms[0]) if not term: raise AnsibleError("cert: term is empty") want = as_str(terms[1]).strip() if len(terms) == 2 else "" domains = lookup_loader.get( "domains", loader=self._loader, templar=getattr(self, "_templar", None) ).run([], variables=variables)[0] applications = lookup_loader.get( "applications", loader=self._loader, templar=getattr(self, "_templar", None) ).run([], variables=variables)[0] enabled_default = require(variables, "TLS_ENABLED", (bool, int)) mode_default = as_str(require(variables, "TLS_MODE", str)) if mode_default not in AVAILABLE_FLAVORS: raise AnsibleError( f"cert: TLS_MODE must be one of {sorted(AVAILABLE_FLAVORS)}, got '{mode_default}'" ) forced_mode = as_str(kwargs.get("mode", "auto")).lower() app_id, primary_domain = resolve_term( term, domains=domains, applications=applications, forced_mode=forced_mode, err_prefix="cert", ) app = applications.get(app_id, {}) if not isinstance(app, dict): app = {} enabled = resolve_enabled(app, bool(enabled_default)) mode = resolve_mode(app, enabled, mode_default, err_prefix="cert") cert_file = "" key_file = "" ca_file = "" san_domains: list[str] = [] cert_id = "" scope = "app" if mode == "off": pass elif mode == "letsencrypt": le_live_raw = require(variables, "LETSENCRYPT_LIVE_PATH", str) le_live = render_strict( le_live_raw, variables=variables, var_name="LETSENCRYPT_LIVE_PATH", err_prefix="cert", ) le_name = resolve_le_name(app, primary_domain) cert_id = le_name cert_file = _join(le_live, le_name, LE_FULLCHAIN) key_file = _join(le_live, le_name, LE_PRIVKEY) all_domains = collect_domains_for_app(domains, app_id, err_prefix="cert") all_domains = ( uniq_preserve([primary_domain, *all_domains]) if all_domains else [primary_domain] ) san_override = override_san_list(app) if san_override is None: san_domains = all_domains[:] else: san_domains = uniq_preserve([primary_domain, *san_override]) elif mode == "self_signed": ss_base_raw = require(variables, "TLS_SELFSIGNED_BASE_PATH", str) ss_base = render_strict( ss_base_raw, variables=variables, var_name="TLS_SELFSIGNED_BASE_PATH", err_prefix="cert", ) ss_scope = as_str(variables.get("TLS_SELFSIGNED_SCOPE")).lower() if ss_scope not in {"app", "global"}: raise AnsibleError( "cert: TLS_SELFSIGNED_SCOPE must be 'app' or 'global'" ) scope = ss_scope if ss_scope == "global": cert_id = "global" cert_file = _join(ss_base, cert_id, LE_FULLCHAIN) key_file = _join(ss_base, cert_id, LE_PRIVKEY) san_domains = _require_current_play_domains_all_strict(variables) if primary_domain: san_domains = uniq_preserve([primary_domain, *san_domains]) else: cert_id = app_id cert_file = _join(ss_base, app_id, primary_domain, LE_FULLCHAIN) key_file = _join(ss_base, app_id, primary_domain, LE_PRIVKEY) all_domains = collect_domains_for_app( domains, app_id, err_prefix="cert" ) all_domains = ( uniq_preserve([primary_domain, *all_domains]) if all_domains else [primary_domain] ) san_override = override_san_list(app) if san_override is None: san_domains = all_domains[:] else: san_domains = uniq_preserve([primary_domain, *san_override]) else: raise AnsibleError(f"cert: unsupported mode '{mode}'") resolved: dict[str, Any] = { "application_id": app_id, "domain": primary_domain, "enabled": enabled, "mode": mode, "scope": scope, "cert_id": cert_id, "domains": {"san": san_domains}, "files": {"cert": cert_file, "key": key_file, "ca": ca_file}, } if want: return [want_get(resolved, want)] return [resolved]