Skip to content

Commit fc61e2e

Browse files
committed
Add nodes_debug_exec tool in pkg/ocp package
1 parent 82a4ef2 commit fc61e2e

15 files changed

+1739
-156
lines changed

README.md

Lines changed: 238 additions & 152 deletions
Large diffs are not rendered by default.

pkg/kubernetes/kubernetes.go

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"strings"
77

88
"k8s.io/apimachinery/pkg/runtime"
9+
corev1client "k8s.io/client-go/kubernetes/typed/core/v1"
910

1011
"github.com/fsnotify/fsnotify"
1112

@@ -38,7 +39,8 @@ const (
3839
type CloseWatchKubeConfig func() error
3940

4041
type Kubernetes struct {
41-
manager *Manager
42+
manager *Manager
43+
podClientFactory func(namespace string) (corev1client.PodInterface, error)
4244
}
4345

4446
type Manager struct {
@@ -209,6 +211,43 @@ func (m *Manager) Derived(ctx context.Context) (*Kubernetes, error) {
209211
return derived, nil
210212
}
211213

214+
func (k *Kubernetes) podsClient(namespace string) (corev1client.PodInterface, error) {
215+
if k.podClientFactory != nil {
216+
return k.podClientFactory(namespace)
217+
}
218+
if k.manager == nil || k.manager.accessControlClientSet == nil {
219+
return nil, errors.New("kubernetes manager is not initialized")
220+
}
221+
return k.manager.accessControlClientSet.Pods(namespace)
222+
}
223+
224+
// GetPodsClient returns a PodInterface for the specified namespace.
225+
// This is exported for use by related packages like ocp.
226+
func (k *Kubernetes) GetPodsClient(namespace string) (corev1client.PodInterface, error) {
227+
return k.podsClient(namespace)
228+
}
229+
230+
// SetPodClientFactory sets the pod client factory function.
231+
// This is primarily used for testing purposes.
232+
func (k *Kubernetes) SetPodClientFactory(factory func(namespace string) (corev1client.PodInterface, error)) {
233+
k.podClientFactory = factory
234+
}
235+
236+
// NewKubernetesForTesting creates a new Kubernetes instance with the given manager.
237+
// This is primarily used for testing purposes.
238+
func NewKubernetesForTesting(manager *Manager) *Kubernetes {
239+
return &Kubernetes{manager: manager}
240+
}
241+
242+
// NewManagerForTesting creates a new Manager instance with the given configuration.
243+
// This is primarily used for testing purposes.
244+
func NewManagerForTesting(staticConfig *config.StaticConfig, clientCmdConfig clientcmd.ClientConfig) *Manager {
245+
return &Manager{
246+
staticConfig: staticConfig,
247+
clientCmdConfig: clientCmdConfig,
248+
}
249+
}
250+
212251
func (k *Kubernetes) NewHelm() *helm.Helm {
213252
// This is a derived Kubernetes, so it already has the Helm initialized
214253
return helm.NewHelm(k.manager)

pkg/kubernetes/pods.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ func (k *Kubernetes) PodsDelete(ctx context.Context, namespace, name string) (st
9797
}
9898

9999
func (k *Kubernetes) PodsLog(ctx context.Context, namespace, name, container string, previous bool, tail int64) (string, error) {
100-
pods, err := k.manager.accessControlClientSet.Pods(k.NamespaceOrDefault(namespace))
100+
pods, err := k.podsClient(k.NamespaceOrDefault(namespace))
101101
if err != nil {
102102
return "", err
103103
}

pkg/mcp/nodes_test.go

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
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.MethodPost && req.URL.Path == "/api/v1/namespaces/"+namespace+"/pods":
48+
body, err := io.ReadAll(req.Body)
49+
if err != nil {
50+
t.Fatalf("failed to read create body: %v", err)
51+
}
52+
created := &v1.Pod{}
53+
if _, _, err = codec.Decode(body, nil, created); err != nil {
54+
t.Fatalf("failed to decode create body: %v", err)
55+
}
56+
createdPod = *created
57+
createdPod.ObjectMeta = metav1.ObjectMeta{
58+
Namespace: namespace,
59+
Name: createdPod.GenerateName + "abc",
60+
}
61+
w.Header().Set("Content-Type", "application/json")
62+
_ = json.NewEncoder(w).Encode(&createdPod)
63+
case req.Method == http.MethodGet && createdPod.Name != "" && req.URL.Path == "/api/v1/namespaces/"+namespace+"/pods/"+createdPod.Name:
64+
podStatus := createdPod.DeepCopy()
65+
podStatus.Status = v1.PodStatus{
66+
Phase: v1.PodSucceeded,
67+
ContainerStatuses: []v1.ContainerStatus{{
68+
Name: "debug",
69+
State: v1.ContainerState{Terminated: &v1.ContainerStateTerminated{
70+
ExitCode: 0,
71+
}},
72+
}},
73+
}
74+
w.Header().Set("Content-Type", "application/json")
75+
_ = json.NewEncoder(w).Encode(podStatus)
76+
case req.Method == http.MethodDelete && createdPod.Name != "" && req.URL.Path == "/api/v1/namespaces/"+namespace+"/pods/"+createdPod.Name:
77+
deleteCalled = true
78+
w.Header().Set("Content-Type", "application/json")
79+
_ = json.NewEncoder(w).Encode(&metav1.Status{Status: "Success"})
80+
case req.Method == http.MethodGet && createdPod.Name != "" && req.URL.Path == "/api/v1/namespaces/"+namespace+"/pods/"+createdPod.Name+"/log":
81+
w.Header().Set("Content-Type", "text/plain")
82+
_, _ = w.Write([]byte(logOutput))
83+
}
84+
}))
85+
86+
toolResult, err := c.callTool("nodes_debug_exec", map[string]any{
87+
"node": "worker-0",
88+
"namespace": namespace,
89+
"command": []any{"uname", "-a"},
90+
})
91+
92+
t.Run("call succeeds", func(t *testing.T) {
93+
if err != nil {
94+
t.Fatalf("call tool failed: %v", err)
95+
}
96+
if toolResult.IsError {
97+
t.Fatalf("tool returned error: %v", toolResult.Content)
98+
}
99+
if len(toolResult.Content) == 0 {
100+
t.Fatalf("expected output content")
101+
}
102+
text := toolResult.Content[0].(mcp.TextContent).Text
103+
if text != logOutput {
104+
t.Fatalf("unexpected tool output %q", text)
105+
}
106+
})
107+
108+
t.Run("debug pod shaped correctly", func(t *testing.T) {
109+
if createdPod.Spec.Containers == nil || len(createdPod.Spec.Containers) != 1 {
110+
t.Fatalf("expected single container in debug pod")
111+
}
112+
container := createdPod.Spec.Containers[0]
113+
expectedPrefix := []string{"chroot", "/host", "uname", "-a"}
114+
if !equalStringSlices(container.Command, expectedPrefix) {
115+
t.Fatalf("unexpected debug command: %v", container.Command)
116+
}
117+
if container.SecurityContext == nil || container.SecurityContext.Privileged == nil || !*container.SecurityContext.Privileged {
118+
t.Fatalf("expected privileged container")
119+
}
120+
if len(createdPod.Spec.Volumes) == 0 || createdPod.Spec.Volumes[0].HostPath == nil {
121+
t.Fatalf("expected hostPath volume on debug pod")
122+
}
123+
if !deleteCalled {
124+
t.Fatalf("expected debug pod to be deleted")
125+
}
126+
})
127+
})
128+
}
129+
130+
func equalStringSlices(a, b []string) bool {
131+
if len(a) != len(b) {
132+
return false
133+
}
134+
for i := range a {
135+
if a[i] != b[i] {
136+
return false
137+
}
138+
}
139+
return true
140+
}
141+
142+
func TestNodesDebugExecToolNonZeroExit(t *testing.T) {
143+
testCase(t, func(c *mcpContext) {
144+
mockServer := test.NewMockServer()
145+
defer mockServer.Close()
146+
c.withKubeConfig(mockServer.Config())
147+
148+
const namespace = "default"
149+
const errorMessage = "failed"
150+
151+
scheme := runtime.NewScheme()
152+
_ = v1.AddToScheme(scheme)
153+
codec := serializer.NewCodecFactory(scheme).UniversalDeserializer()
154+
155+
mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
156+
switch {
157+
case req.URL.Path == "/api":
158+
w.Header().Set("Content-Type", "application/json")
159+
_, _ = w.Write([]byte(`{"kind":"APIVersions","versions":["v1"],"serverAddressByClientCIDRs":[{"clientCIDR":"0.0.0.0/0"}]}`))
160+
case req.URL.Path == "/apis":
161+
w.Header().Set("Content-Type", "application/json")
162+
_, _ = w.Write([]byte(`{"kind":"APIGroupList","apiVersion":"v1","groups":[]}`))
163+
case req.URL.Path == "/api/v1":
164+
w.Header().Set("Content-Type", "application/json")
165+
_, _ = w.Write([]byte(`{"kind":"APIResourceList","apiVersion":"v1","resources":[{"name":"pods","singularName":"","namespaced":true,"kind":"Pod","verbs":["get","list","watch","create","update","patch","delete"]}]}`))
166+
case req.Method == http.MethodPost && req.URL.Path == "/api/v1/namespaces/"+namespace+"/pods":
167+
body, err := io.ReadAll(req.Body)
168+
if err != nil {
169+
t.Fatalf("failed to read create body: %v", err)
170+
}
171+
pod := &v1.Pod{}
172+
if _, _, err = codec.Decode(body, nil, pod); err != nil {
173+
t.Fatalf("failed to decode create body: %v", err)
174+
}
175+
pod.ObjectMeta = metav1.ObjectMeta{Name: pod.GenerateName + "xyz", Namespace: namespace}
176+
w.Header().Set("Content-Type", "application/json")
177+
_ = json.NewEncoder(w).Encode(pod)
178+
case strings.HasPrefix(req.URL.Path, "/api/v1/namespaces/"+namespace+"/pods/") && strings.HasSuffix(req.URL.Path, "/log"):
179+
w.Header().Set("Content-Type", "text/plain")
180+
_, _ = w.Write([]byte(errorMessage))
181+
case req.Method == http.MethodGet && strings.HasPrefix(req.URL.Path, "/api/v1/namespaces/"+namespace+"/pods/"):
182+
pod := &v1.Pod{}
183+
pod.Status = v1.PodStatus{
184+
Phase: v1.PodSucceeded,
185+
ContainerStatuses: []v1.ContainerStatus{{
186+
Name: "debug",
187+
State: v1.ContainerState{Terminated: &v1.ContainerStateTerminated{
188+
ExitCode: 2,
189+
Reason: "Error",
190+
}},
191+
}},
192+
}
193+
w.Header().Set("Content-Type", "application/json")
194+
_ = json.NewEncoder(w).Encode(pod)
195+
}
196+
}))
197+
198+
toolResult, err := c.callTool("nodes_debug_exec", map[string]any{
199+
"node": "infra-1",
200+
"command": []any{"journalctl"},
201+
})
202+
203+
if err != nil {
204+
t.Fatalf("call tool failed: %v", err)
205+
}
206+
if !toolResult.IsError {
207+
t.Fatalf("expected tool to return error")
208+
}
209+
text := toolResult.Content[0].(mcp.TextContent).Text
210+
if !strings.Contains(text, "command exited with code 2") {
211+
t.Fatalf("expected exit code message, got %q", text)
212+
}
213+
if !strings.Contains(text, "Error") {
214+
t.Fatalf("expected error reason included, got %q", text)
215+
}
216+
})
217+
}

