guardia-itsm/routers/chatops_extended.py
2026-06-04 08:13:41 +09:00

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