feat(kubernetes): pods_get for explicit or nil namespace

This commit is contained in:
Marc Nuri
2025-02-17 08:52:22 +01:00
parent 0f12797365
commit f591e2b06b
7 changed files with 215 additions and 51 deletions

View File

@@ -1,25 +1,23 @@
package kubernetes
import (
"k8s.io/client-go/tools/clientcmd"
clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
"k8s.io/client-go/tools/clientcmd/api/latest"
)
func ConfigurationView() (string, error) {
// TODO: consider in cluster run mode (current approach only shows kubeconfig)
pathOptions := clientcmd.NewDefaultPathOptions()
cfg, err := pathOptions.GetStartingConfig()
cfg, err := resolveConfig().RawConfig()
if err != nil {
return "", err
}
if err = clientcmdapi.MinifyConfig(cfg); err != nil {
if err = clientcmdapi.MinifyConfig(&cfg); err != nil {
return "", err
}
if err = clientcmdapi.FlattenConfig(cfg); err != nil {
if err = clientcmdapi.FlattenConfig(&cfg); err != nil {
return "", err
}
convertedObj, err := latest.Scheme.ConvertToVersion(cfg, latest.ExternalVersion)
convertedObj, err := latest.Scheme.ConvertToVersion(&cfg, latest.ExternalVersion)
if err != nil {
return "", err
}

View File

@@ -5,6 +5,7 @@ import (
"k8s.io/client-go/rest"
"k8s.io/client-go/restmapper"
"k8s.io/client-go/tools/clientcmd"
clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
"sigs.k8s.io/yaml"
)
@@ -37,11 +38,28 @@ func marshal(v any) (string, error) {
return string(ret), nil
}
func resolveConfig() clientcmd.ClientConfig {
pathOptions := clientcmd.NewDefaultPathOptions()
//cfg, err := pathOptions.GetStartingConfig()
//if err != nil {
// return nil, err
//}
//if err = clientcmdapi.MinifyConfig(cfg); err != nil {
// return nil, err
//}
//if err = clientcmdapi.FlattenConfig(cfg); err != nil {
// return nil, err
//}
//return cfg, nil
return clientcmd.NewNonInteractiveDeferredLoadingClientConfig(
&clientcmd.ClientConfigLoadingRules{ExplicitPath: pathOptions.GetDefaultFilename()},
&clientcmd.ConfigOverrides{ClusterInfo: clientcmdapi.Cluster{Server: ""}})
}
func resolveClientConfig() (*rest.Config, error) {
inClusterConfig, err := rest.InClusterConfig()
if err == nil && inClusterConfig != nil {
return inClusterConfig, nil
}
pathOptions := clientcmd.NewDefaultPathOptions()
return clientcmd.BuildConfigFromFlags("", pathOptions.GetDefaultFilename())
return resolveConfig().ClientConfig()
}

View File

@@ -16,3 +16,14 @@ func (k *Kubernetes) PodsListInNamespace(ctx context.Context, namespace string)
Group: "", Version: "v1", Kind: "Pod",
}, namespace)
}
func (k *Kubernetes) PodsGet(ctx context.Context, namespace, name string) (string, error) {
if namespace == "" {
if ns, _, nsErr := resolveConfig().Namespace(); nsErr == nil {
namespace = ns
}
}
return k.ResourcesGet(ctx, &schema.GroupVersionKind{
Group: "", Version: "v1", Kind: "Pod",
}, namespace, name)
}

View File

