diff --git a/pkg/k8s/k8s.go b/pkg/k8s/k8s.go index 22a8bad..f84a047 100644 --- a/pkg/k8s/k8s.go +++ b/pkg/k8s/k8s.go @@ -157,6 +157,47 @@ func (k *K8sTool) handlePatchResource(ctx context.Context, request mcp.CallToolR return k.runKubectlCommandWithCacheInvalidation(ctx, request.Header, args...) } +// Patch resource status +func (k *K8sTool) handlePatchStatus(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + resourceType := mcp.ParseString(request, "resource_type", "") + resourceName := mcp.ParseString(request, "resource_name", "") + patch := mcp.ParseString(request, "patch", "") + namespace := mcp.ParseString(request, "namespace", "default") + + if resourceType == "" || resourceName == "" || patch == "" { + return mcp.NewToolResultError("resource_type, resource_name, and patch parameters are required"), nil + } + + // Validate resource name for security + if err := security.ValidateK8sResourceName(resourceName); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Invalid resource name: %v", err)), nil + } + + // Validate namespace for security + if err := security.ValidateNamespace(namespace); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Invalid namespace: %v", err)), nil + } + + // Validate patch content as JSON/YAML + if err := security.ValidateYAMLContent(patch); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Invalid patch content: %v", err)), nil + } + + args := []string{ + "patch", + resourceType, + resourceName, + "--subresource=status", + "--type=merge", + "-p", + patch, + "-n", + namespace, + } + + return k.runKubectlCommandWithCacheInvalidation(ctx, request.Header, args...) +} + // Apply manifest from content func (k *K8sTool) handleApplyManifest(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { manifest := mcp.ParseString(request, "manifest", "") @@ -683,6 +724,14 @@ func RegisterTools(s *server.MCPServer, llm llms.Model, kubeconfig string, readO mcp.WithString("namespace", mcp.Description("Namespace of the resource (default: default)")), ), telemetry.AdaptToolHandler(telemetry.WithTracing("k8s_patch_resource", k8sTool.handlePatchResource))) + s.AddTool(mcp.NewTool("k8s_patch_status", + mcp.WithDescription("Patch the status of a Kubernetes resource"), + mcp.WithString("resource_type", mcp.Description("Type of resource (deployment, service, etc.)"), mcp.Required()), + mcp.WithString("resource_name", mcp.Description("Name of the resource"), mcp.Required()), + mcp.WithString("patch", mcp.Description("JSON/YAML status patch"), mcp.Required()), + mcp.WithString("namespace", mcp.Description("Namespace of the resource (default: default)")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("k8s_patch_status", k8sTool.handlePatchStatus))) + s.AddTool(mcp.NewTool("k8s_apply_manifest", mcp.WithDescription("Apply a YAML manifest to the Kubernetes cluster"), mcp.WithString("manifest", mcp.Description("YAML manifest content"), mcp.Required()), diff --git a/pkg/k8s/k8s_test.go b/pkg/k8s/k8s_test.go index 8ac0340..44df8a9 100644 --- a/pkg/k8s/k8s_test.go +++ b/pkg/k8s/k8s_test.go @@ -264,6 +264,56 @@ func TestHandlePatchResource(t *testing.T) { }) } +func TestHandlePatchStatus(t *testing.T) { + ctx := context.Background() + + t.Run("missing parameters", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + ctx := cmd.WithShellExecutor(context.Background(), mock) + + k8sTool := newTestK8sTool() + + req := mcp.CallToolRequest{} + req.Params.Arguments = map[string]interface{}{ + "resource_type": "customresource", + // Missing resource_name and patch + } + + result, err := k8sTool.handlePatchStatus(ctx, req) + assert.NoError(t, err) + assert.NotNil(t, result) + assert.True(t, result.IsError) + + // Verify no commands were executed since parameters are missing + callLog := mock.GetCallLog() + assert.Len(t, callLog, 0) + }) + + t.Run("valid parameters", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + expectedOutput := `customresource.kagent.dev/test-resource patched` + mock.AddCommandString("kubectl", []string{"patch", "customresource", "test-resource", "--subresource=status", "--type=merge", "-p", `{"status":{"phase":"Ready"}}`, "-n", "default"}, expectedOutput, nil) + ctx := cmd.WithShellExecutor(ctx, mock) + + k8sTool := newTestK8sTool() + + req := mcp.CallToolRequest{} + req.Params.Arguments = map[string]interface{}{ + "resource_type": "customresource", + "resource_name": "test-resource", + "patch": `{"status":{"phase":"Ready"}}`, + } + + result, err := k8sTool.handlePatchStatus(ctx, req) + assert.NoError(t, err) + assert.NotNil(t, result) + assert.False(t, result.IsError) + + resultText := getResultText(result) + assert.Contains(t, resultText, "patched") + }) +} + func TestHandleDeleteResource(t *testing.T) { ctx := context.Background()