Files
computer-playbook/plugins/lookup/service.py
T
kevinveenbirkenbachandClaude Opus 4.7 85f1caccc7 refactor(utils): consolidate role-scoped helpers under utils/roles and split templating
Tighten the utils namespace so each subpackage owns one concern:

- utils/applications/* moves under utils/roles/applications/* (helpers
  that operate on the runtime applications dict and meta/services.yml).
- utils/service_registry.py and utils/database_service.py move under
  utils/roles/applications/services/ as registry.py and database.py.
- utils/entity_name_utils.py becomes utils/roles/entity_name.py.
- utils/invokable.py, utils/valid_deploy_id.py, and the existing
  utils/roles/resource_validation.py group under utils/roles/validation/
  as invokable.py, deploy_id.py, and resources.py.
- utils/templating.py and utils/jinja_strict.py split into
  utils/templating/ansible.py (Templar + TrustedAsTemplate, lookup-env
  shortcut) and utils/templating/jinja.py (vanilla Jinja2 Environment,
  multi-pass strict render).

Every caller in utils, cli, plugins, roles/<role>/{filter,lookup}_plugins,
tests, and the docs that link to the moved files is updated to the new
import paths. Test files mirror the new source layout
(tests/unit/utils/{roles,templating}/...).

New READMEs at utils/cleanup/, utils/roles/, utils/roles/applications/services/,
utils/roles/validation/, and utils/templating/ follow the
documentation.md and readme_md.md guidelines.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 03:53:43 +02:00

233 lines
7.1 KiB
Python

from __future__ import annotations
from typing import Any
from ansible.errors import AnsibleError
from ansible.plugins.lookup import LookupBase
from utils.cache.applications import get_merged_applications
from utils.roles.applications.config import get
from utils.roles.applications.services.registry import (
build_role_to_primary_service_key,
build_service_registry_from_applications,
canonical_service_key,
equivalent_service_keys,
)
def _get_service_flag(
applications: dict[str, Any],
app_id: str,
service_keys: list[str],
flag: str,
) -> bool:
return any(
bool(
get(
applications=applications,
application_id=app_id,
config_path=f"services.{service_key}.{flag}",
strict=False,
default=False,
skip_missing_app=True,
)
)
for service_key in service_keys
)
def _get_enabled_service_keys(
applications: dict[str, Any],
app_id: str,
) -> list[str]:
services = get(
applications=applications,
application_id=app_id,
config_path="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],
service_key: str,
) -> str | None:
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
return None
def _is_service_required(
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.
Exposed via the ``required`` flag on the ``service`` lookup result.
"""
if app_id in visited:
return False
visited.add(app_id)
service_keys = equivalent_service_keys(service_registry, service_key)
enabled = _get_service_flag(applications, app_id, service_keys, "enabled")
shared = _get_service_flag(applications, app_id, service_keys, "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, svc
)
if (
dep_app_id
and dep_app_id in applications
and _is_service_required(
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]:
return build_role_to_primary_service_key(service_registry)
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", "")
return term, str(role)
if term in role_to_key:
key = role_to_key[term]
entry = service_registry[key]
role = entry.get("role", "")
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]
equivalent_keys = equivalent_service_keys(service_registry, service_key)
any_enabled = any(
_get_service_flag(applications, app_id, equivalent_keys, "enabled")
for app_id in deployed
)
any_shared = any(
_get_service_flag(applications, app_id, equivalent_keys, "shared")
for app_id in deployed
)
primary_key = canonical_service_key(service_registry, service_key)
any_required = any(
_is_service_required(applications, service_registry, app_id, primary_key, set())
for app_id in deployed
)
return {"enabled": any_enabled, "shared": any_shared, "required": any_required}
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' and 'group_names' from Ansible variables and discovers
service providers from the role-local services metadata.
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 services.<key>.enabled: true
shared — True if any deployed app has services.<key>.shared: true
required — True if any deployed app has both enabled AND shared (direct
or transitively via its own enabled service dependencies).
"Required" was chosen over "needed" to express that the
service is contractually required by a real consumer, not
merely convenient.
"""
def run(
self,
terms: list[Any],
variables: dict[str, Any] | None = None,
**kwargs: Any,
) -> list[dict[str, Any]]:
if not terms:
return []
vars_ = variables or getattr(self._templar, "available_variables", {}) or {}
applications = get_merged_applications(
variables=vars_,
roles_dir=kwargs.get("roles_dir"),
templar=getattr(self, "_templar", None),
)
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 = build_service_registry_from_applications(applications)
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