mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2025-07-18 06:24:25 +02:00
188 lines
5.9 KiB
Python
188 lines
5.9 KiB
Python
#!/usr/bin/env python3
|
||
import os
|
||
import argparse
|
||
import yaml
|
||
import json
|
||
from collections import deque
|
||
from typing import List, Dict, Any, Set
|
||
|
||
|
||
def find_role_meta(roles_dir: str, role: str) -> str:
|
||
path = os.path.join(roles_dir, role, 'meta', 'main.yml')
|
||
if not os.path.isfile(path):
|
||
raise FileNotFoundError(f"Metadata not found for role: {role}")
|
||
return path
|
||
|
||
|
||
def load_meta(path: str) -> Dict[str, Any]:
|
||
"""
|
||
Load meta/main.yml → return galaxy_info + run_after + dependencies
|
||
"""
|
||
with open(path, 'r') as f:
|
||
data = yaml.safe_load(f) or {}
|
||
|
||
galaxy_info = data.get('galaxy_info', {}) or {}
|
||
return {
|
||
'galaxy_info': galaxy_info,
|
||
'run_after': galaxy_info.get('run_after', []) or [],
|
||
'dependencies': data.get('dependencies', []) or []
|
||
}
|
||
|
||
def build_single_graph(
|
||
start_role: str,
|
||
dep_type: str,
|
||
direction: str,
|
||
roles_dir: str,
|
||
max_depth: int
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
Build one graph for one dependency type and direction:
|
||
- 'to': follow edges source→target
|
||
- 'from': reverse edges (find roles listing this role)
|
||
- max_depth > 0: limit hops to max_depth
|
||
- max_depth ≤ 0: stop when you’d revisit a node already on the path
|
||
"""
|
||
nodes: Dict[str, Dict[str, Any]] = {}
|
||
links: List[Dict[str, str]] = []
|
||
|
||
def traverse(role: str, depth: int, path: Set[str]):
|
||
# Register node once
|
||
if role not in nodes:
|
||
meta = load_meta(find_role_meta(roles_dir, role))
|
||
node = {'id': role}
|
||
node.update(meta['galaxy_info'])
|
||
node['doc_url'] = f"https://docs.cymais.cloud/roles/{role}/README.html"
|
||
node['source_url'] = (
|
||
f"https://github.com/kevinveenbirkenbach/cymais/tree/master/roles/{role}"
|
||
)
|
||
nodes[role] = node
|
||
|
||
# Depth guard
|
||
if max_depth > 0 and depth >= max_depth:
|
||
return
|
||
|
||
# Determine neighbors according to direction
|
||
if direction == 'to':
|
||
neighbors = load_meta(find_role_meta(roles_dir, role)).get(dep_type, [])
|
||
for tgt in neighbors:
|
||
links.append({'source': role, 'target': tgt, 'type': dep_type})
|
||
# General cycle check
|
||
if tgt in path:
|
||
continue
|
||
traverse(tgt, depth + 1, path | {tgt})
|
||
|
||
else: # direction == 'from'
|
||
# Find all roles that list this role in their dep_type
|
||
for other in os.listdir(roles_dir):
|
||
try:
|
||
meta_o = load_meta(find_role_meta(roles_dir, other))
|
||
except FileNotFoundError:
|
||
continue
|
||
if role in meta_o.get(dep_type, []):
|
||
links.append({'source': other, 'target': role, 'type': dep_type})
|
||
if other in path:
|
||
continue
|
||
traverse(other, depth + 1, path | {other})
|
||
|
||
# Kick off recursion
|
||
traverse(start_role, depth=0, path={start_role})
|
||
return {'nodes': list(nodes.values()), 'links': links}
|
||
|
||
def build_mappings(
|
||
start_role: str,
|
||
mappings: List[Dict[str, str]],
|
||
roles_dir: str,
|
||
max_depth: int
|
||
) -> Dict[str, Any]:
|
||
result: Dict[str, Any] = {}
|
||
for mapping in mappings:
|
||
for dep_type, direction in mapping.items():
|
||
key = f"{dep_type}_{direction}"
|
||
result[key] = build_single_graph(
|
||
start_role, dep_type, direction, roles_dir, max_depth)
|
||
return result
|
||
|
||
|
||
def output_graph(graph_data: Any, fmt: str, start: str, key: str):
|
||
base = f"{start}_{key}"
|
||
if fmt == 'console':
|
||
print(f"--- {base} ---")
|
||
print(yaml.safe_dump(graph_data, sort_keys=False))
|
||
elif fmt in ('yaml', 'json'):
|
||
path = f"{base}.{fmt}"
|
||
with open(path, 'w') as f:
|
||
if fmt == 'yaml':
|
||
yaml.safe_dump(graph_data, f, sort_keys=False)
|
||
else:
|
||
json.dump(graph_data, f, indent=2)
|
||
print(f"Wrote {path}")
|
||
else:
|
||
raise ValueError(f"Unknown format: {fmt}")
|
||
|
||
|
||
def main():
|
||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||
default_roles_dir = os.path.abspath(os.path.join(script_dir, '..', '..', 'roles'))
|
||
|
||
parser = argparse.ArgumentParser(
|
||
description="Generate graphs based on dependency mappings"
|
||
)
|
||
parser.add_argument(
|
||
'-r', '--role',
|
||
required=True,
|
||
help="Starting role name"
|
||
)
|
||
parser.add_argument(
|
||
'-m', '--mapping',
|
||
nargs='+',
|
||
default=[
|
||
'run_after:to',
|
||
'run_after:from',
|
||
'dependencies:to',
|
||
'dependencies:from'
|
||
],
|
||
help="Mapping entries as type:direction (default all 4 combos)"
|
||
)
|
||
parser.add_argument(
|
||
'-D', '--depth',
|
||
type=int,
|
||
default=0,
|
||
help="Max recursion depth (>0) or <=0 to stop on cycle"
|
||
)
|
||
parser.add_argument(
|
||
'-o', '--output',
|
||
choices=['yaml', 'json', 'console'],
|
||
default='console',
|
||
help="Output format"
|
||
)
|
||
parser.add_argument(
|
||
'--roles-dir',
|
||
default=default_roles_dir,
|
||
help="Roles directory"
|
||
)
|
||
args = parser.parse_args()
|
||
|
||
mappings: List[Dict[str, str]] = []
|
||
for entry in args.mapping:
|
||
if ':' not in entry:
|
||
parser.error(f"Invalid mapping '{entry}', must be type:direction")
|
||
dep_type, direction = entry.split(':', 1)
|
||
if dep_type not in ('run_after', 'dependencies'):
|
||
parser.error(f"Unknown dependency type '{dep_type}'")
|
||
if direction not in ('to', 'from'):
|
||
parser.error(f"Unknown direction '{direction}'")
|
||
mappings.append({dep_type: direction})
|
||
|
||
graphs = build_mappings(
|
||
start_role=args.role,
|
||
mappings=mappings,
|
||
roles_dir=args.roles_dir,
|
||
max_depth=args.depth
|
||
)
|
||
|
||
for key, graph_data in graphs.items():
|
||
output_graph(graph_data, args.output, args.role, key)
|
||
|
||
if __name__ == '__main__':
|
||
main()
|