feat(cicd): 자동 배포 완전 자동화 — 훅+스케줄러+수동+ITSM연동 [auto-sync]

This commit is contained in:
GUARDiA AutoDeploy 2026-06-03 15:11:57 +09:00 committed by DESKTOP-TKLFCPR\ython
parent 0f8d98074a
commit a2e9be2042
2 changed files with 174 additions and 0 deletions

View File

@ -449,6 +449,10 @@ app.include_router(auto_finetune.router) # 미래준비 — LoRA 자동 파
app.include_router(self_report.router) # 독립지원 — 자율 주간 보고서 app.include_router(self_report.router) # 독립지원 — 자율 주간 보고서
app.include_router(independence_meter.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") @app.middleware("http")

170
routers/cicd_deploy.py Normal file
View File

@ -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)