Skip to content

Commit be94848

Browse files
committed
Add nodes_debug_exec tool in pkg/ocp package
1 parent 80d6c70 commit be94848

19 files changed

+1741
-10
lines changed

README.md

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

200200
<!-- AVAILABLE-TOOLSETS-START -->
201201

202-
| Toolset | Description |
203-
|---------|-------------------------------------------------------------------------------------|
204-
| config | View and manage the current local Kubernetes configuration (kubeconfig) |
205-
| core | Most common tools for Kubernetes management (Pods, Generic Resources, Events, etc.) |
206-
| helm | Tools for managing Helm charts and releases |
202+
| Toolset | Description |
203+
|----------------|-------------------------------------------------------------------------------------|
204+
| config | View and manage the current local Kubernetes configuration (kubeconfig) |
205+
| core | Most common tools for Kubernetes management (Pods, Generic Resources, Events, etc.) |
206+
| helm | Tools for managing Helm charts and releases |
207+
| openshift-core | Core OpenShift-specific tools (Node debugging, etc.) |
207208

208209
<!-- AVAILABLE-TOOLSETS-END -->
209210

@@ -322,6 +323,19 @@ In case multi-cluster support is enabled (default) and you have access to multip
322323

323324
</details>
324325

326+
<details>
327+
328+
<summary>openshift-core</summary>
329+
330+
- **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.
331+
- `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']).
332+
- `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.
333+
- `namespace` (`string`) - Namespace to create the temporary debug pod in (optional, defaults to the current namespace or 'default').
334+
- `node` (`string`) **(required)** - Name of the node to debug (e.g. worker-0).
335+
- `timeout_seconds` (`integer`) - Maximum time to wait for the command to complete before timing out (optional, defaults to 60 seconds).
336+
337+
</details>
338+
325339

