diff --git a/README.md b/README.md index 0ca13ad..ec8842a 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ A powerful and flexible Kubernetes [Model Context Protocol (MCP)](https://blog.m - **Get** a pod by name from the specified namespace. - **Delete** a pod by name from the specified namespace. - **Show logs** for a pod by name from the specified namespace. + - **Top** gets resource usage metrics for all pods or a specific pod in the specified namespace. - **Exec** into a pod and run a command. - **Run** a container image in a pod and optionally expose it. - **✅ Namespaces**: List Kubernetes Namespaces. @@ -314,6 +315,23 @@ Run a Kubernetes Pod in the current or provided namespace with the provided cont - TCP/IP port to expose from the Pod container - No port exposed if not provided +### `pods_top` + +Lists the resource consumption (CPU and memory) as recorded by the Kubernetes Metrics Server for the specified Kubernetes Pods in the all namespaces, the provided namespace, or the current namespace + +**Parameters:** +- `all_namespaces` (`boolean`, optional, default: `true`) + - If `true`, lists resource consumption for Pods in all namespaces + - If `false`, lists resource consumption for Pods in the configured or provided namespace +- `namespace` (`string`, optional) + - Namespace to list the Pod resources from + - If not provided, will list Pods from the configured namespace (in case all_namespaces is false) +- `name` (`string`, optional) + - Name of the Pod to get resource consumption from + - If not provided, will list resource consumption for all Pods in the applicable namespace(s) +- `label_selector` (`string`, optional) + - Kubernetes label selector (e.g. 'app=myapp,env=prod' or 'app in (myapp,yourapp)'), use this option when you want to filter the pods by label (Optional, only applicable when name is not provided) + ### `projects_list` List all the OpenShift projects in the current cluster diff --git a/go.mod b/go.mod index 59bb140..b82eb0e 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,8 @@ require ( k8s.io/cli-runtime v0.33.1 k8s.io/client-go v0.33.1 k8s.io/klog/v2 v2.130.1 + k8s.io/kubectl v0.33.0 + k8s.io/metrics v0.33.0 k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 sigs.k8s.io/controller-runtime v0.21.0 sigs.k8s.io/controller-runtime/tools/setup-envtest v0.0.0-20250211091558-894df3a7e664 @@ -126,7 +128,6 @@ require ( k8s.io/apiserver v0.33.1 // indirect k8s.io/component-base v0.33.1 // indirect k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect - k8s.io/kubectl v0.33.0 // indirect oras.land/oras-go/v2 v2.5.0 // indirect sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect sigs.k8s.io/kustomize/api v0.19.0 // indirect diff --git a/go.sum b/go.sum index 67badc0..5871ec8 100644 --- a/go.sum +++ b/go.sum @@ -462,6 +462,8 @@ k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff h1:/usPimJzUKKu+m+TE36gUy k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff/go.mod h1:5jIi+8yX4RIb8wk3XwBo5Pq2ccx4FP10ohkbSKCZoK8= k8s.io/kubectl v0.33.0 h1:HiRb1yqibBSCqic4pRZP+viiOBAnIdwYDpzUFejs07g= k8s.io/kubectl v0.33.0/go.mod h1:gAlGBuS1Jq1fYZ9AjGWbI/5Vk3M/VW2DK4g10Fpyn/0= +k8s.io/metrics v0.33.0 h1:sKe5sC9qb1RakMhs8LWYNuN2ne6OTCWexj8Jos3rO2Y= +k8s.io/metrics v0.33.0/go.mod h1:XewckTFXmE2AJiP7PT3EXaY7hi7bler3t2ZLyOdQYzU= k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro= k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= oras.land/oras-go/v2 v2.5.0 h1:o8Me9kLY74Vp5uw07QXPiitjsw7qNXi8Twd+19Zf02c= diff --git a/pkg/kubernetes/pods.go b/pkg/kubernetes/pods.go index 5dfec1e..3558e11 100644 --- a/pkg/kubernetes/pods.go +++ b/pkg/kubernetes/pods.go @@ -3,7 +3,11 @@ package kubernetes import ( "bytes" "context" + "errors" "fmt" + "k8s.io/metrics/pkg/apis/metrics" + metricsv1beta1api "k8s.io/metrics/pkg/apis/metrics/v1beta1" + metricsclientset "k8s.io/metrics/pkg/client/clientset/versioned" "github.com/manusa/kubernetes-mcp-server/pkg/version" v1 "k8s.io/api/core/v1" @@ -18,6 +22,13 @@ import ( "k8s.io/client-go/tools/remotecommand" ) +type PodsTopOptions struct { + metav1.ListOptions + AllNamespaces bool + Namespace string + Name string +} + func (k *Kubernetes) PodsListInAllNamespaces(ctx context.Context, options ResourceListOptions) (runtime.Unstructured, error) { return k.ResourcesList(ctx, &schema.GroupVersionKind{ Group: "", Version: "v1", Kind: "Pod", @@ -175,6 +186,38 @@ func (k *Kubernetes) PodsRun(ctx context.Context, namespace, name, image string, return k.resourcesCreateOrUpdate(ctx, toCreate) } +func (k *Kubernetes) PodsTop(ctx context.Context, options PodsTopOptions) (*metrics.PodMetricsList, error) { + // TODO, maybe move to mcp Tools setup and omit in case metrics aren't available in the target cluster + if !k.supportsGroupVersion(metrics.GroupName + "/" + metricsv1beta1api.SchemeGroupVersion.Version) { + return nil, errors.New("metrics API is not available") + } + namespace := options.Namespace + if options.AllNamespaces && namespace == "" { + namespace = "" + } else { + namespace = k.NamespaceOrDefault(namespace) + } + metricsClient, err := metricsclientset.NewForConfig(k.cfg) + if err != nil { + return nil, fmt.Errorf("failed to create metrics client: %w", err) + } + versionedMetrics := &metricsv1beta1api.PodMetricsList{} + if options.Name != "" { + m, err := metricsClient.MetricsV1beta1().PodMetricses(namespace).Get(ctx, options.Name, metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to get metrics for pod %s/%s: %w", namespace, options.Name, err) + } + versionedMetrics.Items = []metricsv1beta1api.PodMetrics{*m} + } else { + versionedMetrics, err = metricsClient.MetricsV1beta1().PodMetricses(namespace).List(ctx, options.ListOptions) + if err != nil { + return nil, fmt.Errorf("failed to list pod metrics in namespace %s: %w", namespace, err) + } + } + convertedMetrics := &metrics.PodMetricsList{} + return convertedMetrics, metricsv1beta1api.Convert_v1beta1_PodMetricsList_To_metrics_PodMetricsList(versionedMetrics, convertedMetrics, nil) +} + func (k *Kubernetes) PodsExec(ctx context.Context, namespace, name, container string, command []string) (string, error) { namespace = k.NamespaceOrDefault(namespace) pod, err := k.clientSet.CoreV1().Pods(namespace).Get(ctx, name, metav1.GetOptions{}) diff --git a/pkg/mcp/pods.go b/pkg/mcp/pods.go index a7d720f..ec15e16 100644 --- a/pkg/mcp/pods.go +++ b/pkg/mcp/pods.go @@ -1,11 +1,13 @@ package mcp import ( + "bytes" "context" "errors" "fmt" "github.com/manusa/kubernetes-mcp-server/pkg/kubernetes" "github.com/manusa/kubernetes-mcp-server/pkg/output" + "k8s.io/kubectl/pkg/metricsutil" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" @@ -53,6 +55,19 @@ func (s *Server) initPods() []server.ServerTool { mcp.WithIdempotentHintAnnotation(true), mcp.WithOpenWorldHintAnnotation(true), ), Handler: s.podsDelete}, + {Tool: mcp.NewTool("pods_top", + mcp.WithDescription("List the resource consumption (CPU and memory) as recorded by the Kubernetes Metrics Server for the specified Kubernetes Pods in the all namespaces, the provided namespace, or the current namespace"), + mcp.WithBoolean("all_namespaces", mcp.Description("If true, list the resource consumption for all Pods in all namespaces. If false, list the resource consumption for Pods in the provided namespace or the current namespace"), mcp.DefaultBool(true)), + mcp.WithString("namespace", mcp.Description("Namespace to get the Pods resource consumption from (Optional, current namespace if not provided and all_namespaces is false)")), + mcp.WithString("name", mcp.Description("Name of the Pod to get the resource consumption from (Optional, all Pods in the namespace if not provided)")), + mcp.WithString("label_selector", mcp.Description("Kubernetes label selector (e.g. 'app=myapp,env=prod' or 'app in (myapp,yourapp)'), use this option when you want to filter the pods by label (Optional, only applicable when name is not provided)"), mcp.Pattern("([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]")), + // Tool annotations + mcp.WithTitleAnnotation("Pods: Top"), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithDestructiveHintAnnotation(false), + mcp.WithIdempotentHintAnnotation(true), + mcp.WithOpenWorldHintAnnotation(true), + ), Handler: s.podsTop}, {Tool: mcp.NewTool("pods_exec", mcp.WithDescription("Execute a command in a Kubernetes Pod in the current or provided namespace with the provided name and command"), mcp.WithString("namespace", mcp.Description("Namespace of the Pod where the command will be executed")), @@ -125,10 +140,10 @@ func (s *Server) podsListInNamespace(ctx context.Context, ctr mcp.CallToolReques if ns == nil { return NewTextResult("", errors.New("failed to list pods in namespace, missing argument namespace")), nil } - labelSelector := ctr.GetArguments()["labelSelector"] resourceListOptions := kubernetes.ResourceListOptions{ AsTable: s.configuration.ListOutput.AsTable(), } + labelSelector := ctr.GetArguments()["labelSelector"] if labelSelector != nil { resourceListOptions.ListOptions.LabelSelector = labelSelector.(string) } @@ -171,6 +186,33 @@ func (s *Server) podsDelete(ctx context.Context, ctr mcp.CallToolRequest) (*mcp. return NewTextResult(ret, err), nil } +func (s *Server) podsTop(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { + podsTopOptions := kubernetes.PodsTopOptions{AllNamespaces: true} + if v, ok := ctr.GetArguments()["namespace"].(string); ok { + podsTopOptions.Namespace = v + } + if v, ok := ctr.GetArguments()["all_namespaces"].(bool); ok { + podsTopOptions.AllNamespaces = v + } + if v, ok := ctr.GetArguments()["name"].(string); ok { + podsTopOptions.Name = v + } + if v, ok := ctr.GetArguments()["label_selector"].(string); ok { + podsTopOptions.LabelSelector = v + } + ret, err := s.k.Derived(ctx).PodsTop(ctx, podsTopOptions) + if err != nil { + return NewTextResult("", fmt.Errorf("failed to get pods top: %v", err)), nil + } + buf := new(bytes.Buffer) + printer := metricsutil.NewTopCmdPrinter(buf) + err = printer.PrintPodMetrics(ret.Items, true, true, false, "", true) + if err != nil { + return NewTextResult("", fmt.Errorf("failed to get pods top: %v", err)), nil + } + return NewTextResult(buf.String(), nil), nil +} + func (s *Server) podsExec(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { ns := ctr.GetArguments()["namespace"] if ns == nil { diff --git a/pkg/mcp/pods_exec_test.go b/pkg/mcp/pods_exec_test.go index 39c5b10..1e0b092 100644 --- a/pkg/mcp/pods_exec_test.go +++ b/pkg/mcp/pods_exec_test.go @@ -97,6 +97,5 @@ func TestPodsExec(t *testing.T) { t.Errorf("expected container name not found %v", podsExecInNamespaceAndContainer.Content[0].(mcp.TextContent).Text) } }) - }) } diff --git a/pkg/mcp/pods_top_test.go b/pkg/mcp/pods_top_test.go new file mode 100644 index 0000000..713de94 --- /dev/null +++ b/pkg/mcp/pods_top_test.go @@ -0,0 +1,195 @@ +package mcp + +import ( + "github.com/mark3labs/mcp-go/mcp" + "net/http" + "regexp" + "testing" +) + +func TestPodsTop(t *testing.T) { + testCase(t, func(c *mcpContext) { + mockServer := NewMockServer() + defer mockServer.Close() + c.withKubeConfig(mockServer.config) + metricsApiAvailable := false + mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + println("Request received:", req.Method, req.URL.Path) // TODO: REMOVE LINE + w.Header().Set("Content-Type", "application/json") + // Request Performed by DiscoveryClient to Kube API (Get API Groups legacy -core-) + if req.URL.Path == "/api" { + if !metricsApiAvailable { + + _, _ = w.Write([]byte(`{"kind":"APIVersions","versions":[],"serverAddressByClientCIDRs":[{"clientCIDR":"0.0.0.0/0"}]}`)) + } else { + _, _ = w.Write([]byte(`{"kind":"APIVersions","versions":["metrics.k8s.io/v1beta1"],"serverAddressByClientCIDRs":[{"clientCIDR":"0.0.0.0/0"}]}`)) + } + return + } + // Request Performed by DiscoveryClient to Kube API (Get API Groups) + if req.URL.Path == "/apis" { + _, _ = w.Write([]byte(`{"kind":"APIGroupList","apiVersion":"v1","groups":[]}`)) + return + } + // Request Performed by DiscoveryClient to Kube API (Get API Resources) + if metricsApiAvailable && req.URL.Path == "/apis/metrics.k8s.io/v1beta1" { + _, _ = w.Write([]byte(`{"kind":"APIResourceList","apiVersion":"v1","groupVersion":"metrics.k8s.io/v1beta1","resources":[{"name":"pods","singularName":"","namespaced":true,"kind":"PodMetrics","verbs":["get","list"]}]}`)) + return + } + // Pod Metrics from all namespaces + if metricsApiAvailable && req.URL.Path == "/apis/metrics.k8s.io/v1beta1/pods" { + if req.URL.Query().Get("labelSelector") == "app=pod-ns-5-42" { + _, _ = w.Write([]byte(`{"kind":"PodMetricsList","apiVersion":"metrics.k8s.io/v1beta1","items":[` + + `{"metadata":{"name":"pod-ns-5-42","namespace":"ns-5"},"containers":[{"name":"container-1","usage":{"cpu":"42m","memory":"42Mi"}}]}` + + `]}`)) + } else { + _, _ = w.Write([]byte(`{"kind":"PodMetricsList","apiVersion":"metrics.k8s.io/v1beta1","items":[` + + `{"metadata":{"name":"pod-1","namespace":"default"},"containers":[{"name":"container-1","usage":{"cpu":"100m","memory":"200Mi"}},{"name":"container-2","usage":{"cpu":"200m","memory":"300Mi"}}]},` + + `{"metadata":{"name":"pod-2","namespace":"ns-1"},"containers":[{"name":"container-1-ns-1","usage":{"cpu":"300m","memory":"400Mi"}}]}` + + `]}`)) + + } + return + } + // Pod Metrics from configured namespace + if metricsApiAvailable && req.URL.Path == "/apis/metrics.k8s.io/v1beta1/namespaces/default/pods" { + _, _ = w.Write([]byte(`{"kind":"PodMetricsList","apiVersion":"metrics.k8s.io/v1beta1","items":[` + + `{"metadata":{"name":"pod-1","namespace":"default"},"containers":[{"name":"container-1","usage":{"cpu":"10m","memory":"20Mi"}},{"name":"container-2","usage":{"cpu":"30m","memory":"40Mi"}}]}` + + `]}`)) + return + } + // Pod Metrics from ns-5 namespace + if metricsApiAvailable && req.URL.Path == "/apis/metrics.k8s.io/v1beta1/namespaces/ns-5/pods" { + _, _ = w.Write([]byte(`{"kind":"PodMetricsList","apiVersion":"metrics.k8s.io/v1beta1","items":[` + + `{"metadata":{"name":"pod-ns-5-1","namespace":"ns-5"},"containers":[{"name":"container-1","usage":{"cpu":"10m","memory":"20Mi"}}]}` + + `]}`)) + return + } + // Pod Metrics from ns-5 namespace with pod-ns-5-5 pod name + if metricsApiAvailable && req.URL.Path == "/apis/metrics.k8s.io/v1beta1/namespaces/ns-5/pods/pod-ns-5-5" { + _, _ = w.Write([]byte(`{"kind":"PodMetrics","apiVersion":"metrics.k8s.io/v1beta1",` + + `"metadata":{"name":"pod-ns-5-5","namespace":"ns-5"},` + + `"containers":[{"name":"container-1","usage":{"cpu":"13m","memory":"37Mi"}}]` + + `}`)) + } + })) + podsTopMetricsApiUnavailable, err := c.callTool("pods_top", map[string]interface{}{}) + t.Run("pods_top with metrics API not available", func(t *testing.T) { + if err != nil { + t.Fatalf("call tool failed %v", err) + } + if !podsTopMetricsApiUnavailable.IsError { + t.Errorf("call tool should have returned an error") + } + if podsTopMetricsApiUnavailable.Content[0].(mcp.TextContent).Text != "failed to get pods top: metrics API is not available" { + t.Errorf("call tool returned unexpected content: %s", podsTopMetricsApiUnavailable.Content[0].(mcp.TextContent).Text) + } + }) + // Enable metrics API addon + metricsApiAvailable = true + d, _ := c.mcpServer.k.ToDiscoveryClient() + d.Invalidate() // Force discovery client to refresh + podsTopDefaults, err := c.callTool("pods_top", map[string]interface{}{}) + t.Run("pods_top defaults returns pod metrics from all namespaces", func(t *testing.T) { + if err != nil { + t.Fatalf("call tool failed %v", err) + } + textContent := podsTopDefaults.Content[0].(mcp.TextContent).Text + if podsTopDefaults.IsError { + t.Fatalf("call tool failed %s", textContent) + } + expectedHeaders := regexp.MustCompile("(?m)^\\s*NAMESPACE\\s+POD\\s+NAME\\s+CPU\\(cores\\)\\s+MEMORY\\(bytes\\)\\s*$") + if !expectedHeaders.MatchString(textContent) { + t.Errorf("Expected headers '%s' not found in output:\n%s", expectedHeaders.String(), textContent) + } + expectedRows := []string{ + "default\\s+pod-1\\s+container-1\\s+100m\\s+200Mi", + "default\\s+pod-1\\s+container-2\\s+200m\\s+300Mi", + "ns-1\\s+pod-2\\s+container-1-ns-1\\s+300m\\s+400Mi", + } + for _, row := range expectedRows { + if !regexp.MustCompile(row).MatchString(textContent) { + t.Errorf("Expected row '%s' not found in output:\n%s", row, textContent) + } + } + expectedTotal := regexp.MustCompile("(?m)^\\s+600m\\s+900Mi\\s*$") + if !expectedTotal.MatchString(textContent) { + t.Errorf("Expected total row '%s' not found in output:\n%s", expectedTotal.String(), textContent) + } + }) + podsTopConfiguredNamespace, err := c.callTool("pods_top", map[string]interface{}{ + "all_namespaces": false, + }) + t.Run("pods_top[allNamespaces=false] returns pod metrics from configured namespace", func(t *testing.T) { + if err != nil { + t.Fatalf("call tool failed %v", err) + } + textContent := podsTopConfiguredNamespace.Content[0].(mcp.TextContent).Text + expectedRows := []string{ + "default\\s+pod-1\\s+container-1\\s+10m\\s+20Mi", + "default\\s+pod-1\\s+container-2\\s+30m\\s+40Mi", + } + for _, row := range expectedRows { + if !regexp.MustCompile(row).MatchString(textContent) { + t.Errorf("Expected row '%s' not found in output:\n%s", row, textContent) + } + } + expectedTotal := regexp.MustCompile("(?m)^\\s+40m\\s+60Mi\\s*$") + if !expectedTotal.MatchString(textContent) { + t.Errorf("Expected total row '%s' not found in output:\n%s", expectedTotal.String(), textContent) + } + }) + podsTopNamespace, err := c.callTool("pods_top", map[string]interface{}{ + "namespace": "ns-5", + }) + t.Run("pods_top[namespace=ns-5] returns pod metrics from provided namespace", func(t *testing.T) { + if err != nil { + t.Fatalf("call tool failed %v", err) + } + textContent := podsTopNamespace.Content[0].(mcp.TextContent).Text + expectedRow := regexp.MustCompile("ns-5\\s+pod-ns-5-1\\s+container-1\\s+10m\\s+20Mi") + if !expectedRow.MatchString(textContent) { + t.Errorf("Expected row '%s' not found in output:\n%s", expectedRow.String(), textContent) + } + expectedTotal := regexp.MustCompile("(?m)^\\s+10m\\s+20Mi\\s*$") + if !expectedTotal.MatchString(textContent) { + t.Errorf("Expected total row '%s' not found in output:\n%s", expectedTotal.String(), textContent) + } + }) + podsTopNamespaceName, err := c.callTool("pods_top", map[string]interface{}{ + "namespace": "ns-5", + "name": "pod-ns-5-5", + }) + t.Run("pods_top[namespace=ns-5,name=pod-ns-5-5] returns pod metrics from provided namespace and name", func(t *testing.T) { + if err != nil { + t.Fatalf("call tool failed %v", err) + } + textContent := podsTopNamespaceName.Content[0].(mcp.TextContent).Text + expectedRow := regexp.MustCompile("ns-5\\s+pod-ns-5-5\\s+container-1\\s+13m\\s+37Mi") + if !expectedRow.MatchString(textContent) { + t.Errorf("Expected row '%s' not found in output:\n%s", expectedRow.String(), textContent) + } + expectedTotal := regexp.MustCompile("(?m)^\\s+13m\\s+37Mi\\s*$") + if !expectedTotal.MatchString(textContent) { + t.Errorf("Expected total row '%s' not found in output:\n%s", expectedTotal.String(), textContent) + } + }) + podsTopNamespaceLabelSelector, err := c.callTool("pods_top", map[string]interface{}{ + "label_selector": "app=pod-ns-5-42", + }) + t.Run("pods_top[label_selector=app=pod-ns-5-42] returns pod metrics from pods matching selector", func(t *testing.T) { + if err != nil { + t.Fatalf("call tool failed %v", err) + } + textContent := podsTopNamespaceLabelSelector.Content[0].(mcp.TextContent).Text + expectedRow := regexp.MustCompile("ns-5\\s+pod-ns-5-42\\s+container-1\\s+42m\\s+42Mi") + if !expectedRow.MatchString(textContent) { + t.Errorf("Expected row '%s' not found in output:\n%s", expectedRow.String(), textContent) + } + expectedTotal := regexp.MustCompile("(?m)^\\s+42m\\s+42Mi\\s*$") + if !expectedTotal.MatchString(textContent) { + t.Errorf("Expected total row '%s' not found in output:\n%s", expectedTotal.String(), textContent) + } + }) + }) +}