""" 워크플로우 엔진 — 정의·템플릿·실행 이력 관리 기능: - 워크플로우 정의 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}