mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 02:54:26 +00:00
`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>
190 lines
6.3 KiB
Python
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)
|