Skip to content

Commit dbe51ab

Browse files
committed
Add nodes_debug_exec tool in pkg/ocp package
1 parent 31058bd commit dbe51ab

19 files changed

+1722
-14
lines changed

README.md

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -208,11 +208,12 @@ The following sets of tools are available (all on by default):
208208

209209
<!-- AVAILABLE-TOOLSETS-START -->
210210

211-
| Toolset | Description |
212-
|---------|-------------------------------------------------------------------------------------|
213-
| config | View and manage the current local Kubernetes configuration (kubeconfig) |
214-
| core | Most common tools for Kubernetes management (Pods, Generic Resources, Events, etc.) |
215-
| helm | Tools for managing Helm charts and releases |
211+
| Toolset | Description |
212+
|----------------|-------------------------------------------------------------------------------------|
213+
| config | View and manage the current local Kubernetes configuration (kubeconfig) |
214+
| core | Most common tools for Kubernetes management (Pods, Generic Resources, Events, etc.) |
215+
| helm | Tools for managing Helm charts and releases |
216+
| openshift-core | Core OpenShift-specific tools (Node debugging, etc.) |
216217

217218
<!-- AVAILABLE-TOOLSETS-END -->
218219

@@ -336,6 +337,19 @@ In case multi-cluster support is enabled (default) and you have access to multip
336337

337338
</details>
338339

340+
<details>
341+
342+
<summary>openshift-core</summary>
343+
344+
- **nodes_debug_exec** - Run commands on an OpenShift node using a privileged debug pod with comprehensive troubleshooting utilities. The debug pod uses the UBI9 toolbox image which includes: systemd tools (systemctl, journalctl), networking tools (ss, ip, ping, traceroute, nmap), process tools (ps, top, lsof, strace), file system tools (find, tar, rsync), and debugging tools (gdb). The host filesystem is mounted at /host, allowing commands to chroot /host if needed to access node-level resources. Output is truncated to the most recent 100 lines, so prefer filters like grep when expecting large logs.
345+
- `command` (`array`) **(required)** - Command to execute on the node. All standard debugging utilities from the UBI9 toolbox are available. The host filesystem is mounted at /host - use 'chroot /host <command>' to access node-level resources, or run commands directly in the toolbox environment. Provide each argument as a separate array item (e.g. ['chroot', '/host', 'systemctl', 'status', 'kubelet'] or ['journalctl', '-u', 'kubelet', '--since', '1 hour ago']).
346+
- `image` (`string`) - Container image to use for the debug pod (optional). Defaults to registry.access.redhat.com/ubi9/toolbox:latest which provides comprehensive debugging and troubleshooting utilities.
347+
- `namespace` (`string`) - Namespace to create the temporary debug pod in (optional, defaults to the current namespace or 'default').
348+
- `node` (`string`) **(required)** - Name of the node to debug (e.g. worker-0).
349+
- `timeout_seconds` (`integer`) - Maximum time to wait for the command to complete before timing out (optional, defaults to 60 seconds).
350+
351+
</details>
352+
339353

340354
<!-- AVAILABLE-TOOLSETS-TOOLS-END -->
341355

internal/tools/update-readme/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
_ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/config"
1616
_ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/core"
1717
_ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/helm"
18+
_ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/openshift"
1819
)
1920

2021
type OpenShift struct{}

pkg/config/config_default_overrides.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package config
33
func defaultOverrides() StaticConfig {
44
return StaticConfig{
55
// IMPORTANT: this file is used to override default config values in downstream builds.
6-
// This is intentionally left blank.
6+
// OpenShift-specific defaults: add openshift-core toolset
7+
Toolsets: []string{"core", "config", "helm", "openshift-core"},
78
}
89
}

