Skip to content

Commit 6476fdc

Browse files
authored
feat(k8s): add k8s_patch_status tool for patching resource status subresource (#50)
Signed-off-by: Felipe Vicens <felipejose.vicensgonzalez@telefonica.com>
1 parent 94be215 commit 6476fdc

2 files changed

Lines changed: 99 additions & 0 deletions

File tree

pkg/k8s/k8s.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,47 @@ func (k *K8sTool) handlePatchResource(ctx context.Context, request mcp.CallToolR
157157
return k.runKubectlCommandWithCacheInvalidation(ctx, request.Header, args...)
158158
}
159159

160+
// Patch resource status
161+
func (k *K8sTool) handlePatchStatus(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
162+
resourceType := mcp.ParseString(request, "resource_type", "")
163+
resourceName := mcp.ParseString(request, "resource_name", "")
164+
patch := mcp.ParseString(request, "patch", "")
165+
namespace := mcp.ParseString(request, "namespace", "default")
166+
167+
if resourceType == "" || resourceName == "" || patch == "" {
168+
return mcp.NewToolResultError("resource_type, resource_name, and patch parameters are required"), nil
169+
}
170+
171+
// Validate resource name for security
172+
if err := security.ValidateK8sResourceName(resourceName); err != nil {
173+
return mcp.NewToolResultError(fmt.Sprintf("Invalid resource name: %v", err)), nil
174+
}
175+
176+
// Validate namespace for security
177+
if err := security.ValidateNamespace(namespace); err != nil {
178+
return mcp.NewToolResultError(fmt.Sprintf("Invalid namespace: %v", err)), nil
179+
}
180+
181+
// Validate patch content as JSON/YAML
182+
if err := security.ValidateYAMLContent(patch); err != nil {
183+
return mcp.NewToolResultError(fmt.Sprintf("Invalid patch content: %v", err)), nil
184+
}
185+
186+
args := []string{
187+
"patch",
188+
resourceType,
189+
resourceName,
190+
"--subresource=status",
191+
"--type=merge",
192+
"-p",
193+
patch,
194+
"-n",
195+
namespace,
196+
}
197+
198+
return k.runKubectlCommandWithCacheInvalidation(ctx, request.Header, args...)
199+
}
200+
160201
// Apply manifest from content
161202
func (k *K8sTool) handleApplyManifest(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
162203
manifest := mcp.ParseString(request, "manifest", "")
@@ -683,6 +724,14 @@ func RegisterTools(s *server.MCPServer, llm llms.Model, kubeconfig string, readO
683724
mcp.WithString("namespace", mcp.Description("Namespace of the resource (default: default)")),
684725
), telemetry.AdaptToolHandler(telemetry.WithTracing("k8s_patch_resource", k8sTool.handlePatchResource)))
685726

727+
s.AddTool(mcp.NewTool("k8s_patch_status",
728+
mcp.WithDescription("Patch the status of a Kubernetes resource"),
729+
mcp.WithString("resource_type", mcp.Description("Type of resource (deployment, service, etc.)"), mcp.Required()),
730+
mcp.WithString("resource_name", mcp.Description("Name of the resource"), mcp.Required()),
731+
mcp.WithString("patch", mcp.Description("JSON/YAML status patch"), mcp.Required()),
732+
mcp.WithString("namespace", mcp.Description("Namespace of the resource (default: default)")),
733+
), telemetry.AdaptToolHandler(telemetry.WithTracing("k8s_patch_status", k8sTool.handlePatchStatus)))
734+
686735
s.AddTool(mcp.NewTool("k8s_apply_manifest",
687736
mcp.WithDescription("Apply a YAML manifest to the Kubernetes cluster"),
688737
mcp.WithString("manifest", mcp.Description("YAML manifest content"), mcp.Required()),

pkg/k8s/k8s_test.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,56 @@ func TestHandlePatchResource(t *testing.T) {
264264
})
265265
}
266266

267+
func TestHandlePatchStatus(t *testing.T) {
268+
ctx := context.Background()
269+
270+
t.Run("missing parameters", func(t *testing.T) {
271+
mock := cmd.NewMockShellExecutor()
272+
ctx := cmd.WithShellExecutor(context.Background(), mock)
273+
274+
k8sTool := newTestK8sTool()
275+
276+
req := mcp.CallToolRequest{}
277+
req.Params.Arguments = map[string]interface{}{
278+
"resource_type": "customresource",
279+
// Missing resource_name and patch
280+
}
281+
282+
result, err := k8sTool.handlePatchStatus(ctx, req)
283+
assert.NoError(t, err)
284+
assert.NotNil(t, result)
285+
assert.True(t, result.IsError)
286+
287+
// Verify no commands were executed since parameters are missing
288+
callLog := mock.GetCallLog()
289+
assert.Len(t, callLog, 0)
290+
})
291+
292+
t.Run("valid parameters", func(t *testing.T) {
293+
mock := cmd.NewMockShellExecutor()
294+
expectedOutput := `customresource.kagent.dev/test-resource patched`
295+
mock.AddCommandString("kubectl", []string{"patch", "customresource", "test-resource", "--subresource=status", "--type=merge", "-p", `{"status":{"phase":"Ready"}}`, "-n", "default"}, expectedOutput, nil)
296+
ctx := cmd.WithShellExecutor(ctx, mock)
297+
298+
k8sTool := newTestK8sTool()
299+
300+
req := mcp.CallToolRequest{}
301+
req.Params.Arguments = map[string]interface{}{
302+
"resource_type": "customresource",
303+
"resource_name": "test-resource",
304+
"patch": `{"status":{"phase":"Ready"}}`,
305+
}
306+
307+
result, err := k8sTool.handlePatchStatus(ctx, req)
308+
assert.NoError(t, err)
309+
assert.NotNil(t, result)
310+
assert.False(t, result.IsError)
311+
312+
resultText := getResultText(result)
313+
assert.Contains(t, resultText, "patched")
314+
})
315+
}
316+
267317
func TestHandleDeleteResource(t *testing.T) {
268318
ctx := context.Background()
269319

0 commit comments

Comments
 (0)