|
1 | 1 | package mcp |
2 | 2 |
|
3 | 3 | import ( |
| 4 | + "encoding/json" |
| 5 | + "io" |
4 | 6 | "net/http" |
5 | 7 | "strconv" |
| 8 | + "strings" |
6 | 9 | "testing" |
7 | 10 |
|
8 | 11 | "github.com/BurntSushi/toml" |
9 | 12 | "github.com/containers/kubernetes-mcp-server/internal/test" |
10 | 13 | "github.com/mark3labs/mcp-go/mcp" |
11 | 14 | "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" |
12 | 19 | ) |
13 | 20 |
|
14 | 21 | type NodesSuite struct { |
@@ -334,3 +341,249 @@ func (s *NodesSuite) TestNodesStatsSummaryDenied() { |
334 | 341 | func TestNodes(t *testing.T) { |
335 | 342 | suite.Run(t, new(NodesSuite)) |
336 | 343 | } |
| 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 | +} |
0 commit comments