mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 02:54:26 +00:00
Flatten a single list term in the service lookup so query(service, [ids]) does one applications load instead of one per id. Precompute the required service ids in sys-service-loader and gate the preload by set membership, collapsing about 30 applications loads into one. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
279 lines
9.2 KiB
Python
279 lines
9.2 KiB
Python
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.roles.applications.config import get
|
|
from utils.roles.applications.services.registry import (
|
|
build_role_to_primary_service_key,
|
|
build_service_registry_from_applications,
|
|
build_service_registry_from_roles_dir,
|
|
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
|
|
)
|
|
any_local = any(
|
|
_get_service_flag(applications, app_id, equivalent_keys, "enabled")
|
|
and not _get_service_flag(applications, app_id, equivalent_keys, "shared")
|
|
for app_id in deployed
|
|
)
|
|
return {
|
|
"enabled": any_enabled,
|
|
"shared": any_shared,
|
|
"required": any_required,
|
|
"local": any_local,
|
|
}
|
|
|
|
|
|
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
|
|
query('service', ['a', 'b', 'c']) # single list term -> one applications load for many keys
|
|
|
|
A term unknown to the play-local registry (no app in the play declares
|
|
it) falls back to the role-wide static registry read from roles_dir,
|
|
the same source the sys-service-loader iterates. Such a key resolves
|
|
with its provider role and all deployment flags computed against the
|
|
play (typically all false). Only a term unknown to BOTH registries
|
|
raises.
|
|
|
|
Optional kwarg:
|
|
roles_dir — roles directory for the static fallback registry
|
|
(default: <cwd>/roles, matching the service_registry
|
|
lookup convention).
|
|
|
|
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.
|
|
local — True if any deployed app has enabled AND NOT shared, i.e.
|
|
the service runs embedded inside that app's own compose
|
|
stack rather than centrally. Mutually exclusive with the
|
|
shared/required axis when enabled is True.
|
|
"""
|
|
|
|
def run(
|
|
self,
|
|
terms: list[Any],
|
|
variables: dict[str, Any] | None = None,
|
|
**kwargs: Any,
|
|
) -> list[dict[str, Any]]:
|
|
if not terms:
|
|
return []
|
|
|
|
if len(terms) == 1 and isinstance(terms[0], (list, tuple)):
|
|
terms = list(terms[0])
|
|
|
|
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]
|
|
|
|
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)
|
|
|
|
roles_dir = Path(kwargs.get("roles_dir") or Path.cwd() / "roles")
|
|
static_registry: dict[str, Any] | None = None
|
|
static_role_to_key: dict[str, str] | None = None
|
|
|
|
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")
|
|
|
|
registry = service_registry
|
|
try:
|
|
service_key, role = _resolve_term(
|
|
term_str, service_registry, role_to_key
|
|
)
|
|
except AnsibleError:
|
|
if static_registry is None:
|
|
static_registry = build_service_registry_from_roles_dir(roles_dir)
|
|
static_role_to_key = _build_role_to_key(static_registry)
|
|
service_key, role = _resolve_term(
|
|
term_str, static_registry, static_role_to_key
|
|
)
|
|
registry = static_registry
|
|
flags = _compute_flags(applications, group_names, registry, service_key)
|
|
results.append({"id": service_key, "role": role, **flags})
|
|
|
|
return results
|