"""GUARDiA CI/CD 자동 배포 — workspace → Gitea → 서버 배포 트리거.""" from __future__ import annotations import asyncio, logging, subprocess from datetime import datetime from pathlib import Path from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException from pydantic import BaseModel from sqlalchemy import select, desc from sqlalchemy.ext.asyncio import AsyncSession from core.auth import get_current_user, require_admin_role from database import get_db from models import User logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/cicd", tags=["CI/CD 자동배포"]) MONOREPO = Path("/opt/guardia") # 서버 소스 루트 GITEA_BASE = "https://zio:Zio%40Admin2026%21@zioinfo.co.kr:3000/zio" REPO_MAP = { "guardia-itsm": {"src": "/opt/guardia/workspace/guardia-itsm", "service": "guardia"}, "guardia-manager": {"src": "/opt/guardia/workspace/guardia-manager", "service": "guardia-manager"}, "zioinfo-web": {"src": "/opt/zioinfo/workspace/zioinfo-web", "service": "zioinfo"}, "guardia-docs": {"src": "/opt/guardia/workspace/guardia-docs", "service": None}, } # 배포 이력 (메모리 — 재시작 시 초기화) _deploy_history: list[dict] = [] def _run(cmd: str | list, timeout: int = 120) -> tuple[bool, str]: try: r = subprocess.run( cmd, shell=isinstance(cmd, str), capture_output=True, text=True, timeout=timeout ) return r.returncode == 0, (r.stdout + r.stderr)[:500] except Exception as e: return False, str(e) async def _deploy_repo(repo_name: str, triggered_by: str = "manual"): """workspace → Gitea push → webhook 트리거.""" entry = { "repo": repo_name, "triggered_by": triggered_by, "started_at": datetime.utcnow().isoformat(), "status": "running", "steps": [] } _deploy_history.insert(0, entry) if len(_deploy_history) > 50: _deploy_history.pop() def step(name: str, cmd): ok, out = _run(cmd) entry["steps"].append({"name": name, "ok": ok, "out": out[:200]}) logger.info("[cicd:%s] %s → %s", repo_name, name, "OK" if ok else "FAIL") return ok info = REPO_MAP.get(repo_name) if not info: entry["status"] = "error" entry["steps"].append({"name": "validate", "ok": False, "out": "알 수 없는 repo"}) return src = info["src"] repos_dir = f"/opt/guardia/repos/{repo_name}" try: # 1. repos/ clone (없는 경우) import os if not os.path.isdir(f"{repos_dir}/.git"): ok = step("clone", f"git clone {GITEA_BASE}/{repo_name}.git {repos_dir}") if not ok: entry["status"] = "failed"; return # 2. workspace → repos/ 동기화 ok = step("sync", ( f"rsync -a --delete " f"--exclude='.git' --exclude='__pycache__' --exclude='*.pyc' " f"--exclude='node_modules' --exclude='.env' --exclude='dist/' " f"{src}/ {repos_dir}/" )) if not ok: entry["status"] = "failed"; return # 3. git commit + push → Gitea webhook 트리거 ts = datetime.utcnow().strftime("%Y-%m-%d %H:%M") ok = step("commit", f"bash -c 'cd {repos_dir} && git add -A && (git diff --cached --quiet || git commit -m \"auto-deploy {ts}\")'") ok2 = step("push", f"bash -c 'cd {repos_dir} && git remote set-url origin {GITEA_BASE}/{repo_name}.git && git push origin main'") entry["status"] = "success" if (ok and ok2) else "partial" # 4. 메신저 알림 try: import httpx async with httpx.AsyncClient(timeout=5) as c: await c.post("http://127.0.0.1:9001/api/messenger/webhook", json={ "event": "cicd_deploy", "room": "ops", "message": f"🚀 {repo_name} 배포 {'완료' if ok2 else '실패'} (by {triggered_by})", }) except Exception: pass except Exception as e: entry["status"] = "error" entry["steps"].append({"name": "exception", "ok": False, "out": str(e)}) entry["finished_at"] = datetime.utcnow().isoformat() class DeployRequest(BaseModel): repo: str note: str = "" @router.post("/deploy") async def deploy( req: DeployRequest, background_tasks: BackgroundTasks, user: User = Depends(require_admin_role), ): """수동 배포 트리거.""" if req.repo not in REPO_MAP and req.repo != "all": raise HTTPException(400, detail=f"지원 repo: {list(REPO_MAP)} 또는 'all'") repos = list(REPO_MAP) if req.repo == "all" else [req.repo] for r in repos: background_tasks.add_task(_deploy_repo, r, user.username) return {"ok": True, "repos": repos, "message": "배포 시작됨 (백그라운드)"} @router.get("/status") async def deploy_status(user: User = Depends(get_current_user)): """최근 배포 상태 (최대 20건).""" return {"history": _deploy_history[:20]} @router.get("/repos") async def list_repos(user: User = Depends(get_current_user)): """배포 가능한 repo 목록.""" return {"repos": list(REPO_MAP.keys())} @router.post("/check-and-deploy") async def check_and_deploy( background_tasks: BackgroundTasks, user: User = Depends(require_admin_role), ): """workspace ↔ Gitea 차이 감지 → 변경된 repo만 배포.""" background_tasks.add_task(_check_and_deploy_all, user.username) return {"ok": True, "message": "변경 감지 + 자동 배포 시작됨 (백그라운드)"} async def _check_and_deploy_all(triggered_by: str = "schedule"): """모든 repo 변경 감지 → 배포.""" for repo_name in REPO_MAP: src = REPO_MAP[repo_name]["src"] repos_dir = f"/opt/guardia/repos/{repo_name}" try: import os, hashlib if not os.path.isdir(src): continue # workspace와 repos/ 사이 diff 체크 (간단히 파일 수/mtime 비교) src_count = sum(1 for _ in Path(src).rglob("*") if _.is_file() and ".git" not in str(_)) dest_count = sum(1 for _ in Path(repos_dir).rglob("*") if _.is_file() and ".git" not in str(_)) if os.path.isdir(repos_dir) else 0 if abs(src_count - dest_count) > 0: logger.info("[cicd:check] %s 변경 감지 — 배포 시작", repo_name) await _deploy_repo(repo_name, triggered_by) except Exception as e: logger.warning("[cicd:check] %s 오류: %s", repo_name, e)