feat(kubernetes): resources_create_or_update can create or update any kind of resource

This commit is contained in:
Marc Nuri
2025-02-17 12:05:59 +01:00
parent 3bf7a0fd63
commit 6ae9247bae
6 changed files with 279 additions and 5 deletions

View File

@@ -2,12 +2,24 @@ package kubernetes
import (
"context"
"github.com/manusa/kubernetes-mcp-server/pkg/version"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/util/yaml"
"k8s.io/client-go/discovery"
memory "k8s.io/client-go/discovery/cached"
"k8s.io/client-go/dynamic"
"k8s.io/client-go/restmapper"
"regexp"
"strings"
)
const (
AppKubernetesComponent = "app.kubernetes.io/component"
AppKubernetesManagedBy = "app.kubernetes.io/managed-by"
AppKubernetesName = "app.kubernetes.io/name"
AppKubernetesPartOf = "app.kubernetes.io/part-of"
)
// TODO: WIP
@@ -43,6 +55,46 @@ func (k *Kubernetes) ResourcesGet(ctx context.Context, gvk *schema.GroupVersionK
return marshal(rg)
}
func (k *Kubernetes) ResourcesCreateOrUpdate(ctx context.Context, resource string) (string, error) {
separator := regexp.MustCompile(`\r?\n---\r?\n`)
resources := separator.Split(resource, -1)
var parsedResources []*unstructured.Unstructured
for _, r := range resources {
var obj unstructured.Unstructured
if err := yaml.NewYAMLToJSONDecoder(strings.NewReader(r)).Decode(&obj); err != nil {
return "", err
}
parsedResources = append(parsedResources, &obj)
}
return k.resourcesCreateOrUpdate(ctx, parsedResources)
}
func (k *Kubernetes) resourcesCreateOrUpdate(ctx context.Context, resources []*unstructured.Unstructured) (string, error) {
client, err := dynamic.NewForConfig(k.cfg)
if err != nil {
return "", err
}
for i, obj := range resources {
gvk := obj.GroupVersionKind()
gvr, rErr := k.resourceFor(&gvk)
if rErr != nil {
return "", rErr
}
namespace := obj.GetNamespace()
// If it's a namespaced resource and namespace wasn't provided, try to use the default configured one
if namespaced, nsErr := k.isNamespaced(&gvk); nsErr == nil && namespaced {
namespace = namespaceOrDefault(namespace)
}
resources[i], rErr = client.Resource(*gvr).Namespace(namespace).Apply(ctx, obj.GetName(), obj, metav1.ApplyOptions{
FieldManager: version.BinaryName,
})
if rErr != nil {
return "", rErr
}
}
return marshal(resources)
}
func (k *Kubernetes) resourceFor(gvk *schema.GroupVersionKind) (*schema.GroupVersionResource, error) {
if k.deferredDiscoveryRESTMapper == nil {
d, err := discovery.NewDiscoveryClientForConfig(k.cfg)
@@ -57,3 +109,20 @@ func (k *Kubernetes) resourceFor(gvk *schema.GroupVersionKind) (*schema.GroupVer
}
return &m.Resource, nil
}
func (k *Kubernetes) isNamespaced(gvk *schema.GroupVersionKind) (bool, error) {
d, err := discovery.NewDiscoveryClientForConfig(k.cfg)
if err != nil {
return false, err
}
apiResourceList, err := d.ServerResourcesForGroupVersion(gvk.GroupVersion().String())
if err != nil {
return false, err
}
for _, apiResource := range apiResourceList.APIResources {
if apiResource.Kind == gvk.Kind {
return apiResource.Namespaced, nil
}
}
return false, nil
}

View File

@@ -147,11 +147,7 @@ func (c *mcpContext) newKubernetesClient() *kubernetes.Clientset {
c.withEnvTest()
pathOptions := clientcmd.NewDefaultPathOptions()
cfg, _ := clientcmd.BuildConfigFromFlags("", pathOptions.GetDefaultFilename())
kubernetesClient, err := kubernetes.NewForConfig(cfg)
if err != nil {
panic(err)
}
return kubernetesClient
return kubernetes.NewForConfigOrDie(cfg)
}
// callTool helper function to call a tool by name with arguments

View File

@@ -22,6 +22,7 @@ func NewSever() *Sever {
}
s.initConfiguration()
s.initPods()
s.initResources()
return s
}

View File

