mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-08 19:56:53 +00:00
The same failure class as 3dee332350, in the places that only became
visible once the louder messages were gone. Nine of them are class
docstrings, which the detector used to find the first round skipped
because it walked modules and functions only.
restart_condition carried a section title inside its docstring, where
docutils allows none, and the indented block below it held a second.
cert_utils indented the closing brace of its example shallower than the
body it closes.
Two are regressions of my own first round: filters lacked the blank line
between its new literal block and the paragraph after it, and
fediwall_active had the wrong one of its two colons corrected.
make i18n-extract domain=docs now finishes with zero warnings under -W.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
282 lines
9.2 KiB
Python
282 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
|