pkg/mcp/testdata/toolsets-core-tools.json

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,50 @@
3333
},
3434
"name": "namespaces_list"
3535
},
36+
{
37+
"annotations": {
38+
"title": "Nodes: Debug Exec",
39+
"readOnlyHint": false,
40+
"destructiveHint": true,
41+
"idempotentHint": false,
42+
"openWorldHint": true
43+
},
44+
"description": "Run commands on an OpenShift node using a privileged debug pod (output is truncated to the most recent 100 lines, so prefer filters like grep when expecting large logs).",
45+
"inputSchema": {
46+
"type": "object",
47+
"properties": {
48+
"node": {
49+
"description": "Name of the node to debug (e.g. worker-0).",
50+
"type": "string"
51+
},
52+
"command": {
53+
"description": "Command to execute on the node via chroot. Provide each argument as a separate array item (e.g. ['systemctl', 'status', 'kubelet']).",
54+
"items": {
55+
"type": "string"
56+
},
57+
"type": "array"
58+
},
59+
"namespace": {
60+
"description": "Namespace to create the temporary debug pod in (optional, defaults to the current namespace or 'default').",
61+
"type": "string"
62+
},
63+
"image": {
64+
"description": "Container image to use for the debug pod (optional). Defaults to a Fedora-based utility image that includes chroot.",
65+
"type": "string"
66+
},
67+
"timeout_seconds": {
68+
"description": "Maximum time to wait for the command to complete before timing out (optional, defaults to 300 seconds).",
69+
"minimum": 1,
70+
"type": "integer"
71+
}
72+
},
73+
"required": [
74+
"node",
75+
"command"
76+
]
77+
},
78+
"name": "nodes_debug_exec"
79+
},
3680
{
3781
"annotations": {
3882
"title": "Pods: Delete",

0 commit comments

Comments
 (0)