188 lines
5.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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 youd 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()