480 lines
17 KiB
Python
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}
|