#!/usr/bin/env python3 """Orchestrator: fetch Datadog data, analyze all OWS services, produce dashboard-data.json. Usage: python -m src.orchestrator --env prod python -m src.orchestrator --env prod --service ows-users python -m src.orchestrator --env prod --skip-datadog # reuse saved DD data python -m src.orchestrator --env prod --no-update # skip git pull """ import argparse import json import sys from datetime import datetime, timezone from pathlib import Path from src.analyze_endpoints import ( EndpointAnalysis, detect_framework, run_analysis, write_all_endpoints_csv, write_dead_endpoints_csv, write_summary_json, ) from src.constant_resolver import build_constant_map from src.datadog_client import ServiceInfo, get_resource_stats, get_service_info, list_ows_services from src.repo_manager import ensure_repo from src.service_validator import validate_service, validate_service_github PROJECT_ROOT = Path(__file__).resolve().parent.parent DATA_DIR = PROJECT_ROOT / 'data' DEFAULT_REPO_BASE = Path('/Users/ratoui/work/ows') # Services that live outside the default repo base REPO_OVERRIDES: dict[str, Path] = {} def load_services_file(path: Path) -> list[str]: """Read service names from a file (one per line, ignores empty lines and #comments).""" names = [] for line in path.read_text().splitlines(): line = line.strip().split(',')[0].strip() # handle CSV (take first column) if line and not line.startswith('#'): names.append(line) return names def resolve_repo_path(service_name: str, repo_base: Path) -> Path | None: """Resolve the local repo path for a service (without cloning).""" if service_name in REPO_OVERRIDES: return REPO_OVERRIDES[service_name] path = repo_base / service_name if path.exists(): return path return None def build_details(analyses: list[EndpointAnalysis]) -> list[dict]: """Convert analyses to the dashboard detail format.""" details = [] for a in sorted(analyses, key=lambda x: (x.path, x.method)): details.append( { 'method': a.method, 'path': a.path, 'handler': a.handler, 'file': a.file, 'line': a.line, 'status': a.status, 'dead_functions': a.dead_functions, } ) return details def build_service_result(service: ServiceInfo, framework: str, analyses: list[EndpointAnalysis], summary: dict) -> dict: """Build the per-service JSON structure for the dashboard.""" return { 'name': service.name, 'framework': framework, 'repo_url': service.repo_url, 'team': service.team, 'endpoints': summary['endpoints'], 'functions': summary['functions'], 'details': build_details(analyses), 'dead_functions': summary['dead_functions'], } def run( env: str, repo_base: Path, single_service: str | None = None, services_list: list[str] | None = None, skip_datadog: bool = False, no_update: bool = False, ): """Run the full analysis pipeline.""" print(f'Fetching service list from Datadog catalog (env={env})...') if single_service: services = [get_service_info(single_service)] elif services_list: services = [get_service_info(s) for s in services_list] else: services = list_ows_services(env) print(f'Found {len(services)} services: {", ".join(s.name for s in services)}') results = [] skipped: list[tuple[str, str]] = [] for svc in services: print(f'\n{"=" * 60}') print(f'Processing {svc.name}...') # Phase 1: Ensure repo exists and is up-to-date if no_update: repo_path = resolve_repo_path(svc.name, repo_base) if not repo_path: print(f' SKIP: repo not found at {repo_base / svc.name}') continue else: # Pre-validate via GitHub before cloning if repo isn't local yet if not (repo_base / svc.name).exists(): is_valid, reason = validate_service_github(svc.name) if not is_valid: print(f' SKIP: not a web service ({reason})') skipped.append((svc.name, reason)) continue try: repo_path = ensure_repo(svc.name, repo_base) except Exception as e: print(f' SKIP: could not ensure repo: {e}', file=sys.stderr) continue if not repo_path.exists(): print(f' SKIP: repo not found at {repo_path}') continue # Validate: is this actually a web service with endpoints? is_valid, reason = validate_service(repo_path) if not is_valid: print(f' SKIP: not a web service ({reason})') skipped.append((svc.name, reason)) continue # Detect framework framework = detect_framework(repo_path) print(f' Framework: {framework}') # Phase 2: Fetch or load Datadog data service_dir = DATA_DIR / svc.name service_dir.mkdir(parents=True, exist_ok=True) if skip_datadog: dd_resources_path = service_dir / 'dd-resources.json' if dd_resources_path.exists(): with open(dd_resources_path) as f: dd_names = set(json.load(f)) print(f' Loaded {len(dd_names)} resources from saved DD data') else: print(f' SKIP: no saved DD data at {dd_resources_path}') continue else: print(' Fetching Datadog resource names...') try: raw_data, dd_names = get_resource_stats(svc.name, env, framework=framework) print(f' Got {len(dd_names)} resources from Datadog') # Save raw DD data for offline debugging with open(service_dir / 'dd-raw-response.json', 'w') as f: json.dump(raw_data, f, indent=2) with open(service_dir / 'dd-resources.json', 'w') as f: json.dump(sorted(dd_names), f, indent=2) except Exception as e: print(f' ERROR fetching Datadog data: {e}', file=sys.stderr) continue # Phase 3: Build constant map from config files constant_map = build_constant_map(repo_path) if constant_map: print(f' Resolved {len(constant_map)} path constants from config') # Run analysis print(' Analyzing endpoints...') analyses, summary = run_analysis( repo_path, dd_names, framework=framework, constant_map=constant_map, ) ep = summary['endpoints'] fn = summary['functions'] print(f' Endpoints: {ep["total"]} total, {ep["used"]["count"]} used, {ep["unused"]["count"]} unused') print(f' Functions: {fn["total"]} tracked, {fn["dead"]} dead ({fn["dead_percent"]}%)') service_result = build_service_result(svc, framework, analyses, summary) results.append(service_result) # Persist per-service data write_all_endpoints_csv(analyses, service_dir / 'all_endpoints.csv') write_dead_endpoints_csv(analyses, service_dir / 'dead_endpoints.csv') write_summary_json(summary, service_dir / 'summary.json') with open(service_dir / 'service-result.json', 'w') as f: json.dump(service_result, f, indent=2) print(f' Data saved to: {service_dir}/') # Build aggregate dashboard from all persisted service results all_services = [] for service_dir in sorted(DATA_DIR.iterdir()): result_file = service_dir / 'service-result.json' if service_dir.is_dir() and result_file.exists(): with open(result_file) as f: all_services.append(json.load(f)) dashboard_data = { 'generated_at': datetime.now(timezone.utc).isoformat(), 'env': env, 'services': all_services, } output_path = DATA_DIR / 'dashboard-data.json' with open(output_path, 'w') as f: json.dump(dashboard_data, f, indent=2) print(f'\n{"=" * 60}') print(f'Dashboard data written to: {output_path}') print(f'Services in dashboard: {len(all_services)} (analyzed {len(results)} this run)') total_endpoints = sum(s['endpoints']['total'] for s in results) total_dead_funcs = sum(s['functions']['dead'] for s in results) print(f'Total endpoints: {total_endpoints}') print(f'Total dead functions: {total_dead_funcs}') if skipped: print(f'\nSkipped {len(skipped)} non-web-service repos:') for name, reason in skipped: print(f' {name}: {reason}') def main(): parser = argparse.ArgumentParser(description='Orchestrate OWS endpoint analysis') parser.add_argument('--env', default='prod', help='Datadog environment (default: prod)') parser.add_argument('--repo-base', type=Path, default=DEFAULT_REPO_BASE, help='Base directory containing OWS repos') parser.add_argument('--service', type=str, default=None, help='Analyze a single service instead of all') parser.add_argument('--services', type=Path, default=None, help='File with service names (one per line)') parser.add_argument('--skip-datadog', action='store_true', help='Skip DD fetch, reuse saved dd-resources.json') parser.add_argument('--no-update', action='store_true', help='Skip git clone/pull, use repos as-is') args = parser.parse_args() services_list = load_services_file(args.services) if args.services else None run( env=args.env, repo_base=args.repo_base, single_service=args.service, services_list=services_list, skip_datadog=args.skip_datadog, no_update=args.no_update, ) if __name__ == '__main__': main()