feat(cicd): 자동 배포 완전 자동화 — 훅+스케줄러+수동+ITSM연동 [auto-sync]
This commit is contained in:
parent
0f8d98074a
commit
a2e9be2042
4
main.py
4
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")
|
||||
|
||||
170
routers/cicd_deploy.py
Normal file
170
routers/cicd_deploy.py
Normal 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)
|
||||
Loading…
Reference in New Issue
Block a user