diff --git a/main.py b/main.py index 49a873a..a12197f 100644 --- a/main.py +++ b/main.py @@ -449,6 +449,10 @@ app.include_router(auto_finetune.router) # 미래준비 — LoRA 자동 파 app.include_router(self_report.router) # 독립지원 — 자율 주간 보고서 app.include_router(independence_meter.router) # 독립지원 — 자립도 측정·추적 +# ── CI/CD 자동 배포 ────────────────────────────────────────────────────────── +from routers import cicd_deploy +app.include_router(cicd_deploy.router) # workspace → Gitea → 서버 배포 트리거 + # ── 개방망 보안 헤더 미들웨어 ──────────────────────────────────────────────── @app.middleware("http") diff --git a/routers/cicd_deploy.py b/routers/cicd_deploy.py new file mode 100644 index 0000000..cd3bb60 --- /dev/null +++ b/routers/cicd_deploy.py @@ -0,0 +1,170 @@ +"""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)