@@ -213,6 +213,17 @@ func TestPodsLog(t *testing.T) {
return
}
})
t.Run("pods_log with not found name returns error", func(t *testing.T) {
toolResult, _ := c.callTool("pods_log", map[string]interface{}{"name": "not-found"})
if toolResult.IsError != true {
t.Fatalf("call tool should fail")
return
}
if toolResult.Content[0].(map[string]interface{})["text"].(string) != "failed to get pod not-found log in namespace : pods \"not-found\" not found" {
t.Fatalf("invalid error message, got %v", toolResult.Content[0].(map[string]interface{})["text"].(string))
return
}
})
podsLogNilNamespace, err := c.callTool("pods_log", map[string]interface{}{
"name": "a-pod-in-default",
})

36
pkg/mcp/resources.go Normal file
View File

@@ -0,0 +1,36 @@
package mcp
import (
"context"
"errors"
"fmt"
"github.com/manusa/kubernetes-mcp-server/pkg/kubernetes"
"github.com/mark3labs/mcp-go/mcp"
)
func (s *Sever) initResources() {
s.server.AddTool(mcp.NewTool(
"resources_create_or_update",
mcp.WithDescription("Create or update a Kubernetes resource in the current cluster by providing a YAML or JSON representation of the resource"),
mcp.WithString("resource",
mcp.Description("A JSON or YAML containing a representation of the Kubernetes resource. Should include top-level fields such as apiVersion,kind,metadata, and spec"),
mcp.Required(),
),
), resourcesCreateOrUpdate)
}
func resourcesCreateOrUpdate(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
k, err := kubernetes.NewKubernetes()
if err != nil {
return NewTextResult("", fmt.Errorf("failed to create or update resources: %v", err)), nil
}
resource := ctr.Params.Arguments["resource"]
if resource == nil || resource == "" {
return NewTextResult("", errors.New("failed to create or update resources, missing argument resource")), nil
}
ret, err := k.ResourcesCreateOrUpdate(ctx, resource.(string))
if err != nil {
return NewTextResult("", fmt.Errorf("failed to create or update resources: %v", err)), nil
}
return NewTextResult(ret, err), nil
}

161
pkg/mcp/resources_test.go Normal file
View File