@@ -27,6 +27,22 @@ func (k *Kubernetes) ResourcesList(ctx context.Context, gvk *schema.GroupVersion
return marshal(rl.Items)
}
func (k *Kubernetes) ResourcesGet(ctx context.Context, gvk *schema.GroupVersionKind, namespace, name string) (string, error) {
client, err := dynamic.NewForConfig(k.cfg)
if err != nil {
return "", err
}
gvr, err := k.resourceFor(gvk)
if err != nil {
return "", err
}
rg, err := client.Resource(*gvr).Namespace(namespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
return "", err
}
return marshal(rg)
}
func (k *Kubernetes) resourceFor(gvk *schema.GroupVersionKind) (*schema.GroupVersionResource, error) {
if k.deferredDiscoveryRESTMapper == nil {
d, err := discovery.NewDiscoveryClientForConfig(k.cfg)

View File

@@ -6,6 +6,8 @@ import (
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/spf13/afero"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
@@ -55,6 +57,8 @@ func TestMain(m *testing.M) {
BinaryAssetsDirectory: filepath.Join(envTestDir, "k8s", versionDir),
}
envTestRestConfig, _ = envTest.Start()
kc, _ := kubernetes.NewForConfig(envTestRestConfig)
createTestData(context.Background(), kc)
// Test!
code := m.Run()
@@ -111,6 +115,7 @@ func testCase(t *testing.T, test func(c *mcpContext)) {
test(mcpCtx)
}
// withKubeConfig sets up a fake kubeconfig in the temp directory based on the provided rest.Config
func (c *mcpContext) withKubeConfig(rc *rest.Config) *api.Config {
fakeConfig := api.NewConfig()
fakeConfig.CurrentContext = "fake-context"
@@ -132,10 +137,12 @@ func (c *mcpContext) withKubeConfig(rc *rest.Config) *api.Config {
return fakeConfig
}
// withEnvTest sets up the environment for kubeconfig to be used with envTest
func (c *mcpContext) withEnvTest() {
c.withKubeConfig(envTestRestConfig)
}
// newKubernetesClient creates a new Kubernetes client with the current kubeconfig
func (c *mcpContext) newKubernetesClient() *kubernetes.Clientset {
c.withEnvTest()
pathOptions := clientcmd.NewDefaultPathOptions()
@@ -147,9 +154,44 @@ func (c *mcpContext) newKubernetesClient() *kubernetes.Clientset {
return kubernetesClient
}
// callTool helper function to call a tool by name with arguments
func (c *mcpContext) callTool(name string, args map[string]interface{}) (*mcp.CallToolResult, error) {
callToolRequest := mcp.CallToolRequest{}
callToolRequest.Params.Name = name
callToolRequest.Params.Arguments = args
return c.mcpClient.CallTool(c.ctx, callToolRequest)
}
func createTestData(ctx context.Context, kc *kubernetes.Clientset) {
_, _ = kc.CoreV1().Namespaces().
Create(ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "ns-1"}}, metav1.CreateOptions{})
_, _ = kc.CoreV1().Namespaces().
Create(ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "ns-2"}}, metav1.CreateOptions{})
_, _ = kc.CoreV1().Pods("default").
Create(ctx, &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{Name: "a-pod-in-default"},
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{Name: "nginx", Image: "nginx"},
},
},
}, metav1.CreateOptions{})
_, _ = kc.CoreV1().Pods("ns-1").
Create(ctx, &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{Name: "a-pod-in-ns-1"},
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{Name: "nginx", Image: "nginx"},
},
},
}, metav1.CreateOptions{})
_, _ = kc.CoreV1().Pods("ns-2").
Create(ctx, &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{Name: "a-pod-in-ns-2"},
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{Name: "nginx", Image: "nginx"},
},
},
}, metav1.CreateOptions{})
}

View File

@@ -21,6 +21,17 @@ func (s *Sever) initPods() {
mcp.Required(),
),
), podsListInNamespace)
s.server.AddTool(mcp.NewTool(
"pods_get",
mcp.WithDescription("Get a Kubernetes Pod in the current namespace with the provided name"),
mcp.WithString("namespace",
mcp.Description("Namespace to get the Pod from"),
),
mcp.WithString("name",
mcp.Description("Name of the Pod"),
mcp.Required(),
),
), podsGet)
}
func podsListInAllNamespaces(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
@@ -50,3 +61,23 @@ func podsListInNamespace(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.Cal
}
return NewTextResult(ret, err), nil
}
func podsGet(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
k, err := kubernetes.NewKubernetes()
if err != nil {
return NewTextResult("", fmt.Errorf("failed to get pod: %v", err)), nil
}
ns := ctr.Params.Arguments["namespace"]
if ns == nil {
ns = ""
}
name := ctr.Params.Arguments["name"]
if name == nil {
return NewTextResult("", errors.New("failed to get pod, missing argument name")), nil
}
ret, err := k.PodsGet(ctx, ns.(string), name.(string))
if err != nil {
return NewTextResult("", fmt.Errorf("failed to get pod %s in namespace %s: %v", name, ns, err)), nil
}
return NewTextResult(ret, err), nil
}

