Files
computer-playbook/plugins/lookup/application_closure.py
kevinveenbirkenbachandClaude Opus 5 9d7fc70900 perf(sys-service-loader): scope the preload to the whitelist closure
A whitelist-scoped update preloaded and fully executed every provider role
the host is a member of, because the gate only asked whether the role was in
group_names. On this inventory that meant a single-app update built compose
contexts, rendered configs and vhosts for 17 unrelated applications.

reachable_roles() is lifted out of applications_if_group_and_all_deps() so the
same traversal can start from an arbitrary seed set instead of group_names.
The old function now calls it and filters as before, so every existing caller
keeps its result. A new application_closure lookup exposes it to Ansible, and
sys-service-loader gates each preload on the whitelist's closure. An empty
whitelist yields an empty closure, which the gate reads as the full inventory
scope, so a no-apps deploy is unchanged.

Measured on mode=update apps=web-app-snipe-it: 42:49 before, 17:18 after, with
ok tasks dropping from 4903 to 2030. Nothing outside the closure ran, and
snipe-it's real dependencies (mariadb, redis, openldap, postgres, keycloak,
mailu, prometheus, seaweedfs, cdn, logout) all still did.

The closure also gates the required branch, deliberately. `required` reads as
a global property but plugins/lookup/service.py derives it from group_names,
so it means "some provisioned app needs this" and admits nearly every service.
Exempting it was measured at 42:39 against 17:18 for the same deploy, which is
the entire saving. A service whose provider lies outside the closure is by
definition required by an application outside the deploy scope.

Out of scope: seeding applications_current_play from the closure. That shrinks
CURRENT_PLAY_DOMAINS_ALL, which cert SANs, openresty vhosts, DNS records and
the webserver health check all consume at full inventory scope.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 11:31:12 +02:00

61 lines
2.0 KiB
Python

"""Lookup ``application_closure``: roles reachable from a seed application set.
Walks the same edges the constructor's role set is built from, so a
whitelist-scoped deploy and the full-inventory deploy agree on what a role
depends on. The traversal itself lives in
``utils/roles/applications/in_group_deps``; this only exposes it to Ansible.
{{ lookup('application_closure', ['web-app-snipe-it']) }}
Returns the seed roles plus every role reachable through ``meta/main.yml``
dependencies and declared service edges, transitively. An empty seed yields an
empty list, which callers read as "no scoping requested".
"""
from __future__ import annotations
from typing import Any
from ansible.errors import AnsibleError
from ansible.plugins.loader import lookup_loader
from ansible.plugins.lookup import LookupBase
from utils import PROJECT_ROOT
from utils.roles.applications.in_group_deps import reachable_roles
class LookupModule(LookupBase):
def run(
self,
terms: list[Any] | None,
variables: dict[str, Any] | None = None,
**kwargs: Any,
) -> list[list[str]]:
seeds: list[str] = []
for term in terms or []:
if isinstance(term, (list, tuple, set)):
seeds.extend(str(entry) for entry in term)
elif term:
seeds.append(str(term))
if not seeds:
return [[]]
roles_dir = kwargs.get("roles_dir") or str(PROJECT_ROOT / "roles")
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]
try:
included = reachable_roles(
applications,
seeds,
roles_dir=roles_dir,
project_root=str(PROJECT_ROOT),
)
except (TypeError, ValueError) as exc:
raise AnsibleError(f"application_closure: {exc}") from exc
return [sorted(included | set(seeds))]