mirror of
https://github.com/openshift/openshift-mcp-server.git
synced 2025-10-17 14:27:48 +03:00
feat(kubernetes): resources_create_or_update can create or update any kind of resource
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -22,6 +22,7 @@ func NewSever() *Sever {
|
||||
}
|
||||
s.initConfiguration()
|
||||
s.initPods()
|
||||
s.initResources()
|
||||
return s
|
||||
}
|
||||
|
||||
|
||||
@@ -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
36
pkg/mcp/resources.go
Normal 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
161
pkg/mcp/resources_test.go
Normal 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
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user