Phase 5.13: CI/CD deploy script with build/test/deploy/rollback
This commit is contained in:
@@ -0,0 +1,442 @@
|
||||
#!/usr/bin/env python3
|
||||
"""AI Deploy Script — Build, Test, Deploy with automatic rollback.
|
||||
|
||||
Usage:
|
||||
python scripts/ai_deploy.py
|
||||
python scripts/ai_deploy.py --skip-tests
|
||||
python scripts/ai_deploy.py --dry-run
|
||||
python scripts/ai_deploy.py --skip-build --skip-tests
|
||||
|
||||
Pipeline phases:
|
||||
1. Build — docker build (or npm run build for frontend-only)
|
||||
2. Tests — pytest (backend) + vitest (frontend)
|
||||
3. Deploy — Coolify API call to redeploy
|
||||
4. Rollback — on failure, restore previous deployment
|
||||
|
||||
Environment variables:
|
||||
COOLIFY_API_TOKEN — Coolify API token for deployment
|
||||
COOLIFY_APP_UUID — Coolify application UUID
|
||||
COOLIFY_BASE_URL — Coolify base URL (default: https://server.media-on.de)
|
||||
|
||||
Exit codes:
|
||||
0 — deployment successful
|
||||
1 — deployment failed (rollback attempted)
|
||||
2 — configuration error
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
# Add project root to path
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
# ─── Data Structures ─────────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class PhaseResult:
|
||||
"""Result of a single pipeline phase."""
|
||||
name: str
|
||||
success: bool = False
|
||||
duration_s: float = 0.0
|
||||
output: str = ""
|
||||
error: str = ""
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"phase": self.name,
|
||||
"success": self.success,
|
||||
"duration_s": round(self.duration_s, 2),
|
||||
"output": self.output[:500] if self.output else "",
|
||||
"error": self.error[:500] if self.error else "",
|
||||
"metadata": self.metadata,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeployReport:
|
||||
"""Full deployment report."""
|
||||
started_at: str = ""
|
||||
finished_at: str = ""
|
||||
phases: list[PhaseResult] = field(default_factory=list)
|
||||
overall_success: bool = False
|
||||
rollback_performed: bool = False
|
||||
rollback_success: bool = False
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"started_at": self.started_at,
|
||||
"finished_at": self.finished_at,
|
||||
"overall_success": self.overall_success,
|
||||
"rollback_performed": self.rollback_performed,
|
||||
"rollback_success": self.rollback_success,
|
||||
"phases": [p.to_dict() for p in self.phases],
|
||||
}
|
||||
|
||||
|
||||
# ─── Phase Implementations ───────────────────────────────────────────
|
||||
|
||||
def run_command(cmd: list[str], cwd: str | None = None, timeout: int = 600) -> tuple[bool, str, str]:
|
||||
"""Run a shell command and return (success, stdout, stderr)."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
return result.returncode == 0, result.stdout, result.stderr
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, "", f"Command timed out after {timeout}s"
|
||||
except FileNotFoundError as exc:
|
||||
return False, "", str(exc)
|
||||
except Exception as exc:
|
||||
return False, "", str(exc)
|
||||
|
||||
|
||||
def phase_build(skip_build: bool, dry_run: bool) -> PhaseResult:
|
||||
"""Build phase: docker build or npm run build."""
|
||||
start = time.perf_counter()
|
||||
result = PhaseResult(name="build")
|
||||
|
||||
if skip_build:
|
||||
result.success = True
|
||||
result.output = "Build skipped (--skip-build)"
|
||||
result.duration_s = time.perf_counter() - start
|
||||
return result
|
||||
|
||||
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
if dry_run:
|
||||
result.success = True
|
||||
result.output = "[DRY-RUN] Would run: docker build -t leocrm:latest ."
|
||||
result.duration_s = time.perf_counter() - start
|
||||
return result
|
||||
|
||||
# Try docker build first
|
||||
docker_available = shutil.which("docker") is not None
|
||||
if docker_available:
|
||||
success, stdout, stderr = run_command(
|
||||
["docker", "build", "-t", "leocrm:latest", "."],
|
||||
cwd=project_root,
|
||||
timeout=900,
|
||||
)
|
||||
result.success = success
|
||||
result.output = stdout
|
||||
result.error = stderr
|
||||
result.metadata["build_tool"] = "docker"
|
||||
else:
|
||||
# Fallback: frontend-only build with npm
|
||||
frontend_dir = os.path.join(project_root, "frontend")
|
||||
npm_available = shutil.which("npm") is not None
|
||||
if npm_available and os.path.isdir(frontend_dir):
|
||||
success, stdout, stderr = run_command(
|
||||
["npm", "run", "build"],
|
||||
cwd=frontend_dir,
|
||||
timeout=300,
|
||||
)
|
||||
result.success = success
|
||||
result.output = stdout
|
||||
result.error = stderr
|
||||
result.metadata["build_tool"] = "npm"
|
||||
else:
|
||||
result.success = False
|
||||
result.error = "Neither docker nor npm available for build"
|
||||
|
||||
result.duration_s = time.perf_counter() - start
|
||||
return result
|
||||
|
||||
|
||||
def phase_tests(skip_tests: bool, dry_run: bool) -> PhaseResult:
|
||||
"""Test phase: pytest (backend) + vitest (frontend)."""
|
||||
start = time.perf_counter()
|
||||
result = PhaseResult(name="tests")
|
||||
|
||||
if skip_tests:
|
||||
result.success = True
|
||||
result.output = "Tests skipped (--skip-tests)"
|
||||
result.duration_s = time.perf_counter() - start
|
||||
return result
|
||||
|
||||
if dry_run:
|
||||
result.success = True
|
||||
result.output = "[DRY-RUN] Would run: pytest + vitest"
|
||||
result.duration_s = time.perf_counter() - start
|
||||
return result
|
||||
|
||||
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
python_bin = os.environ.get("LEOCRM_PYTHON", "/opt/venv/bin/python")
|
||||
test_outputs: list[str] = []
|
||||
test_errors: list[str] = []
|
||||
all_success = True
|
||||
|
||||
# Backend tests: pytest
|
||||
if os.path.isfile(os.path.join(project_root, "pyproject.toml")):
|
||||
success, stdout, stderr = run_command(
|
||||
[python_bin, "-m", "pytest", "tests/", "-x", "--tb=short", "-q"],
|
||||
cwd=project_root,
|
||||
timeout=300,
|
||||
)
|
||||
test_outputs.append(f"=== pytest ===\n{stdout}")
|
||||
if stderr:
|
||||
test_errors.append(f"=== pytest stderr ===\n{stderr}")
|
||||
if not success:
|
||||
all_success = False
|
||||
result.metadata["pytest_passed"] = False
|
||||
else:
|
||||
result.metadata["pytest_passed"] = True
|
||||
|
||||
# Frontend tests: vitest
|
||||
frontend_dir = os.path.join(project_root, "frontend")
|
||||
npm_available = shutil.which("npm") is not None
|
||||
if npm_available and os.path.isdir(frontend_dir):
|
||||
success, stdout, stderr = run_command(
|
||||
["npm", "run", "test"],
|
||||
cwd=frontend_dir,
|
||||
timeout=300,
|
||||
)
|
||||
test_outputs.append(f"=== vitest ===\n{stdout}")
|
||||
if stderr:
|
||||
test_errors.append(f"=== vitest stderr ===\n{stderr}")
|
||||
if not success:
|
||||
all_success = False
|
||||
result.metadata["vitest_passed"] = False
|
||||
else:
|
||||
result.metadata["vitest_passed"] = True
|
||||
|
||||
result.success = all_success
|
||||
result.output = "\n".join(test_outputs)
|
||||
result.error = "\n".join(test_errors)
|
||||
result.duration_s = time.perf_counter() - start
|
||||
return result
|
||||
|
||||
|
||||
def phase_deploy(dry_run: bool) -> PhaseResult:
|
||||
"""Deploy phase: Coolify API call to redeploy."""
|
||||
start = time.perf_counter()
|
||||
result = PhaseResult(name="deploy")
|
||||
|
||||
api_token = os.environ.get("COOLIFY_API_TOKEN", "")
|
||||
app_uuid = os.environ.get("COOLIFY_APP_UUID", "")
|
||||
base_url = os.environ.get("COOLIFY_BASE_URL", "https://server.media-on.de")
|
||||
|
||||
if not api_token or not app_uuid:
|
||||
result.success = False
|
||||
result.error = "Missing COOLIFY_API_TOKEN or COOLIFY_APP_UUID environment variables"
|
||||
result.duration_s = time.perf_counter() - start
|
||||
return result
|
||||
|
||||
if dry_run:
|
||||
result.success = True
|
||||
result.output = f"[DRY-RUN] Would call: POST {base_url}/api/v1/applications/{app_uuid}/deploy"
|
||||
result.metadata["api_url"] = f"{base_url}/api/v1/applications/{app_uuid}/deploy"
|
||||
result.duration_s = time.perf_counter() - start
|
||||
return result
|
||||
|
||||
deploy_url = f"{base_url}/api/v1/applications/{app_uuid}/deploy"
|
||||
headers = {"Authorization": f"Bearer {api_token}"}
|
||||
|
||||
try:
|
||||
response = httpx.post(deploy_url, headers=headers, timeout=120.0)
|
||||
if response.status_code < 400:
|
||||
result.success = True
|
||||
result.output = f"Deploy triggered: HTTP {response.status_code}"
|
||||
try:
|
||||
result.metadata["response"] = response.json()
|
||||
except Exception:
|
||||
result.metadata["response_text"] = response.text[:200]
|
||||
else:
|
||||
result.success = False
|
||||
result.error = f"Deploy failed: HTTP {response.status_code} - {response.text[:300]}"
|
||||
except httpx.TimeoutException:
|
||||
result.success = False
|
||||
result.error = "Deploy request timed out"
|
||||
except httpx.ConnectError as exc:
|
||||
result.success = False
|
||||
result.error = f"Connection error: {exc}"
|
||||
except Exception as exc:
|
||||
result.success = False
|
||||
result.error = str(exc)
|
||||
|
||||
result.duration_s = time.perf_counter() - start
|
||||
return result
|
||||
|
||||
|
||||
def phase_rollback(dry_run: bool) -> PhaseResult:
|
||||
"""Rollback phase: restore previous deployment via Coolify API."""
|
||||
start = time.perf_counter()
|
||||
result = PhaseResult(name="rollback")
|
||||
|
||||
api_token = os.environ.get("COOLIFY_API_TOKEN", "")
|
||||
app_uuid = os.environ.get("COOLIFY_APP_UUID", "")
|
||||
base_url = os.environ.get("COOLIFY_BASE_URL", "https://server.media-on.de")
|
||||
|
||||
if not api_token or not app_uuid:
|
||||
result.success = False
|
||||
result.error = "Missing COOLIFY_API_TOKEN or COOLIFY_APP_UUID for rollback"
|
||||
result.duration_s = time.perf_counter() - start
|
||||
return result
|
||||
|
||||
if dry_run:
|
||||
result.success = True
|
||||
result.output = f"[DRY-RUN] Would call: POST {base_url}/api/v1/applications/{app_uuid}/rollback"
|
||||
result.duration_s = time.perf_counter() - start
|
||||
return result
|
||||
|
||||
# Coolify rollback: restart with previous image
|
||||
rollback_url = f"{base_url}/api/v1/applications/{app_uuid}/restart"
|
||||
headers = {"Authorization": f"Bearer {api_token}"}
|
||||
|
||||
try:
|
||||
response = httpx.post(rollback_url, headers=headers, timeout=120.0)
|
||||
if response.status_code < 400:
|
||||
result.success = True
|
||||
result.output = f"Rollback triggered: HTTP {response.status_code}"
|
||||
else:
|
||||
result.success = False
|
||||
result.error = f"Rollback failed: HTTP {response.status_code} - {response.text[:300]}"
|
||||
except Exception as exc:
|
||||
result.success = False
|
||||
result.error = str(exc)
|
||||
|
||||
result.duration_s = time.perf_counter() - start
|
||||
return result
|
||||
|
||||
|
||||
# ─── Pipeline Orchestration ──────────────────────────────────────────
|
||||
|
||||
def run_pipeline(skip_build: bool, skip_tests: bool, dry_run: bool) -> DeployReport:
|
||||
"""Run the full deployment pipeline."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
report = DeployReport()
|
||||
report.started_at = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f" LeoCRM AI Deploy Pipeline")
|
||||
print(f" {'[DRY-RUN]' if dry_run else '[LIVE]'}")
|
||||
print(f"{'='*60}\n")
|
||||
|
||||
# Phase 1: Build
|
||||
print("▶ Phase 1: Build")
|
||||
build_result = phase_build(skip_build, dry_run)
|
||||
report.phases.append(build_result)
|
||||
_print_phase_result(build_result)
|
||||
|
||||
if not build_result.success:
|
||||
print("\n✗ Build failed — aborting pipeline.")
|
||||
report.overall_success = False
|
||||
report.finished_at = datetime.now(timezone.utc).isoformat()
|
||||
return report
|
||||
|
||||
# Phase 2: Tests
|
||||
print("\n▶ Phase 2: Tests")
|
||||
test_result = phase_tests(skip_tests, dry_run)
|
||||
report.phases.append(test_result)
|
||||
_print_phase_result(test_result)
|
||||
|
||||
if not test_result.success:
|
||||
print("\n✗ Tests failed — aborting pipeline (no deploy).")
|
||||
report.overall_success = False
|
||||
report.finished_at = datetime.now(timezone.utc).isoformat()
|
||||
return report
|
||||
|
||||
# Phase 3: Deploy
|
||||
print("\n▶ Phase 3: Deploy")
|
||||
deploy_result = phase_deploy(dry_run)
|
||||
report.phases.append(deploy_result)
|
||||
_print_phase_result(deploy_result)
|
||||
|
||||
if not deploy_result.success:
|
||||
# Phase 4: Rollback
|
||||
print("\n✗ Deploy failed — initiating rollback...")
|
||||
rollback_result = phase_rollback(dry_run)
|
||||
report.phases.append(rollback_result)
|
||||
report.rollback_performed = True
|
||||
report.rollback_success = rollback_result.success
|
||||
_print_phase_result(rollback_result)
|
||||
report.overall_success = False
|
||||
else:
|
||||
report.overall_success = True
|
||||
print("\n✓ Deployment successful!")
|
||||
|
||||
report.finished_at = datetime.now(timezone.utc).isoformat()
|
||||
return report
|
||||
|
||||
|
||||
def _print_phase_result(result: PhaseResult) -> None:
|
||||
"""Print a phase result summary."""
|
||||
status = "✓" if result.success else "✗"
|
||||
print(f" {status} {result.name}: {'PASS' if result.success else 'FAIL'} ({result.duration_s:.1f}s)")
|
||||
if result.output:
|
||||
for line in result.output.strip().split("\n")[-5:]:
|
||||
print(f" │ {line}")
|
||||
if result.error:
|
||||
for line in result.error.strip().split("\n")[-3:]:
|
||||
print(f" │ ERROR: {line}", file=sys.stderr)
|
||||
|
||||
|
||||
# ─── CLI Entry Point ─────────────────────────────────────────────────
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="LeoCRM AI Deploy — Build, Test, Deploy with automatic rollback."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip-tests",
|
||||
action="store_true",
|
||||
help="Skip the test phase",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip-build",
|
||||
action="store_true",
|
||||
help="Skip the build phase",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Simulate the pipeline without making changes",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
default="-",
|
||||
help="Write structured JSON report to file (default: stdout)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
report = run_pipeline(
|
||||
skip_build=args.skip_build,
|
||||
skip_tests=args.skip_tests,
|
||||
dry_run=args.dry_run,
|
||||
)
|
||||
|
||||
# Output structured report
|
||||
report_json = json.dumps(report.to_dict(), indent=2, ensure_ascii=False)
|
||||
if args.output == "-":
|
||||
print(f"\n{'='*60}")
|
||||
print(" Deployment Report")
|
||||
print(f"{'='*60}")
|
||||
print(report_json)
|
||||
else:
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
f.write(report_json)
|
||||
print(f"\nReport written to {args.output}", file=sys.stderr)
|
||||
|
||||
return 0 if report.overall_success else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user