@@ -0,0 +1,161 @@
package mcp
import (
v1 "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset/typed/apiextensions/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/dynamic"
"testing"
)
func TestResourcesCreateOrUpdate(t *testing.T) {
testCase(t, func(c *mcpContext) {
c.withEnvTest()
t.Run("resources_create_or_update with nil resource returns error", func(t *testing.T) {
toolResult, _ := c.callTool("resources_create_or_update", 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 create or update resources, missing argument resource" {
t.Fatalf("invalid error message, got %v", toolResult.Content[0].(map[string]interface{})["text"].(string))
return
}
})
t.Run("resources_create_or_update with empty resource returns error", func(t *testing.T) {
toolResult, _ := c.callTool("resources_create_or_update", map[string]interface{}{"resource": ""})
if toolResult.IsError != true {
t.Fatalf("call tool should fail")
return
}
if toolResult.Content[0].(map[string]interface{})["text"].(string) != "failed to create or update resources, missing argument resource" {
t.Fatalf("invalid error message, got %v", toolResult.Content[0].(map[string]interface{})["text"].(string))
return
}
})
client := c.newKubernetesClient()
configMapYaml := "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: a-cm-created-or-updated\n namespace: default\n"
resourcesCreateOrUpdateCm1, err := c.callTool("resources_create_or_update", map[string]interface{}{"resource": configMapYaml})
t.Run("resources_create_or_update with valid namespaced yaml resource returns success", func(t *testing.T) {
if err != nil {
t.Fatalf("call tool failed %v", err)
return
}
if resourcesCreateOrUpdateCm1.IsError {
t.Fatalf("call tool failed")
return
}
})
t.Run("resources_create_or_update with valid namespaced yaml resource creates ConfigMap", func(t *testing.T) {
cm, _ := client.CoreV1().ConfigMaps("default").Get(c.ctx, "a-cm-created-or-updated", metav1.GetOptions{})
if cm == nil {
t.Fatalf("ConfigMap not found")
return
}
})
configMapJson := "{\"apiVersion\": \"v1\", \"kind\": \"ConfigMap\", \"metadata\": {\"name\": \"a-cm-created-or-updated-2\", \"namespace\": \"default\"}}"
resourcesCreateOrUpdateCm2, err := c.callTool("resources_create_or_update", map[string]interface{}{"resource": configMapJson})
t.Run("resources_create_or_update with valid namespaced json resource returns success", func(t *testing.T) {
if err != nil {
t.Fatalf("call tool failed %v", err)
return
}
if resourcesCreateOrUpdateCm2.IsError {
t.Fatalf("call tool failed")
return
}
})
t.Run("resources_create_or_update with valid namespaced json resource creates config map", func(t *testing.T) {
cm, _ := client.CoreV1().ConfigMaps("default").Get(c.ctx, "a-cm-created-or-updated-2", metav1.GetOptions{})
if cm == nil {
t.Fatalf("ConfigMap not found")
return
}
})
customResourceDefinitionJson := `
{
"apiVersion": "apiextensions.k8s.io/v1",
"kind": "CustomResourceDefinition",
"metadata": {"name": "customs.example.com"},
"spec": {
"group": "example.com",
"versions": [{
"name": "v1","served": true,"storage": true,
"schema": {"openAPIV3Schema": {"type": "object"}}
}],
"scope": "Namespaced",
"names": {"plural": "customs","singular": "custom","kind": "Custom"}
}
}`
resourcesCreateOrUpdateCrd, err := c.callTool("resources_create_or_update", map[string]interface{}{"resource": customResourceDefinitionJson})
t.Run("resources_create_or_update with valid cluster-scoped json resource returns success", func(t *testing.T) {
if err != nil {
t.Fatalf("call tool failed %v", err)
return
}
if resourcesCreateOrUpdateCrd.IsError {
t.Fatalf("call tool failed")
return
}
})
t.Run("resources_create_or_update with valid cluster-scoped json resource creates custom resource definition", func(t *testing.T) {
apiExtensionsV1Client := v1.NewForConfigOrDie(envTestRestConfig)
_, err = apiExtensionsV1Client.CustomResourceDefinitions().Get(c.ctx, "customs.example.com", metav1.GetOptions{})
if err != nil {
t.Fatalf("custom resource definition not found")
return
}
})
customJson := "{\"apiVersion\": \"example.com/v1\", \"kind\": \"Custom\", \"metadata\": {\"name\": \"a-custom-resource\"}}"
resourcesCreateOrUpdateCustom, err := c.callTool("resources_create_or_update", map[string]interface{}{"resource": customJson})
t.Run("resources_create_or_update with valid namespaced json resource returns success", func(t *testing.T) {
if err != nil {
t.Fatalf("call tool failed %v", err)
return
}
if resourcesCreateOrUpdateCustom.IsError {
t.Fatalf("call tool failed")
return
}
})
t.Run("resources_create_or_update with valid namespaced json resource creates custom resource", func(t *testing.T) {
dynamicClient := dynamic.NewForConfigOrDie(envTestRestConfig)
_, err = dynamicClient.
Resource(schema.GroupVersionResource{Group: "example.com", Version: "v1", Resource: "customs"}).
Namespace("default").
Get(c.ctx, "a-custom-resource", metav1.GetOptions{})
if err != nil {
t.Fatalf("custom resource not found")
return
}
})
customJsonUpdated := "{\"apiVersion\": \"example.com/v1\", \"kind\": \"Custom\", \"metadata\": {\"name\": \"a-custom-resource\",\"annotations\": {\"updated\": \"true\"}}}"
resourcesCreateOrUpdateCustomUpdated, err := c.callTool("resources_create_or_update", map[string]interface{}{"resource": customJsonUpdated})
t.Run("resources_create_or_update with valid namespaced json resource updates custom resource", func(t *testing.T) {
if err != nil {
t.Fatalf("call tool failed %v", err)
return
}
if resourcesCreateOrUpdateCustomUpdated.IsError {
t.Fatalf("call tool failed")
return
}
})
t.Run("resources_create_or_update with valid namespaced json resource updates custom resource", func(t *testing.T) {
dynamicClient := dynamic.NewForConfigOrDie(envTestRestConfig)
customResource, _ := dynamicClient.
Resource(schema.GroupVersionResource{Group: "example.com", Version: "v1", Resource: "customs"}).
Namespace("default").
Get(c.ctx, "a-custom-resource", metav1.GetOptions{})
if customResource == nil {
t.Fatalf("custom resource not found")
return
}
annotations := customResource.GetAnnotations()
if annotations == nil || annotations["updated"] != "true" {
t.Fatalf("custom resource not updated")
return
}
})
})
}