Files
computer-playbook/utils/roles/applications/in_group_deps.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

181 lines
5.4 KiB
Python

from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
from typing import Any
from utils.cache.yaml import load_yaml_any
from utils.roles.applications.services.registry import (
build_service_registry_from_applications,
build_service_registry_from_roles_dir,
resolve_service_dependency_roles_from_config,
)
from utils.roles.mapping import ROLE_FILE_META_MAIN
MetaDepsResolver = Callable[[str, str], list[str]]
def load_service_registry(project_root: str) -> dict[str, Any]:
roles_dir = str(Path(project_root) / "roles")
if not Path(roles_dir).is_dir():
return {}
try:
return build_service_registry_from_roles_dir(Path(roles_dir))
except Exception:
return {}
def meta_deps_from_disk(role: str, roles_dir: str) -> list[str]:
meta_file = str(Path(roles_dir) / role / ROLE_FILE_META_MAIN)
if not Path(meta_file).is_file():
return []
try:
meta = load_yaml_any(meta_file, default_if_missing={}) or {}
except Exception:
return []
if not isinstance(meta, dict):
return []
deps: list[str] = []
for dep in meta.get("dependencies", []):
if isinstance(dep, str):
deps.append(dep)
elif isinstance(dep, dict):
name = dep.get("role") or dep.get("name")
if name:
deps.append(name)
return deps
def _service_dep_roles(
role: str,
applications: dict[str, Any],
service_registry: dict[str, Any],
) -> list[str]:
app_conf = applications.get(role) or {}
roles = resolve_service_dependency_roles_from_config(app_conf, service_registry)
return [dep_role for dep_role in roles if dep_role != role]
def _collect_reachable_roles(
role: str,
applications: dict[str, Any],
service_registry: dict[str, Any],
roles_dir: str,
seen: set[str],
meta_deps_resolver: MetaDepsResolver,
blocked: frozenset[str],
) -> None:
if role in blocked or role in seen:
return
seen.add(role)
for dep in meta_deps_resolver(role, roles_dir):
_collect_reachable_roles(
dep,
applications,
service_registry,
roles_dir,
seen,
meta_deps_resolver,
blocked,
)
for dep_role in _service_dep_roles(role, applications, service_registry):
_collect_reachable_roles(
dep_role,
applications,
service_registry,
roles_dir,
seen,
meta_deps_resolver,
blocked,
)
def reachable_roles(
applications: dict[str, Any],
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,
) -> 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(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")
if roles_dir is None:
roles_dir = str(Path(project_root) / "roles")
if service_registry is None:
if project_root is None:
raise ValueError(
"'project_root' is required when 'service_registry' is not provided"
)
if applications:
service_registry = build_service_registry_from_applications(applications)
else:
service_registry = load_service_registry(project_root)
elif not isinstance(service_registry, dict):
raise ValueError("'service_registry' must be a mapping")
resolver = meta_deps_resolver or meta_deps_from_disk
blocked_roles = frozenset(blocked or ())
included: set[str] = set()
for seed in seeds:
_collect_reachable_roles(
seed,
applications,
service_registry,
roles_dir,
included,
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()
if key in group_names or key in included
}