171 lines
6.5 KiB
Python
171 lines
6.5 KiB
Python
"""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 = "http://zio:Zio%40Admin2026%21@101.79.17.164: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)
|