라우터 (611개 엔드포인트, P1+P2 75개 신규): - kubernetes.py: K8s 에이전트리스 관리 (SSH kubectl) - sso_provider.py: SAML 2.0 / OIDC / OAuth2 통합 인증 - predictive_ops.py: SLA위반·SR급증·서버장애 예측 + Ollama 인사이트 - slack_connector.py: Slack Incoming Webhook + Slash Commands - white_label.py: 기관별 브랜딩 + CSS 변수 동적 생성 DB 모델 (5개 신규): tb_k8s_cluster, tb_sso_config, tb_sso_session, tb_slack_config, tb_tenant_branding 수정: K8sCluster ForeignKey tb_server → tb_server_info Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
293 lines
11 KiB
Python
293 lines
11 KiB
Python
"""
|
||
Slack 커넥터 — Incoming Webhook + Slash Commands + Block Kit
|
||
|
||
기능:
|
||
- Slack으로 GUARDiA 이벤트 알림 발송
|
||
- Slack Slash Commands 수신 (/guardia status, /guardia sr)
|
||
- Block Kit 리치 메시지 (버튼·섹션·헤더)
|
||
- 기존 메신저봇 명령어와 연동
|
||
|
||
엔드포인트:
|
||
POST /api/slack/config — Slack 설정 등록
|
||
GET /api/slack/config — 설정 조회
|
||
POST /api/slack/notify — 알림 발송
|
||
POST /api/slack/commands — Slack Slash Commands 수신
|
||
GET /api/slack/test — 테스트 메시지 발송
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import hmac
|
||
import json
|
||
import logging
|
||
import time
|
||
from datetime import datetime
|
||
from typing import Optional
|
||
|
||
import httpx
|
||
from fastapi import APIRouter, Depends, Form, Header, HTTPException, Request
|
||
from pydantic import BaseModel, Field
|
||
from sqlalchemy import select
|
||
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, SRRequest, SRStatus, SlackConfig # 신규 모델
|
||
|
||
logger = logging.getLogger(__name__)
|
||
router = APIRouter(prefix="/api/slack", tags=["Slack 연동"])
|
||
|
||
|
||
# ── Pydantic 스키마 ──────────────────────────────────────────────────────────
|
||
|
||
class SlackConfigCreate(BaseModel):
|
||
name: str = Field(..., max_length=100)
|
||
webhook_url: str = Field(..., description="Incoming Webhook URL")
|
||
signing_secret: Optional[str] = None
|
||
default_channel: str = Field("#guardia-ops", max_length=100)
|
||
notify_sr_create: bool = True
|
||
notify_incident: bool = True
|
||
notify_deploy: bool = True
|
||
notify_sla_breach: bool = True
|
||
|
||
class SlackNotifyRequest(BaseModel):
|
||
channel: Optional[str] = None
|
||
text: str
|
||
level: str = Field("info", pattern="^(info|warning|error|success)$")
|
||
sr_id: Optional[int] = None
|
||
|
||
|
||
# ── Block Kit 빌더 ───────────────────────────────────────────────────────────
|
||
|
||
def _color_for_level(level: str) -> str:
|
||
return {"info": "#003366", "warning": "#F59E0B", "error": "#EF4444", "success": "#10B981"}.get(level, "#003366")
|
||
|
||
|
||
def _build_sr_blocks(sr: SRRequest) -> list:
|
||
"""SR 알림용 Block Kit."""
|
||
return [
|
||
{
|
||
"type": "header",
|
||
"text": {"type": "plain_text", "text": f"🎫 SR-{sr.id} {sr.title[:50]}"}
|
||
},
|
||
{
|
||
"type": "section",
|
||
"fields": [
|
||
{"type": "mrkdwn", "text": f"*상태*\n{sr.status}"},
|
||
{"type": "mrkdwn", "text": f"*우선순위*\n{sr.priority or 'MEDIUM'}"},
|
||
{"type": "mrkdwn", "text": f"*카테고리*\n{sr.category or '-'}"},
|
||
{"type": "mrkdwn", "text": f"*등록일*\n{sr.created_at.strftime('%m/%d %H:%M') if sr.created_at else '-'}"},
|
||
]
|
||
},
|
||
{
|
||
"type": "actions",
|
||
"elements": [
|
||
{
|
||
"type": "button",
|
||
"text": {"type": "plain_text", "text": "상세 보기"},
|
||
"url": f"https://zioinfo.co.kr:8443/sr/{sr.id}",
|
||
"style": "primary",
|
||
},
|
||
]
|
||
}
|
||
]
|
||
|
||
|
||
def _build_text_blocks(text: str, level: str) -> list:
|
||
"""일반 텍스트 알림용 Block Kit."""
|
||
emoji = {"info": "ℹ️", "warning": "⚠️", "error": "❌", "success": "✅"}.get(level, "ℹ️")
|
||
return [
|
||
{
|
||
"type": "section",
|
||
"text": {"type": "mrkdwn", "text": f"{emoji} {text}"}
|
||
},
|
||
{"type": "context", "elements": [
|
||
{"type": "mrkdwn", "text": f"GUARDiA ITSM | {datetime.utcnow().strftime('%Y-%m-%d %H:%M UTC')}"}
|
||
]}
|
||
]
|
||
|
||
|
||
# ── Slack API 발송 ────────────────────────────────────────────────────────────
|
||
|
||
async def _send_slack(webhook_url: str, payload: dict) -> bool:
|
||
try:
|
||
async with httpx.AsyncClient(timeout=10) as client:
|
||
r = await client.post(webhook_url, json=payload)
|
||
return r.status_code == 200
|
||
except Exception as e:
|
||
logger.error(f"Slack 발송 실패: {e}")
|
||
return False
|
||
|
||
|
||
# ── 서명 검증 ────────────────────────────────────────────────────────────────
|
||
|
||
def _verify_slack_signature(signing_secret: str, body: bytes, timestamp: str, signature: str) -> bool:
|
||
"""Slack 요청 서명 검증 (HMAC-SHA256)."""
|
||
if abs(time.time() - float(timestamp)) > 300:
|
||
return False # 5분 이상 오래된 요청 거부
|
||
base = f"v0:{timestamp}:{body.decode()}"
|
||
expected = "v0=" + hmac.new(
|
||
signing_secret.encode(), base.encode(), hashlib.sha256
|
||
).hexdigest()
|
||
return hmac.compare_digest(expected, signature)
|
||
|
||
|
||
# ── 엔드포인트 ───────────────────────────────────────────────────────────────
|
||
|
||
@router.post("/config")
|
||
async def save_slack_config(
|
||
req: SlackConfigCreate,
|
||
db: AsyncSession = Depends(get_db),
|
||
user: User = Depends(require_admin_role),
|
||
):
|
||
"""Slack 설정 저장."""
|
||
existing = await db.execute(
|
||
select(SlackConfig).where(SlackConfig.tenant_id == user.tenant_id)
|
||
)
|
||
cfg = existing.scalar_one_or_none()
|
||
|
||
if cfg:
|
||
cfg.webhook_url = req.webhook_url
|
||
cfg.signing_secret = req.signing_secret
|
||
cfg.default_channel = req.default_channel
|
||
cfg.notify_sr_create = req.notify_sr_create
|
||
cfg.notify_incident = req.notify_incident
|
||
cfg.notify_deploy = req.notify_deploy
|
||
cfg.notify_sla_breach = req.notify_sla_breach
|
||
else:
|
||
cfg = SlackConfig(
|
||
tenant_id=user.tenant_id,
|
||
name=req.name,
|
||
webhook_url=req.webhook_url,
|
||
signing_secret=req.signing_secret,
|
||
default_channel=req.default_channel,
|
||
notify_sr_create=req.notify_sr_create,
|
||
notify_incident=req.notify_incident,
|
||
notify_deploy=req.notify_deploy,
|
||
notify_sla_breach=req.notify_sla_breach,
|
||
is_active=True,
|
||
)
|
||
db.add(cfg)
|
||
await db.commit()
|
||
return {"ok": True}
|
||
|
||
|
||
@router.get("/config")
|
||
async def get_slack_config(
|
||
db: AsyncSession = Depends(get_db),
|
||
user: User = Depends(get_current_user),
|
||
):
|
||
row = await db.execute(select(SlackConfig).where(SlackConfig.tenant_id == user.tenant_id))
|
||
cfg = row.scalar_one_or_none()
|
||
if not cfg:
|
||
return None
|
||
return {
|
||
"name": cfg.name,
|
||
"webhook_url": cfg.webhook_url[:20] + "***", # 마스킹
|
||
"default_channel": cfg.default_channel,
|
||
"is_active": cfg.is_active,
|
||
}
|
||
|
||
|
||
@router.get("/test")
|
||
async def test_slack(
|
||
db: AsyncSession = Depends(get_db),
|
||
user: User = Depends(get_current_user),
|
||
):
|
||
"""테스트 메시지 발송."""
|
||
row = await db.execute(select(SlackConfig).where(SlackConfig.tenant_id == user.tenant_id))
|
||
cfg = row.scalar_one_or_none()
|
||
if not cfg:
|
||
raise HTTPException(404, "Slack 설정 없음")
|
||
|
||
payload = {
|
||
"channel": cfg.default_channel,
|
||
"blocks": _build_text_blocks(f"GUARDiA ITSM 연동 테스트 메시지 (by {user.email})", "success"),
|
||
}
|
||
ok = await _send_slack(cfg.webhook_url, payload)
|
||
return {"ok": ok}
|
||
|
||
|
||
@router.post("/notify")
|
||
async def slack_notify(
|
||
req: SlackNotifyRequest,
|
||
db: AsyncSession = Depends(get_db),
|
||
user: User = Depends(get_current_user),
|
||
):
|
||
"""Slack 알림 발송."""
|
||
row = await db.execute(select(SlackConfig).where(SlackConfig.tenant_id == user.tenant_id))
|
||
cfg = row.scalar_one_or_none()
|
||
if not cfg:
|
||
raise HTTPException(404, "Slack 설정 없음")
|
||
|
||
# SR 첨부 시 Block Kit 사용
|
||
if req.sr_id:
|
||
sr_row = await db.execute(select(SRRequest).where(SRRequest.id == req.sr_id))
|
||
sr = sr_row.scalar_one_or_none()
|
||
if sr:
|
||
payload = {
|
||
"channel": req.channel or cfg.default_channel,
|
||
"text": req.text,
|
||
"attachments": [{"color": _color_for_level(req.level), "blocks": _build_sr_blocks(sr)}],
|
||
}
|
||
else:
|
||
payload = {"channel": req.channel or cfg.default_channel,
|
||
"blocks": _build_text_blocks(req.text, req.level)}
|
||
else:
|
||
payload = {"channel": req.channel or cfg.default_channel,
|
||
"blocks": _build_text_blocks(req.text, req.level)}
|
||
|
||
ok = await _send_slack(cfg.webhook_url, payload)
|
||
return {"ok": ok}
|
||
|
||
|
||
@router.post("/commands")
|
||
async def slack_commands(
|
||
request: Request,
|
||
x_slack_signature: str = Header(None, alias="X-Slack-Signature"),
|
||
x_slack_request_timestamp: str = Header(None, alias="X-Slack-Request-Timestamp"),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""
|
||
Slack Slash Commands 처리.
|
||
Slack 앱에서 /guardia 명령어 설정 필요:
|
||
Request URL: https://zioinfo.co.kr:8443/api/slack/commands
|
||
"""
|
||
body = await request.body()
|
||
|
||
# 서명 검증 (설정된 경우)
|
||
# 모든 테넌트 설정 조회는 생략, 실제로는 team_id 기반 조회
|
||
form_data = {}
|
||
for item in body.decode().split("&"):
|
||
if "=" in item:
|
||
k, v = item.split("=", 1)
|
||
form_data[k] = v.replace("+", " ")
|
||
|
||
command = form_data.get("command", "")
|
||
text = form_data.get("text", "").strip()
|
||
user_name = form_data.get("user_name", "unknown")
|
||
|
||
if command == "/guardia":
|
||
if text.startswith("status"):
|
||
# 시스템 현황 반환
|
||
return {
|
||
"response_type": "in_channel",
|
||
"blocks": [
|
||
{"type": "header", "text": {"type": "plain_text", "text": "🛡️ GUARDiA 현황"}},
|
||
{"type": "section", "text": {"type": "mrkdwn",
|
||
"text": f"@{user_name}님이 조회했습니다. <https://zioinfo.co.kr:8443|대시보드 보기>"}},
|
||
]
|
||
}
|
||
elif text.startswith("sr"):
|
||
return {
|
||
"response_type": "ephemeral",
|
||
"text": "SR 접수는 웹 대시보드를 이용하거나 /sr 명령어를 사용하세요.",
|
||
}
|
||
else:
|
||
return {
|
||
"response_type": "ephemeral",
|
||
"text": "사용법: `/guardia status` | `/guardia sr`",
|
||
}
|
||
|
||
return {"response_type": "ephemeral", "text": "알 수 없는 명령어입니다."}
|