From d5cacb9527d4d6e62dfed6e27082b459aa073750 Mon Sep 17 00:00:00 2001 From: Marc Nuri Date: Fri, 28 Mar 2025 10:49:21 +0100 Subject: [PATCH] feat: pods_exec minimal implementation --- README.md | 3 +- go.mod | 3 ++ go.sum | 8 +++++ pkg/kubernetes/kubernetes.go | 11 +++++++ pkg/kubernetes/pods.go | 64 ++++++++++++++++++++++++++++++++++++ pkg/mcp/pods.go | 47 ++++++++++++++++++++++++++ 6 files changed, 135 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 68b29d2..5fd7c7e 100644 --- a/README.md +++ b/README.md @@ -16,13 +16,14 @@ A powerful and flexible Kubernetes [Model Context Protocol (MCP)](https://blog.m - **✅ Configuration**: - Automatically detect changes in the Kubernetes configuration and update the MCP server. - **View** and manage the current [Kubernetes `.kube/config`](https://blog.marcnuri.com/where-is-my-default-kubeconfig-file) or in-cluster configuration. -- **✅ Generic Kubernetes Resources**: Perform operations on any Kubernetes resource. +- **✅ Generic Kubernetes Resources**: Perform operations on **any** Kubernetes or OpenShift resource. - Any CRUD operation (Create or Update, Get, List, Delete). - **✅ Pods**: Perform Pod-specific operations. - **List** pods in all namespaces or in a specific namespace. - **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. + - **Exec** into a pod and run a command. - **Run** a container image in a pod and optionally expose it. - **✅ Namespaces**: List Kubernetes Namespaces. - **✅ Events**: View Kubernetes events in all namespaces or in a specific namespace. diff --git a/go.mod b/go.mod index 6e5adc5..5f22cd7 100644 --- a/go.mod +++ b/go.mod @@ -35,13 +35,16 @@ require ( github.com/google/go-cmp v0.6.0 // indirect github.com/google/gofuzz v1.2.0 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.5.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/mailru/easyjson v0.7.7 // indirect + github.com/moby/spdystream v0.5.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/sagikazarmark/locafero v0.7.0 // indirect diff --git a/go.sum b/go.sum index 384e2fb..7d42f47 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -50,6 +52,8 @@ github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad h1:a6HEuzUHeKH6hwfN/Z github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= @@ -69,6 +73,8 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0 github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mark3labs/mcp-go v0.16.0 h1:hNOr0EqhSUra5jm1Wv6+BOynzIa+bMtfP3zgde70MvY= github.com/mark3labs/mcp-go v0.16.0/go.mod h1:KmJndYv7GIgcPVwEKJjNcbhVQ+hJGJhrCCB/9xITzpE= +github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU= +github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -76,6 +82,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= github.com/onsi/ginkgo/v2 v2.22.2 h1:/3X8Panh8/WwhU/3Ssa6rCKqPLuAkVY2I0RoyDLySlU= github.com/onsi/ginkgo/v2 v2.22.2/go.mod h1:oeMosUL+8LtarXBHu/c0bx2D/K9zyQ6uX3cTyztHwsk= github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8= diff --git a/pkg/kubernetes/kubernetes.go b/pkg/kubernetes/kubernetes.go index c22d9c8..5ec933f 100644 --- a/pkg/kubernetes/kubernetes.go +++ b/pkg/kubernetes/kubernetes.go @@ -2,7 +2,9 @@ package kubernetes import ( "github.com/fsnotify/fsnotify" + v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/discovery" "k8s.io/client-go/discovery/cached/memory" "k8s.io/client-go/dynamic" @@ -24,6 +26,8 @@ type Kubernetes struct { cfg *rest.Config kubeConfigFiles []string CloseWatchKubeConfig CloseWatchKubeConfig + scheme *runtime.Scheme + parameterCodec *runtime.ParameterCodec clientSet *kubernetes.Clientset discoveryClient *discovery.DiscoveryClient deferredDiscoveryRESTMapper *restmapper.DeferredDiscoveryRESTMapper @@ -47,9 +51,16 @@ func NewKubernetes() (*Kubernetes, error) { if err != nil { return nil, err } + scheme := runtime.NewScheme() + if err = v1.AddToScheme(scheme); err != nil { + return nil, err + } + parameterCodec := runtime.NewParameterCodec(scheme) return &Kubernetes{ cfg: cfg, kubeConfigFiles: resolveConfig().ConfigAccess().GetLoadingPrecedence(), + scheme: scheme, + parameterCodec: ¶meterCodec, clientSet: clientSet, discoveryClient: discoveryClient, deferredDiscoveryRESTMapper: restmapper.NewDeferredDiscoveryRESTMapper(memory.NewMemCacheClient(discoveryClient)), diff --git a/pkg/kubernetes/pods.go b/pkg/kubernetes/pods.go index 570cd60..d1d9931 100644 --- a/pkg/kubernetes/pods.go +++ b/pkg/kubernetes/pods.go @@ -1,7 +1,9 @@ package kubernetes import ( + "bytes" "context" + "fmt" "github.com/manusa/kubernetes-mcp-server/pkg/version" v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -9,8 +11,10 @@ import ( labelutil "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/httpstream" "k8s.io/apimachinery/pkg/util/intstr" "k8s.io/apimachinery/pkg/util/rand" + "k8s.io/client-go/tools/remotecommand" ) func (k *Kubernetes) PodsListInAllNamespaces(ctx context.Context) (string, error) { @@ -168,3 +172,63 @@ func (k *Kubernetes) PodsRun(ctx context.Context, namespace, name, image string, } return k.resourcesCreateOrUpdate(ctx, toCreate) } + +func (k *Kubernetes) PodsExec(ctx context.Context, namespace, name, container string, command []string) (string, error) { + namespace = namespaceOrDefault(namespace) + pod, err := k.clientSet.CoreV1().Pods(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return "", err + } + // https://github.com/kubernetes/kubectl/blob/5366de04e168bcbc11f5e340d131a9ca8b7d0df4/pkg/cmd/exec/exec.go#L350-L352 + if pod.Status.Phase == v1.PodSucceeded || pod.Status.Phase == v1.PodFailed { + return "", fmt.Errorf("cannot exec into a container in a completed pod; current phase is %s", pod.Status.Phase) + } + podExecOptions := &v1.PodExecOptions{ + Command: command, + Stdout: true, + Stderr: true, + } + executor, err := k.createExecutor(namespace, name, podExecOptions) + if err != nil { + return "", err + } + if container == "" { + container = pod.Spec.Containers[0].Name + } + stdout := bytes.NewBuffer(make([]byte, 0)) + stderr := bytes.NewBuffer(make([]byte, 0)) + if err = executor.StreamWithContext(ctx, remotecommand.StreamOptions{ + Stdout: stdout, Stderr: stderr, Tty: false, + }); err != nil { + return "", err + } + if stdout.Len() > 0 { + return stdout.String(), nil + } + if stderr.Len() > 0 { + return stderr.String(), nil + } + return "", nil +} + +func (k *Kubernetes) createExecutor(namespace, name string, podExecOptions *v1.PodExecOptions) (remotecommand.Executor, error) { + // Compute URL + // https://github.com/kubernetes/kubectl/blob/5366de04e168bcbc11f5e340d131a9ca8b7d0df4/pkg/cmd/exec/exec.go#L382-L397 + req := k.clientSet.CoreV1().RESTClient().Post(). + Resource("pods"). + Namespace(namespace). + Name(name). + SubResource("exec") + req.VersionedParams(podExecOptions, *k.parameterCodec) + spdyExec, err := remotecommand.NewSPDYExecutor(k.cfg, "POST", req.URL()) + if err != nil { + return nil, err + } + webSocketExec, err := remotecommand.NewWebSocketExecutor(k.cfg, "GET", req.URL().String()) + if err != nil { + return nil, err + } + return remotecommand.NewFallbackExecutor(webSocketExec, spdyExec, func(err error) bool { + return httpstream.IsUpgradeFailure(err) || httpstream.IsHTTPSProxyError(err) + }) +} diff --git a/pkg/mcp/pods.go b/pkg/mcp/pods.go index d8a8cca..8508d69 100644 --- a/pkg/mcp/pods.go +++ b/pkg/mcp/pods.go @@ -27,6 +27,24 @@ func (s *Server) initPods() []server.ServerTool { mcp.WithString("namespace", mcp.Description("Namespace to delete the Pod from")), mcp.WithString("name", mcp.Description("Name of the Pod to delete"), mcp.Required()), ), s.podsDelete}, + {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 to get the Pod logs from")), + mcp.WithString("name", mcp.Description("Name of the Pod to get the logs from"), mcp.Required()), + mcp.WithArray("command", mcp.Description("Command to execute in the Pod container. "+ + "The first item is the command to be run, and the rest are the arguments to that command. "+ + `Example: ["ls", "-l", "/tmp"]`), + // TODO: manual fix to ensure that the items property gets initialized (Gemini) + // https://www.googlecloudcommunity.com/gc/AI-ML/Gemini-API-400-Bad-Request-Array-fields-breaks-function-calling/m-p/769835?nobounce + func(schema map[string]interface{}) { + schema["type"] = "array" + schema["items"] = map[string]interface{}{ + "type": "string", + } + }, + mcp.Required(), + ), + ), s.podsExec}, {mcp.NewTool("pods_log", mcp.WithDescription("Get the logs of a Kubernetes Pod in the current or provided namespace with the provided name"), mcp.WithString("namespace", mcp.Description("Namespace to get the Pod logs from")), @@ -94,6 +112,35 @@ func (s *Server) podsDelete(ctx context.Context, ctr mcp.CallToolRequest) (*mcp. return NewTextResult(ret, err), nil } +func (s *Server) podsExec(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { + ns := ctr.Params.Arguments["namespace"] + if ns == nil { + ns = "" + } + name := ctr.Params.Arguments["name"] + if name == nil { + return NewTextResult("", errors.New("failed to exec in pod, missing argument name")), nil + } + commandArg := ctr.Params.Arguments["command"] + command := make([]string, 0) + if _, ok := commandArg.([]interface{}); ok { + for _, cmd := range commandArg.([]interface{}) { + if _, ok := cmd.(string); ok { + command = append(command, cmd.(string)) + } + } + } else { + return NewTextResult("", errors.New("failed to exec in pod, invalid command argument")), nil + } + ret, err := s.k.PodsExec(ctx, ns.(string), name.(string), "", command) + if err != nil { + return NewTextResult("", fmt.Errorf("failed to exec in pod %s in namespace %s: %v", name, ns, err)), nil + } else if ret == "" { + ret = fmt.Sprintf("The executed command in pod %s in namespace %s has not produced any output", name, ns) + } + return NewTextResult(ret, err), nil +} + func (s *Server) podsLog(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { ns := ctr.Params.Arguments["namespace"] if ns == nil {