""" 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", [])