View File

@@ -1,11 +1,7 @@
package mcp
import (
"context"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/client-go/kubernetes"
"sigs.k8s.io/yaml"
"testing"
)
@@ -13,7 +9,6 @@ import (
func TestPodsListInAllNamespaces(t *testing.T) {
testCase(t, func(c *mcpContext) {
c.withEnvTest()
createTestData(c.ctx, c.newKubernetesClient())
toolResult, err := c.callTool("pods_list", map[string]interface{}{})
t.Run("pods_list returns pods list", func(t *testing.T) {
if err != nil {
@@ -29,34 +24,34 @@ func TestPodsListInAllNamespaces(t *testing.T) {
return
}
})
t.Run("pods_list returns 2 items", func(t *testing.T) {
if len(decoded) != 2 {
t.Fatalf("invalid pods count, expected 2, got %v", len(decoded))
t.Run("pods_list returns 3 items", func(t *testing.T) {
if len(decoded) != 3 {
t.Fatalf("invalid pods count, expected 3, got %v", len(decoded))
return
}
})
t.Run("pods_list returns pod in ns-1", func(t *testing.T) {
if decoded[0].GetName() != "a-pod-in-ns-1" {
t.Fatalf("invalid pod name, expected a-pod-in-ns-1, got %v", decoded[0].GetName())
if decoded[1].GetName() != "a-pod-in-ns-1" {
t.Fatalf("invalid pod name, expected a-pod-in-ns-1, got %v", decoded[1].GetName())
return
}
if decoded[0].GetNamespace() != "ns-1" {
t.Fatalf("invalid pod namespace, expected ns-1, got %v", decoded[0].GetNamespace())
if decoded[1].GetNamespace() != "ns-1" {
t.Fatalf("invalid pod namespace, expected ns-1, got %v", decoded[1].GetNamespace())
return
}
})
t.Run("pods_list returns pod in ns-2", func(t *testing.T) {
if decoded[1].GetName() != "a-pod-in-ns-2" {
t.Fatalf("invalid pod name, expected a-pod-in-ns-2, got %v", decoded[1].GetName())
if decoded[2].GetName() != "a-pod-in-ns-2" {
t.Fatalf("invalid pod name, expected a-pod-in-ns-2, got %v", decoded[2].GetName())
return
}
if decoded[1].GetNamespace() != "ns-2" {
t.Fatalf("invalid pod namespace, expected ns-2, got %v", decoded[1].GetNamespace())
if decoded[2].GetNamespace() != "ns-2" {
t.Fatalf("invalid pod namespace, expected ns-2, got %v", decoded[2].GetNamespace())
return
}
})
t.Run("pods_list omits managed fields", func(t *testing.T) {
if decoded[0].GetManagedFields() != nil {
if decoded[1].GetManagedFields() != nil {
t.Fatalf("managed fields should be omitted, got %v", decoded[0].GetManagedFields())
return
}
@@ -67,7 +62,7 @@ func TestPodsListInAllNamespaces(t *testing.T) {
func TestPodsListInNamespace(t *testing.T) {
testCase(t, func(c *mcpContext) {
c.withEnvTest()
t.Run("pods_list_in_namespace with nil namespace returns pods list", func(t *testing.T) {
t.Run("pods_list_in_namespace with nil namespace returns error", func(t *testing.T) {
toolResult, _ := c.callTool("pods_list_in_namespace", map[string]interface{}{})
if toolResult.IsError != true {
t.Fatalf("call tool should fail")
@@ -78,7 +73,6 @@ func TestPodsListInNamespace(t *testing.T) {
return
}
})
createTestData(c.ctx, c.newKubernetesClient())
toolResult, err := c.callTool("pods_list_in_namespace", map[string]interface{}{
"namespace": "ns-1",
})
@@ -123,30 +117,84 @@ func TestPodsListInNamespace(t *testing.T) {
}
})
})
}
func createTestData(ctx context.Context, kc *kubernetes.Clientset) {
_, _ = kc.CoreV1().Namespaces().
Create(ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "ns-1"}}, metav1.CreateOptions{})
_, _ = kc.CoreV1().Namespaces().
Create(ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "ns-2"}}, metav1.CreateOptions{})
_, _ = kc.CoreV1().Pods("ns-1").
Create(ctx, &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{Name: "a-pod-in-ns-1"},
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{Name: "nginx", Image: "nginx"},
},
},
}, metav1.CreateOptions{})
_, _ = kc.CoreV1().Pods("ns-2").
Create(ctx, &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{Name: "a-pod-in-ns-2"},
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{Name: "nginx", Image: "nginx"},
},
},
}, metav1.CreateOptions{})
func TestPodsGet(t *testing.T) {
testCase(t, func(c *mcpContext) {
c.withEnvTest()
t.Run("pods_get with nil name returns error", func(t *testing.T) {
toolResult, _ := c.callTool("pods_get", map[string]interface{}{})
if toolResult.IsError != true {
t.Fatalf("call tool should fail")
return
}
if toolResult.Content[0].(map[string]interface{})["text"].(string) != "failed to get pod, missing argument name" {
t.Fatalf("invalid error message, got %v", toolResult.Content[0].(map[string]interface{})["text"].(string))
return
}
})
podsGetNilNamespace, err := c.callTool("pods_get", map[string]interface{}{
"name": "a-pod-in-default",
})
t.Run("pods_get with name and nil namespace returns pod", func(t *testing.T) {
if err != nil {
t.Fatalf("call tool failed %v", err)
return
}
if podsGetNilNamespace.IsError {
t.Fatalf("call tool failed")
return
}
})
var decodedNilNamespace unstructured.Unstructured
err = yaml.Unmarshal([]byte(podsGetNilNamespace.Content[0].(map[string]interface{})["text"].(string)), &decodedNilNamespace)
t.Run("pods_get with name and nil namespace has yaml content", func(t *testing.T) {
if err != nil {
t.Fatalf("invalid tool result content %v", err)
return
}
})
t.Run("pods_get with name and nil namespace returns pod in default", func(t *testing.T) {
if decodedNilNamespace.GetName() != "a-pod-in-default" {
t.Fatalf("invalid pod name, expected a-pod-in-default, got %v", decodedNilNamespace.GetName())
return
}
if decodedNilNamespace.GetNamespace() != "default" {
t.Fatalf("invalid pod namespace, expected default, got %v", decodedNilNamespace.GetNamespace())
return
}
})
podsGetInNamespace, err := c.callTool("pods_get", map[string]interface{}{
"namespace": "ns-1",
"name": "a-pod-in-ns-1",
})
t.Run("pods_get with name and namespace returns pod", func(t *testing.T) {
if err != nil {
t.Fatalf("call tool failed %v", err)
return
}
if podsGetInNamespace.IsError {
t.Fatalf("call tool failed")
return
}
})
var decodedInNamespace unstructured.Unstructured
err = yaml.Unmarshal([]byte(podsGetInNamespace.Content[0].(map[string]interface{})["text"].(string)), &decodedInNamespace)
t.Run("pods_get with name and namespace has yaml content", func(t *testing.T) {
if err != nil {
t.Fatalf("invalid tool result content %v", err)
return
}
})
t.Run("pods_get with name and namespace returns pod in ns-1", func(t *testing.T) {
if decodedInNamespace.GetName() != "a-pod-in-ns-1" {
t.Fatalf("invalid pod name, expected a-pod-in-ns-1, got %v", decodedInNamespace.GetName())
return
}
if decodedInNamespace.GetNamespace() != "ns-1" {
t.Fatalf("invalid pod namespace, ns-1 ns-1, got %v", decodedInNamespace.GetNamespace())
return
}
})
})
}