pkg/config/config_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -167,8 +167,8 @@ func (s *ConfigSuite) TestReadConfigValidPreservesDefaultsForMissingFields() {
167167
s.Equalf("table", config.ListOutput, "Expected ListOutput to be table, got %s", config.ListOutput)
168168
})
169169
s.Run("toolsets defaulted correctly", func() {
170-
s.Require().Lenf(config.Toolsets, 3, "Expected 3 toolsets, got %d", len(config.Toolsets))
171-
for _, toolset := range []string{"core", "config", "helm"} {
170+
s.Require().Lenf(config.Toolsets, 4, "Expected 4 toolsets, got %d", len(config.Toolsets))
171+
for _, toolset := range []string{"core", "config", "helm", "openshift-core"} {
172172
s.Containsf(config.Toolsets, toolset, "Expected toolsets to contain %s", toolset)
173173
}
174174
})

pkg/kubernetes-mcp-server/cmd/root_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,15 +137,15 @@ func TestToolsets(t *testing.T) {
137137
rootCmd := NewMCPServer(ioStreams)
138138
rootCmd.SetArgs([]string{"--help"})
139139
o, err := captureOutput(rootCmd.Execute) // --help doesn't use logger/klog, cobra prints directly to stdout
140-
if !strings.Contains(o, "Comma-separated list of MCP toolsets to use (available toolsets: config, core, helm).") {
140+
if !strings.Contains(o, "Comma-separated list of MCP toolsets to use (available toolsets: config, core, helm, openshift-core).") {
141141
t.Fatalf("Expected all available toolsets, got %s %v", o, err)
142142
}
143143
})
144144
t.Run("default", func(t *testing.T) {
145145
ioStreams, out := testStream()
146146
rootCmd := NewMCPServer(ioStreams)
147147
rootCmd.SetArgs([]string{"--version", "--port=1337", "--log-level=1"})
148-
if err := rootCmd.Execute(); !strings.Contains(out.String(), "- Toolsets: core, config, helm") {
148+
if err := rootCmd.Execute(); !strings.Contains(out.String(), "- Toolsets: core, config, helm, openshift-core") {
149149
t.Fatalf("Expected toolsets 'full', got %s %v", out, err)
150150
}
151151
})

pkg/mcp/nodes_test.go

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,21 @@
11
package mcp
22

33
import (
4+
"encoding/json"
5+
"io"
46
"net/http"
57
"strconv"
8+
"strings"
69
"testing"
710

811
"github.com/BurntSushi/toml"
912
"github.com/containers/kubernetes-mcp-server/internal/test"
1013
"github.com/mark3labs/mcp-go/mcp"
1114
"github.com/stretchr/testify/suite"
15+
v1 "k8s.io/api/core/v1"
16+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
17+
"k8s.io/apimachinery/pkg/runtime"
18+
"k8s.io/apimachinery/pkg/runtime/serializer"
1219
)
1320

1421
type NodesSuite struct {
@@ -334,3 +341,249 @@ func (s *NodesSuite) TestNodesStatsSummaryDenied() {
334341
func TestNodes(t *testing.T) {
335342
suite.Run(t, new(NodesSuite))
336343
}
344+
345+
// Tests below are for the nodes_debug_exec tool (OpenShift-specific)
346+
347+
type NodesDebugExecSuite struct {
348+
BaseMcpSuite
349+
mockServer *test.MockServer
350+
}
351+
352+
func (s *NodesDebugExecSuite) SetupTest() {
353+
s.BaseMcpSuite.SetupTest()
354+
s.mockServer = test.NewMockServer()
355+
s.Cfg.KubeConfig = s.mockServer.KubeconfigFile(s.T())
356+
}
357+
358+
func (s *NodesDebugExecSuite) TearDownTest() {
359+
s.BaseMcpSuite.TearDownTest()
360+
if s.mockServer != nil {
361+
s.mockServer.Close()
362+
}
363+
}
364+
365+
func (s *NodesDebugExecSuite) TestNodesDebugExecTool() {
366+
s.Run("nodes_debug_exec with successful execution", func() {
367+
368+
var (
369+
createdPod v1.Pod
370+
deleteCalled bool
371+
)
372+
const namespace = "debug"
373+
const logOutput = "filesystem repaired"
374+
375+
scheme := runtime.NewScheme()
376+
_ = v1.AddToScheme(scheme)
377+
codec := serializer.NewCodecFactory(scheme).UniversalDeserializer()
378+
379+
s.mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
380+
switch {
381+
case req.URL.Path == "/api":
382+
w.Header().Set("Content-Type", "application/json")
383+
_, _ = w.Write([]byte(`{"kind":"APIVersions","versions":["v1"],"serverAddressByClientCIDRs":[{"clientCIDR":"0.0.0.0/0"}]}`))
384+
case req.URL.Path == "/apis":
385+
w.Header().Set("Content-Type", "application/json")
386+
_, _ = w.Write([]byte(`{"kind":"APIGroupList","apiVersion":"v1","groups":[]}`))
387+
case req.URL.Path == "/api/v1":
388+
w.Header().Set("Content-Type", "application/json")
389+
_, _ = w.Write([]byte(`{"kind":"APIResourceList","apiVersion":"v1","resources":[{"name":"pods","singularName":"","namespaced":true,"kind":"Pod","verbs":["get","list","watch","create","update","patch","delete"]}]}`))
390+
case req.Method == http.MethodPatch && strings.HasPrefix(req.URL.Path, "/api/v1/namespaces/"+namespace+"/pods/"):
391+
// Handle server-side apply (PATCH with fieldManager query param)
392+
body, err := io.ReadAll(req.Body)
393+
if err != nil {
394+
s.T().Fatalf("failed to read apply body: %v", err)
395+
}
396+
created := &v1.Pod{}
397+
if _, _, err = codec.Decode(body, nil, created); err != nil {
398+
s.T().Fatalf("failed to decode apply body: %v", err)
399+
}
400+
createdPod = *created
401+
// Keep the name from the request URL if it was provided
402+
pathParts := strings.Split(req.URL.Path, "/")
403+
if len(pathParts) > 0 {
404+
createdPod.Name = pathParts[len(pathParts)-1]
405+
}
406+
createdPod.Namespace = namespace
407+
w.Header().Set("Content-Type", "application/json")
408+
_ = json.NewEncoder(w).Encode(&createdPod)
409+
case req.Method == http.MethodPost && req.URL.Path == "/api/v1/namespaces/"+namespace+"/pods":
410+
body, err := io.ReadAll(req.Body)
411+
if err != nil {
412+
s.T().Fatalf("failed to read create body: %v", err)
413+
}
414+
created := &v1.Pod{}
415+
if _, _, err = codec.Decode(body, nil, created); err != nil {
416+
s.T().Fatalf("failed to decode create body: %v", err)
417+
}
418+
createdPod = *created
419+
createdPod.ObjectMeta = metav1.ObjectMeta{
420+
Namespace: namespace,
421+
Name: createdPod.GenerateName + "abc",
422+
}
423+
w.Header().Set("Content-Type", "application/json")
424+
_ = json.NewEncoder(w).Encode(&createdPod)
425+
case req.Method == http.MethodGet && createdPod.Name != "" && req.URL.Path == "/api/v1/namespaces/"+namespace+"/pods/"+createdPod.Name:
426+
podStatus := createdPod.DeepCopy()
427+
podStatus.Status = v1.PodStatus{
428+
Phase: v1.PodSucceeded,
429+
ContainerStatuses: []v1.ContainerStatus{{
430+
Name: "debug",
431+
State: v1.ContainerState{Terminated: &v1.ContainerStateTerminated{
432+
ExitCode: 0,
433+
}},
434+
}},
435+
}
436+
w.Header().Set("Content-Type", "application/json")
437+
_ = json.NewEncoder(w).Encode(podStatus)
438+
case req.Method == http.MethodDelete && createdPod.Name != "" && req.URL.Path == "/api/v1/namespaces/"+namespace+"/pods/"+createdPod.Name:
439+
deleteCalled = true
440+
w.Header().Set("Content-Type", "application/json")
441+
_ = json.NewEncoder(w).Encode(&metav1.Status{Status: "Success"})
442+
case req.Method == http.MethodGet && createdPod.Name != "" && req.URL.Path == "/api/v1/namespaces/"+namespace+"/pods/"+createdPod.Name+"/log":
443+
w.Header().Set("Content-Type", "text/plain")
444+
_, _ = w.Write([]byte(logOutput))
445+
}
446+
}))
447+
448+
s.InitMcpClient()
449+
toolResult, err := s.CallTool("nodes_debug_exec", map[string]interface{}{
450+
"node": "worker-0",
451+
"namespace": namespace,
452+
"command": []interface{}{"uname", "-a"},
453+
})
454+
455+
s.Run("call succeeds", func() {
456+
s.Nilf(err, "call tool should not error: %v", err)
457+
s.Falsef(toolResult.IsError, "tool should not return error: %v", toolResult.Content)
458+
s.NotEmpty(toolResult.Content, "expected output content")
459+
text := toolResult.Content[0].(mcp.TextContent).Text
460+
s.Equalf(logOutput, text, "unexpected tool output %q", text)
461+
})
462+
463+
s.Run("debug pod shaped correctly", func() {
464+
s.Require().NotNil(createdPod.Spec.Containers, "expected containers in debug pod")
465+
s.Require().Len(createdPod.Spec.Containers, 1, "expected single container in debug pod")
466+
container := createdPod.Spec.Containers[0]
467+
expectedCommand := []string{"uname", "-a"}
468+
s.Truef(equalStringSlices(container.Command, expectedCommand),
469+
"unexpected debug command: %v", container.Command)
470+
s.Require().NotNil(container.SecurityContext, "expected security context")
471+
s.Require().NotNil(container.SecurityContext.Privileged, "expected privileged field")
472+
s.Truef(*container.SecurityContext.Privileged, "expected privileged container")
473+
s.Require().NotEmpty(createdPod.Spec.Volumes, "expected volumes on debug pod")
474+
s.Require().NotNil(createdPod.Spec.Volumes[0].HostPath, "expected hostPath volume")
475+
s.Truef(deleteCalled, "expected debug pod to be deleted")
476+
})
477+
})
478+
}
479+
480+
func equalStringSlices(a, b []string) bool {
481+
if len(a) != len(b) {
482+
return false
483+
}
484+
for i := range a {
485+
if a[i] != b[i] {
486+
return false
487+
}
488+
}
489+
return true
490+
}
491+
492+
func (s *NodesDebugExecSuite) TestNodesDebugExecToolNonZeroExit() {
493+
s.Run("nodes_debug_exec with non-zero exit code", func() {
494+
const namespace = "default"
495+
const errorMessage = "failed"
496+
497+
scheme := runtime.NewScheme()
498+
_ = v1.AddToScheme(scheme)
499+
codec := serializer.NewCodecFactory(scheme).UniversalDeserializer()
500+
501+
s.mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
502+
switch {
503+
case req.URL.Path == "/api":
504+
w.Header().Set("Content-Type", "application/json")
505+
_, _ = w.Write([]byte(`{"kind":"APIVersions","versions":["v1"],"serverAddressByClientCIDRs":[{"clientCIDR":"0.0.0.0/0"}]}`))
506+
case req.URL.Path == "/apis":
507+
w.Header().Set("Content-Type", "application/json")
508+
_, _ = w.Write([]byte(`{"kind":"APIGroupList","apiVersion":"v1","groups":[]}`))
509+
case req.URL.Path == "/api/v1":
510+
w.Header().Set("Content-Type", "application/json")
511+
_, _ = w.Write([]byte(`{"kind":"APIResourceList","apiVersion":"v1","resources":[{"name":"pods","singularName":"","namespaced":true,"kind":"Pod","verbs":["get","list","watch","create","update","patch","delete"]}]}`))
512+
case req.Method == http.MethodPatch && strings.HasPrefix(req.URL.Path, "/api/v1/namespaces/"+namespace+"/pods/"):
513+
// Handle server-side apply (PATCH with fieldManager query param)
514+
body, err := io.ReadAll(req.Body)
515+
if err != nil {
516+
s.T().Fatalf("failed to read apply body: %v", err)
517+
}
518+
pod := &v1.Pod{}
519+
if _, _, err = codec.Decode(body, nil, pod); err != nil {
520+
s.T().Fatalf("failed to decode apply body: %v", err)
521+
}
522+
// Keep the name from the request URL if it was provided
523+
pathParts := strings.Split(req.URL.Path, "/")
524+
if len(pathParts) > 0 {
525+
pod.Name = pathParts[len(pathParts)-1]
526+
}
527+
pod.Namespace = namespace
528+
w.Header().Set("Content-Type", "application/json")
529+
_ = json.NewEncoder(w).Encode(pod)
530+
case req.Method == http.MethodPost && req.URL.Path == "/api/v1/namespaces/"+namespace+"/pods":
531+
body, err := io.ReadAll(req.Body)
532+
if err != nil {
533+
s.T().Fatalf("failed to read create body: %v", err)
534+
}
535+
pod := &v1.Pod{}
536+
if _, _, err = codec.Decode(body, nil, pod); err != nil {
537+
s.T().Fatalf("failed to decode create body: %v", err)
538+
}
539+
pod.ObjectMeta = metav1.ObjectMeta{Name: pod.GenerateName + "xyz", Namespace: namespace}
540+
w.Header().Set("Content-Type", "application/json")
541+
_ = json.NewEncoder(w).Encode(pod)
542+
case strings.HasPrefix(req.URL.Path, "/api/v1/namespaces/"+namespace+"/pods/") && strings.HasSuffix(req.URL.Path, "/log"):
543+
w.Header().Set("Content-Type", "text/plain")
544+
_, _ = w.Write([]byte(errorMessage))
545+
case req.Method == http.MethodGet && strings.HasPrefix(req.URL.Path, "/api/v1/namespaces/"+namespace+"/pods/"):
546+
pathParts := strings.Split(req.URL.Path, "/")
547+
podName := pathParts[len(pathParts)-1]
548+
pod := &v1.Pod{
549+
TypeMeta: metav1.TypeMeta{
550+
APIVersion: "v1",
551+
Kind: "Pod",
552+
},
553+
ObjectMeta: metav1.ObjectMeta{
554+
Name: podName,
555+
Namespace: namespace,
556+
},
557+
}
558+
pod.Status = v1.PodStatus{
559+
Phase: v1.PodSucceeded,
560+
ContainerStatuses: []v1.ContainerStatus{{
561+
Name: "debug",
562+
State: v1.ContainerState{Terminated: &v1.ContainerStateTerminated{
563+
ExitCode: 2,
564+
Reason: "Error",
565+
}},
566+
}},
567+
}
568+
w.Header().Set("Content-Type", "application/json")
569+
_ = json.NewEncoder(w).Encode(pod)
570+
}
571+
}))
572+
573+
s.InitMcpClient()
574+
toolResult, err := s.CallTool("nodes_debug_exec", map[string]interface{}{
575+
"node": "infra-1",
576+
"command": []interface{}{"journalctl"},
577+
})
578+
579+
s.Nilf(err, "call tool should not error: %v", err)
580+
s.Truef(toolResult.IsError, "expected tool to return error")
581+
text := toolResult.Content[0].(mcp.TextContent).Text
582+
s.Containsf(text, "command exited with code 2", "expected exit code message, got %q", text)
583+
s.Containsf(text, "Error", "expected error reason included, got %q", text)
584+
})
585+
}
586+
587+
func TestNodesDebugExec(t *testing.T) {
588+
suite.Run(t, new(NodesDebugExecSuite))
589+
}

pkg/mcp/openshift_modules.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
package mcp
2+
3+
import _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/openshift"

0 commit comments

Comments
 (0)