Files
computer-playbook/utils/roles/order.py
kevinveenbirkenbachandClaude Opus 5 351b64e2c5 refactor(deploy): resolve role groups at runtime instead of generating task files
`cli build include` generated one tasks/groups/<group>-roles.yml per invokable category during setup, and the stage plays included those files. They were written by the in-container UID, which is why a dedicated clean-container-owned helper had to delete them from inside the container.

The stage plays now loop over lookup('group_roles', <group>) and include tasks/utils/setup/{group,role}.yml directly. ordered_roles() in utils/roles/order.py holds the run_after topological sort the generator used to perform at build time.

utils/roles/stage.py:_group_names() no longer globs the generated files; it reads get_invokable_paths(), the same categories.yml source those files were derived from.

Measured: handlers still flush after every role (ansible-core 2.20.2, three loop items, three separate handler runs), and resolving all 18 groups costs 0.60s cold against 1.4s for the generator.

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

190 lines
6.3 KiB
Python

"""Resolve the roles of a group into their run_after order.
The play used to consume a task file per group, generated at setup time. The
order is derived from data that already lives in the roles, so it is resolved
here instead and read through the `group_roles` lookup.
"""
from __future__ import annotations
from collections import defaultdict, deque
from functools import cache
from pathlib import Path
from typing import Any
from utils.cache.yaml import load_yaml
from utils.roles.mapping import ROLE_FILE_META_MAIN, ROLE_FILE_VARS_MAIN
def find_roles(roles_dir: str | Path, prefixes: list[str] | None = None):
"""Yield ``(role_path, meta_file)`` for every role matching *prefixes*.
Args:
roles_dir: directory holding the role folders.
prefixes: name prefixes to keep; all roles when empty or None.
"""
for path in sorted(Path(roles_dir).iterdir()):
if prefixes and not any(
path.name.startswith(pref) or path.name == pref.rstrip("-")
for pref in prefixes
):
continue
meta_file = path / ROLE_FILE_META_MAIN
if path.is_dir() and meta_file.is_file():
yield path, meta_file
def load_run_after(meta_file: str | Path) -> list[str]:
"""Return the run_after list declared by the role owning *meta_file*."""
from utils.roles.meta_lookup import get_role_run_after
role_path = Path(meta_file).parent.parent.resolve()
try:
return get_role_run_after(str(role_path), role_name=role_path.name)
except Exception: # noqa: BLE001 a role without readable run_after simply has none
return []
def load_application_id(role_path: str | Path) -> str | None:
"""Return the application_id from the role's vars/main.yml, or None."""
vars_file = Path(role_path) / ROLE_FILE_VARS_MAIN
if vars_file.exists():
return load_yaml(str(vars_file)).get("application_id")
return None
def build_dependency_graph(roles_dir: str | Path, prefixes: list[str] | None = None):
"""Return ``(graph, in_degree, roles)`` over the roles matching *prefixes*.
Only run_after edges pointing inside the selected set become edges, so a
group resolves independently of the roles outside it.
"""
graph: dict[str, list[str]] = defaultdict(list)
in_degree: dict[str, int] = defaultdict(int)
roles: dict[str, dict[str, Any]] = {}
role_entries = list(find_roles(roles_dir, prefixes))
in_scope = {path.name for path, _ in role_entries}
for role_path, meta_file in role_entries:
role_name = role_path.name
in_scope_deps = [d for d in load_run_after(meta_file) if d in in_scope]
roles[role_name] = {
"role_name": role_name,
"run_after": in_scope_deps,
"application_id": load_application_id(role_path),
"path": str(role_path),
}
for dependency in in_scope_deps:
graph[dependency].append(role_name)
in_degree[role_name] += 1
if role_name not in in_degree:
in_degree[role_name] = 0
return graph, in_degree, roles
def find_cycle(roles: dict[str, dict[str, Any]]) -> list[str] | None:
"""Return a run_after cycle as a role list with the start repeated, or None."""
visited: set[str] = set()
stack: set[str] = set()
def dfs(node: str, path: list[str]) -> list[str] | None:
visited.add(node)
stack.add(node)
path.append(node)
for dep in roles.get(node, {}).get("run_after", []):
if dep not in visited:
found = dfs(dep, path)
if found:
return found
elif dep in stack:
return [*path[path.index(dep) :], dep]
stack.remove(node)
path.pop()
return None
for role in roles:
if role not in visited:
cycle = dfs(role, [])
if cycle:
return cycle
return None
def topological_sort(graph, in_degree, roles=None) -> list[str]:
"""Return the role names in run_after order.
Raises:
RuntimeError: when the graph does not resolve, naming the cycle and the
roles left unsorted so the failure is actionable.
"""
queue = deque([r for r, d in in_degree.items() if d == 0])
sorted_roles: list[str] = []
local_in = dict(in_degree)
while queue:
role = queue.popleft()
sorted_roles.append(role)
for nbr in graph.get(role, []):
local_in[nbr] -= 1
if local_in[nbr] == 0:
queue.append(nbr)
if len(sorted_roles) != len(in_degree):
cycle = find_cycle(roles or {})
unsorted = [r for r in in_degree if r not in sorted_roles]
reason = (
f"Circular dependency detected: {' -> '.join(cycle)}"
if cycle
else "Unresolved dependencies among roles (possible cycle or missing role)."
)
details = []
if unsorted:
details.append("Unsorted roles and their declared run_after dependencies:")
details += [
f" - {r} depends on {roles.get(r, {}).get('run_after', [])!r}"
for r in unsorted
]
raise RuntimeError(
"\n".join(
[
"❌ Dependency resolution failed",
reason,
*details,
f"Full dependency graph: {dict(graph)!r}",
]
)
)
return sorted_roles
@cache
def ordered_roles(roles_dir: str, group: str) -> tuple[dict[str, str], ...]:
"""Return ``({'role': …, 'app': …}, …)`` for *group*, in run_after order.
Args:
roles_dir: directory holding the role folders.
group: group name without the trailing dash, e.g. ``web-app``.
Raises:
ValueError: when a role in the group declares no application_id.
"""
graph, in_degree, roles = build_dependency_graph(roles_dir, [f"{group}-"])
entries = []
for role_name in topological_sort(graph, in_degree, roles):
role = roles[role_name]
if role.get("application_id") is None:
vars_file = Path(role["path"]) / ROLE_FILE_VARS_MAIN
raise ValueError(f"'application_id' missing in {vars_file}")
entries.append({"role": role_name, "app": role["application_id"]})
return tuple(entries)