""" RPA Engine — 소스 기반 Validation 학습 + 자동화 실행 """ from __future__ import annotations import ast import inspect import importlib import re from datetime import datetime from pathlib import Path from typing import Any, Dict, List, Optional import httpx from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, delete BASE_DIR = Path(__file__).resolve().parent.parent # itsm/ # ── Validation 학습 ───────────────────────────────────────────────────────── class ValidationLearner: """프로젝트 소스(models.py)를 AST 파싱하여 Pydantic 스키마 validation 규칙 추출.""" ENUM_MAP: Dict[str, List[str]] = { "SRType": ["DEPLOY", "RESTART", "LOG", "INQUIRY", "OTHER"], "SRStatus": ["RECEIVED","PARSED","PENDING_APPROVAL","APPROVED", "IN_PROGRESS","PENDING_PM_VALIDATION","COMPLETED", "FAILED_ROLLBACK","REJECTED"], "Priority": ["CRITICAL", "HIGH", "MEDIUM", "LOW"], "ApprovalResult":["PENDING", "APPROVED", "REJECTED"], } # 엔드포인트 → 스키마 매핑 (routers/ 분석으로 자동 보완) ENDPOINT_SCHEMA: Dict[str, str] = { "POST /api/tasks": "SRCreate", "PATCH /api/tasks/status": "SRStatusUpdate", "POST /api/approvals": "ApprovalCreate", "POST /api/institutions": "InstitutionCreate", "PUT /api/institutions": "InstitutionCreate", "POST /api/servers": "ServerCreate", "POST /api/incidents": "IncidentCreate", "POST /api/change": "RFCCreate", "POST /api/problems": "ProblemCreate", "POST /api/catalog": "ServiceCatalogCreate", } def learn_from_source(self) -> Dict[str, Any]: """models.py AST 파싱으로 validation 규칙 추출.""" models_path = BASE_DIR / "models.py" source = models_path.read_text(encoding="utf-8") tree = ast.parse(source) rules: List[Dict] = [] schemas_found: List[str] = [] for node in ast.walk(tree): if not isinstance(node, ast.ClassDef): continue # BaseModel 상속 클래스만 bases = [getattr(b, "id", "") for b in node.bases] if "BaseModel" not in bases: continue class_name = node.name schemas_found.append(class_name) # 엔드포인트 찾기 endpoint = self._find_endpoint(class_name) for item in node.body: if not isinstance(item, ast.AnnAssign): continue rule = self._extract_field_rule(item, class_name, endpoint) if rule: rules.append(rule) return {"rules": rules, "schemas": schemas_found} def _extract_field_rule(self, node: ast.AnnAssign, class_name: str, endpoint: str) -> Optional[Dict]: """단일 필드 annotation에서 validation 규칙 추출.""" if not isinstance(node.target, ast.Name): return None field_name = node.target.id if field_name.startswith("_"): return None annotation = node.annotation # ast.AnnAssign: .value = 기본값 (없으면 None) → None이면 required is_required = node.value is None field_type = "str" allowed_values: List[str] = [] constraints: Dict = {} # 타입 분석 type_str = ast.unparse(annotation) if hasattr(ast, "unparse") else str(annotation) # Optional[X] → is_required=False if "Optional" in type_str: is_required = False inner = re.sub(r"Optional\[(.+)\]", r"\1", type_str) type_str = inner # Enum 타입 for enum_name, vals in self.ENUM_MAP.items(): if enum_name in type_str: field_type = "enum" allowed_values = vals break else: if "int" in type_str: field_type = "int" elif "float" in type_str: field_type = "float" elif "bool" in type_str: field_type = "bool" elif "List" in type_str or "list" in type_str: field_type = "list" elif "datetime" in type_str.lower(): field_type = "datetime" else: field_type = "str" return { "endpoint": endpoint, "schema_class": class_name, "field_name": field_name, "field_type": field_type, "is_required": is_required, "allowed_values": allowed_values, "constraints": constraints, "learned_at": datetime.now().isoformat(), } def _find_endpoint(self, class_name: str) -> str: for ep, schema in self.ENDPOINT_SCHEMA.items(): if schema == class_name: return ep # 자동 추론: SRCreate → POST /api/tasks (by name pattern) name_lower = class_name.lower().replace("create", "").replace("update", "") return f"POST /api/{name_lower}s" # ── Validation 검증기 ──────────────────────────────────────────────────────── class RPAValidator: """tb_rpa_validation 규칙으로 payload 검증.""" def __init__(self, rules: List[Dict]): self.rules = {r["field_name"]: r for r in rules} def validate(self, payload: Dict[str, Any]) -> List[str]: """ payload 검증. 오류 목록 반환 (빈 리스트 = 통과). """ errors: List[str] = [] for field_name, rule in self.rules.items(): val = payload.get(field_name) # 필수 필드 검사 if rule["is_required"] and (val is None or val == ""): errors.append(f"[{field_name}] 필수 항목입니다.") continue if val is None: continue # optional이고 값 없으면 skip # Enum 검사 if rule["field_type"] == "enum" and rule["allowed_values"]: if val not in rule["allowed_values"]: errors.append( f"[{field_name}] 허용값: {rule['allowed_values']} 중 하나여야 합니다. (입력: {val!r})" ) # 타입 검사 elif rule["field_type"] == "int": try: int(val) except (TypeError, ValueError): errors.append(f"[{field_name}] 정수 타입이어야 합니다.") elif rule["field_type"] == "bool": if not isinstance(val, bool): errors.append(f"[{field_name}] 불리언(true/false) 타입이어야 합니다.") # 제약 조건 검사 c = rule.get("constraints", {}) if c.get("max_length") and isinstance(val, str): if len(val) > c["max_length"]: errors.append(f"[{field_name}] 최대 {c['max_length']}자 초과.") if c.get("min_length") and isinstance(val, str): if len(val) < c["min_length"]: errors.append(f"[{field_name}] 최소 {c['min_length']}자 이상 필요.") if c.get("ge") is not None and isinstance(val, (int, float)): if val < c["ge"]: errors.append(f"[{field_name}] {c['ge']} 이상이어야 합니다.") if c.get("le") is not None and isinstance(val, (int, float)): if val > c["le"]: errors.append(f"[{field_name}] {c['le']} 이하여야 합니다.") return errors # ── RPA 실행 엔진 ──────────────────────────────────────────────────────────── class RPAExecutor: """RPA 작업 실행기 — validation 후 ITSM API 호출.""" def __init__(self, base_url: str, token: str): self.base_url = base_url.rstrip("/") self.headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} async def execute( self, task_type: str, payload: Dict[str, Any], dry_run: bool = False, retry: int = 3, ) -> Dict[str, Any]: """ 단발성 RPA 실행. dry_run=True → validation만 수행, API 호출 없음. """ endpoint_map = { "SR_CREATE": ("POST", "/api/tasks"), "SR_STATUS_UPDATE": ("PATCH", f"/api/tasks/{payload.get('sr_id', 0)}/status"), "APPROVAL_PROCESS": ("POST", "/api/approvals"), "INCIDENT_CREATE": ("POST", "/api/incidents"), "SHELL_EXEC": ("POST", "/api/ssh/exec"), } if task_type not in endpoint_map: return {"status": "FAILED", "error": f"알 수 없는 task_type: {task_type}"} method, path = endpoint_map[task_type] result = {"task_type": task_type, "endpoint": f"{method} {path}", "dry_run": dry_run} if dry_run: result["status"] = "DRY_RUN_OK" result["message"] = "Validation 통과. dry_run=true이므로 실제 실행 생략." return result # 실제 API 호출 (재시도 포함) url = f"{self.base_url}{path}" last_err = None async with httpx.AsyncClient(timeout=30) as client: for attempt in range(1, retry + 1): try: resp = getattr(client, method.lower()) r = await resp(url, json=payload, headers=self.headers) if r.status_code < 300: result["status"] = "SUCCESS" result["response"] = r.json() return result elif r.status_code < 500: # 4xx → 재시도 없음 result["status"] = "FAILED" result["error"] = r.json() return result else: last_err = f"HTTP {r.status_code}: {r.text[:200]}" except Exception as e: last_err = str(e) if attempt < retry: import asyncio await asyncio.sleep(2 ** attempt) # 지수 백오프 result["status"] = "FAILED" result["error"] = f"{retry}회 재시도 후 실패: {last_err}" return result