feat(kubernetes): marshal all resources to yaml omitting managed fields

This commit is contained in:
Marc Nuri
2025-02-15 04:59:00 +01:00
parent e1432e7222
commit 9ad87d362d
5 changed files with 145 additions and 59 deletions

View File

@@ -2,13 +2,14 @@ package kubernetes
import (
"context"
"encoding/json"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/discovery"
memory "k8s.io/client-go/discovery/cached"
"k8s.io/client-go/dynamic"
"k8s.io/client-go/restmapper"
"sigs.k8s.io/yaml"
)
// TODO: WIP
@@ -29,7 +30,15 @@ func (k *Kubernetes) ResourcesList(ctx context.Context, gvk *schema.GroupVersion
}
func marshal(v any) (string, error) {
ret, err := json.Marshal(v)
switch t := v.(type) {
case []unstructured.Unstructured:
for i := range t {
t[i].SetManagedFields(nil)
}
case unstructured.Unstructured:
t.SetManagedFields(nil)
}
ret, err := yaml.Marshal(v)
if err != nil {
return "", err
}

View File

@@ -65,13 +65,11 @@ func (c *mcpContext) afterEach() {
c.testServer.Close()
}
func testCase(test func(t *testing.T, c *mcpContext)) func(*testing.T) {
return func(t *testing.T) {
mcpCtx := &mcpContext{}
mcpCtx.beforeEach(t)
defer mcpCtx.afterEach()
test(t, mcpCtx)
}
func testCase(t *testing.T, test func(c *mcpContext)) {
mcpCtx := &mcpContext{}
mcpCtx.beforeEach(t)
defer mcpCtx.afterEach()
test(mcpCtx)
}
func (c *mcpContext) withKubeConfig(rc *rest.Config) *api.Config {

View File

@@ -2,24 +2,78 @@ package mcp
import (
"github.com/mark3labs/mcp-go/mcp"
"strings"
v1 "k8s.io/client-go/tools/clientcmd/api/v1"
"sigs.k8s.io/yaml"
"testing"
)
func TestConfigurationView(t *testing.T) {
t.Run("configuration_view returns configuration", testCase(func(t *testing.T, c *mcpContext) {
testCase(t, func(c *mcpContext) {
configurationGet := mcp.CallToolRequest{}
configurationGet.Params.Name = "configuration_view"
configurationGet.Params.Arguments = map[string]interface{}{}
tools, err := c.mcpClient.CallTool(c.ctx, configurationGet)
if err != nil {
t.Fatalf("call tool failed %v", err)
return
}
resultContent := tools.Content[0].(map[string]interface{})["text"].(string)
if !strings.Contains(resultContent, "cluster: fake\n") {
t.Fatalf("mismatch in kube config: %s", resultContent)
return
}
}))
toolResult, err := c.mcpClient.CallTool(c.ctx, configurationGet)
t.Run("configuration_view returns configuration", func(t *testing.T) {
if err != nil {
t.Fatalf("call tool failed %v", err)
return
}
})
var decoded *v1.Config
err = yaml.Unmarshal([]byte(toolResult.Content[0].(map[string]interface{})["text"].(string)), &decoded)
t.Run("configuration_view has yaml content", func(t *testing.T) {
if err != nil {
t.Fatalf("invalid tool result content %v", err)
return
}
})
t.Run("configuration_view returns current-context", func(t *testing.T) {
if decoded.CurrentContext != "fake-context" {
t.Fatalf("fake-context not found: %v", decoded.CurrentContext)
return
}
})
t.Run("configuration_view returns context info", func(t *testing.T) {
if len(decoded.Contexts) != 1 {
t.Fatalf("invalid context count, expected 1, got %v", len(decoded.Contexts))
return
}
if decoded.Contexts[0].Name != "fake-context" {
t.Fatalf("fake-context not found: %v", decoded.Contexts)
return
}
if decoded.Contexts[0].Context.Cluster != "fake" {
t.Fatalf("fake-cluster not found: %v", decoded.Contexts)
return
}
if decoded.Contexts[0].Context.AuthInfo != "fake" {
t.Fatalf("fake-auth not found: %v", decoded.Contexts)
return
}
})
t.Run("configuration_view returns cluster info", func(t *testing.T) {
if len(decoded.Clusters) != 1 {
t.Fatalf("invalid cluster count, expected 1, got %v", len(decoded.Clusters))
return
}
if decoded.Clusters[0].Name != "fake" {
t.Fatalf("fake-cluster not found: %v", decoded.Clusters)
return
}
if decoded.Clusters[0].Cluster.Server != "https://example.com" {
t.Fatalf("fake-server not found: %v", decoded.Clusters)
return
}
})
t.Run("configuration_view returns auth info", func(t *testing.T) {
if len(decoded.AuthInfos) != 1 {
t.Fatalf("invalid auth info count, expected 1, got %v", len(decoded.AuthInfos))
return
}
if decoded.AuthInfos[0].Name != "fake" {
t.Fatalf("fake-auth not found: %v", decoded.AuthInfos)
return
}
})
})
}

View File

@@ -7,17 +7,25 @@ import (
func TestTools(t *testing.T) {
expectedNames := []string{"pods_list", "pods_list_in_namespace", "configuration_view"}
t.Run("Has configuration_view tool", testCase(func(t *testing.T, c *mcpContext) {
testCase(t, func(c *mcpContext) {
tools, err := c.mcpClient.ListTools(c.ctx, mcp.ListToolsRequest{})
t.Run("ListTools returns tools", func(t *testing.T) {
if err != nil {
t.Fatalf("call ListTools failed %v", err)
return
}
})
nameSet := make(map[string]bool)
for _, tool := range tools.Tools {
nameSet[tool.Name] = true
}
for _, name := range expectedNames {
if nameSet[name] != true {
t.Fatalf("tool name mismatch %v", err)
return
}
t.Run("ListTools has "+name+" tool", func(t *testing.T) {
if nameSet[name] != true {
t.Fatalf("tool %s not found", name)
return
}
})
}
}))
})
}

View File

@@ -2,52 +2,69 @@ package mcp
import (
"context"
"encoding/json"
"github.com/mark3labs/mcp-go/mcp"
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"
)
func TestPodsListInAllNamespaces(t *testing.T) {
t.Run("pods_list", testCase(func(t *testing.T, c *mcpContext) {
testCase(t, func(c *mcpContext) {
createTestData(c.ctx, c.newKubernetesClient())
configurationGet := mcp.CallToolRequest{}
configurationGet.Params.Name = "pods_list"
configurationGet.Params.Arguments = map[string]interface{}{}
toolResult, err := c.mcpClient.CallTool(c.ctx, configurationGet)
if err != nil {
t.Fatalf("call tool failed %v", err)
return
}
t.Run("pods_list returns pods list", func(t *testing.T) {
if err != nil {
t.Fatalf("call tool failed %v", err)
return
}
})
var decoded []unstructured.Unstructured
if json.Unmarshal([]byte(toolResult.Content[0].(map[string]interface{})["text"].(string)), &decoded) != nil {
t.Fatalf("invalid tool result content %v", err)
return
}
if len(decoded) != 2 {
t.Fatalf("invalid pods count, expected 2, got %v", len(decoded))
return
}
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())
return
}
if decoded[0].GetNamespace() != "ns-1" {
t.Fatalf("invalid pod namespace, expected ns-1, got %v", decoded[0].GetNamespace())
return
}
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())
return
}
if decoded[1].GetNamespace() != "ns-2" {
t.Fatalf("invalid pod namespace, expected ns-2, got %v", decoded[1].GetNamespace())
return
}
}))
err = yaml.Unmarshal([]byte(toolResult.Content[0].(map[string]interface{})["text"].(string)), &decoded)
t.Run("pods_list has yaml content", func(t *testing.T) {
if err != nil {
t.Fatalf("invalid tool result content %v", err)
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))
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())
return
}
if decoded[0].GetNamespace() != "ns-1" {
t.Fatalf("invalid pod namespace, expected ns-1, got %v", decoded[0].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())
return
}
if decoded[1].GetNamespace() != "ns-2" {
t.Fatalf("invalid pod namespace, expected ns-2, got %v", decoded[1].GetNamespace())
return
}
})
t.Run("pods_list omits managed fields", func(t *testing.T) {
if decoded[0].GetManagedFields() != nil {
t.Fatalf("managed fields should be omitted, got %v", decoded[0].GetManagedFields())
return
}
})
})
}
func createTestData(ctx context.Context, kc *kubernetes.Clientset) {