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

480 lines
17 KiB
Python

"""
워크플로우 엔진 — 정의·템플릿·실행 이력 관리
기능:
- 워크플로우 정의 CRUD (단계별 JSON 스텝 구성)
- 내장 템플릿 라이브러리 (SR 자동처리, SLA 에스컬레이션, SSL 갱신 등 5종)
- 수동 트리거 (즉시 실행)
- 실행 이력 조회 (전체 / 단건 상세)
- 활성화/비활성화 토글
엔드포인트:
GET /api/workflow-engine/definitions — 워크플로우 목록
POST /api/workflow-engine/definitions — 워크플로우 생성
PUT /api/workflow-engine/definitions/{id} — 수정
GET /api/workflow-engine/templates — 템플릿 라이브러리
POST /api/workflow-engine/trigger — 수동 트리거
GET /api/workflow-engine/runs — 실행 이력
GET /api/workflow-engine/runs/{id} — 실행 상세
POST /api/workflow-engine/definitions/{id}/activate — 활성화
"""
from __future__ import annotations
import json
import logging
from datetime import datetime
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import select, 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 WorkflowDefinition, WorkflowRun, User
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/workflow-engine", tags=["Workflow Engine"])
# ── 내장 템플릿 시드 데이터 ──────────────────────────────────────────────────
BUILTIN_TEMPLATES: List[Dict[str, Any]] = [
{
"name": "SR 자동처리",
"description": "LOW 우선순위 SR을 자동으로 접수·배정·처리한다.",
"trigger": {"event": "SR_CREATED", "condition": {"priority": "LOW"}},
"steps": [
{"seq": 1, "type": "auto_assign", "params": {"role": "ENGINEER"}},
{"seq": 2, "type": "notify", "params": {"channel": "messenger", "message": "SR 자동 배정됨"}},
{"seq": 3, "type": "update_status", "params": {"status": "IN_PROGRESS"}},
],
},
{
"name": "SLA 에스컬레이션",
"description": "SLA 임박 SR을 자동으로 관리자에게 에스컬레이션한다.",
"trigger": {"event": "SLA_WARNING", "condition": {"remaining_hours": {"lte": 2}}},
"steps": [
{"seq": 1, "type": "escalate", "params": {"target_role": "PM"}},
{"seq": 2, "type": "notify", "params": {"channel": "messenger", "message": "SLA 2시간 이하 — 에스컬레이션"}},
],
},
{
"name": "SSL 인증서 갱신",
"description": "만료 30일 전 SSL 인증서를 자동으로 갱신 SR을 생성한다.",
"trigger": {"event": "CRON", "cron_expr": "0 9 * * *"},
"steps": [
{"seq": 1, "type": "check_ssl", "params": {"threshold_days": 30}},
{"seq": 2, "type": "create_sr", "params": {"title": "SSL 인증서 갱신 필요", "priority": "HIGH"}},
{"seq": 3, "type": "notify", "params": {"channel": "messenger", "message": "SSL 갱신 SR 생성됨"}},
],
},
{
"name": "서버 이상 감지 → SR 생성",
"description": "이상 탐지 이벤트 발생 시 자동으로 인시던트 SR을 생성한다.",
"trigger": {"event": "ANOMALY_DETECTED", "condition": {}},
"steps": [
{"seq": 1, "type": "create_sr", "params": {"title": "서버 이상 감지: {server_id}", "priority": "CRITICAL", "category": "MONITORING"}},
{"seq": 2, "type": "notify", "params": {"channel": "oncall", "message": "인시던트 SR 자동 생성"}},
],
},
{
"name": "정기 보고서 생성",
"description": "매월 1일 오전 8시에 월간 운영 보고서를 자동 생성한다.",
"trigger": {"event": "CRON", "cron_expr": "0 8 1 * *"},
"steps": [
{"seq": 1, "type": "generate_report", "params": {"type": "monthly", "format": "pdf"}},
{"seq": 2, "type": "notify", "params": {"channel": "email", "message": "월간 보고서 생성 완료"}},
],
},
]
# ── Pydantic 스키마 ──────────────────────────────────────────────────────────
class WorkflowStep(BaseModel):
seq: int
type: str
params: Dict[str, Any] = Field(default_factory=dict)
class WorkflowCreate(BaseModel):
name: str = Field(..., max_length=300)
trigger: Dict[str, Any] = Field(default_factory=dict, description="트리거 조건 JSON")
steps: List[WorkflowStep] = Field(..., min_length=1, description="실행 단계 목록")
active: bool = False
class WorkflowUpdate(BaseModel):
name: Optional[str] = Field(None, max_length=300)
trigger: Optional[Dict[str, Any]] = None
steps: Optional[List[WorkflowStep]] = None
active: Optional[bool] = None
class WorkflowOut(BaseModel):
id: int
name: str
trigger: Optional[Dict[str, Any]]
steps: Optional[List[Dict[str, Any]]]
active: bool
created_at: datetime
class WorkflowRunOut(BaseModel):
id: int
definition_id: Optional[int]
definition_name: Optional[str]
status: str
trigger_data: Optional[Dict[str, Any]]
step_results: Optional[List[Dict[str, Any]]]
started_at: datetime
finished_at: Optional[datetime]
class TriggerRequest(BaseModel):
definition_id: int
payload: Dict[str, Any] = Field(default_factory=dict)
class TemplateOut(BaseModel):
index: int
name: str
description: str
trigger: Dict[str, Any]
steps: List[Dict[str, Any]]
# ── 워크플로우 실행 내부 로직 ────────────────────────────────────────────────
async def _execute_step(step: dict, payload: dict, db: AsyncSession) -> dict:
"""단일 스텝 실행 (타입별 처리)."""
step_type = step.get("type", "")
params = step.get("params", {})
if step_type == "auto_assign":
return {"type": step_type, "result": "ok", "detail": f"role={params.get('role')}"}
elif step_type == "notify":
channel = params.get("channel", "messenger")
message = params.get("message", "").format_map({**payload, **{"server_id": payload.get("server_id", "")}})
logger.info(f"[WorkflowEngine] 알림 전송: channel={channel}, msg={message[:80]}")
return {"type": step_type, "result": "ok", "channel": channel}
elif step_type == "escalate":
return {"type": step_type, "result": "ok", "target": params.get("target_role")}
elif step_type == "update_status":
return {"type": step_type, "result": "ok", "status": params.get("status")}
elif step_type == "create_sr":
title = params.get("title", "자동 SR").format_map(
{**payload, "server_id": payload.get("server_id", "unknown")}
)
return {"type": step_type, "result": "ok", "title": title}
elif step_type == "check_ssl":
return {"type": step_type, "result": "ok", "threshold_days": params.get("threshold_days", 30)}
elif step_type == "generate_report":
return {"type": step_type, "result": "ok", "report_type": params.get("type"), "format": params.get("format")}
else:
return {"type": step_type, "result": "skipped", "reason": "unknown step type"}
async def _run_workflow(run_id: int, definition_id: int, payload: dict) -> None:
"""워크플로우 백그라운드 실행."""
from database import SessionLocal
async with SessionLocal() as db:
run_row = await db.execute(
select(WorkflowRun).where(WorkflowRun.id == run_id)
)
run = run_row.scalar_one_or_none()
def_row = await db.execute(
select(WorkflowDefinition).where(WorkflowDefinition.id == definition_id)
)
defn = def_row.scalar_one_or_none()
if not run or not defn:
return
step_results = []
try:
steps = json.loads(defn.steps) if defn.steps else []
steps_sorted = sorted(steps, key=lambda s: s.get("seq", 0))
for step in steps_sorted:
result = await _execute_step(step, payload, db)
step_results.append(result)
run.status = "success"
except Exception as e:
run.status = "failed"
step_results.append({"error": str(e)[:300]})
logger.error(f"[WorkflowEngine] run={run_id} 실패: {e}")
finally:
run.finished_at = datetime.utcnow()
run.step_results = json.dumps(step_results, ensure_ascii=False)
await db.commit()
# ── 템플릿 시드 초기화 ────────────────────────────────────────────────────────
async def _seed_templates(db: AsyncSession) -> None:
"""앱 시작 시 내장 템플릿이 없으면 시드 데이터를 삽입한다."""
count_row = await db.execute(
select(WorkflowDefinition)
)
if count_row.scalars().first() is not None:
return # 이미 존재
for tpl in BUILTIN_TEMPLATES:
defn = WorkflowDefinition(
name=tpl["name"],
trigger=json.dumps(tpl["trigger"], ensure_ascii=False),
steps=json.dumps(tpl["steps"], ensure_ascii=False),
active=False,
created_at=datetime.utcnow(),
)
db.add(defn)
await db.commit()
logger.info("[WorkflowEngine] 내장 템플릿 5종 시드 완료")
# ── 엔드포인트 ───────────────────────────────────────────────────────────────
@router.get("/definitions", response_model=List[WorkflowOut])
async def list_definitions(
active_only: bool = False,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""워크플로우 정의 목록."""
# 최초 조회 시 템플릿 시드
await _seed_templates(db)
q = select(WorkflowDefinition).order_by(desc(WorkflowDefinition.created_at))
if active_only:
q = q.where(WorkflowDefinition.active == True)
rows = await db.execute(q)
defns = rows.scalars().all()
return [
WorkflowOut(
id=d.id,
name=d.name,
trigger=json.loads(d.trigger) if d.trigger else {},
steps=json.loads(d.steps) if d.steps else [],
active=d.active,
created_at=d.created_at,
)
for d in defns
]
@router.post("/definitions", response_model=WorkflowOut, status_code=201)
async def create_definition(
req: WorkflowCreate,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_admin_role),
):
"""워크플로우 정의 생성."""
defn = WorkflowDefinition(
name=req.name,
trigger=json.dumps(req.trigger, ensure_ascii=False),
steps=json.dumps([s.model_dump() for s in req.steps], ensure_ascii=False),
active=req.active,
created_at=datetime.utcnow(),
)
db.add(defn)
await db.commit()
await db.refresh(defn)
logger.info(f"[WorkflowEngine] 정의 생성: id={defn.id}, name={defn.name}")
return WorkflowOut(
id=defn.id,
name=defn.name,
trigger=json.loads(defn.trigger) if defn.trigger else {},
steps=json.loads(defn.steps) if defn.steps else [],
active=defn.active,
created_at=defn.created_at,
)
@router.put("/definitions/{definition_id}", response_model=WorkflowOut)
async def update_definition(
definition_id: int,
req: WorkflowUpdate,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_admin_role),
):
"""워크플로우 정의 수정."""
row = await db.execute(
select(WorkflowDefinition).where(WorkflowDefinition.id == definition_id)
)
defn = row.scalar_one_or_none()
if not defn:
raise HTTPException(404, "워크플로우 정의를 찾을 수 없습니다")
if req.name is not None:
defn.name = req.name
if req.trigger is not None:
defn.trigger = json.dumps(req.trigger, ensure_ascii=False)
if req.steps is not None:
defn.steps = json.dumps([s.model_dump() for s in req.steps], ensure_ascii=False)
if req.active is not None:
defn.active = req.active
await db.commit()
await db.refresh(defn)
return WorkflowOut(
id=defn.id,
name=defn.name,
trigger=json.loads(defn.trigger) if defn.trigger else {},
steps=json.loads(defn.steps) if defn.steps else [],
active=defn.active,
created_at=defn.created_at,
)
@router.get("/templates", response_model=List[TemplateOut])
async def list_templates(
user: User = Depends(get_current_user),
):
"""내장 워크플로우 템플릿 라이브러리."""
return [
TemplateOut(
index=i,
name=tpl["name"],
description=tpl["description"],
trigger=tpl["trigger"],
steps=tpl["steps"],
)
for i, tpl in enumerate(BUILTIN_TEMPLATES)
]
@router.post("/trigger")
async def manual_trigger(
req: TriggerRequest,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""워크플로우 수동 트리거."""
row = await db.execute(
select(WorkflowDefinition).where(WorkflowDefinition.id == req.definition_id)
)
defn = row.scalar_one_or_none()
if not defn:
raise HTTPException(404, "워크플로우 정의를 찾을 수 없습니다")
run = WorkflowRun(
definition_id=defn.id,
trigger_data=json.dumps(req.payload, ensure_ascii=False),
status="running",
started_at=datetime.utcnow(),
)
db.add(run)
await db.commit()
await db.refresh(run)
background_tasks.add_task(_run_workflow, run.id, defn.id, req.payload)
logger.info(f"[WorkflowEngine] 수동 트리거: def={defn.id}, run={run.id}, by={user.username}")
return {
"ok": True,
"run_id": run.id,
"definition_id": defn.id,
"definition_name": defn.name,
"status": "running",
}
@router.get("/runs", response_model=List[WorkflowRunOut])
async def list_runs(
limit: int = 50,
definition_id: Optional[int] = None,
status: Optional[str] = None,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""실행 이력 목록."""
q = (
select(WorkflowRun, WorkflowDefinition.name.label("def_name"))
.outerjoin(WorkflowDefinition, WorkflowRun.definition_id == WorkflowDefinition.id)
.order_by(desc(WorkflowRun.started_at))
.limit(limit)
)
if definition_id:
q = q.where(WorkflowRun.definition_id == definition_id)
if status:
q = q.where(WorkflowRun.status == status)
rows = await db.execute(q)
result = []
for r in rows.all():
run = r.WorkflowRun
result.append(
WorkflowRunOut(
id=run.id,
definition_id=run.definition_id,
definition_name=r.def_name,
status=run.status,
trigger_data=json.loads(run.trigger_data) if run.trigger_data else None,
step_results=json.loads(run.step_results) if run.step_results else None,
started_at=run.started_at,
finished_at=run.finished_at,
)
)
return result
@router.get("/runs/{run_id}", response_model=WorkflowRunOut)
async def get_run(
run_id: int,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""실행 상세 조회."""
q = (
select(WorkflowRun, WorkflowDefinition.name.label("def_name"))
.outerjoin(WorkflowDefinition, WorkflowRun.definition_id == WorkflowDefinition.id)
.where(WorkflowRun.id == run_id)
)
row = await db.execute(q)
r = row.first()
if not r:
raise HTTPException(404, "실행 이력을 찾을 수 없습니다")
run = r.WorkflowRun
return WorkflowRunOut(
id=run.id,
definition_id=run.definition_id,
definition_name=r.def_name,
status=run.status,
trigger_data=json.loads(run.trigger_data) if run.trigger_data else None,
step_results=json.loads(run.step_results) if run.step_results else None,
started_at=run.started_at,
finished_at=run.finished_at,
)
@router.post("/definitions/{definition_id}/activate")
async def activate_definition(
definition_id: int,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_admin_role),
):
"""워크플로우 정의 활성화."""
row = await db.execute(
select(WorkflowDefinition).where(WorkflowDefinition.id == definition_id)
)
defn = row.scalar_one_or_none()
if not defn:
raise HTTPException(404, "워크플로우 정의를 찾을 수 없습니다")
defn.active = True
await db.commit()
logger.info(f"[WorkflowEngine] 정의 활성화: id={definition_id}, name={defn.name}")
return {"ok": True, "id": definition_id, "name": defn.name, "active": True}