diff --git a/routers/messenger.py b/routers/messenger.py index 57ebfa3..96d4f6d 100644 --- a/routers/messenger.py +++ b/routers/messenger.py @@ -446,6 +446,52 @@ async def handle_bot_command( bg.add_task(_cmd_vuln_scan, cmd.room, cmd.user, target) return BotReply(room=cmd.room, text=f"[취약점 스캔] {target} 스캔 시작 (약 30초 소요)...") + # ── /cicd [project] ─── CI/CD 전체 현황 ───────────────────────────────── + elif keyword in ("/cicd", "!cicd"): + project = parts[1] if len(parts) > 1 else None + reply = await _cmd_cicd_status(project) + return BotReply(room=cmd.room, text=reply) + + # ── /jenkins [build|status|log] ─── Jenkins 제어 ────────────────── + elif keyword in ("/jenkins", "!jenkins"): + if len(parts) < 2: + return BotReply(room=cmd.room, + text="사용법: /jenkins [build|status|log]\n" + "예) /jenkins guardia-itsm build\n" + "예) /jenkins guardia-itsm status") + job = parts[1] + action = parts[2].lower() if len(parts) > 2 else "status" + if action == "build": + bg.add_task(_cmd_jenkins_trigger, cmd.room, cmd.user, job) + return BotReply(room=cmd.room, text=f"[Jenkins] {job} 빌드 트리거 요청 중...") + else: + reply = await _cmd_jenkins_status(job, action) + return BotReply(room=cmd.room, text=reply) + + # ── /git [branch|pr|log] ─── Gitea 저장소 상태 ─────────────────── + elif keyword in ("/git", "!git"): + if len(parts) < 2: + return BotReply(room=cmd.room, + text="사용법: /git <저장소> [branch|pr|log]\n" + "예) /git guardia-itsm log\n" + "예) /git zioinfo-web pr") + repo = parts[1] + action = parts[2].lower() if len(parts) > 2 else "log" + reply = await _cmd_gitea_status(repo, action) + return BotReply(room=cmd.room, text=reply) + + # ── /release [version] ─── 릴리즈 배포 트리거 ───────────────── + elif keyword in ("/release", "!release"): + if len(parts) < 2: + return BotReply(room=cmd.room, + text="사용법: /release <프로젝트명> [버전]\n" + "예) /release guardia-itsm v2.1.0") + project = parts[1] + version = parts[2] if len(parts) > 2 else "latest" + bg.add_task(_cmd_release, cmd.room, cmd.user, project, version) + return BotReply(room=cmd.room, + text=f"[릴리즈] {project} {version} 배포 파이프라인 시작...") + # ── /help ───────────────────────────────────────────────────────────────── elif keyword == "/help": return BotReply(room=cmd.room, text=_help_text()) @@ -1371,6 +1417,241 @@ def _get_internal_token() -> str: return os.environ.get("INTERNAL_API_TOKEN", "") +# ── CI/CD 봇 명령어 헬퍼 함수 ──────────────────────────────────────────────── + +JENKINS_URL = "http://localhost:9080" # Nginx 뒤 내부 포트 +GITEA_URL = "http://localhost:9003" # Nginx 뒤 내부 포트 +JENKINS_USER = "admin" +JENKINS_TOKEN_ENV = "JENKINS_API_TOKEN" # 환경변수에서 읽기 +GITEA_USER = "zio" +GITEA_TOKEN_ENV = "GITEA_API_TOKEN" # 환경변수에서 읽기 + +import os as _os + + +def _jenkins_auth(): + token = _os.environ.get(JENKINS_TOKEN_ENV, "") + return (JENKINS_USER, token) if token else (JENKINS_USER, "") + + +def _gitea_headers(): + token = _os.environ.get(GITEA_TOKEN_ENV, "") + headers = {"Content-Type": "application/json"} + if token: + headers["Authorization"] = f"token {token}" + else: + # 토큰 없으면 Basic Auth (Gitea 기본) + import base64 + cred = base64.b64encode(b"zio:Zio@Admin2026!").decode() + headers["Authorization"] = f"Basic {cred}" + return headers + + +async def _cmd_cicd_status(project: Optional[str]) -> str: + """CI/CD 전체 현황: Jenkins 최근 빌드 + Gitea 최근 커밋.""" + lines = ["[CI/CD 현황]"] + try: + async with httpx.AsyncClient(timeout=10.0) as c: + # Jenkins: 모든 Job 목록 + r = await c.get( + f"{JENKINS_URL}/api/json?tree=jobs[name,color,lastBuild[number,result,timestamp,duration]]", + auth=_jenkins_auth(), + ) + if r.status_code == 200: + jobs = r.json().get("jobs", []) + if project: + jobs = [j for j in jobs if project.lower() in j["name"].lower()] + lines.append("\n■ Jenkins 빌드") + for j in jobs[:8]: + lb = j.get("lastBuild") or {} + result = lb.get("result", "N/A") or "진행중" + num = lb.get("number", "-") + icon = {"SUCCESS":"✅","FAILURE":"❌","UNSTABLE":"⚠️", + "ABORTED":"⛔","진행중":"🔄"}.get(result, "❓") + lines.append(f" {icon} {j['name']} #{num} — {result}") + else: + lines.append(f"\n■ Jenkins 연결 실패 (HTTP {r.status_code})") + + # Gitea: 저장소 목록 + r2 = await c.get( + f"{GITEA_URL}/api/v1/repos/search?limit=5", + headers=_gitea_headers(), + ) + if r2.status_code == 200: + repos = r2.json().get("data", []) + if project: + repos = [rep for rep in repos if project.lower() in rep["name"].lower()] + lines.append("\n■ Gitea 저장소") + for rep in repos[:5]: + updated = (rep.get("updated_at") or "")[:10] + lines.append(f" 📁 {rep['full_name']} — 최근: {updated}") + else: + lines.append(f"\n■ Gitea 연결 실패 (HTTP {r2.status_code})") + + except Exception as e: + lines.append(f"\n연결 오류: {str(e)[:80]}") + lines.append("Jenkins/Gitea JENKINS_API_TOKEN, GITEA_API_TOKEN 환경변수를 확인하세요.") + + return "\n".join(lines) + + +async def _cmd_jenkins_status(job: str, action: str) -> str: + """Jenkins 잡 상태/로그 조회.""" + try: + async with httpx.AsyncClient(timeout=15.0) as c: + if action == "log": + r = await c.get( + f"{JENKINS_URL}/job/{job}/lastBuild/consoleText", + auth=_jenkins_auth(), + ) + if r.status_code == 200: + log = r.text[-1200:] # 마지막 1200자 + return f"[Jenkins] {job} 최근 빌드 로그:\n```\n{log}\n```" + return f"[Jenkins] {job} 로그 조회 실패 (HTTP {r.status_code})" + else: + # status + r = await c.get( + f"{JENKINS_URL}/job/{job}/api/json?tree=name,color,lastBuild[number,result,timestamp,duration,url]", + auth=_jenkins_auth(), + ) + if r.status_code == 200: + d = r.json() + lb = d.get("lastBuild") or {} + result = lb.get("result", "진행중") or "진행중" + num = lb.get("number", "N/A") + dur_sec = (lb.get("duration", 0) or 0) // 1000 + icon = {"SUCCESS":"✅","FAILURE":"❌","UNSTABLE":"⚠️", + "ABORTED":"⛔","진행중":"🔄"}.get(result, "❓") + return ( + f"[Jenkins] {job}\n" + f" 최근 빌드: #{num}\n" + f" 결과: {icon} {result}\n" + f" 소요시간: {dur_sec}초\n" + f" 빌드 URL: {JENKINS_URL}/job/{job}/{num}/" + ) + return f"[Jenkins] {job} 조회 실패 (HTTP {r.status_code})\n잡 이름을 확인하세요." + except Exception as e: + return f"[Jenkins] 연결 오류: {str(e)[:100]}" + + +async def _cmd_jenkins_trigger(room: str, actor: str, job: str): + """Jenkins 빌드 트리거 (백그라운드).""" + try: + async with httpx.AsyncClient(timeout=15.0) as c: + r = await c.post( + f"{JENKINS_URL}/job/{job}/build", + auth=_jenkins_auth(), + ) + if r.status_code in (200, 201): + await _send_to_room(room, f"[Jenkins] ✅ {job} 빌드 트리거 완료 by {actor}\n빌드 상태 확인: /jenkins {job} status") + else: + await _send_to_room(room, f"[Jenkins] ❌ {job} 빌드 트리거 실패 (HTTP {r.status_code})") + except Exception as e: + await _send_to_room(room, f"[Jenkins] 연결 오류: {str(e)[:100]}") + + +async def _cmd_gitea_status(repo: str, action: str) -> str: + """Gitea 저장소 상태 조회.""" + # repo 형식: 'guardia-itsm' → 'zio/guardia-itsm' 자동 보완 + full_repo = repo if "/" in repo else f"zio/{repo}" + try: + async with httpx.AsyncClient(timeout=10.0) as c: + if action == "pr": + r = await c.get( + f"{GITEA_URL}/api/v1/repos/{full_repo}/pulls?state=open&limit=5", + headers=_gitea_headers(), + ) + if r.status_code == 200: + prs = r.json() + if not prs: + return f"[Gitea] {full_repo} — 오픈 PR 없음" + lines = [f"[Gitea] {full_repo} 오픈 PR {len(prs)}건"] + for pr in prs: + lines.append(f" #{pr['number']} {pr['title']} — {pr['user']['login']}") + return "\n".join(lines) + return f"[Gitea] PR 조회 실패 (HTTP {r.status_code})" + + elif action == "branch": + r = await c.get( + f"{GITEA_URL}/api/v1/repos/{full_repo}/branches?limit=5", + headers=_gitea_headers(), + ) + if r.status_code == 200: + branches = r.json() + lines = [f"[Gitea] {full_repo} 브랜치 목록"] + for b in branches: + commit = b.get("commit", {}).get("id", "")[:7] + lines.append(f" 🌿 {b['name']} — {commit}") + return "\n".join(lines) + return f"[Gitea] 브랜치 조회 실패 (HTTP {r.status_code})" + + else: # log (최근 커밋) + r = await c.get( + f"{GITEA_URL}/api/v1/repos/{full_repo}/commits?limit=5", + headers=_gitea_headers(), + ) + if r.status_code == 200: + commits = r.json() + lines = [f"[Gitea] {full_repo} 최근 커밋"] + for cm in commits: + sha = cm.get("sha", "")[:7] + msg = (cm.get("commit", {}).get("message") or "")[:50].split("\n")[0] + auth = cm.get("commit", {}).get("author", {}).get("name", "") + lines.append(f" {sha} {msg} — {auth}") + return "\n".join(lines) + return f"[Gitea] 커밋 조회 실패 (HTTP {r.status_code})" + except Exception as e: + return f"[Gitea] 연결 오류: {str(e)[:100]}" + + +async def _cmd_release(room: str, actor: str, project: str, version: str): + """릴리즈 배포 파이프라인 (Jenkins + 배포 서버).""" + # 저장소명 → Jenkins Job 이름 매핑 + job_map = { + "guardia-itsm": "guardia-itsm", + "guardia-manager": "guardia-manager", + "zioinfo-web": "zioinfo-web", + "guardia-messenger":"guardia-messenger", + } + job = job_map.get(project.lower(), project) + + try: + await _send_to_room(room, + f"[릴리즈] 🚀 {project} {version} 배포 파이프라인 시작 by {actor}") + + async with httpx.AsyncClient(timeout=15.0) as c: + # 1단계: Jenkins 빌드 트리거 + r = await c.post( + f"{JENKINS_URL}/job/{job}/buildWithParameters", + auth=_jenkins_auth(), + params={"VERSION": version, "ACTOR": actor}, + ) + if r.status_code in (200, 201): + await _send_to_room(room, f"[릴리즈] ✅ 1단계 — Jenkins 빌드 트리거 완료 ({job})") + else: + # 파라미터 없는 빌드 시도 + r2 = await c.post( + f"{JENKINS_URL}/job/{job}/build", + auth=_jenkins_auth(), + ) + if r2.status_code in (200, 201): + await _send_to_room(room, f"[릴리즈] ✅ 1단계 — Jenkins 빌드 트리거 완료") + else: + await _send_to_room(room, + f"[릴리즈] ⚠️ Jenkins 빌드 트리거 실패 (HTTP {r.status_code})\n" + f"수동 빌드: {JENKINS_URL}/job/{job}/") + return + + # 2단계: 완료 후 상태 확인 안내 + await _send_to_room(room, + f"[릴리즈] ℹ️ 빌드 상태 확인:\n" + f" /jenkins {job} status\n" + f" /jenkins {job} log") + + except Exception as e: + await _send_to_room(room, f"[릴리즈] 연결 오류: {str(e)[:100]}") + + def _help_text() -> str: return """GUARDiA ITSM 봇 명령어 ━━━━━━━━━━━━━━━━━━━━━━━━ @@ -1404,6 +1685,12 @@ def _help_text() -> str: /checklist → 공공기관 이행 현황 /perf [url] → 성능 테스트 +[CI/CD 파이프라인] +/cicd [project] → CI/CD 전체 현황 (Jenkins + Gitea) +/jenkins [build|status|log] → Jenkins 빌드 트리거·상태·로그 +/git [log|pr|branch] → Gitea 저장소 커밋·PR·브랜치 +/release [version] → 릴리즈 배포 파이프라인 실행 + [배포 제어] !vibe [project_id] → 바이브 코딩 세션 !build → 빌드 실행