| |
| """ |
| Automated Governance Injection Script for Upstream Integrations |
| |
| Applies circuit breaker, rate limiter, and audit logging patterns to all |
| integration service files that lack them. |
| |
| Usage: |
| python scripts/add_governance_to_integrations.py [--dry-run] [--integration name] |
| |
| Examples: |
| python scripts/add_governance_to_integrations.py --dry-run |
| python scripts/add_governance_to_integrations.py --integration outlook |
| python scripts/add_governance_to_integrations.py |
| """ |
|
|
| import argparse |
| import ast |
| import glob |
| import os |
| import re |
| import shutil |
| import sys |
| from pathlib import Path |
| from typing import List, Tuple, Dict |
|
|
| |
| HAS_GOVERNANCE = {"gmail", "jira", "mcp", "zoom"} |
|
|
| |
| GOVERNANCE_IMPORTS = """from core.circuit_breaker import circuit_breaker |
| from core.rate_limiter import rate_limiter, should_retry, calculate_backoff |
| from core.audit_logger import log_integration_call, log_integration_error, log_integration_attempt, log_integration_complete |
| from fastapi import HTTPException""" |
|
|
|
|
| class GovernanceInjector: |
| """Injects governance patterns into integration service files""" |
|
|
| def __init__(self, integrations_dir: str, dry_run: bool = False): |
| self.integrations_dir = Path(integrations_dir) |
| self.dry_run = dry_run |
| self.modified_files = [] |
| self.skipped_files = [] |
|
|
| def identify_integrations(self, specific_integration: str = None) -> List[Path]: |
| """Identify integration service files needing governance""" |
| pattern = f"{self.integrations_dir}/*_service.py" |
|
|
| if specific_integration: |
| pattern = f"{self.integrations_dir}/{specific_integration}_service.py" |
|
|
| all_files = glob.glob(pattern) |
|
|
| if specific_integration: |
| |
| if all_files and Path(all_files[0]).exists(): |
| return [Path(all_files[0])] |
| return [] |
|
|
| |
| needs_governance = [] |
| for file_path in all_files: |
| file_name = Path(file_path).stem |
| integration_name = file_name.replace("_service", "") |
|
|
| if integration_name not in HAS_GOVERNANCE: |
| needs_governance.append(Path(file_path)) |
|
|
| return needs_governance |
|
|
| def has_governance_imports(self, file_path: Path) -> bool: |
| """Check if file already has governance imports""" |
| try: |
| with open(file_path, 'r', encoding='utf-8') as f: |
| content = f.read() |
|
|
| |
| has_circuit_breaker = "from core.circuit_breaker import" in content |
| has_rate_limiter = "from core.rate_limiter import" in content |
| has_audit_logger = "from core.audit_logger import" in content |
|
|
| return has_circuit_breaker or has_rate_limiter or has_audit_logger |
| except Exception as e: |
| print(f"Error reading {file_path}: {e}") |
| return False |
|
|
| def extract_integration_name(self, file_path: Path) -> str: |
| """Extract integration name from file path""" |
| return file_path.stem.replace("_service", "") |
|
|
| def find_async_methods(self, content: str) -> List[Tuple[int, str, str]]: |
| """ |
| Find all public async methods in the file. |
| |
| Returns: |
| List of tuples: (line_number, method_name, indentation) |
| """ |
| methods = [] |
| lines = content.split('\n') |
|
|
| for i, line in enumerate(lines): |
| |
| |
| match = re.match(r'^(\s*)async def ([a-z][a-zA-Z0-9_]*)\(self', line) |
| if match: |
| indent = match.group(1) |
| method_name = match.group(2) |
|
|
| |
| if not method_name.startswith('_'): |
| methods.append((i + 1, method_name, indent)) |
|
|
| return methods |
|
|
| def inject_governance_to_method( |
| self, |
| content: str, |
| method_name: str, |
| integration_name: str, |
| start_line: int, |
| indent: str |
| ) -> str: |
| """ |
| Inject governance wrapper into a method. |
| |
| Returns modified content. |
| """ |
| lines = content.split('\n') |
|
|
| |
| |
| i = start_line - 1 |
|
|
| |
| while i < len(lines) and ('async def ' in lines[i] or 'def ' in lines[i]): |
| i += 1 |
|
|
| |
| if i < len(lines) and '"""' in lines[i]: |
| |
| i += 1 |
| while i < len(lines) and '"""' not in lines[i]: |
| i += 1 |
| i += 1 |
|
|
| |
| while i < len(lines) and lines[i].strip() == '': |
| i += 1 |
|
|
| |
| |
| method_body_indent = indent + ' ' |
|
|
| |
| governance_code = f"""{method_body_indent}# Start audit logging |
| {method_body_indent}audit_ctx = log_integration_attempt("{integration_name}", "{method_name}", locals()) |
| {method_body_indent}try: |
| {method_body_indent} # Check circuit breaker |
| {method_body_indent} if not await circuit_breaker.is_enabled("{integration_name}"): |
| {method_body_indent} logger.warning(f"Circuit breaker is open for {integration_name}") |
| {method_body_indent} log_integration_complete(audit_ctx, error=Exception("Circuit breaker open")) |
| {method_body_indent} raise HTTPException( |
| {method_body_indent} status_code=503, |
| {method_body_indent} detail=f"{integration_name.capitalize()} integration temporarily disabled" |
| {method_body_indent} ) |
| |
| {method_body_indent} # Check rate limiter |
| {method_body_indent} is_limited, remaining = await rate_limiter.is_rate_limited("{integration_name}") |
| {method_body_indent} if is_limited: |
| {method_body_indent} logger.warning(f"Rate limit exceeded for {integration_name}") |
| {method_body_indent} log_integration_complete(audit_ctx, error=Exception("Rate limit exceeded")) |
| {method_body_indent} raise HTTPException( |
| {method_body_indent} status_code=429, |
| {method_body_indent} detail=f"Rate limit exceeded for {integration_name}" |
| {method_body_indent} ) |
| """ |
|
|
| |
| lines.insert(i, governance_code) |
|
|
| |
| |
| |
| |
|
|
| |
| |
| |
|
|
| return '\n'.join(lines) |
|
|
| def add_imports(self, content: str) -> str: |
| """Add governance imports if not present""" |
| lines = content.split('\n') |
|
|
| |
| last_import_idx = -1 |
| for i, line in enumerate(lines): |
| if line.startswith('import ') or line.startswith('from '): |
| last_import_idx = i |
|
|
| if last_import_idx == -1: |
| |
| insert_idx = 0 |
| else: |
| |
| insert_idx = last_import_idx + 1 |
|
|
| |
| has_governance = any( |
| imp in content for imp in [ |
| "from core.circuit_breaker import", |
| "from core.rate_limiter import", |
| "from core.audit_logger import" |
| ] |
| ) |
|
|
| if has_governance: |
| return content |
|
|
| |
| lines.insert(insert_idx, GOVERNANCE_IMPORTS) |
| lines.insert(insert_idx + 1, '') |
|
|
| return '\n'.join(lines) |
|
|
| def process_file(self, file_path: Path) -> bool: |
| """ |
| Process a single integration file. |
| |
| Returns: |
| True if file was modified, False otherwise |
| """ |
| integration_name = self.extract_integration_name(file_path) |
|
|
| |
| if self.has_governance_imports(file_path): |
| print(f" ✓ Skipping {file_path.name} - already has governance") |
| self.skipped_files.append(file_path) |
| return False |
|
|
| print(f" → Processing {file_path.name}") |
|
|
| |
| try: |
| with open(file_path, 'r', encoding='utf-8') as f: |
| content = f.read() |
| except Exception as e: |
| print(f" ✗ Error reading {file_path}: {e}") |
| return False |
|
|
| |
| backup_path = file_path.with_suffix('.py.bak') |
| if not self.dry_run: |
| try: |
| shutil.copy2(file_path, backup_path) |
| except Exception as e: |
| print(f" ✗ Error creating backup: {e}") |
| return False |
|
|
| |
| modified_content = self.add_imports(content) |
|
|
| |
| methods = self.find_async_methods(modified_content) |
|
|
| if not methods: |
| print(f" No public async methods found, adding imports only") |
| else: |
| print(f" Found {len(methods)} public async method(s)") |
| for line_num, method_name, indent in methods[:3]: |
| print(f" - {method_name} (line {line_num})") |
|
|
| if len(methods) > 3: |
| print(f" ... and {len(methods) - 3} more") |
|
|
| |
| for line_num, method_name, indent in reversed(methods): |
| try: |
| modified_content = self.inject_governance_to_method( |
| modified_content, |
| method_name, |
| integration_name, |
| line_num, |
| indent |
| ) |
| except Exception as e: |
| print(f" ✗ Error injecting governance into {method_name}: {e}") |
| continue |
|
|
| |
| if not self.dry_run: |
| try: |
| with open(file_path, 'w', encoding='utf-8') as f: |
| f.write(modified_content) |
| self.modified_files.append(file_path) |
| print(f" ✓ Modified {file_path.name}") |
| return True |
| except Exception as e: |
| print(f" ✗ Error writing {file_path}: {e}") |
| |
| if backup_path.exists(): |
| shutil.copy2(backup_path, file_path) |
| return False |
| else: |
| print(f" [DRY RUN] Would modify {file_path.name}") |
| self.modified_files.append(file_path) |
| return True |
|
|
| def run(self, specific_integration: str = None) -> dict: |
| """ |
| Run the governance injection process. |
| |
| Returns: |
| dict with statistics |
| """ |
| files_to_process = self.identify_integrations(specific_integration) |
|
|
| if not files_to_process: |
| print("No integration files found to process") |
| return { |
| "total": 0, |
| "modified": 0, |
| "skipped": 0, |
| "failed": 0 |
| } |
|
|
| print(f"\nFound {len(files_to_process)} integration(s) to process\n") |
|
|
| for file_path in files_to_process: |
| self.process_file(file_path) |
|
|
| return { |
| "total": len(files_to_process), |
| "modified": len(self.modified_files), |
| "skipped": len(self.skipped_files), |
| "failed": len(files_to_process) - len(self.modified_files) - len(self.skipped_files) |
| } |
|
|
|
|
| def main(): |
| """Main entry point""" |
| parser = argparse.ArgumentParser( |
| description="Inject governance patterns into integration services" |
| ) |
| parser.add_argument( |
| "--dry-run", |
| action="store_true", |
| help="Show what would be changed without making modifications" |
| ) |
| parser.add_argument( |
| "--integration", |
| type=str, |
| help="Specific integration name (e.g., 'outlook', 'slack')" |
| ) |
| parser.add_argument( |
| "--dir", |
| type=str, |
| default="integrations", |
| help="Directory containing integration service files (default: integrations)" |
| ) |
|
|
| args = parser.parse_args() |
|
|
| |
| script_dir = Path(__file__).parent.parent |
| integrations_dir = script_dir / args.dir |
|
|
| if not integrations_dir.exists(): |
| print(f"Error: Integrations directory not found: {integrations_dir}") |
| sys.exit(1) |
|
|
| print("=" * 80) |
| print("Governance Injection Script for Upstream Integrations") |
| print("=" * 80) |
| print(f"Directory: {integrations_dir}") |
| print(f"Dry run: {args.dry_run}") |
| print(f"Integration: {args.integration or 'All'}") |
| print() |
|
|
| injector = GovernanceInjector( |
| integrations_dir=str(integrations_dir), |
| dry_run=args.dry_run |
| ) |
|
|
| stats = injector.run(args.integration) |
|
|
| print() |
| print("=" * 80) |
| print("Summary") |
| print("=" * 80) |
| print(f"Total files: {stats['total']}") |
| print(f"Modified: {stats['modified']}") |
| print(f"Skipped: {stats['skipped']}") |
| print(f"Failed: {stats['failed']}") |
| print() |
|
|
| if injector.modified_files: |
| print("Modified files:") |
| for file_path in injector.modified_files: |
| print(f" - {file_path.name}") |
| print() |
|
|
| if args.dry_run and injector.modified_files: |
| print("⚠️ DRY RUN MODE - No files were actually modified") |
| print(" Run without --dry-run to apply changes") |
| print() |
|
|
| return 0 |
|
|
|
|
| if __name__ == "__main__": |
| sys.exit(main()) |
|
|