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>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5 committed 2026-08-06 11:31:12 +02:00
1 parent 0b192dac99
commit 9d7fc70900
5 files changed
+178 -9

No files matched your search

+60
View File
@@ -0,0 +1,60 @@
"""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))]
@@ -4,3 +4,4 @@
when:
- lookup('vars', service_run_once_var, default='__missing__') == '__missing__'
- (service_key in _required_service_ids) or (service_role in group_names)
- (_preload_closure | length == 0) or (service_role in _preload_closure)
+4
View File
@@ -40,6 +40,10 @@
| list
}}
- name: "🔍 Scope the preload to the whitelist's dependency closure"
ansible.builtin.set_fact:
_preload_closure: "{{ lookup('application_closure', lookup('deployment').whitelist) }}"
- name: "🔌 Load shared service"
include_tasks: load_service.yml
vars:
@@ -0,0 +1,72 @@
"""Unit tests for the whitelist-scoped dependency closure."""
from __future__ import annotations
import unittest
from utils.roles.applications import in_group_deps as deps_mod
SERVICE_REGISTRY = {
"mariadb": {"role": "svc-db-mariadb"},
"ldap": {"role": "svc-db-openldap"},
"postgres": {"role": "svc-db-postgres"},
}
def _uses(service_key):
return {"services": {service_key: {"enabled": True, "shared": True}}}
APPS = {
"web-app-alpha": _uses("mariadb"),
"web-app-beta": _uses("postgres"),
"web-app-solo": {},
"svc-db-mariadb": _uses("ldap"),
"svc-db-openldap": {},
"svc-db-postgres": {},
}
def _resolver(mapping):
return lambda role, roles_dir: (mapping or {}).get(role, [])
class TestReachableRoles(unittest.TestCase):
def _closure(self, seeds, meta_deps=None):
return deps_mod.reachable_roles(
APPS,
seeds,
project_root="/unused",
roles_dir="/unused",
service_registry=SERVICE_REGISTRY,
meta_deps_resolver=_resolver(meta_deps),
)
def test_no_seed_reaches_nothing(self):
self.assertEqual(self._closure([]), set())
def test_an_app_without_dependencies_reaches_only_itself(self):
self.assertEqual(self._closure(["web-app-solo"]), {"web-app-solo"})
def test_service_edges_are_followed_transitively(self):
self.assertEqual(
self._closure(["web-app-alpha"]),
{"web-app-alpha", "svc-db-mariadb", "svc-db-openldap"},
)
def test_an_unrelated_app_stays_outside_the_closure(self):
self.assertNotIn("web-app-beta", self._closure(["web-app-alpha"]))
self.assertNotIn("svc-db-postgres", self._closure(["web-app-alpha"]))
def test_meta_dependencies_are_followed(self):
closure = self._closure(
["web-app-solo"], meta_deps={"web-app-solo": ["svc-db-postgres"]}
)
self.assertEqual(closure, {"web-app-solo", "svc-db-postgres"})
def test_seeding_every_app_reaches_every_app(self):
self.assertEqual(self._closure(sorted(APPS)), set(APPS))
if __name__ == "__main__":
unittest.main()
+41 -9
View File
@@ -94,20 +94,31 @@ def _collect_reachable_roles(
)
def applications_if_group_and_all_deps(
def reachable_roles(
applications: dict[str, Any],
group_names: list[str],
seeds: list[str],
*,
project_root: str | None = None,
roles_dir: str | None = None,
service_registry: dict[str, Any] | None = None,
meta_deps_resolver: MetaDepsResolver | None = None,
blocked: frozenset[str] | set[str] | None = None,
) -> dict[str, Any]:
) -> set[str]:
"""Return every role reachable from ``seeds`` through meta and service edges.
Args:
applications: the merged applications map.
seeds: role ids to start the traversal from.
project_root: repository root, used to derive ``roles_dir`` and registry.
roles_dir: roles directory, when it is not derived from ``project_root``.
service_registry: prebuilt registry, otherwise built from the inputs.
meta_deps_resolver: reads a role's ``meta/main.yml`` dependencies.
blocked: roles the traversal must not enter.
"""
if not isinstance(applications, dict):
raise TypeError("'applications' must be a mapping")
if not isinstance(group_names, list):
raise TypeError("'group_names' must be a list")
if not isinstance(seeds, list):
raise TypeError("'seeds' must be a list")
if not project_root and not roles_dir:
raise ValueError("'project_root' or 'roles_dir' must be provided")
@@ -126,21 +137,42 @@ def applications_if_group_and_all_deps(
elif not isinstance(service_registry, dict):
raise ValueError("'service_registry' must be a mapping")
meta_deps_resolver = meta_deps_resolver or meta_deps_from_disk
resolver = meta_deps_resolver or meta_deps_from_disk
blocked_roles = frozenset(blocked or ())
included: set[str] = set()
for group in group_names:
for seed in seeds:
_collect_reachable_roles(
group,
seed,
applications,
service_registry,
roles_dir,
included,
meta_deps_resolver,
resolver,
blocked_roles,
)
return included
def applications_if_group_and_all_deps(
applications: dict[str, Any],
group_names: list[str],
*,
project_root: str | None = None,
roles_dir: str | None = None,
service_registry: dict[str, Any] | None = None,
meta_deps_resolver: MetaDepsResolver | None = None,
blocked: frozenset[str] | set[str] | None = None,
) -> dict[str, Any]:
included = reachable_roles(
applications,
group_names,
project_root=project_root,
roles_dir=roles_dir,
service_registry=service_registry,
meta_deps_resolver=meta_deps_resolver,
blocked=blocked,
)
return {
key: cfg
for key, cfg in applications.items()