feat(sre): enforce verified kubernetes repairs
This commit is contained in:
@@ -594,6 +594,19 @@ class MCPBridge:
|
||||
# Kubernetes: 使用真實 ActionExecutor
|
||||
# =============================================
|
||||
if server.name == "kubernetes":
|
||||
if tool_name in {
|
||||
"kubectl_delete",
|
||||
"kubectl_scale",
|
||||
"kubectl_restart",
|
||||
"kubectl_rollout_undo",
|
||||
}:
|
||||
return {
|
||||
"error": (
|
||||
"kubernetes_controlled_executor:"
|
||||
"provider_required_no_raw_mutation_fallback"
|
||||
),
|
||||
"runtime_write_performed": False,
|
||||
}
|
||||
from src.services.executor import get_executor
|
||||
|
||||
executor = get_executor()
|
||||
@@ -609,69 +622,6 @@ class MCPBridge:
|
||||
return result.k8s_response.get("stdout", "")
|
||||
return {"error": result.error}
|
||||
|
||||
elif tool_name == "kubectl_delete":
|
||||
namespace = parameters.get("namespace", settings.AWOOOI_K8S_NAMESPACE)
|
||||
resource = parameters.get("resource", "pod")
|
||||
name = parameters.get("name", "")
|
||||
if not name:
|
||||
return {"error": "Missing 'name' parameter"}
|
||||
|
||||
# Dry-run 驗證
|
||||
if resource == "pod":
|
||||
dry_run = await executor.validate_pod_exists(name, namespace)
|
||||
else:
|
||||
dry_run = await executor.validate_deployment_exists(name, namespace)
|
||||
|
||||
if not dry_run.passed:
|
||||
return {"error": dry_run.message, "dry_run": False}
|
||||
|
||||
# 執行刪除
|
||||
if resource == "pod":
|
||||
result = await executor.delete_pod(name, namespace)
|
||||
else:
|
||||
# deployment 不支援直接刪除,改用 restart
|
||||
return {"error": "Direct deployment deletion not supported, use restart"}
|
||||
|
||||
return {
|
||||
"success": result.success,
|
||||
"message": result.message,
|
||||
"duration_ms": result.duration_ms,
|
||||
}
|
||||
|
||||
elif tool_name == "kubectl_scale":
|
||||
namespace = parameters.get("namespace", settings.AWOOOI_K8S_NAMESPACE)
|
||||
deployment = parameters.get("deployment", "")
|
||||
replicas = parameters.get("replicas", 1)
|
||||
if not deployment:
|
||||
return {"error": "Missing 'deployment' parameter"}
|
||||
|
||||
cmd = f"kubectl scale deployment/{deployment} --replicas={replicas} -n {namespace}"
|
||||
result = await executor.execute_kubectl_command(cmd)
|
||||
return {
|
||||
"success": result.success,
|
||||
"scaled": result.success,
|
||||
"replicas": replicas,
|
||||
"message": result.message,
|
||||
}
|
||||
|
||||
elif tool_name == "kubectl_restart":
|
||||
namespace = parameters.get("namespace", settings.AWOOOI_K8S_NAMESPACE)
|
||||
deployment = parameters.get("deployment", "")
|
||||
if not deployment:
|
||||
return {"error": "Missing 'deployment' parameter"}
|
||||
|
||||
dry_run = await executor.validate_deployment_exists(deployment, namespace)
|
||||
if not dry_run.passed:
|
||||
return {"error": dry_run.message, "dry_run": False}
|
||||
|
||||
result = await executor.restart_deployment(deployment, namespace)
|
||||
return {
|
||||
"success": result.success,
|
||||
"restarted": result.success,
|
||||
"message": result.message,
|
||||
"duration_ms": result.duration_ms,
|
||||
}
|
||||
|
||||
else:
|
||||
return {"error": f"Unknown kubernetes tool: {tool_name}"}
|
||||
|
||||
|
||||
@@ -67,6 +67,7 @@ class K8sProvider(MCPToolProvider):
|
||||
def __init__(self) -> None:
|
||||
# Lazy import to avoid circular dependency
|
||||
self._executor = None
|
||||
self._rollout_verifier = None
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
@@ -261,6 +262,56 @@ class K8sProvider(MCPToolProvider):
|
||||
executor = self._get_executor()
|
||||
|
||||
try:
|
||||
if tool_name in {
|
||||
"kubectl_delete",
|
||||
"kubectl_scale",
|
||||
"kubectl_restart",
|
||||
"kubectl_rollout_undo",
|
||||
}:
|
||||
from src.services.kubernetes_controlled_executor import (
|
||||
validate_kubernetes_callback_execution_capability,
|
||||
)
|
||||
|
||||
claim = parameters.get("_controlled_execution_claim")
|
||||
capability = parameters.get("_controlled_execution_capability")
|
||||
claim_valid, claim_reason = (
|
||||
validate_kubernetes_callback_execution_capability(
|
||||
tool_name=tool_name,
|
||||
parameters=parameters,
|
||||
claim=claim if isinstance(claim, dict) else None,
|
||||
capability=capability,
|
||||
)
|
||||
)
|
||||
if not claim_valid:
|
||||
return MCPToolResult(
|
||||
success=False,
|
||||
execution_id=execution_id,
|
||||
error=f"kubernetes_controlled_executor:{claim_reason}",
|
||||
)
|
||||
if tool_name == "kubectl_restart":
|
||||
from src.services.kubernetes_controlled_executor import (
|
||||
execute_kubernetes_controlled_claim,
|
||||
)
|
||||
|
||||
controlled_result = await execute_kubernetes_controlled_claim(
|
||||
claim,
|
||||
executor=executor,
|
||||
verifier=self._rollout_verifier,
|
||||
)
|
||||
output = {
|
||||
"success": controlled_result.success,
|
||||
"restarted": controlled_result.success,
|
||||
"message": controlled_result.message,
|
||||
"duration_ms": controlled_result.duration_ms,
|
||||
"controlled_receipt": controlled_result.k8s_response,
|
||||
}
|
||||
return MCPToolResult(
|
||||
success=controlled_result.success,
|
||||
execution_id=execution_id,
|
||||
output=output,
|
||||
error=controlled_result.error,
|
||||
)
|
||||
|
||||
if tool_name == "kubectl_get":
|
||||
output = await self._kubectl_get(executor, parameters)
|
||||
elif tool_name == "kubectl_delete":
|
||||
@@ -290,6 +341,17 @@ class K8sProvider(MCPToolProvider):
|
||||
error=f"Unknown tool: {tool_name}",
|
||||
)
|
||||
|
||||
output_error = output.get("error") if isinstance(output, dict) else None
|
||||
output_failed = (
|
||||
isinstance(output, dict) and output.get("success") is False
|
||||
)
|
||||
if output_error or output_failed:
|
||||
return MCPToolResult(
|
||||
success=False,
|
||||
execution_id=execution_id,
|
||||
output=output,
|
||||
error=str(output_error or "kubernetes_tool_execution_failed"),
|
||||
)
|
||||
return MCPToolResult(
|
||||
success=True,
|
||||
execution_id=execution_id,
|
||||
@@ -317,27 +379,23 @@ class K8sProvider(MCPToolProvider):
|
||||
return {"error": result.error}
|
||||
|
||||
async def _kubectl_delete(self, executor, parameters: dict) -> dict:
|
||||
namespace = parameters.get("namespace", "awoooi-prod")
|
||||
namespace = _validate_namespace(
|
||||
str(parameters.get("namespace", DEFAULT_NAMESPACE))
|
||||
)
|
||||
resource = parameters.get("resource", "pod")
|
||||
name = parameters.get("name", "")
|
||||
name = _validate_name(str(parameters.get("name", "")))
|
||||
|
||||
if not name:
|
||||
return {"error": "Missing 'name' parameter"}
|
||||
if resource != "pod":
|
||||
return {"error": "Only exact pod deletion is supported"}
|
||||
|
||||
# Dry-run validation
|
||||
if resource == "pod":
|
||||
dry_run = await executor.validate_pod_exists(name, namespace)
|
||||
else:
|
||||
dry_run = await executor.validate_deployment_exists(name, namespace)
|
||||
dry_run = await executor.validate_pod_exists(name, namespace)
|
||||
|
||||
if not dry_run.passed:
|
||||
return {"error": dry_run.message, "dry_run": False}
|
||||
|
||||
# Execute deletion
|
||||
if resource == "pod":
|
||||
result = await executor.delete_pod(name, namespace)
|
||||
else:
|
||||
return {"error": "Direct deployment deletion not supported, use restart"}
|
||||
result = await executor.delete_pod(name, namespace)
|
||||
|
||||
return {
|
||||
"success": result.success,
|
||||
@@ -346,11 +404,13 @@ class K8sProvider(MCPToolProvider):
|
||||
}
|
||||
|
||||
async def _kubectl_scale(self, executor, parameters: dict) -> dict:
|
||||
namespace = parameters.get("namespace", "awoooi-prod")
|
||||
deployment = parameters.get("deployment", "")
|
||||
|
||||
if not deployment:
|
||||
return {"error": "Missing 'deployment' parameter"}
|
||||
namespace = _validate_namespace(
|
||||
str(parameters.get("namespace", DEFAULT_NAMESPACE))
|
||||
)
|
||||
deployment = _validate_name(
|
||||
str(parameters.get("deployment", "")),
|
||||
"deployment",
|
||||
)
|
||||
|
||||
# 2026-04-27 ogt + Claude Sonnet 4.6: 補 dry-run 驗證,與 _kubectl_restart 對齊
|
||||
# 根因:_kubectl_scale 缺 validate_deployment_exists → gitea(docker-compose 服務)
|
||||
@@ -381,11 +441,13 @@ class K8sProvider(MCPToolProvider):
|
||||
}
|
||||
|
||||
async def _kubectl_restart(self, executor, parameters: dict) -> dict:
|
||||
namespace = parameters.get("namespace", "awoooi-prod")
|
||||
deployment = parameters.get("deployment", "")
|
||||
|
||||
if not deployment:
|
||||
return {"error": "Missing 'deployment' parameter"}
|
||||
namespace = _validate_namespace(
|
||||
str(parameters.get("namespace", DEFAULT_NAMESPACE))
|
||||
)
|
||||
deployment = _validate_name(
|
||||
str(parameters.get("deployment", "")),
|
||||
"deployment",
|
||||
)
|
||||
|
||||
dry_run = await executor.validate_deployment_exists(deployment, namespace)
|
||||
if not dry_run.passed:
|
||||
@@ -401,11 +463,13 @@ class K8sProvider(MCPToolProvider):
|
||||
}
|
||||
|
||||
async def _kubectl_rollout_undo(self, executor, parameters: dict) -> dict:
|
||||
namespace = parameters.get("namespace", "awoooi-prod")
|
||||
deployment = parameters.get("deployment", "")
|
||||
|
||||
if not deployment:
|
||||
return {"error": "Missing 'deployment' parameter"}
|
||||
namespace = _validate_namespace(
|
||||
str(parameters.get("namespace", DEFAULT_NAMESPACE))
|
||||
)
|
||||
deployment = _validate_name(
|
||||
str(parameters.get("deployment", "")),
|
||||
"deployment",
|
||||
)
|
||||
|
||||
dry_run = await executor.validate_deployment_exists(deployment, namespace)
|
||||
if not dry_run.passed:
|
||||
|
||||
@@ -16,6 +16,10 @@ from src.plugins.mcp.interfaces import MCPTool, MCPToolProvider, MCPToolResult
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
_EPHEMERAL_PROVIDER_PARAMETER_KEYS = frozenset({
|
||||
"_controlled_execution_capability",
|
||||
})
|
||||
|
||||
|
||||
class AuditedMCPToolProvider(MCPToolProvider):
|
||||
"""Provider wrapper that writes every MCP tool call to the audit subsystem."""
|
||||
@@ -52,6 +56,11 @@ class AuditedMCPToolProvider(MCPToolProvider):
|
||||
key: value for key, value in parameters.items()
|
||||
if key != "_mcp_audit"
|
||||
}
|
||||
audit_input_parameters = {
|
||||
key: value
|
||||
for key, value in parameters.items()
|
||||
if key not in _EPHEMERAL_PROVIDER_PARAMETER_KEYS
|
||||
}
|
||||
started = monotonic_ms()
|
||||
result: MCPToolResult | None = None
|
||||
try:
|
||||
@@ -66,7 +75,7 @@ class AuditedMCPToolProvider(MCPToolProvider):
|
||||
await record_mcp_call(
|
||||
mcp_server=self.name,
|
||||
tool_name=tool_name,
|
||||
input_params=parameters,
|
||||
input_params=audit_input_parameters,
|
||||
output_result=result.output if result else None,
|
||||
duration_ms=duration_ms,
|
||||
success=bool(result.success) if result else False,
|
||||
|
||||
Reference in New Issue
Block a user