Files
awoooi/apps/api/src/services/operation_parser.py
Your Name 607358c4dd fix(approval): route SSH actions through SSHProvider on manual approve
parse_operation_from_action only knew kubectl and Chinese restart phrases,
so any "ssh host '...'" action approved via Telegram fell through to
"Could not parse operation type" and reported a fake failure even though
the LLM had proposed a valid host repair.

Adds OperationType.SSH_HOST, makes the parser detect ssh prefixes (with
optional flags / user@host) before kubectl patterns, and routes the
SSH_HOST branch in approval_execution.execute_in_background through
SSHProvider with the same tool keywords decision_manager uses
(ssh_docker_prune / ssh_docker_restart / ssh_systemctl_restart /
ssh_diagnose). Unroutable SSH actions now fail loudly with a descriptive
error instead of silently breaking.

Trigger: 2026-05-02 incidents INC-20260502-D6D0B7 / E12EE4 / 557055
were approved by the user but executor reported "Could not parse" and
left the alerts pending.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 12:31:37 +08:00

213 lines
9.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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
# 2026-04-06 ogt: Phase 26 — 預設 namespace 改為 awoooi-prod
# 原本 "default" 導致 203 次執行全失敗deployment 全在 awoooi-prod
DEFAULT_NAMESPACE = "awoooi-prod"
@dataclass
class ParsedOperation:
"""
解析後的操作資訊
Attributes:
operation_type: K8s 操作類型 (RESTART_DEPLOYMENT, DELETE_POD, etc.)
resource_name: 目標資源名稱
namespace: K8s Namespace (預設 "awoooi-prod")
Note:
支援 tuple 解包以向後兼容:
op, resource, ns = parse_operation_from_action(action)
"""
operation_type: OperationType | None
resource_name: str | None
namespace: str
def __iter__(self):
"""支援 tuple 解包: op, resource, ns = parsed"""
return iter((self.operation_type, self.resource_name, self.namespace))
def parse_operation_from_action(action: str) -> ParsedOperation:
"""
從 action 字串解析操作類型與目標資源
支援格式:
- kubectl 指令: "kubectl delete pod nginx-xxx -n production"
- 英文自然語言: "Restart deployment api-backend"
- 中文自然語言: "重新啟動 awoooi-worker 服務"
- SSH 指令: "ssh 192.168.0.110 'docker prune ...'" / "ssh wooo@192.168.0.110 ..."
Examples:
"kubectl delete pod nginx-xxx -n production"
→ ParsedOperation(DELETE_POD, "nginx-xxx", "production")
"ssh 192.168.0.110 'docker image prune -a -f'"
→ ParsedOperation(SSH_HOST, "192.168.0.110", "host")
Args:
action: 操作指令字串
Returns:
ParsedOperation: 解析後的操作資訊
"""
action_lower = action.lower()
# 2026-05-02 ogt + Claude Sonnet 4.6: SSH host 操作識別
# 根因approval_execution 把 SSH action 丟進 kubectl parser → 全部 None → 「Could not parse」
# 修法:第一順位偵測 ssh回 SSH_HOSTapproval_execution 走 SSHProvider
# 支援 "ssh host '...'" / "ssh user@host ..." / "ssh -o opt host ..."
ssh_match = re.search(
r"^\s*ssh\s+(?:-[a-zA-Z]\s*\S+\s+)*(?:[a-zA-Z][\w-]*@)?([a-zA-Z0-9][\w.-]*)",
action_lower,
)
if ssh_match:
host = ssh_match.group(1)
# namespace 欄位借用為「host_class」下游不必改
return ParsedOperation(OperationType.SSH_HOST, host, "host")
# 2026-04-24 ogt + Claude Sonnet 4.6: Gate 11 修復 — 唯讀指令識別INVESTIGATE
# 根因parse_operation_from_action 完全不認識 kubectl get/top/describe/logs → 回 None → 執行失敗
# 修法:優先匹配唯讀指令,回傳 OperationType.INVESTIGATE零衝擊blast_radius score=1
kubectl_ro_match = re.search(
r"kubectl\s+(get|top|describe|logs|version)\s*([a-z][\w.-]*)?",
action_lower,
)
if kubectl_ro_match:
ns_match = re.search(r"-n\s+(\S+)", action_lower)
namespace = ns_match.group(1) if ns_match else DEFAULT_NAMESPACE
resource = kubectl_ro_match.group(2) or "pods"
return ParsedOperation(OperationType.INVESTIGATE, resource, namespace)
# Pattern: kubectl rollout restart deployment|statefulset|daemonset/<name>
kubectl_restart_match = re.search(
r"kubectl\s+rollout\s+restart\s+(deployment|deploy|statefulset|sts|daemonset|ds)/([a-z0-9][\w.-]*)",
action_lower,
)
if kubectl_restart_match:
resource_type = kubectl_restart_match.group(1)
resource_name = kubectl_restart_match.group(2)
ns_match = re.search(r"-n\s+(\S+)", action_lower)
namespace = ns_match.group(1) if ns_match else DEFAULT_NAMESPACE
if resource_type in {"statefulset", "sts"}:
return ParsedOperation(OperationType.RESTART_STATEFULSET, resource_name, namespace)
if resource_type in {"daemonset", "ds"}:
return ParsedOperation(OperationType.RESTART_DAEMONSET, resource_name, namespace)
return ParsedOperation(OperationType.RESTART_DEPLOYMENT, resource_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_NAMESPACE
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_NAMESPACE)
# 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_NAMESPACE
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_NAMESPACE
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_NAMESPACE)
# 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_NAMESPACE)
return ParsedOperation(
OperationType.RESTART_DEPLOYMENT, resource_name, DEFAULT_NAMESPACE
)
# 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_NAMESPACE
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_NAMESPACE)
# 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_NAMESPACE)
# 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_NAMESPACE)
# 移除常見的後綴
deploy_name = re.sub(r"-deployment$", "", resource_name)
return ParsedOperation(
OperationType.RESTART_DEPLOYMENT, deploy_name, DEFAULT_NAMESPACE
)
return ParsedOperation(None, None, DEFAULT_NAMESPACE)