mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 19:01:54 +00:00
- plugins/lookup/status.py: new lookup returning {whitelist, running, groups, all}, cached per (whitelist, groups). Consumers replace direct group_vars references.
- web-app-keycloak/filter_plugins/rbac_per_app.py: add `rbac_app_ids` (intersect input list with invokable apps; sorted, deduped).
- web-app-keycloak/tasks/update/05c_per_app_mappers.yml: drop two set_fact tasks; consume KEYCLOAK_RBAC_{ALL,RUN}_APP_IDS as constants from vars/main.yml. Split orphan-purge scope (all host-deployed) from mapper-sync scope (current run).
- Rename Ansible-side `allowed_applications` → `APPLICATIONS_WHITELIST` (group_vars/all default, deploy CLI `-e` injection, generated include `when:` clause, debug task, test asserts). Python kwarg name unchanged (internal contract).
- test-e2e-playwright: `TEST_E2E_PLAYWRIGHT_ONLY_ROLES` consumes `lookup('status').running`; `TEST_E2E_PLAYWRIGHT_APPS` moved from set_fact to vars/main.yml constant.
- 01_constructor debug: collapse to single `status:` block over the lookup result.
- web-app-{opencloud,opentalk}/vars: drop dead `group_names | default([])` + fix latent `| lower`-on-list precedence bug.
- New lint `test_no_jinja_default_on_ansible_magic_var.py`: forbid `| default(...)` on Ansible magic vars (group_names, inventory_hostname, …); meta-scan + 7 fixture positive cases.
- Unit tests for `rbac_app_ids` (10) and `status` lookup (8).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
230 lines
7.3 KiB
Python
230 lines
7.3 KiB
Python
#!/usr/bin/env python3
|
|
|
|
import os
|
|
import sys
|
|
import argparse
|
|
from collections import defaultdict, deque
|
|
|
|
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, prefixes=None):
|
|
"""
|
|
Find all roles in the given directory whose names start with
|
|
any of the provided prefixes. If prefixes is empty or None,
|
|
include all roles.
|
|
"""
|
|
for entry in os.listdir(roles_dir):
|
|
if prefixes:
|
|
if not any(entry.startswith(pref) for pref in prefixes):
|
|
continue
|
|
path = os.path.join(roles_dir, entry)
|
|
meta_file = os.path.join(path, ROLE_FILE_META_MAIN)
|
|
if os.path.isdir(path) and os.path.isfile(meta_file):
|
|
yield path, meta_file
|
|
|
|
def load_run_after(meta_file):
|
|
"""Return the role's `run_after` list."""
|
|
from utils.roles.meta_lookup import get_role_run_after
|
|
|
|
role_path = os.path.dirname(os.path.dirname(meta_file))
|
|
role_name = os.path.basename(role_path)
|
|
try:
|
|
return get_role_run_after(role_path, role_name=role_name)
|
|
except Exception:
|
|
return []
|
|
|
|
def load_application_id(role_path):
|
|
"""Load the application_id from the vars/main.yml of the role."""
|
|
vars_file = os.path.join(role_path, ROLE_FILE_VARS_MAIN)
|
|
if os.path.exists(vars_file):
|
|
data = load_yaml(vars_file)
|
|
return data.get('application_id')
|
|
return None
|
|
|
|
def build_dependency_graph(roles_dir, prefixes=None):
|
|
"""
|
|
Build a dependency graph where each key is a role name and
|
|
its value is a list of roles that depend on it.
|
|
Also return in_degree counts and the roles metadata map.
|
|
"""
|
|
graph = defaultdict(list)
|
|
in_degree = defaultdict(int)
|
|
roles = {}
|
|
|
|
for role_path, meta_file in find_roles(roles_dir, prefixes):
|
|
run_after = load_run_after(meta_file)
|
|
application_id = load_application_id(role_path)
|
|
role_name = os.path.basename(role_path)
|
|
|
|
roles[role_name] = {
|
|
'role_name': role_name,
|
|
'run_after': run_after,
|
|
'application_id': application_id,
|
|
'path': role_path
|
|
}
|
|
|
|
for dependency in run_after:
|
|
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):
|
|
"""
|
|
Detect a cycle in the run_after relations:
|
|
roles: dict mapping role_name -> { 'run_after': [...], ... }
|
|
Returns a list of role_names forming the cycle (with the start repeated at end), or None.
|
|
"""
|
|
visited = set()
|
|
stack = set()
|
|
|
|
def dfs(node, path):
|
|
visited.add(node)
|
|
stack.add(node)
|
|
path.append(node)
|
|
for dep in roles.get(node, {}).get('run_after', []):
|
|
if dep not in visited:
|
|
res = dfs(dep, path)
|
|
if res:
|
|
return res
|
|
elif dep in stack:
|
|
idx = path.index(dep)
|
|
return path[idx:] + [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):
|
|
"""
|
|
Perform topological sort on the dependency graph.
|
|
If a cycle is detected, raise an Exception with detailed debug info.
|
|
"""
|
|
|
|
queue = deque([r for r, d in in_degree.items() if d == 0])
|
|
sorted_roles = []
|
|
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):
|
|
# Something went wrong: likely a cycle
|
|
cycle = find_cycle(roles or {})
|
|
unsorted = [r for r in in_degree if r not in sorted_roles]
|
|
|
|
header = "❌ Dependency resolution failed"
|
|
if cycle:
|
|
reason = f"Circular dependency detected: {' -> '.join(cycle)}"
|
|
else:
|
|
reason = "Unresolved dependencies among roles (possible cycle or missing role)."
|
|
|
|
details = []
|
|
if unsorted:
|
|
details.append("Unsorted roles and their declared run_after dependencies:")
|
|
for r in unsorted:
|
|
deps = roles.get(r, {}).get('run_after', [])
|
|
details.append(f" - {r} depends on {deps!r}")
|
|
|
|
graph_repr = f"Full dependency graph: {dict(graph)!r}"
|
|
|
|
raise Exception("\n".join([header, reason] + details + [graph_repr]))
|
|
|
|
return sorted_roles
|
|
|
|
def print_dependency_tree(graph):
|
|
"""Print the dependency tree visually on the console."""
|
|
def print_node(role, indent=0):
|
|
print(" " * indent + role)
|
|
for dep in graph.get(role, []):
|
|
print_node(dep, indent + 1)
|
|
|
|
all_roles = set(graph.keys())
|
|
dependent = {r for deps in graph.values() for r in deps}
|
|
roots = all_roles - dependent
|
|
|
|
for root in roots:
|
|
print_node(root)
|
|
|
|
def gen_condi_role_incl(roles_dir, prefixes=None):
|
|
"""
|
|
Generate playbook entries based on the sorted order.
|
|
Raises a ValueError if application_id is missing.
|
|
"""
|
|
graph, in_degree, roles = build_dependency_graph(roles_dir, prefixes)
|
|
sorted_names = topological_sort(graph, in_degree, roles)
|
|
|
|
entries = []
|
|
for role_name in sorted_names:
|
|
role = roles[role_name]
|
|
|
|
if role.get('application_id') is None:
|
|
vars_file = os.path.join(role['path'], ROLE_FILE_VARS_MAIN)
|
|
raise ValueError(f"'application_id' missing in {vars_file}")
|
|
|
|
app_id = role['application_id']
|
|
entries.append(
|
|
f"- name: setup {app_id}\n"
|
|
f" when: ('{app_id}' | application_allowed(group_names, lookup('status').whitelist))\n"
|
|
f" include_role:\n"
|
|
f" name: {role_name}\n"
|
|
)
|
|
entries.append(
|
|
f"- name: flush handlers after {app_id}\n"
|
|
f" meta: flush_handlers\n"
|
|
)
|
|
|
|
return entries
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description='Generate an Ansible playbook include file from Docker roles, sorted by run_after order.'
|
|
)
|
|
parser.add_argument('roles_dir', help='Path to directory containing role folders')
|
|
parser.add_argument(
|
|
'-p', '--prefix',
|
|
action='append',
|
|
help='Only include roles whose names start with any of these prefixes; can be specified multiple times'
|
|
)
|
|
parser.add_argument('-o', '--output', default=None,
|
|
help='Output file path (default: stdout)')
|
|
parser.add_argument('-t', '--tree', action='store_true',
|
|
help='Display the dependency tree of roles and exit')
|
|
|
|
args = parser.parse_args()
|
|
prefixes = args.prefix or []
|
|
|
|
if args.tree:
|
|
graph, _, _ = build_dependency_graph(args.roles_dir, prefixes)
|
|
print_dependency_tree(graph)
|
|
sys.exit(0)
|
|
|
|
entries = gen_condi_role_incl(args.roles_dir, prefixes)
|
|
output = ''.join(entries)
|
|
|
|
if args.output:
|
|
os.makedirs(os.path.dirname(args.output), exist_ok=True)
|
|
with open(args.output, 'w') as f:
|
|
f.write(output)
|
|
print(f"Playbook entries written to {args.output}")
|
|
else:
|
|
print(output)
|
|
|
|
if __name__ == '__main__':
|
|
main()
|