""" GitOps Workflow Engine Git-based network automation with environment promotion and audit trails Integrates with ArgoCD/Flux for declarative deployments """ import json import logging import subprocess from datetime import datetime from pathlib import Path from typing import Any, Dict, List, Optional from dataclasses import dataclass, asdict logger = logging.getLogger(__name__) @dataclass class ChangeRequest: """Network change request""" id: str title: str description: str author: str created_at: str environment: str # dev, lab, staging, prod network_model: Dict[str, Any] # Review reviewers: List[str] approved_by: List[str] = None status: str = "draft" # draft, review, approved, deployed, failed # Git branch: str = None commit_sha: str = None pr_url: str = None def __post_init__(self): if self.approved_by is None: self.approved_by = [] def to_dict(self) -> Dict[str, Any]: return asdict(self) class GitOpsEngine: """ GitOps workflow engine for network automation """ def __init__(self, repo_path: Optional[Path] = None): """ Initialize GitOps engine Args: repo_path: Path to Git repository (defaults to current dir) """ self.repo_path = repo_path or Path.cwd() # Verify Git repository if not (self.repo_path / ".git").exists(): logger.warning(f"{self.repo_path} is not a Git repository") logger.info("Run: git init") # Environment promotion order self.environments = ["dev", "lab", "staging", "prod"] # Change requests database self.cr_db_path = self.repo_path / ".overgrowth" / "change_requests" self.cr_db_path.mkdir(parents=True, exist_ok=True) def create_change_request( self, title: str, description: str, author: str, environment: str, network_model: Dict[str, Any], reviewers: List[str] ) -> ChangeRequest: """ Create new change request Args: title: CR title description: What's changing author: Who initiated the change environment: Target environment network_model: New network model reviewers: Required reviewers Returns: ChangeRequest object """ # Validate environment if environment not in self.environments: raise ValueError(f"Invalid environment: {environment}. Must be one of {self.environments}") # Generate CR ID cr_id = f"CR-{datetime.now().strftime('%Y%m%d-%H%M%S')}" # Create branch name branch = f"change/{environment}/{cr_id}" cr = ChangeRequest( id=cr_id, title=title, description=description, author=author, created_at=datetime.now().isoformat(), environment=environment, network_model=network_model, reviewers=reviewers, branch=branch, status="draft" ) # Save CR self._save_cr(cr) logger.info(f"Created change request: {cr_id}") return cr def commit_change( self, cr: ChangeRequest, message: Optional[str] = None ) -> str: """ Commit change to Git Args: cr: Change request message: Commit message (optional) Returns: Commit SHA """ # Create branch self._git_checkout_branch(cr.branch) # Write network model to environment file env_file = self.repo_path / "environments" / cr.environment / "network.yaml" env_file.parent.mkdir(parents=True, exist_ok=True) import yaml with open(env_file, 'w') as f: yaml.dump(cr.network_model, f, default_flow_style=False) # Git add self._git_add(str(env_file)) # Git commit commit_msg = message or f"{cr.id}: {cr.title}" commit_sha = self._git_commit(commit_msg) # Update CR cr.commit_sha = commit_sha cr.status = "review" self._save_cr(cr) logger.info(f"Committed change: {commit_sha}") return commit_sha def create_pull_request( self, cr: ChangeRequest, base_branch: str = "main" ) -> str: """ Create pull request (simulated - would use GitHub/GitLab API) Args: cr: Change request base_branch: Target branch Returns: PR URL """ # In real implementation, would use GitHub API: # gh pr create --title "{cr.title}" --body "{cr.description}" --base {base_branch} --head {cr.branch} pr_url = f"https://github.com/org/repo/pull/{cr.id}" # Update CR cr.pr_url = pr_url cr.status = "review" self._save_cr(cr) logger.info(f"Created pull request: {pr_url}") return pr_url def approve_change( self, cr_id: str, reviewer: str ): """ Approve change request Args: cr_id: Change request ID reviewer: Who is approving """ cr = self._load_cr(cr_id) if reviewer not in cr.reviewers: raise ValueError(f"{reviewer} is not a reviewer for {cr_id}") if reviewer in cr.approved_by: logger.warning(f"{reviewer} already approved {cr_id}") return cr.approved_by.append(reviewer) # Check if all reviewers approved if set(cr.approved_by) == set(cr.reviewers): cr.status = "approved" logger.info(f"Change request {cr_id} fully approved") self._save_cr(cr) def merge_change( self, cr_id: str, target_branch: str = "main" ) -> str: """ Merge approved change Args: cr_id: Change request ID target_branch: Branch to merge into Returns: Merge commit SHA """ cr = self._load_cr(cr_id) if cr.status != "approved": raise ValueError(f"Cannot merge {cr_id}: not approved (status: {cr.status})") # Checkout target branch self._git_checkout(target_branch) # Merge self._git_merge(cr.branch) # Get merge commit merge_sha = self._git_log_latest() # Update CR cr.status = "deployed" self._save_cr(cr) logger.info(f"Merged {cr_id} to {target_branch}: {merge_sha}") return merge_sha def promote_to_next_environment( self, cr_id: str ) -> Optional[ChangeRequest]: """ Promote change to next environment Args: cr_id: Change request ID Returns: New CR for next environment, or None if already in prod """ cr = self._load_cr(cr_id) if cr.status != "deployed": raise ValueError(f"Cannot promote {cr_id}: not deployed yet") # Find next environment current_idx = self.environments.index(cr.environment) if current_idx >= len(self.environments) - 1: logger.info(f"{cr_id} already in prod - cannot promote further") return None next_env = self.environments[current_idx + 1] # Create new CR for next environment new_cr = self.create_change_request( title=f"Promote: {cr.title}", description=f"Promoting from {cr.environment} to {next_env}\n\nOriginal CR: {cr_id}", author=cr.author, environment=next_env, network_model=cr.network_model, reviewers=cr.reviewers ) logger.info(f"Promoted {cr_id} from {cr.environment} to {next_env}: {new_cr.id}") return new_cr def rollback_change( self, cr_id: str ) -> str: """ Rollback deployed change Args: cr_id: Change request ID to rollback Returns: Rollback commit SHA """ cr = self._load_cr(cr_id) if not cr.commit_sha: raise ValueError(f"Cannot rollback {cr_id}: no commit SHA") # Git revert self._git_checkout("main") revert_sha = self._git_revert(cr.commit_sha) logger.info(f"Rolled back {cr_id}: {revert_sha}") return revert_sha def get_deployment_history( self, environment: Optional[str] = None, limit: int = 50 ) -> List[ChangeRequest]: """ Get deployment history Args: environment: Filter by environment limit: Max number to return Returns: List of change requests """ crs = [] for cr_file in self.cr_db_path.glob("*.json"): try: with open(cr_file) as f: data = json.load(f) cr = ChangeRequest(**data) if environment and cr.environment != environment: continue crs.append(cr) except Exception as e: logger.error(f"Failed to load CR from {cr_file}: {e}") # Sort by created_at (newest first) crs.sort(key=lambda x: x.created_at, reverse=True) return crs[:limit] def generate_argocd_app( self, environment: str, namespace: str = "network-automation" ) -> Dict[str, Any]: """ Generate ArgoCD Application manifest Args: environment: Environment name namespace: Kubernetes namespace Returns: ArgoCD App manifest """ return { 'apiVersion': 'argoproj.io/v1alpha1', 'kind': 'Application', 'metadata': { 'name': f'network-{environment}', 'namespace': 'argocd' }, 'spec': { 'project': 'network-automation', 'source': { 'repoURL': 'https://github.com/org/overgrowth.git', 'targetRevision': 'main', 'path': f'environments/{environment}' }, 'destination': { 'server': 'https://kubernetes.default.svc', 'namespace': namespace }, 'syncPolicy': { 'automated': { 'prune': True, 'selfHeal': True }, 'syncOptions': [ 'CreateNamespace=true' ] } } } def generate_flux_kustomization( self, environment: str ) -> Dict[str, Any]: """ Generate Flux Kustomization manifest Args: environment: Environment name Returns: Flux Kustomization manifest """ return { 'apiVersion': 'kustomize.toolkit.fluxcd.io/v1', 'kind': 'Kustomization', 'metadata': { 'name': f'network-{environment}', 'namespace': 'flux-system' }, 'spec': { 'interval': '5m', 'path': f'./environments/{environment}', 'prune': True, 'sourceRef': { 'kind': 'GitRepository', 'name': 'overgrowth' }, 'validation': 'client', 'healthChecks': [ { 'apiVersion': 'apps/v1', 'kind': 'Deployment', 'name': 'network-controller', 'namespace': 'network-automation' } ] } } # Git helpers def _git_checkout(self, branch: str): """Checkout existing branch""" try: subprocess.run( ["git", "checkout", branch], cwd=self.repo_path, check=True, capture_output=True ) except subprocess.CalledProcessError as e: logger.error(f"Git checkout failed: {e.stderr.decode()}") raise def _git_checkout_branch(self, branch: str): """Create and checkout new branch""" try: subprocess.run( ["git", "checkout", "-b", branch], cwd=self.repo_path, check=True, capture_output=True ) except subprocess.CalledProcessError: # Branch might exist, try switching self._git_checkout(branch) def _git_add(self, file_path: str): """Git add file""" subprocess.run( ["git", "add", file_path], cwd=self.repo_path, check=True ) def _git_commit(self, message: str) -> str: """Git commit and return SHA""" subprocess.run( ["git", "commit", "-m", message], cwd=self.repo_path, check=True, capture_output=True ) return self._git_log_latest() def _git_log_latest(self) -> str: """Get latest commit SHA""" result = subprocess.run( ["git", "log", "-1", "--format=%H"], cwd=self.repo_path, check=True, capture_output=True ) return result.stdout.decode().strip() def _git_merge(self, branch: str): """Merge branch""" subprocess.run( ["git", "merge", branch, "--no-ff"], cwd=self.repo_path, check=True, capture_output=True ) def _git_revert(self, commit_sha: str) -> str: """Revert commit""" subprocess.run( ["git", "revert", commit_sha, "--no-edit"], cwd=self.repo_path, check=True, capture_output=True ) return self._git_log_latest() # CR database helpers def _save_cr(self, cr: ChangeRequest): """Save change request to database""" cr_file = self.cr_db_path / f"{cr.id}.json" with open(cr_file, 'w') as f: json.dump(cr.to_dict(), f, indent=2) def _load_cr(self, cr_id: str) -> ChangeRequest: """Load change request from database""" cr_file = self.cr_db_path / f"{cr_id}.json" if not cr_file.exists(): raise FileNotFoundError(f"Change request {cr_id} not found") with open(cr_file) as f: data = json.load(f) return ChangeRequest(**data) def create_gitops_workflow( title: str, description: str, author: str, network_model: Dict[str, Any], reviewers: List[str], start_environment: str = "dev" ) -> ChangeRequest: """ Create complete GitOps workflow Args: title: Change title description: What's changing author: Who initiated network_model: Network model to deploy reviewers: Required reviewers start_environment: Starting environment (default: dev) Returns: Initial change request """ engine = GitOpsEngine() # Create change request cr = engine.create_change_request( title=title, description=description, author=author, environment=start_environment, network_model=network_model, reviewers=reviewers ) logger.info(f"Created GitOps workflow: {cr.id}") logger.info(f"Next steps:") logger.info(f" 1. Review changes: git diff {cr.branch}") logger.info(f" 2. Request reviews from: {', '.join(reviewers)}") logger.info(f" 3. After approval, merge to deploy") logger.info(f" 4. Promote through: {' → '.join(engine.environments[engine.environments.index(start_environment):])}") return cr