482 lines
17 KiB
Python
482 lines
17 KiB
Python
"""
|
|
ChatOps 확장 라우터 — 채널별 webhook, 인터랙티브, 브로드캐스트, 통계
|
|
|
|
지원 채널: kakao | slack | internal
|
|
지원 명령어: /sr create, /status, /deploy, /approve, /report, /patch, /workflow
|
|
|
|
엔드포인트:
|
|
POST /api/chatops/webhook/{channel} — 채널별 webhook 수신
|
|
GET /api/chatops/commands — 명령어 목록
|
|
POST /api/chatops/interactive — 인터랙티브 버튼 처리
|
|
GET /api/chatops/channels — 연동 채널 현황
|
|
POST /api/chatops/broadcast — 전 채널 공지
|
|
GET /api/chatops/stats — 사용 통계
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from datetime import datetime, timedelta
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Path, Query
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import select, func, and_, 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 ChatOpsCommand, User
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/api/chatops", tags=["ChatOps Extended"])
|
|
|
|
# ── 지원 채널 정의 ────────────────────────────────────────────────────────────
|
|
|
|
SUPPORTED_CHANNELS = {
|
|
"kakao": {"name": "카카오워크", "enabled": True, "icon": "💬"},
|
|
"slack": {"name": "Slack", "enabled": True, "icon": "🟢"},
|
|
"internal": {"name": "내부 메신저", "enabled": True, "icon": "🏢"},
|
|
}
|
|
|
|
# ── 지원 명령어 목록 ──────────────────────────────────────────────────────────
|
|
|
|
COMMAND_DEFINITIONS = [
|
|
{
|
|
"command": "/sr create",
|
|
"description": "서비스 요청 생성",
|
|
"usage": "/sr create <제목> <내용>",
|
|
"example": "/sr create 서버 재시작 web-01 서버를 재시작해주세요",
|
|
"roles": ["ENGINEER", "PM", "ADMIN"],
|
|
},
|
|
{
|
|
"command": "/status",
|
|
"description": "SR 상태 조회",
|
|
"usage": "/status [SR-ID]",
|
|
"example": "/status SR-2026-001",
|
|
"roles": ["ENGINEER", "PM", "ADMIN"],
|
|
},
|
|
{
|
|
"command": "/deploy",
|
|
"description": "배포 실행 요청",
|
|
"usage": "/deploy <프로젝트명> <환경>",
|
|
"example": "/deploy guardia-itsm prod",
|
|
"roles": ["ENGINEER", "ADMIN"],
|
|
},
|
|
{
|
|
"command": "/approve",
|
|
"description": "SR 또는 배포 승인",
|
|
"usage": "/approve <SR-ID|배포ID>",
|
|
"example": "/approve SR-2026-001",
|
|
"roles": ["PM", "ADMIN"],
|
|
},
|
|
{
|
|
"command": "/report",
|
|
"description": "운영 리포트 요청",
|
|
"usage": "/report [daily|weekly|monthly]",
|
|
"example": "/report daily",
|
|
"roles": ["PM", "ADMIN"],
|
|
},
|
|
{
|
|
"command": "/patch",
|
|
"description": "보안 패치 적용 요청",
|
|
"usage": "/patch <CVE-ID> <서버명>",
|
|
"example": "/patch CVE-2024-1234 web-01",
|
|
"roles": ["ENGINEER", "ADMIN"],
|
|
},
|
|
{
|
|
"command": "/workflow",
|
|
"description": "자율 워크플로우 실행",
|
|
"usage": "/workflow <워크플로우명> [인수...]",
|
|
"example": "/workflow restart-service web-01 tomcat",
|
|
"roles": ["ENGINEER", "ADMIN"],
|
|
},
|
|
]
|
|
|
|
|
|
# ── Pydantic 스키마 ───────────────────────────────────────────────────────────
|
|
|
|
class WebhookPayload(BaseModel):
|
|
"""채널에서 수신하는 webhook 페이로드."""
|
|
user_id: str
|
|
message: str
|
|
room_id: Optional[str] = None
|
|
extra: Optional[Dict[str, Any]] = None
|
|
|
|
|
|
class InteractivePayload(BaseModel):
|
|
"""인터랙티브 버튼 클릭 처리 페이로드."""
|
|
action_id: str # 버튼 액션 ID (approve_sr / reject_sr / view_detail 등)
|
|
target_id: str # 대상 리소스 ID
|
|
user_id: str
|
|
channel: str = "internal"
|
|
extra: Optional[Dict[str, Any]] = None
|
|
|
|
|
|
class BroadcastRequest(BaseModel):
|
|
"""전 채널 공지 요청."""
|
|
message: str
|
|
title: Optional[str] = None
|
|
channels: Optional[List[str]] = None # None이면 활성 전체 채널
|
|
priority: str = "NORMAL" # NORMAL | HIGH | CRITICAL
|
|
|
|
|
|
class CommandOut(BaseModel):
|
|
command: str
|
|
description: str
|
|
usage: str
|
|
example: str
|
|
roles: List[str]
|
|
|
|
|
|
class ChannelStatus(BaseModel):
|
|
channel: str
|
|
name: str
|
|
enabled: bool
|
|
icon: str
|
|
total_cmds: int
|
|
success_rate: float
|
|
|
|
|
|
class ChatOpsStats(BaseModel):
|
|
total_commands: int
|
|
commands_today: int
|
|
success_rate: float
|
|
top_commands: List[Dict[str, Any]]
|
|
top_users: List[Dict[str, Any]]
|
|
channel_breakdown: Dict[str, int]
|
|
|
|
|
|
# ── 명령어 파서 ───────────────────────────────────────────────────────────────
|
|
|
|
def _parse_command(message: str) -> Optional[Dict[str, Any]]:
|
|
"""메시지에서 슬래시 명령어를 파싱한다."""
|
|
stripped = message.strip()
|
|
if not stripped.startswith("/"):
|
|
return None
|
|
|
|
parts = stripped.split(None, 2) # ['/cmd', 'sub', '나머지']
|
|
if not parts:
|
|
return None
|
|
|
|
cmd_part = parts[0].lower() # '/sr'
|
|
sub_cmd = parts[1].lower() if len(parts) > 1 else ""
|
|
args = parts[2] if len(parts) > 2 else ""
|
|
|
|
# 두 단어 명령어 매칭 (/sr create)
|
|
full_cmd = f"{cmd_part} {sub_cmd}".strip()
|
|
for defn in COMMAND_DEFINITIONS:
|
|
if defn["command"] == full_cmd:
|
|
return {"command": full_cmd, "args": args.strip()}
|
|
|
|
# 단일 단어 명령어 매칭 (/status, /report, /patch, /workflow, /approve, /deploy)
|
|
for defn in COMMAND_DEFINITIONS:
|
|
base = defn["command"].split()[0]
|
|
if base == cmd_part:
|
|
rest = (sub_cmd + " " + args).strip()
|
|
return {"command": cmd_part, "args": rest}
|
|
|
|
return {"command": cmd_part, "args": (sub_cmd + " " + args).strip()}
|
|
|
|
|
|
async def _execute_command(
|
|
parsed: Dict[str, Any],
|
|
user_id: str,
|
|
channel: str,
|
|
db: AsyncSession,
|
|
) -> str:
|
|
"""파싱된 명령어를 실행하고 응답 텍스트를 반환한다."""
|
|
cmd = parsed["command"]
|
|
args = parsed["args"]
|
|
|
|
if cmd == "/sr create":
|
|
parts = args.split(None, 1) if args else []
|
|
title = parts[0] if parts else "미제목 SR"
|
|
desc = parts[1] if len(parts) > 1 else ""
|
|
return f"SR 접수 완료. 제목: {title}\n설명: {desc}\n담당자 자동 배정 중..."
|
|
|
|
if cmd in ("/status",):
|
|
sr_id = args.strip()
|
|
if sr_id:
|
|
return f"{sr_id} 상태를 조회합니다. /api/tasks/{sr_id} 에서 확인하세요."
|
|
return "SR 전체 현황: /api/dashboard/stats 에서 확인하세요."
|
|
|
|
if cmd in ("/deploy",):
|
|
parts = args.split() if args else []
|
|
project = parts[0] if parts else "unknown"
|
|
env = parts[1] if len(parts) > 1 else "dev"
|
|
return f"배포 요청 등록: {project} → {env} 환경. PM 승인 후 실행됩니다."
|
|
|
|
if cmd in ("/approve",):
|
|
target = args.strip()
|
|
if not target:
|
|
return "승인 대상 ID를 입력하세요. 예) /approve SR-2026-001"
|
|
return f"{target} 승인 처리 완료. 엔지니어에게 알림 발송됩니다."
|
|
|
|
if cmd in ("/report",):
|
|
period = args.strip() or "daily"
|
|
return f"{period} 운영 리포트 생성 중... /api/report/generate 에서 확인하세요."
|
|
|
|
if cmd in ("/patch",):
|
|
parts = args.split() if args else []
|
|
cve = parts[0] if parts else "CVE-미지정"
|
|
server = parts[1] if len(parts) > 1 else "전체"
|
|
return f"보안 패치 요청: {cve} → {server}. 패치 계획이 생성되었습니다."
|
|
|
|
if cmd in ("/workflow",):
|
|
parts = args.split(None, 1) if args else []
|
|
wf_name = parts[0] if parts else "unknown"
|
|
wf_args = parts[1] if len(parts) > 1 else ""
|
|
return f"워크플로우 실행: {wf_name}({wf_args}). /api/autonomous/status 에서 확인하세요."
|
|
|
|
return f"알 수 없는 명령어: {cmd}. /api/chatops/commands 에서 지원 명령어를 확인하세요."
|
|
|
|
|
|
# ── 엔드포인트 ────────────────────────────────────────────────────────────────
|
|
|
|
@router.post("/webhook/{channel}")
|
|
async def receive_webhook(
|
|
channel: str = Path(..., description="채널 ID: kakao|slack|internal"),
|
|
payload: WebhookPayload = ...,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""채널별 webhook 수신 및 명령어 처리."""
|
|
channel_lower = channel.lower()
|
|
if channel_lower not in SUPPORTED_CHANNELS:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"지원하지 않는 채널입니다: {channel}. 지원 채널: {list(SUPPORTED_CHANNELS.keys())}"
|
|
)
|
|
if not SUPPORTED_CHANNELS[channel_lower]["enabled"]:
|
|
raise HTTPException(status_code=503, detail=f"{channel} 채널이 비활성 상태입니다.")
|
|
|
|
parsed = _parse_command(payload.message)
|
|
success = parsed is not None
|
|
args_str = parsed["args"] if parsed else None
|
|
cmd_str = parsed["command"] if parsed else payload.message[:200]
|
|
|
|
response_text = ""
|
|
if parsed:
|
|
try:
|
|
response_text = await _execute_command(parsed, payload.user_id, channel_lower, db)
|
|
except Exception as exc:
|
|
logger.warning(f"ChatOps 명령 실행 오류: {exc}")
|
|
response_text = "명령 처리 중 오류가 발생했습니다."
|
|
success = False
|
|
else:
|
|
response_text = "명령어 형식이 올바르지 않습니다. /api/chatops/commands 에서 사용법을 확인하세요."
|
|
|
|
log = ChatOpsCommand(
|
|
channel=channel_lower,
|
|
command=cmd_str,
|
|
args=args_str,
|
|
user_id=payload.user_id,
|
|
response=response_text,
|
|
success=success,
|
|
)
|
|
db.add(log)
|
|
await db.commit()
|
|
await db.refresh(log)
|
|
|
|
return {
|
|
"id": log.id,
|
|
"channel": channel_lower,
|
|
"command": cmd_str,
|
|
"response": response_text,
|
|
"success": success,
|
|
}
|
|
|
|
|
|
@router.get("/commands", response_model=List[CommandOut])
|
|
async def list_commands(
|
|
user: User = Depends(get_current_user),
|
|
):
|
|
"""지원 명령어 목록 반환."""
|
|
return [CommandOut(**d) for d in COMMAND_DEFINITIONS]
|
|
|
|
|
|
@router.post("/interactive")
|
|
async def handle_interactive(
|
|
payload: InteractivePayload,
|
|
db: AsyncSession = Depends(get_db),
|
|
user: User = Depends(get_current_user),
|
|
):
|
|
"""인터랙티브 버튼/액션 처리."""
|
|
action = payload.action_id.lower()
|
|
target = payload.target_id
|
|
channel = payload.channel.lower()
|
|
|
|
if action == "approve_sr":
|
|
result_msg = f"SR {target} 승인 완료 (사용자: {payload.user_id})"
|
|
elif action == "reject_sr":
|
|
result_msg = f"SR {target} 반려 완료 (사용자: {payload.user_id})"
|
|
elif action == "view_detail":
|
|
result_msg = f"{target} 상세 조회 링크: /api/tasks/{target}"
|
|
elif action == "deploy_confirm":
|
|
result_msg = f"배포 {target} 실행 확인 (사용자: {payload.user_id})"
|
|
elif action == "escalate":
|
|
result_msg = f"{target} 에스컬레이션 완료 — 상위 담당자에게 알림 발송"
|
|
else:
|
|
result_msg = f"알 수 없는 액션: {action}"
|
|
|
|
log = ChatOpsCommand(
|
|
channel=channel,
|
|
command=f"interactive:{action}",
|
|
args=target,
|
|
user_id=payload.user_id,
|
|
response=result_msg,
|
|
success=True,
|
|
)
|
|
db.add(log)
|
|
await db.commit()
|
|
await db.refresh(log)
|
|
|
|
return {
|
|
"id": log.id,
|
|
"action_id": action,
|
|
"target_id": target,
|
|
"result": result_msg,
|
|
"processed_at": log.created_at,
|
|
}
|
|
|
|
|
|
@router.get("/channels", response_model=List[ChannelStatus])
|
|
async def list_channels(
|
|
db: AsyncSession = Depends(get_db),
|
|
user: User = Depends(get_current_user),
|
|
):
|
|
"""연동 채널 현황 — 각 채널의 사용량 및 성공률 포함."""
|
|
result = []
|
|
for ch_id, info in SUPPORTED_CHANNELS.items():
|
|
total_r = await db.execute(
|
|
select(func.count(ChatOpsCommand.id)).where(ChatOpsCommand.channel == ch_id)
|
|
)
|
|
total = total_r.scalar() or 0
|
|
|
|
success_r = await db.execute(
|
|
select(func.count(ChatOpsCommand.id)).where(
|
|
and_(ChatOpsCommand.channel == ch_id, ChatOpsCommand.success == True)
|
|
)
|
|
)
|
|
successes = success_r.scalar() or 0
|
|
rate = round(successes / total * 100, 1) if total > 0 else 0.0
|
|
|
|
result.append(ChannelStatus(
|
|
channel=ch_id,
|
|
name=info["name"],
|
|
enabled=info["enabled"],
|
|
icon=info["icon"],
|
|
total_cmds=total,
|
|
success_rate=rate,
|
|
))
|
|
return result
|
|
|
|
|
|
@router.post("/broadcast")
|
|
async def broadcast_message(
|
|
req: BroadcastRequest,
|
|
db: AsyncSession = Depends(get_db),
|
|
user: User = Depends(require_admin_role),
|
|
):
|
|
"""관리자 전용 — 전 채널 (또는 지정 채널) 공지 발송."""
|
|
target_channels = req.channels or list(SUPPORTED_CHANNELS.keys())
|
|
invalid = [c for c in target_channels if c not in SUPPORTED_CHANNELS]
|
|
if invalid:
|
|
raise HTTPException(status_code=400, detail=f"유효하지 않은 채널: {invalid}")
|
|
|
|
sent_channels = []
|
|
for ch in target_channels:
|
|
if not SUPPORTED_CHANNELS[ch]["enabled"]:
|
|
continue
|
|
log = ChatOpsCommand(
|
|
channel=ch,
|
|
command="broadcast",
|
|
args=req.title or "",
|
|
user_id=str(user.id),
|
|
response=req.message[:2000],
|
|
success=True,
|
|
)
|
|
db.add(log)
|
|
sent_channels.append(ch)
|
|
|
|
await db.commit()
|
|
|
|
return {
|
|
"status": "SENT",
|
|
"sent_channels": sent_channels,
|
|
"skipped_channels": [c for c in target_channels if c not in sent_channels],
|
|
"priority": req.priority,
|
|
"message_length": len(req.message),
|
|
}
|
|
|
|
|
|
@router.get("/stats")
|
|
async def chatops_stats(
|
|
days: int = Query(7, ge=1, le=90, description="통계 기간 (일)"),
|
|
db: AsyncSession = Depends(get_db),
|
|
user: User = Depends(get_current_user),
|
|
):
|
|
"""ChatOps 사용 통계 반환."""
|
|
since = datetime.utcnow() - timedelta(days=days)
|
|
today = datetime.utcnow().date()
|
|
|
|
# 전체 명령 수
|
|
total_r = await db.execute(
|
|
select(func.count(ChatOpsCommand.id)).where(ChatOpsCommand.created_at >= since)
|
|
)
|
|
total = total_r.scalar() or 0
|
|
|
|
# 오늘 명령 수
|
|
today_r = await db.execute(
|
|
select(func.count(ChatOpsCommand.id)).where(
|
|
func.date(ChatOpsCommand.created_at) == today
|
|
)
|
|
)
|
|
today_count = today_r.scalar() or 0
|
|
|
|
# 전체 성공률
|
|
success_r = await db.execute(
|
|
select(func.count(ChatOpsCommand.id)).where(
|
|
and_(ChatOpsCommand.created_at >= since, ChatOpsCommand.success == True)
|
|
)
|
|
)
|
|
successes = success_r.scalar() or 0
|
|
success_rate = round(successes / total * 100, 1) if total > 0 else 0.0
|
|
|
|
# 채널별 명령 수
|
|
channel_rows = await db.execute(
|
|
select(ChatOpsCommand.channel, func.count(ChatOpsCommand.id).label("cnt"))
|
|
.where(ChatOpsCommand.created_at >= since)
|
|
.group_by(ChatOpsCommand.channel)
|
|
)
|
|
channel_breakdown = {row.channel: row.cnt for row in channel_rows}
|
|
|
|
# 많이 사용된 명령어 TOP 5
|
|
cmd_rows = await db.execute(
|
|
select(ChatOpsCommand.command, func.count(ChatOpsCommand.id).label("cnt"))
|
|
.where(ChatOpsCommand.created_at >= since)
|
|
.group_by(ChatOpsCommand.command)
|
|
.order_by(desc("cnt"))
|
|
.limit(5)
|
|
)
|
|
top_commands = [{"command": r.command, "count": r.cnt} for r in cmd_rows]
|
|
|
|
# 활성 사용자 TOP 5
|
|
user_rows = await db.execute(
|
|
select(ChatOpsCommand.user_id, func.count(ChatOpsCommand.id).label("cnt"))
|
|
.where(ChatOpsCommand.created_at >= since)
|
|
.group_by(ChatOpsCommand.user_id)
|
|
.order_by(desc("cnt"))
|
|
.limit(5)
|
|
)
|
|
top_users = [{"user_id": r.user_id, "count": r.cnt} for r in user_rows]
|
|
|
|
return ChatOpsStats(
|
|
total_commands=total,
|
|
commands_today=today_count,
|
|
success_rate=success_rate,
|
|
top_commands=top_commands,
|
|
top_users=top_users,
|
|
channel_breakdown=channel_breakdown,
|
|
)
|