"""Enumerate consumer roles for a given service. Returns ``[{id, canonical_domain, canonical_url, iframe}, …]`` for every role whose merged applications config declares ``services..{enabled, shared}`` as truthy. ``iframe`` reflects ``services..iframe`` (defaulting to ``enabled``) so consumers can tell embeddable cards from those that must open in a new tab. A role keeps declaring the service for inventory completeness but can opt out of this consumer-target list by setting ``services..scrape: false`` or ``services..track: false``. ``scrape: false`` is used by 301-redirect-only vhosts that declare ``services.prometheus`` (so the role-wiring contract stays satisfied) yet never serve a request through lua-resty-prometheus, so they emit no ``app=""`` label and have no scrape target. ``track: false`` is the symmetric matomo opt-out: static-file (autoindex) and 301-redirect vhosts that declare ``services.matomo`` keep the role-wiring intact but carry no user-facing HTML page worth a ``_paq`` tracker, so they are dropped from ``MATOMO_TARGET_ROLES_JSON`` and the matomo tracker e2e contract. Kwargs: scope: ``host`` (default) restricts to roles in ``group_names``; ``deployment`` restricts to roles present anywhere in ``groups``, which is what a container-network consumer can actually reach; ``all`` returns every declaring role, deployed or not. direction: opt-in MCP filter. When set (``server``/``client``/``both``), only roles whose block declares that ``direction`` (or ``both``) are returned, and each entry additionally carries ``transport``, ``auth``, ``auth_subject``, ``credential`` (``owner``/``source``/ ``key``), ``allowed_consumers``, ``supported_transports``, ``supported_auths`` and an ``endpoint`` dict (``service_key``, ``path``, ``port`` taken from the referenced service's ``ports.internal`` and only then ``ports.local``: the entry is consumed to build a container-network URL, where a host-published port is always the wrong one). Callers that omit ``direction`` keep the original 4-key entries. """ from __future__ import annotations from typing import TYPE_CHECKING, Any from ansible.errors import AnsibleError from ansible.plugins.loader import lookup_loader from ansible.plugins.lookup import LookupBase from utils.domains.primary_domain import get_primary_domain from utils.roles.applications.mcp import ( DEFAULT_MCP_TRANSPORT, derive_allowed_consumers, ) from utils.roles.entity.name import get_entity_name if TYPE_CHECKING: from collections.abc import Sequence def _resolve_endpoint_port( services: dict[str, Any], endpoint: dict[str, Any] ) -> int | None: service_key = endpoint.get("service_key") port_key = endpoint.get("port_key") if not service_key or not port_key: return None target = services.get(str(service_key)) if not isinstance(target, dict): return None ports = target.get("ports") if not isinstance(ports, dict): return None for namespace in ("internal", "local"): ns = ports.get(namespace) if isinstance(ns, dict) and port_key in ns: try: return int(ns[port_key]) except (TypeError, ValueError): return None return None def _resolve_canonical_domain(role_id: str, app_config: dict[str, Any]) -> str: domains = app_config.get("domains") if not isinstance(domains, dict) or not domains.get("canonical"): return "" return get_primary_domain({role_id: domains["canonical"]}, role_id) def _deployed_roles(scope: str, vars_: dict[str, Any]) -> set[str] | None: """Return the role ids the scope admits, or None for no restriction. Args: scope: ``host``, ``deployment`` or ``all``. vars_: the templating variables, carrying ``group_names``/``groups``. """ if scope == "all": return None if scope == "deployment": groups = vars_.get("groups") if not isinstance(groups, dict): return None return {str(name) for name, hosts in groups.items() if hosts} names = vars_.get("group_names") if not isinstance(names, (list, tuple, set)) or not names: return None return {str(name) for name in names} class LookupModule(LookupBase): def run( self, terms: Sequence[Any] | None, variables: dict[str, Any] | None = None, **kwargs: Any, ) -> list[Any]: terms = list(terms or []) if len(terms) != 1: raise AnsibleError( "roles_with_service: expected exactly one term — the " "service name (e.g. 'dashboard', 'prometheus', 'matomo')." ) service_name = str(terms[0]).strip() if not service_name: raise AnsibleError("roles_with_service: service name must be non-empty") vars_ = variables or getattr(self._templar, "available_variables", {}) or {} applications = lookup_loader.get( "applications", loader=self._loader, templar=getattr(self, "_templar", None) ).run([], variables=vars_)[0] services_by_role = { role_id: config.get("services") for role_id, config in applications.items() if isinstance(config, dict) } scope = str(kwargs.get("scope", "host")).strip().lower() topic = kwargs.get("topic") direction_raw = kwargs.get("direction") direction = ( str(direction_raw).strip().lower() if direction_raw is not None else None ) deployed = _deployed_roles(scope, vars_) tls_lookup = lookup_loader.get( "tls", loader=self._loader, templar=self._templar ) results: list[dict[str, Any]] = [] for role_id, app_config in applications.items(): if not isinstance(app_config, dict): continue services = app_config.get("services") if not isinstance(services, dict): services = {} block = app_config.get(topic) if topic else services.get(service_name) if not isinstance(block, dict): continue if not bool(block.get("enabled")): continue if not bool(block.get("shared")): continue if block.get("scrape") is False: continue if block.get("track") is False: continue if direction is not None: block_direction = str(block.get("direction") or "").strip().lower() if block_direction not in (direction, "both"): continue if deployed is not None and str(role_id) not in deployed: continue if get_entity_name(str(role_id)) == service_name: continue canonical = _resolve_canonical_domain(str(role_id), app_config) if not canonical and direction is None: continue canonical_url = "" if canonical: resolved = tls_lookup.run( [str(role_id), "url.base"], variables=variables ) canonical_url = str(resolved[0]).rstrip("/") iframe = ( bool(block["iframe"]) if "iframe" in block else bool(block.get("enabled")) ) entry: dict[str, Any] = { "id": str(role_id), "canonical_domain": canonical, "canonical_url": canonical_url, "iframe": iframe, } if direction is not None: endpoint = block.get("endpoint") endpoint = endpoint if isinstance(endpoint, dict) else {} credential = block.get("credential") credential = credential if isinstance(credential, dict) else {} entry["transport"] = str( block.get("transport") or DEFAULT_MCP_TRANSPORT ) entry["auth"] = block.get("auth") entry["auth_subject"] = block.get("auth_subject") entry["credential"] = { "owner": credential.get("owner"), "source": credential.get("source"), "key": credential.get("key"), } entry["allowed_consumers"] = derive_allowed_consumers( str(role_id), services_by_role ) tools = block.get("tools") tools = tools if isinstance(tools, dict) else {} entry["tools"] = list(tools.get("allowlist") or []) entry["mutating"] = ( [] if tools.get("mutating_tools_enabled") else list(tools.get("mutating") or []) ) entry["supported_transports"] = list( block.get("supported_transports") or [] ) entry["supported_auths"] = list(block.get("supported_auths") or []) entry["endpoint"] = { "service_key": endpoint.get("service_key"), "path": endpoint.get("path"), "port": _resolve_endpoint_port(services, endpoint), } results.append(entry) results.sort(key=lambda r: r["id"]) return [results]