Files
computer-playbook/plugins/lookup/service.py
T

255 lines
8.2 KiB
Python

from __future__ import annotations
from typing import Any, Dict, List, Optional, Set, Tuple
from ansible.errors import AnsibleError
from ansible.plugins.lookup import LookupBase
from utils.config_utils import get_app_conf
def _get_service_flag(
applications: Dict[str, Any],
app_id: str,
service_key: str,
flag: str,
) -> bool:
return bool(
get_app_conf(
applications=applications,
application_id=app_id,
config_path=f"compose.services.{service_key}.{flag}",
strict=False,
default=False,
skip_missing_app=True,
)
)
def _get_enabled_service_keys(
applications: Dict[str, Any],
app_id: str,
) -> List[str]:
services = get_app_conf(
applications=applications,
application_id=app_id,
config_path="compose.services",
strict=False,
default={},
skip_missing_app=True,
)
if not isinstance(services, dict):
return []
return [
k
for k, v in services.items()
if isinstance(v, dict) and v.get("enabled", False)
]
def _resolve_service_provider_app_id(
applications: Dict[str, Any],
service_registry: Dict[str, Any],
app_id: str,
service_key: str,
) -> Optional[str]:
"""Resolve a service key to the provider role/application id.
Transitive service recursion must follow SERVICE_REGISTRY so short keys such
as ``asset`` recurse into ``web-svc-asset`` instead of requiring callers to
use full application ids inside compose.services.
"""
entry = service_registry.get(service_key)
if not isinstance(entry, dict):
return None
role = entry.get("role")
if isinstance(role, str) and role:
return role
role_template = entry.get("role_template")
if not isinstance(role_template, str) or not role_template:
return None
service_type = get_app_conf(
applications=applications,
application_id=app_id,
config_path=f"compose.services.{service_key}.type",
strict=False,
default="",
skip_missing_app=True,
)
if not isinstance(service_type, str) or not service_type:
return None
return role_template.replace("{type}", service_type)
def _is_service_needed(
applications: Dict[str, Any],
service_registry: Dict[str, Any],
app_id: str,
service_key: str,
visited: Set[str],
) -> bool:
"""
Return True if app_id directly or transitively (via its enabled services)
has service_key with both enabled: true AND shared: true.
Uses visited to prevent infinite loops.
"""
if app_id in visited:
return False
visited.add(app_id)
enabled = _get_service_flag(applications, app_id, service_key, "enabled")
shared = _get_service_flag(applications, app_id, service_key, "shared")
if enabled and shared:
return True
for svc in _get_enabled_service_keys(applications, app_id):
if svc == service_key:
continue
dep_app_id = _resolve_service_provider_app_id(
applications, service_registry, app_id, svc
)
if dep_app_id and dep_app_id in applications:
if _is_service_needed(
applications, service_registry, dep_app_id, service_key, visited
):
return True
return False
def _build_role_to_key(service_registry: Dict[str, Any]) -> Dict[str, str]:
"""Map each role name to its canonical service key.
When multiple keys share the same role, the canonical key is determined by
the 'canonical' field on alias entries. The primary key (no 'canonical'
field) is used as the fallback so that role-based reverse lookup always
returns a single, deterministic id.
"""
result: Dict[str, str] = {}
for key, entry in service_registry.items():
if not isinstance(entry, dict) or "role" not in entry:
continue
role = entry["role"]
canonical_key = entry.get("canonical", key)
result[role] = canonical_key
return result
def _resolve_term(
term: str,
service_registry: Dict[str, Any],
role_to_key: Dict[str, str],
) -> Tuple[str, str]:
"""
Resolve a term (service key or role name) to (service_key, role).
Raises AnsibleError if the term is not a known key or role.
"""
if term in service_registry:
entry = service_registry[term]
role = entry.get("role") or entry.get("role_template", "")
return term, str(role)
if term in role_to_key:
key = role_to_key[term]
entry = service_registry[key]
role = entry.get("role") or entry.get("role_template", "")
return key, str(role)
raise AnsibleError(
f"service: '{term}' is neither a known service key nor a known role name. "
f"Known keys: {sorted(service_registry)}. "
f"Known roles: {sorted(role_to_key)}."
)
def _compute_flags(
applications: Dict[str, Any],
group_names: List[str],
service_registry: Dict[str, Any],
service_key: str,
) -> Dict[str, bool]:
deployed = [app_id for app_id in group_names if app_id in applications]
any_enabled = any(
_get_service_flag(applications, app_id, service_key, "enabled")
for app_id in deployed
)
any_shared = any(
_get_service_flag(applications, app_id, service_key, "shared")
for app_id in deployed
)
any_needed = any(
_is_service_needed(applications, service_registry, app_id, service_key, set())
for app_id in deployed
)
return {"enabled": any_enabled, "shared": any_shared, "needed": any_needed}
class LookupModule(LookupBase):
"""
Resolve a service by key or role name and return its aggregated deployment flags.
Usage:
lookup('service', 'matomo')
lookup('service', 'web-app-matomo') # resolved via reverse mapping
Reads 'applications', 'group_names', and 'SERVICE_REGISTRY' from Ansible variables.
The 'SERVICE_REGISTRY' variable is the canonical key → role mapping from
group_vars/all/20_services.yml and is automatically available in all plays.
Returns a dict per term:
id — canonical service key (e.g. 'matomo')
role — provider role name (e.g. 'web-app-matomo')
enabled — True if any deployed app has compose.services.<key>.enabled: true
shared — True if any deployed app has compose.services.<key>.shared: true
needed — True if any deployed app has both enabled AND shared (direct or
transitively via its own enabled service dependencies)
"""
def run(
self,
terms: List[Any],
variables: Optional[Dict[str, Any]] = None,
**kwargs: Any,
) -> List[Dict[str, Any]]:
if not terms:
return []
vars_ = variables or getattr(self._templar, "available_variables", {}) or {}
applications = kwargs.get("applications", vars_.get("applications"))
if not isinstance(applications, dict):
raise AnsibleError(
"service: required variable 'applications' must be a mapping"
)
group_names = kwargs.get("group_names", vars_.get("group_names", []))
if not isinstance(group_names, list):
raise AnsibleError(
"service: required variable 'group_names' must be a list"
)
service_registry = kwargs.get("service_registry", vars_.get("SERVICE_REGISTRY"))
if not isinstance(service_registry, dict):
raise AnsibleError(
"service: required variable 'SERVICE_REGISTRY' must be a mapping "
"(loaded from group_vars/all/20_services.yml)"
)
role_to_key = _build_role_to_key(service_registry)
results: List[Dict[str, Any]] = []
for term in terms:
term_str = str(term).strip()
if not term_str:
raise AnsibleError("service: service key/role must not be empty")
service_key, role = _resolve_term(term_str, service_registry, role_to_key)
flags = _compute_flags(
applications, group_names, service_registry, service_key
)
results.append({"id": service_key, "role": role, **flags})
return results