guardia-itsm/routers/servicenow.py
2026-06-02 06:07:36 +09:00

152 lines
6.5 KiB
Python

"""
ServiceNow 연동 커넥터
기능:
- ServiceNow CMDB CI 목록 조회
- Incident 양방향 동기화
- GUARDiA SR → ServiceNow Incident 생성
- ServiceNow Change Request 조회
엔드포인트:
POST /api/servicenow/config — 연동 설정
GET /api/servicenow/config — 설정 조회
POST /api/servicenow/test — 연결 테스트
GET /api/servicenow/incidents — Incident 목록
POST /api/servicenow/sync/{sr_id} — SR → ServiceNow Incident 생성
GET /api/servicenow/cmdb — CMDB CI 목록
GET /api/servicenow/changes — Change Request 목록
"""
from __future__ import annotations
import json
import logging
from datetime import datetime
from typing import Optional
import httpx
from fastapi import APIRouter, Depends, HTTPException
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, ServiceNowConfig, ServiceNowMapping # 신규
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/servicenow", tags=["ServiceNow"])
PRIORITY_MAP = {"HIGH": "1", "MEDIUM": "2", "LOW": "3"}
class SNowConfigCreate(BaseModel):
instance_url: str = Field(..., description="https://company.service-now.com")
username: str
password: str
assignment_group: Optional[str] = None
async def _snow_request(cfg: ServiceNowConfig, method: str, path: str,
payload: Optional[dict] = None) -> Optional[dict]:
url = f"{cfg.instance_url.rstrip('/')}/api/now/{path}"
try:
async with httpx.AsyncClient(timeout=15, verify=False) as client:
r = await getattr(client, method.lower())(
url, json=payload,
auth=(cfg.username, cfg.password_enc),
headers={"Content-Type": "application/json", "Accept": "application/json"}
)
return r.json() if r.status_code in (200, 201) else None
except Exception as e:
logger.error(f"ServiceNow API 실패: {e}")
return None
@router.post("/config")
async def save_config(
req: SNowConfigCreate,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_admin_role),
):
row = await db.execute(select(ServiceNowConfig).where(ServiceNowConfig.tenant_id == user.tenant_id))
cfg = row.scalar_one_or_none()
if cfg:
cfg.instance_url = req.instance_url; cfg.username = req.username
cfg.password_enc = req.password; cfg.assignment_group = req.assignment_group
else:
cfg = ServiceNowConfig(
tenant_id=user.tenant_id, instance_url=req.instance_url,
username=req.username, password_enc=req.password,
assignment_group=req.assignment_group, is_active=True, created_at=datetime.utcnow()
)
db.add(cfg)
await db.commit()
return {"ok": True}
@router.post("/test")
async def test_connection(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
row = await db.execute(select(ServiceNowConfig).where(ServiceNowConfig.tenant_id == user.tenant_id))
cfg = row.scalar_one_or_none()
if not cfg: raise HTTPException(404, "설정 없음")
data = await _snow_request(cfg, "GET", "table/sys_user?sysparm_limit=1")
return {"ok": bool(data), "instance": cfg.instance_url}
@router.get("/incidents")
async def list_incidents(limit: int = 20, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
row = await db.execute(select(ServiceNowConfig).where(ServiceNowConfig.tenant_id == user.tenant_id))
cfg = row.scalar_one_or_none()
if not cfg: raise HTTPException(404, "설정 없음")
data = await _snow_request(cfg, "GET", f"table/incident?sysparm_limit={limit}&sysparm_fields=number,short_description,state,priority,opened_at")
records = (data or {}).get("result", [])
return [{"number": r.get("number"), "title": r.get("short_description"),
"state": r.get("state"), "priority": r.get("priority"), "opened_at": r.get("opened_at")} for r in records]
@router.post("/sync/{sr_id}")
async def sync_to_servicenow(sr_id: int, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
"""SR → ServiceNow Incident 생성."""
sr_row = await db.execute(select(SRRequest).where(SRRequest.id == sr_id))
sr = sr_row.scalar_one_or_none()
if not sr: raise HTTPException(404, "SR 없음")
cfg_row = await db.execute(select(ServiceNowConfig).where(ServiceNowConfig.tenant_id == user.tenant_id))
cfg = cfg_row.scalar_one_or_none()
if not cfg: raise HTTPException(404, "ServiceNow 설정 없음")
payload = {
"short_description": f"[GUARDiA SR-{sr.id}] {sr.title}",
"description": sr.description or "",
"impact": PRIORITY_MAP.get((sr.priority or "MEDIUM").upper(), "2"),
"urgency": PRIORITY_MAP.get((sr.priority or "MEDIUM").upper(), "2"),
}
if cfg.assignment_group:
payload["assignment_group"] = {"name": cfg.assignment_group}
data = await _snow_request(cfg, "POST", "table/incident", payload)
if data and data.get("result"):
sn_number = data["result"].get("number", "")
mapping = ServiceNowMapping(sr_id=sr.id, snow_number=sn_number, config_id=cfg.id, synced_at=datetime.utcnow())
db.add(mapping)
await db.commit()
return {"ok": True, "snow_number": sn_number}
raise HTTPException(500, "ServiceNow Incident 생성 실패")
@router.get("/cmdb")
async def list_cmdb(limit: int = 50, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
row = await db.execute(select(ServiceNowConfig).where(ServiceNowConfig.tenant_id == user.tenant_id))
cfg = row.scalar_one_or_none()
if not cfg: raise HTTPException(404, "설정 없음")
data = await _snow_request(cfg, "GET", f"table/cmdb_ci_server?sysparm_limit={limit}&sysparm_fields=name,ip_address,os,status")
return (data or {}).get("result", [])
@router.get("/changes")
async def list_changes(limit: int = 20, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
row = await db.execute(select(ServiceNowConfig).where(ServiceNowConfig.tenant_id == user.tenant_id))
cfg = row.scalar_one_or_none()
if not cfg: raise HTTPException(404, "설정 없음")
data = await _snow_request(cfg, "GET", f"table/change_request?sysparm_limit={limit}&sysparm_fields=number,short_description,state,type,scheduled_start_date")
return (data or {}).get("result", [])