"""Git Deployment Service - Handles automatic deployments from git webhooks.""" import os import json import shutil import subprocess import logging from datetime import datetime from typing import Dict, Optional from app.utils.system import app_process_env, run_checked from app.utils.git_security import git_argv, git_env, validate_ref_name from app.services.deploy_lock import with_deploy_lock logger = logging.getLogger(__name__) class GitDeployService: """Service for managing git-based deployments.""" @classmethod @with_deploy_lock('webhook deploy') def deploy(cls, app_id: int, webhook_id: int = None, commit_sha: str = None, commit_message: str = None, branch: str = None, triggered_by: str = 'webhook') -> Dict: """ Deploy an application by pulling latest code and restarting. Args: app_id: Application ID to deploy webhook_id: Optional webhook that triggered this deployment commit_sha: Git commit SHA commit_message: Git commit message branch: Git branch name triggered_by: How deployment was triggered ('webhook', 'manual', 'rollback') Returns: Dict with deployment result """ from app import db from app.models import Application, GitWebhook, GitDeployment from app.services.docker_service import DockerService # query_active: a webhook can still fire for a deleted app — this pulls # code and rebuilds/restarts containers, so a tombstone must not deploy. app = Application.query_active().filter_by(id=app_id).first() if not app: return {'success': False, 'error': 'Service not found'} webhook = GitWebhook.query.get(webhook_id) if webhook_id else None # Get next version number last_deployment = GitDeployment.query.filter_by(app_id=app_id)\ .order_by(GitDeployment.version.desc()).first() next_version = (last_deployment.version + 1) if last_deployment else 1 # Create deployment record deployment = GitDeployment( app_id=app_id, webhook_id=webhook_id, version=next_version, commit_sha=commit_sha, commit_message=commit_message, branch=branch or (webhook.source_branch if webhook else 'main'), triggered_by=triggered_by, status='running', started_at=datetime.utcnow() ) db.session.add(deployment) db.session.commit() try: # Create snapshot for rollback snapshot = cls._create_snapshot(app) deployment.snapshot_data = json.dumps(snapshot) # Also capture an immutable *config* snapshot (env keys + masked # values, domains, image, volumes, build method) for the deployment # timeline / config diff. Best-effort: never block a deploy. Note we # don't link a Deployment FK here — this path uses GitDeployment. try: from app.services.configuration_service import ConfigurationService ConfigurationService.create_snapshot(app) except Exception as cfg_err: logger.warning('Config snapshot capture failed (git deploy): %s', cfg_err) logs = [] # Step 1: Pre-deployment script if webhook and webhook.pre_deploy_script: logs.append("=== Running pre-deployment script ===") pre_result = cls._run_script(webhook.pre_deploy_script, app.root_path) deployment.pre_script_output = pre_result.get('output', '') logs.append(pre_result.get('output', '')) if not pre_result.get('success'): raise Exception(f"Pre-deployment script failed: {pre_result.get('error')}") # Step 2: Git pull logs.append("\n=== Pulling latest code ===") pull_result = cls._git_pull(app.root_path, branch or (webhook.source_branch if webhook else 'main')) logs.append(pull_result.get('output', '')) if not pull_result.get('success'): raise Exception(f"Git pull failed: {pull_result.get('error')}") if not deployment.commit_sha: # A manual deploy names no commit: record what the pull # checked out, never what was live before it. head = run_checked(['git', 'rev-parse', 'HEAD'], cwd=app.root_path, timeout=10) if head['success']: deployment.commit_sha = head['output'].strip()[:40] or None # Step 3: Restart application logs.append("\n=== Restarting application ===") restart_result = cls._standard_restart(app) deployment.deploy_output = '\n'.join(logs) + '\n' + restart_result.get('output', '') if not restart_result.get('success'): raise Exception(f"Restart failed: {restart_result.get('error')}") # Step 4: Post-deployment script if webhook and webhook.post_deploy_script: logs.append("\n=== Running post-deployment script ===") post_result = cls._run_script(webhook.post_deploy_script, app.root_path) deployment.post_script_output = post_result.get('output', '') if not post_result.get('success'): # Post-script failure is logged but doesn't fail deployment logs.append(f"Warning: Post-deployment script failed: {post_result.get('error')}") # Success deployment.status = 'success' deployment.completed_at = datetime.utcnow() deployment.duration_seconds = int( (deployment.completed_at - deployment.started_at).total_seconds() ) # Update app status app.status = 'running' app.last_deployed_at = datetime.utcnow() db.session.commit() return { 'success': True, 'deployment_id': deployment.id, 'version': deployment.version, 'duration': deployment.duration_seconds, 'message': f'Deployed version {deployment.version} successfully' } except Exception as e: deployment.status = 'failed' deployment.error_message = str(e) deployment.completed_at = datetime.utcnow() deployment.duration_seconds = int( (deployment.completed_at - deployment.started_at).total_seconds() ) db.session.commit() return { 'success': False, 'deployment_id': deployment.id, 'version': deployment.version, 'error': str(e) } @classmethod @with_deploy_lock('rollback') def rollback(cls, app_id: int, target_version: int = None) -> Dict: """ Rollback to a previous deployment version. Args: app_id: Application ID target_version: Version to rollback to (default: previous version) Returns: Dict with rollback result """ from app import db from app.models import Application, GitDeployment from app.services.docker_service import DockerService app = Application.query_active().filter_by(id=app_id).first() if not app: return {'success': False, 'error': 'Service not found'} # Get current deployment current = GitDeployment.query.filter_by(app_id=app_id, status='success')\ .order_by(GitDeployment.version.desc()).first() if not current: return {'success': False, 'error': 'No successful deployment to rollback from'} # Get target deployment if target_version: target = GitDeployment.query.filter_by( app_id=app_id, version=target_version, status='success' ).first() else: # Get previous successful deployment target = GitDeployment.query.filter_by(app_id=app_id, status='success')\ .filter(GitDeployment.version < current.version)\ .order_by(GitDeployment.version.desc()).first() if not target: return {'success': False, 'error': 'No previous version to rollback to'} if not target.snapshot_data: return {'success': False, 'error': 'Target deployment has no snapshot data'} # Create rollback deployment next_version = current.version + 1 deployment = GitDeployment( app_id=app_id, version=next_version, commit_sha=target.commit_sha, commit_message=f'Rollback to v{target.version}', branch=target.branch, triggered_by='rollback', is_rollback=True, rollback_from_version=current.version, status='running', started_at=datetime.utcnow() ) db.session.add(deployment) db.session.commit() try: # Restore snapshot snapshot = json.loads(target.snapshot_data) restore_result = cls._restore_snapshot(app, snapshot) deployment.deploy_output = restore_result.get('output', '') if not restore_result.get('success'): raise Exception(f"Snapshot restore failed: {restore_result.get('error')}") # Restart with restored config restart_result = cls._standard_restart(app) deployment.deploy_output += '\n' + restart_result.get('output', '') if not restart_result.get('success'): raise Exception(f"Restart failed: {restart_result.get('error')}") # Success deployment.status = 'success' deployment.completed_at = datetime.utcnow() deployment.duration_seconds = int( (deployment.completed_at - deployment.started_at).total_seconds() ) deployment.rolled_back_to_id = target.id # Mark current as rolled back current.rolled_back_at = datetime.utcnow() # Update app app.status = 'running' app.last_deployed_at = datetime.utcnow() db.session.commit() return { 'success': True, 'deployment_id': deployment.id, 'version': deployment.version, 'rolled_back_from': current.version, 'rolled_back_to': target.version, 'message': f'Rolled back from v{current.version} to v{target.version}' } except Exception as e: deployment.status = 'failed' deployment.error_message = str(e) deployment.completed_at = datetime.utcnow() db.session.commit() return { 'success': False, 'deployment_id': deployment.id, 'error': str(e) } @classmethod def get_deployments(cls, app_id: int, limit: int = 20) -> Dict: """Get deployment history for an application.""" from app.models import GitDeployment deployments = GitDeployment.query.filter_by(app_id=app_id)\ .order_by(GitDeployment.created_at.desc())\ .limit(limit).all() return { 'success': True, 'deployments': [d.to_dict() for d in deployments], 'count': len(deployments) } @classmethod def get_deployment(cls, deployment_id: int, include_logs: bool = False) -> Dict: """Get a specific deployment.""" from app.models import GitDeployment deployment = GitDeployment.query.get(deployment_id) if not deployment: return {'success': False, 'error': 'Deployment not found'} return { 'success': True, 'deployment': deployment.to_dict_full() if include_logs else deployment.to_dict() } @classmethod def manual_deploy(cls, app_id: int, branch: str = None) -> Dict: """Trigger a manual deployment.""" from app.models import Application, GitWebhook app = Application.query_active().filter_by(id=app_id).first() if not app: return {'success': False, 'error': 'Service not found'} # Find webhook for this app to get settings webhook = GitWebhook.query.filter_by(app_id=app_id, is_active=True).first() return cls.deploy( app_id=app_id, webhook_id=webhook.id if webhook else None, branch=branch or (webhook.source_branch if webhook else 'main'), triggered_by='manual' ) # ==================== INTERNAL HELPERS ==================== @classmethod def _git_pull(cls, path: str, branch: str = 'main') -> Dict: """Pull latest code from git.""" if not os.path.exists(path): return {'success': False, 'error': f'Path does not exist: {path}'} git_dir = os.path.join(path, '.git') if not os.path.exists(git_dir): return {'success': False, 'error': 'Not a git repository'} # The branch flows in from API bodies and webhook configs; validate it # and terminate options with '--' so it cannot be parsed as a git flag # (GHSA-8vx6-432p-h62q). ref_error = validate_ref_name(branch, 'branch') if ref_error: return {'success': False, 'error': ref_error} try: # Fetch and reset to remote branch commands = [ git_argv('fetch', 'origin', '--', branch), git_argv('reset', '--hard', f'origin/{branch}') ] output = [] for cmd in commands: result = run_checked(cmd, cwd=path, timeout=120, env=git_env()) output.append(f"$ {' '.join(cmd)}") output.append(result['output']) if result['stderr']: output.append(result['stderr']) if not result['success']: return { 'success': False, 'error': result['error'], 'output': '\n'.join(output) } return { 'success': True, 'output': '\n'.join(output) } except subprocess.TimeoutExpired: return {'success': False, 'error': 'Git pull timed out'} except Exception as e: return {'success': False, 'error': str(e)} @classmethod def _standard_restart(cls, app, log_callback=None) -> Dict: """Standard restart - validate, build, then stop and start. The default webhook path. It used to go straight to ``compose down``, so a commit that broke the compose file or the build took the site down and left it down. The preflight now validates the compose and builds the images to completion while the old containers are still serving; only then is anything stopped. """ from app.services.docker_service import DockerService from app.services import deploy_preflight_service as preflight output = [] def _log(line: str) -> None: output.append(line) if log_callback: try: log_callback(line) except Exception: pass if getattr(app, 'slot_deploys_enabled', False): # A slot app never stops its live stack: the whole # deploy — preflight, slot boot, gate, switch, watch — runs through # the deploy pipeline, which records it as a Deployment too. The # deploy lock is already held by this thread. from app.services.deployment_service import DeploymentService result = DeploymentService._deploy_locked(app.id, None, False, 'webhook', None, _log) return {'success': bool(result.get('success')), 'output': '\n'.join(output), 'error': result.get('error')} checks = preflight.preflight_compose_project(app.root_path, log=_log) if not checks.ok: # Nothing was stopped — the previous release is still serving. return { 'success': False, 'output': '\n'.join(output), 'error': checks.error, 'preflight': checks.to_dict(), } # ---- switchover: past this line the live stack is down ------------- stop_result = DockerService.compose_down(app.root_path) output.append(f"Stop: {stop_result.get('message', stop_result.get('error', 'Unknown'))}") # Build and start (the build is a cache hit — the preflight already # built these images before anything was stopped) up_result = DockerService.compose_up(app.root_path, detach=True, build=True) output.append(f"Start: {up_result.get('message', up_result.get('error', 'Unknown'))}") return { 'success': up_result.get('success', False), 'output': '\n'.join(output), 'error': up_result.get('error') } @classmethod def _run_script(cls, script: str, cwd: str) -> Dict: """Run a deployment script.""" try: result = run_checked(['bash', '-c', script], cwd=cwd, timeout=300, env=app_process_env({'DEPLOY_DIR': cwd})) return { 'success': result['success'], 'output': result['output'] + result['stderr'], 'error': result['error'], } except subprocess.TimeoutExpired: return {'success': False, 'error': 'Script timed out'} except Exception as e: return {'success': False, 'error': str(e)} @classmethod def _create_snapshot(cls, app) -> Dict: """Create a snapshot of current state for rollback.""" snapshot = { 'created_at': datetime.utcnow().isoformat(), 'app_id': app.id, 'files': {} } # Capture docker-compose.yml compose_path = os.path.join(app.root_path, 'docker-compose.yml') if os.path.exists(compose_path): with open(compose_path, 'r') as f: snapshot['files']['docker-compose.yml'] = f.read() # Capture .env env_path = os.path.join(app.root_path, '.env') if os.path.exists(env_path): with open(env_path, 'r') as f: snapshot['files']['.env'] = f.read() # Capture current git commit try: result = run_checked(['git', 'rev-parse', 'HEAD'], cwd=app.root_path, timeout=10) if result['success']: snapshot['commit_sha'] = result['output'].strip() except Exception: pass return snapshot @classmethod def _restore_snapshot(cls, app, snapshot: Dict) -> Dict: """Restore application state from snapshot.""" output = [] try: # Restore files for filename, content in snapshot.get('files', {}).items(): filepath = os.path.join(app.root_path, filename) with open(filepath, 'w') as f: f.write(content) output.append(f"Restored {filename}") # Checkout specific commit if available if snapshot.get('commit_sha'): result = run_checked(['git', 'checkout', snapshot['commit_sha']], cwd=app.root_path, timeout=60) if result['success']: output.append(f"Checked out commit {snapshot['commit_sha'][:7]}") else: output.append(f"Warning: Could not checkout commit: {result['error']}") return { 'success': True, 'output': '\n'.join(output) } except Exception as e: return { 'success': False, 'error': str(e), 'output': '\n'.join(output) }