feat(api): Phase 16 R4.1 抽取 OperationParser 模組
Strangler Fig Pattern: 從 approvals.py 抽取操作解析邏輯 新增: - src/services/operation_parser.py - ParsedOperation dataclass - 支援中英文指令解析 (kubectl/自然語言) 瘦身 approvals.py: 移除 117 行內嵌邏輯 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -21,7 +21,6 @@ Endpoints:
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import UUID
|
||||
|
||||
@@ -55,7 +54,8 @@ from src.models.approval import (
|
||||
SignResponse,
|
||||
)
|
||||
from src.services.approval_db import get_approval_service, get_timeline_service
|
||||
from src.services.executor import OperationType, get_executor
|
||||
from src.services.executor import get_executor
|
||||
from src.services.operation_parser import parse_operation_from_action
|
||||
from src.services.proposal_service import get_proposal_service
|
||||
|
||||
router = APIRouter(prefix="/approvals", tags=["HITL Approvals"])
|
||||
@@ -162,121 +162,9 @@ async def test_k8s_connection() -> dict:
|
||||
|
||||
# =============================================================================
|
||||
# Background Execution Helper
|
||||
# Phase 16 R4: parse_operation_from_action 已抽取至 src/services/operation_parser.py
|
||||
# =============================================================================
|
||||
|
||||
def parse_operation_from_action(action: str) -> tuple[OperationType | None, str | None, str]:
|
||||
"""
|
||||
從 action 字串解析操作類型與目標資源
|
||||
|
||||
Examples:
|
||||
"kubectl delete pod nginx-xxx -n production"
|
||||
→ (DELETE_POD, "nginx-xxx", "production")
|
||||
|
||||
"Restart deployment api-backend"
|
||||
→ (RESTART_DEPLOYMENT, "api-backend", "default")
|
||||
|
||||
"Scale deployment web-frontend to 5 replicas"
|
||||
→ (SCALE_DEPLOYMENT, "web-frontend", "default")
|
||||
|
||||
"重新啟動 awoooi-worker 服務"
|
||||
→ (RESTART_DEPLOYMENT, "awoooi-worker", "default")
|
||||
|
||||
Returns:
|
||||
(operation_type, resource_name, namespace)
|
||||
"""
|
||||
action_lower = action.lower()
|
||||
|
||||
# Pattern: kubectl rollout restart deployment/<name>
|
||||
kubectl_restart_match = re.search(r'kubectl\s+rollout\s+restart\s+deployment/([a-z0-9][\w.-]*)', action_lower)
|
||||
if kubectl_restart_match:
|
||||
deploy_name = kubectl_restart_match.group(1)
|
||||
ns_match = re.search(r'-n\s+(\S+)', action_lower)
|
||||
namespace = ns_match.group(1) if ns_match else "default"
|
||||
return OperationType.RESTART_DEPLOYMENT, deploy_name, namespace
|
||||
|
||||
# Pattern: kubectl delete pod <name>
|
||||
delete_pod_match = re.search(r'delete\s+pod[:\s]+([a-z0-9][\w.-]*)', action_lower)
|
||||
if delete_pod_match:
|
||||
pod_name = delete_pod_match.group(1)
|
||||
# Extract namespace if present
|
||||
ns_match = re.search(r'-n\s+(\S+)', action_lower)
|
||||
namespace = ns_match.group(1) if ns_match else "default"
|
||||
return OperationType.DELETE_POD, pod_name, namespace
|
||||
|
||||
# Pattern: 刪除 Pod <name> (Chinese delete)
|
||||
chinese_delete_match = re.search(r'刪除\s*[Pp]od\s+([a-z0-9][\w.-]*)', action)
|
||||
if chinese_delete_match:
|
||||
pod_name = chinese_delete_match.group(1)
|
||||
return OperationType.DELETE_POD, pod_name, "default"
|
||||
|
||||
# Pattern: restart deployment <name> (English - with explicit "deployment")
|
||||
restart_deploy_match = re.search(r'restart\s+deployment[:\s]+([a-z0-9][\w.-]*)', action_lower)
|
||||
if restart_deploy_match:
|
||||
deploy_name = restart_deploy_match.group(1)
|
||||
ns_match = re.search(r'-n\s+(\S+)', action_lower)
|
||||
namespace = ns_match.group(1) if ns_match else "default"
|
||||
return OperationType.RESTART_DEPLOYMENT, deploy_name, namespace
|
||||
|
||||
# Pattern: restart <name> (English - without "deployment" keyword)
|
||||
restart_simple_match = re.search(r'restart\s+([a-z0-9][\w.-]*)', action_lower)
|
||||
if restart_simple_match:
|
||||
deploy_name = restart_simple_match.group(1)
|
||||
# Skip if captured word is "deployment" (handled above)
|
||||
if deploy_name != "deployment":
|
||||
ns_match = re.search(r'-n\s+(\S+)', action_lower)
|
||||
namespace = ns_match.group(1) if ns_match else "default"
|
||||
return OperationType.RESTART_DEPLOYMENT, deploy_name, namespace
|
||||
|
||||
# Pattern: 重新啟動 deployment <name> (Chinese with "deployment" keyword)
|
||||
chinese_restart_deploy_match = re.search(r'重新啟動\s+deployment\s+([a-z0-9][\w.-]*)', action, re.IGNORECASE)
|
||||
if chinese_restart_deploy_match:
|
||||
deploy_name = chinese_restart_deploy_match.group(1)
|
||||
return OperationType.RESTART_DEPLOYMENT, deploy_name, "default"
|
||||
|
||||
# Pattern: 重新啟動 <name> 服務 (Chinese)
|
||||
chinese_restart_match = re.search(r'重新啟動\s+([a-z0-9][\w.-]*)\s*服務', action)
|
||||
if chinese_restart_match:
|
||||
resource_name = chinese_restart_match.group(1)
|
||||
# StatefulSet Pod 格式: name-N (如 postgres-primary-0)
|
||||
if re.match(r'.*-\d+$', resource_name):
|
||||
return OperationType.DELETE_POD, resource_name, "default"
|
||||
return OperationType.RESTART_DEPLOYMENT, resource_name, "default"
|
||||
|
||||
# Pattern: scale deployment <name>
|
||||
scale_match = re.search(r'scale\s+(?:deployment[:\s]+)?([a-z0-9][\w.-]*)', action_lower)
|
||||
if scale_match:
|
||||
deploy_name = scale_match.group(1)
|
||||
ns_match = re.search(r'-n\s+(\S+)', action_lower)
|
||||
namespace = ns_match.group(1) if ns_match else "default"
|
||||
return OperationType.SCALE_DEPLOYMENT, deploy_name, namespace
|
||||
|
||||
# Pattern: 擴容 <name> (Chinese scale)
|
||||
chinese_scale_match = re.search(r'擴容\s+([a-z0-9][\w.-]*)', action)
|
||||
if chinese_scale_match:
|
||||
deploy_name = chinese_scale_match.group(1)
|
||||
return OperationType.SCALE_DEPLOYMENT, deploy_name, "default"
|
||||
|
||||
# Pattern: 擴展 <name> 副本數 (Chinese scale variant)
|
||||
chinese_scale2_match = re.search(r'擴展\s+([a-z0-9][\w.-]*)\s*副本', action)
|
||||
if chinese_scale2_match:
|
||||
deploy_name = chinese_scale2_match.group(1)
|
||||
# 移除常見的後綴如 -deployment
|
||||
deploy_name = re.sub(r'-deployment$', '', deploy_name)
|
||||
return OperationType.SCALE_DEPLOYMENT, deploy_name, "default"
|
||||
|
||||
# Pattern: 重新啟動 <name> (Chinese restart without 服務)
|
||||
chinese_restart2_match = re.search(r'重新啟動\s+([a-z0-9][\w.-]*)', action)
|
||||
if chinese_restart2_match:
|
||||
resource_name = chinese_restart2_match.group(1)
|
||||
# StatefulSet Pod 格式: name-N (如 postgres-primary-0)
|
||||
if re.match(r'.*-\d+$', resource_name):
|
||||
return OperationType.DELETE_POD, resource_name, "default"
|
||||
# 移除常見的後綴
|
||||
deploy_name = re.sub(r'-deployment$', '', resource_name)
|
||||
return OperationType.RESTART_DEPLOYMENT, deploy_name, "default"
|
||||
|
||||
return None, None, "default"
|
||||
|
||||
|
||||
async def execute_approved_action(approval: ApprovalRequest) -> None:
|
||||
"""
|
||||
@@ -299,8 +187,11 @@ async def execute_approved_action(approval: ApprovalRequest) -> None:
|
||||
service = get_approval_service()
|
||||
timeline = get_timeline_service()
|
||||
|
||||
# Parse operation details
|
||||
operation_type, resource_name, namespace = parse_operation_from_action(approval.action)
|
||||
# Parse operation details (Phase 16 R4: 使用新的 ParsedOperation dataclass)
|
||||
parsed = parse_operation_from_action(approval.action)
|
||||
operation_type = parsed.operation_type
|
||||
resource_name = parsed.resource_name
|
||||
namespace = parsed.namespace
|
||||
|
||||
if operation_type is None or resource_name is None:
|
||||
logger.warning(
|
||||
|
||||
173
apps/api/src/services/operation_parser.py
Normal file
173
apps/api/src/services/operation_parser.py
Normal file
@@ -0,0 +1,173 @@
|
||||
"""
|
||||
Operation Parser Service - Phase 16 R4 瘦身 Router 抽取
|
||||
=======================================================
|
||||
|
||||
從 approvals.py 抽取操作解析邏輯,支援中英文指令格式。
|
||||
|
||||
功能:
|
||||
- 從 action 字串解析 OperationType
|
||||
- 提取資源名稱與 Namespace
|
||||
- 支援 kubectl 指令與中文自然語言
|
||||
|
||||
版本: v1.0
|
||||
建立: 2026-03-25 (台北時區)
|
||||
建立者: Claude Code (Phase 16 R4)
|
||||
"""
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
from src.services.executor import OperationType
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParsedOperation:
|
||||
"""
|
||||
解析後的操作資訊
|
||||
|
||||
Attributes:
|
||||
operation_type: K8s 操作類型 (RESTART_DEPLOYMENT, DELETE_POD, etc.)
|
||||
resource_name: 目標資源名稱
|
||||
namespace: K8s Namespace (預設 "default")
|
||||
"""
|
||||
|
||||
operation_type: OperationType | None
|
||||
resource_name: str | None
|
||||
namespace: str
|
||||
|
||||
|
||||
def parse_operation_from_action(action: str) -> ParsedOperation:
|
||||
"""
|
||||
從 action 字串解析操作類型與目標資源
|
||||
|
||||
支援格式:
|
||||
- kubectl 指令: "kubectl delete pod nginx-xxx -n production"
|
||||
- 英文自然語言: "Restart deployment api-backend"
|
||||
- 中文自然語言: "重新啟動 awoooi-worker 服務"
|
||||
|
||||
Examples:
|
||||
"kubectl delete pod nginx-xxx -n production"
|
||||
→ ParsedOperation(DELETE_POD, "nginx-xxx", "production")
|
||||
|
||||
"Restart deployment api-backend"
|
||||
→ ParsedOperation(RESTART_DEPLOYMENT, "api-backend", "default")
|
||||
|
||||
"Scale deployment web-frontend to 5 replicas"
|
||||
→ ParsedOperation(SCALE_DEPLOYMENT, "web-frontend", "default")
|
||||
|
||||
"重新啟動 awoooi-worker 服務"
|
||||
→ ParsedOperation(RESTART_DEPLOYMENT, "awoooi-worker", "default")
|
||||
|
||||
Args:
|
||||
action: 操作指令字串
|
||||
|
||||
Returns:
|
||||
ParsedOperation: 解析後的操作資訊
|
||||
"""
|
||||
action_lower = action.lower()
|
||||
|
||||
# Pattern: kubectl rollout restart deployment/<name>
|
||||
kubectl_restart_match = re.search(
|
||||
r"kubectl\s+rollout\s+restart\s+deployment/([a-z0-9][\w.-]*)", action_lower
|
||||
)
|
||||
if kubectl_restart_match:
|
||||
deploy_name = kubectl_restart_match.group(1)
|
||||
ns_match = re.search(r"-n\s+(\S+)", action_lower)
|
||||
namespace = ns_match.group(1) if ns_match else "default"
|
||||
return ParsedOperation(OperationType.RESTART_DEPLOYMENT, deploy_name, namespace)
|
||||
|
||||
# Pattern: kubectl delete pod <name>
|
||||
delete_pod_match = re.search(
|
||||
r"delete\s+pod[:\s]+([a-z0-9][\w.-]*)", action_lower
|
||||
)
|
||||
if delete_pod_match:
|
||||
pod_name = delete_pod_match.group(1)
|
||||
ns_match = re.search(r"-n\s+(\S+)", action_lower)
|
||||
namespace = ns_match.group(1) if ns_match else "default"
|
||||
return ParsedOperation(OperationType.DELETE_POD, pod_name, namespace)
|
||||
|
||||
# Pattern: 刪除 Pod <name> (Chinese delete)
|
||||
chinese_delete_match = re.search(r"刪除\s*[Pp]od\s+([a-z0-9][\w.-]*)", action)
|
||||
if chinese_delete_match:
|
||||
pod_name = chinese_delete_match.group(1)
|
||||
return ParsedOperation(OperationType.DELETE_POD, pod_name, "default")
|
||||
|
||||
# Pattern: restart deployment <name> (English - with explicit "deployment")
|
||||
restart_deploy_match = re.search(
|
||||
r"restart\s+deployment[:\s]+([a-z0-9][\w.-]*)", action_lower
|
||||
)
|
||||
if restart_deploy_match:
|
||||
deploy_name = restart_deploy_match.group(1)
|
||||
ns_match = re.search(r"-n\s+(\S+)", action_lower)
|
||||
namespace = ns_match.group(1) if ns_match else "default"
|
||||
return ParsedOperation(OperationType.RESTART_DEPLOYMENT, deploy_name, namespace)
|
||||
|
||||
# Pattern: restart <name> (English - without "deployment" keyword)
|
||||
restart_simple_match = re.search(r"restart\s+([a-z0-9][\w.-]*)", action_lower)
|
||||
if restart_simple_match:
|
||||
deploy_name = restart_simple_match.group(1)
|
||||
# Skip if captured word is "deployment" (handled above)
|
||||
if deploy_name != "deployment":
|
||||
ns_match = re.search(r"-n\s+(\S+)", action_lower)
|
||||
namespace = ns_match.group(1) if ns_match else "default"
|
||||
return ParsedOperation(
|
||||
OperationType.RESTART_DEPLOYMENT, deploy_name, namespace
|
||||
)
|
||||
|
||||
# Pattern: 重新啟動 deployment <name> (Chinese with "deployment" keyword)
|
||||
chinese_restart_deploy_match = re.search(
|
||||
r"重新啟動\s+deployment\s+([a-z0-9][\w.-]*)", action, re.IGNORECASE
|
||||
)
|
||||
if chinese_restart_deploy_match:
|
||||
deploy_name = chinese_restart_deploy_match.group(1)
|
||||
return ParsedOperation(OperationType.RESTART_DEPLOYMENT, deploy_name, "default")
|
||||
|
||||
# Pattern: 重新啟動 <name> 服務 (Chinese)
|
||||
chinese_restart_match = re.search(r"重新啟動\s+([a-z0-9][\w.-]*)\s*服務", action)
|
||||
if chinese_restart_match:
|
||||
resource_name = chinese_restart_match.group(1)
|
||||
# StatefulSet Pod 格式: name-N (如 postgres-primary-0)
|
||||
if re.match(r".*-\d+$", resource_name):
|
||||
return ParsedOperation(OperationType.DELETE_POD, resource_name, "default")
|
||||
return ParsedOperation(
|
||||
OperationType.RESTART_DEPLOYMENT, resource_name, "default"
|
||||
)
|
||||
|
||||
# Pattern: scale deployment <name>
|
||||
scale_match = re.search(
|
||||
r"scale\s+(?:deployment[:\s]+)?([a-z0-9][\w.-]*)", action_lower
|
||||
)
|
||||
if scale_match:
|
||||
deploy_name = scale_match.group(1)
|
||||
ns_match = re.search(r"-n\s+(\S+)", action_lower)
|
||||
namespace = ns_match.group(1) if ns_match else "default"
|
||||
return ParsedOperation(OperationType.SCALE_DEPLOYMENT, deploy_name, namespace)
|
||||
|
||||
# Pattern: 擴容 <name> (Chinese scale)
|
||||
chinese_scale_match = re.search(r"擴容\s+([a-z0-9][\w.-]*)", action)
|
||||
if chinese_scale_match:
|
||||
deploy_name = chinese_scale_match.group(1)
|
||||
return ParsedOperation(OperationType.SCALE_DEPLOYMENT, deploy_name, "default")
|
||||
|
||||
# Pattern: 擴展 <name> 副本數 (Chinese scale variant)
|
||||
chinese_scale2_match = re.search(r"擴展\s+([a-z0-9][\w.-]*)\s*副本", action)
|
||||
if chinese_scale2_match:
|
||||
deploy_name = chinese_scale2_match.group(1)
|
||||
# 移除常見的後綴如 -deployment
|
||||
deploy_name = re.sub(r"-deployment$", "", deploy_name)
|
||||
return ParsedOperation(OperationType.SCALE_DEPLOYMENT, deploy_name, "default")
|
||||
|
||||
# Pattern: 重新啟動 <name> (Chinese restart without 服務)
|
||||
chinese_restart2_match = re.search(r"重新啟動\s+([a-z0-9][\w.-]*)", action)
|
||||
if chinese_restart2_match:
|
||||
resource_name = chinese_restart2_match.group(1)
|
||||
# StatefulSet Pod 格式: name-N (如 postgres-primary-0)
|
||||
if re.match(r".*-\d+$", resource_name):
|
||||
return ParsedOperation(OperationType.DELETE_POD, resource_name, "default")
|
||||
# 移除常見的後綴
|
||||
deploy_name = re.sub(r"-deployment$", "", resource_name)
|
||||
return ParsedOperation(
|
||||
OperationType.RESTART_DEPLOYMENT, deploy_name, "default"
|
||||
)
|
||||
|
||||
return ParsedOperation(None, None, "default")
|
||||
Reference in New Issue
Block a user