326340
<!-- AVAILABLE-TOOLSETS-TOOLS-END -->
327341

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.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ type StaticConfig struct {
6666
func Default() *StaticConfig {
6767
return &StaticConfig{
6868
ListOutput: "table",
69-
Toolsets: []string{"core", "config", "helm"},
69+
Toolsets: []string{"core", "config", "helm", "openshift-core"},
7070
}
7171
}
7272

pkg/config/config_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,8 +152,8 @@ func (s *ConfigSuite) TestReadConfigValidPreservesDefaultsForMissingFields() {
152152
s.Equalf("table", config.ListOutput, "Expected ListOutput to be table, got %s", config.ListOutput)
153153
})
154154
s.Run("toolsets defaulted correctly", func() {
155-
s.Require().Lenf(config.Toolsets, 3, "Expected 3 toolsets, got %d", len(config.Toolsets))
156-
for _, toolset := range []string{"core", "config", "helm"} {
155+
s.Require().Lenf(config.Toolsets, 4, "Expected 4 toolsets, got %d", len(config.Toolsets))
156+
for _, toolset := range []string{"core", "config", "helm", "openshift-core"} {
157157
s.Containsf(config.Toolsets, toolset, "Expected toolsets to contain %s", toolset)
158158
}
159159
})

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: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
package mcp
2+
3+
import (
4+
"encoding/json"
5+
"io"
6+
"net/http"
7+
"strings"
8+
"testing"
9+
10+
"github.com/mark3labs/mcp-go/mcp"
11+
v1 "k8s.io/api/core/v1"
12+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
13+
"k8s.io/apimachinery/pkg/runtime"
14+
"k8s.io/apimachinery/pkg/runtime/serializer"
15+
16+
"github.com/containers/kubernetes-mcp-server/internal/test"
17+
)
18+
19+
func TestNodesDebugExecTool(t *testing.T) {
20+
testCase(t, func(c *mcpContext) {
21+
mockServer := test.NewMockServer()
22+
defer mockServer.Close()
23+
c.withKubeConfig(mockServer.Config())
24+
25+
var (
26+
createdPod v1.Pod
27+
deleteCalled bool
28+
)
29+
const namespace = "debug"
30+
const logOutput = "filesystem repaired"
31+
32+
scheme := runtime.NewScheme()
33+
_ = v1.AddToScheme(scheme)
34+
codec := serializer.NewCodecFactory(scheme).UniversalDeserializer()
35+
36+
mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
37+
switch {
38+
case req.URL.Path == "/api":
39+
w.Header().Set("Content-Type", "application/json")
40+
_, _ = w.Write([]byte(`{"kind":"APIVersions","versions":["v1"],"serverAddressByClientCIDRs":[{"clientCIDR":"0.0.0.0/0"}]}`))
41+
case req.URL.Path == "/apis":
42+
w.Header().Set("Content-Type", "application/json")
43+
_, _ = w.Write([]byte(`{"kind":"APIGroupList","apiVersion":"v1","groups":[]}`))
44+
case req.URL.Path == "/api/v1":
45+
w.Header().Set("Content-Type", "application/json")
46+
_, _ = w.Write([]byte(`{"kind":"APIResourceList","apiVersion":"v1","resources":[{"name":"pods","singularName":"","namespaced":true,"kind":"Pod","verbs":["get","list","watch","create","update","patch","delete"]}]}`))
47+
case req.Method == http.MethodPatch && strings.HasPrefix(req.URL.Path, "/api/v1/namespaces/"+namespace+"/pods/"):
48+
// Handle server-side apply (PATCH with fieldManager query param)
49+
body, err := io.ReadAll(req.Body)
50+
if err != nil {
51+
t.Fatalf("failed to read apply body: %v", err)
52+
}
53+
created := &v1.Pod{}
54+
if _, _, err = codec.Decode(body, nil, created); err != nil {
55+
t.Fatalf("failed to decode apply body: %v", err)
56+
}
57+
createdPod = *created
58+
// Keep the name from the request URL if it was provided
59+
pathParts := strings.Split(req.URL.Path, "/")
60+
if len(pathParts) > 0 {
61+
createdPod.Name = pathParts[len(pathParts)-1]
62+
}
63+
createdPod.Namespace = namespace
64+
w.Header().Set("Content-Type", "application/json")
65+
_ = json.NewEncoder(w).Encode(&createdPod)
66+
case req.Method == http.MethodPost && req.URL.Path == "/api/v1/namespaces/"+namespace+"/pods":
67+
body, err := io.ReadAll(req.Body)
68+
if err != nil {
69+
t.Fatalf("failed to read create body: %v", err)
70+
}
71+
created := &v1.Pod{}
72+
if _, _, err = codec.Decode(body, nil, created); err != nil {
73+
t.Fatalf("failed to decode create body: %v", err)
74+
}
75+
createdPod = *created
76+
createdPod.ObjectMeta = metav1.ObjectMeta{
77+
Namespace: namespace,
78+
Name: createdPod.GenerateName + "abc",
79+
}
80+
w.Header().Set("Content-Type", "application/json")
81+
_ = json.NewEncoder(w).Encode(&createdPod)
82+
case req.Method == http.MethodGet && createdPod.Name != "" && req.URL.Path == "/api/v1/namespaces/"+namespace+"/pods/"+createdPod.Name:
83+
podStatus := createdPod.DeepCopy()
84+
podStatus.Status = v1.PodStatus{
85+
Phase: v1.PodSucceeded,
86+
ContainerStatuses: []v1.ContainerStatus{{
87+
Name: "debug",
88+
State: v1.ContainerState{Terminated: &v1.ContainerStateTerminated{
89+
ExitCode: 0,
90+
}},
91+
}},
92+
}
93+
w.Header().Set("Content-Type", "application/json")
94+
_ = json.NewEncoder(w).Encode(podStatus)
95+
case req.Method == http.MethodDelete && createdPod.Name != "" && req.URL.Path == "/api/v1/namespaces/"+namespace+"/pods/"+createdPod.Name:
96+
deleteCalled = true
97+
w.Header().Set("Content-Type", "application/json")
98+
_ = json.NewEncoder(w).Encode(&metav1.Status{Status: "Success"})
99+
case req.Method == http.MethodGet && createdPod.Name != "" && req.URL.Path == "/api/v1/namespaces/"+namespace+"/pods/"+createdPod.Name+"/log":
100+
w.Header().Set("Content-Type", "text/plain")
101+
_, _ = w.Write([]byte(logOutput))
102+
}
103+
}))
104+
105+
toolResult, err := c.callTool("nodes_debug_exec", map[string]any{
106+
"node": "worker-0",
107+
"namespace": namespace,
108+
"command": []any{"uname", "-a"},
109+
})
110+
111+
t.Run("call succeeds", func(t *testing.T) {
112+
if err != nil {
113+
t.Fatalf("call tool failed: %v", err)
114+
}
115+
if toolResult.IsError {
116+
t.Fatalf("tool returned error: %v", toolResult.Content)
117+
}
118+
if len(toolResult.Content) == 0 {
119+
t.Fatalf("expected output content")
120+
}
121+
text := toolResult.Content[0].(mcp.TextContent).Text
122+
if text != logOutput {
123+
t.Fatalf("unexpected tool output %q", text)
124+
}
125+
})
126+
127+
t.Run("debug pod shaped correctly", func(t *testing.T) {
128+
if createdPod.Spec.Containers == nil || len(createdPod.Spec.Containers) != 1 {
129+
t.Fatalf("expected single container in debug pod")
130+
}
131+
container := createdPod.Spec.Containers[0]
132+
expectedCommand := []string{"uname", "-a"}
133+
if !equalStringSlices(container.Command, expectedCommand) {
134+
t.Fatalf("unexpected debug command: %v", container.Command)
135+
}
136+
if container.SecurityContext == nil || container.SecurityContext.Privileged == nil || !*container.SecurityContext.Privileged {
137+
t.Fatalf("expected privileged container")
138+
}
139+
if len(createdPod.Spec.Volumes) == 0 || createdPod.Spec.Volumes[0].HostPath == nil {
140+
t.Fatalf("expected hostPath volume on debug pod")
141+
}
142+
if !deleteCalled {
143+
t.Fatalf("expected debug pod to be deleted")
144+
}
145+
})
146+
})
147+
}
148+
149+
func equalStringSlices(a, b []string) bool {
150+
if len(a) != len(b) {
151+
return false
152+
}
153+
for i := range a {
154+
if a[i] != b[i] {
155+
return false
156+
}
157+
}
158+
return true
159+
}
160+
161+
func TestNodesDebugExecToolNonZeroExit(t *testing.T) {
162+
testCase(t, func(c *mcpContext) {
163+
mockServer := test.NewMockServer()
164+
defer mockServer.Close()
165+
c.withKubeConfig(mockServer.Config())
166+
167+
const namespace = "default"
168+
const errorMessage = "failed"
169+
170+
scheme := runtime.NewScheme()
171+
_ = v1.AddToScheme(scheme)
172+
codec := serializer.NewCodecFactory(scheme).UniversalDeserializer()
173+
174+
mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
175+
switch {
176+
case req.URL.Path == "/api":
177+
w.Header().Set("Content-Type", "application/json")
178+
_, _ = w.Write([]byte(`{"kind":"APIVersions","versions":["v1"],"serverAddressByClientCIDRs":[{"clientCIDR":"0.0.0.0/0"}]}`))
179+
case req.URL.Path == "/apis":
180+
w.Header().Set("Content-Type", "application/json")
181+
_, _ = w.Write([]byte(`{"kind":"APIGroupList","apiVersion":"v1","groups":[]}`))
182+
case req.URL.Path == "/api/v1":
183+
w.Header().Set("Content-Type", "application/json")
184+
_, _ = w.Write([]byte(`{"kind":"APIResourceList","apiVersion":"v1","resources":[{"name":"pods","singularName":"","namespaced":true,"kind":"Pod","verbs":["get","list","watch","create","update","patch","delete"]}]}`))
185+
case req.Method == http.MethodPatch && strings.HasPrefix(req.URL.Path, "/api/v1/namespaces/"+namespace+"/pods/"):
186+
// Handle server-side apply (PATCH with fieldManager query param)
187+
body, err := io.ReadAll(req.Body)
188+
if err != nil {
189+
t.Fatalf("failed to read apply body: %v", err)
190+
}
191+
pod := &v1.Pod{}
192+
if _, _, err = codec.Decode(body, nil, pod); err != nil {
193+
t.Fatalf("failed to decode apply body: %v", err)
194+
}
195+
// Keep the name from the request URL if it was provided
196+
pathParts := strings.Split(req.URL.Path, "/")
197+
if len(pathParts) > 0 {
198+
pod.Name = pathParts[len(pathParts)-1]
199+
}
200+
pod.Namespace = namespace
201+
w.Header().Set("Content-Type", "application/json")
202+
_ = json.NewEncoder(w).Encode(pod)
203+
case req.Method == http.MethodPost && req.URL.Path == "/api/v1/namespaces/"+namespace+"/pods":
204+
body, err := io.ReadAll(req.Body)
205+
if err != nil {
206+
t.Fatalf("failed to read create body: %v", err)
207+
}
208+
pod := &v1.Pod{}
209+
if _, _, err = codec.Decode(body, nil, pod); err != nil {
210+
t.Fatalf("failed to decode create body: %v", err)
211+
}
212+
pod.ObjectMeta = metav1.ObjectMeta{Name: pod.GenerateName + "xyz", Namespace: namespace}
213+
w.Header().Set("Content-Type", "application/json")
214+
_ = json.NewEncoder(w).Encode(pod)
215+
case strings.HasPrefix(req.URL.Path, "/api/v1/namespaces/"+namespace+"/pods/") && strings.HasSuffix(req.URL.Path, "/log"):
216+
w.Header().Set("Content-Type", "text/plain")
217+
_, _ = w.Write([]byte(errorMessage))
218+
case req.Method == http.MethodGet && strings.HasPrefix(req.URL.Path, "/api/v1/namespaces/"+namespace+"/pods/"):
219+
pathParts := strings.Split(req.URL.Path, "/")
220+
podName := pathParts[len(pathParts)-1]
221+
pod := &v1.Pod{
222+
TypeMeta: metav1.TypeMeta{
223+
APIVersion: "v1",
224+
Kind: "Pod",
225+
},
226+
ObjectMeta: metav1.ObjectMeta{
227+
Name: podName,
228+
Namespace: namespace,
229+
},
230+
}
231+
pod.Status = v1.PodStatus{
232+
Phase: v1.PodSucceeded,
233+
ContainerStatuses: []v1.ContainerStatus{{
234+
Name: "debug",
235+
State: v1.ContainerState{Terminated: &v1.ContainerStateTerminated{
236+
ExitCode: 2,
237+
Reason: "Error",
238+
}},
239+
}},
240+
}
241+
w.Header().Set("Content-Type", "application/json")
242+
_ = json.NewEncoder(w).Encode(pod)
243+
}
244+
}))
245+
246+
toolResult, err := c.callTool("nodes_debug_exec", map[string]any{
247+
"node": "infra-1",
248+
"command": []any{"journalctl"},
249+
})
250+
251+
if err != nil {
252+
t.Fatalf("call tool failed: %v", err)
253+
}
254+
if !toolResult.IsError {
255+
t.Fatalf("expected tool to return error")
256+
}
257+
text := toolResult.Content[0].(mcp.TextContent).Text
258+
if !strings.Contains(text, "command exited with code 2") {
259+
t.Fatalf("expected exit code message, got %q", text)
260+
}
261+
if !strings.Contains(text, "Error") {
262+
t.Fatalf("expected error reason included, got %q", text)
263+
}
264+
})
265+
}

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)