Skip to content

Commit 6ae9247

Browse files
committed
feat(kubernetes): resources_create_or_update can create or update any kind of resource
1 parent 3bf7a0f commit 6ae9247

File tree

6 files changed

+279
-5
lines changed

6 files changed

+279
-5
lines changed

pkg/kubernetes/resources.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,24 @@ package kubernetes
22

33
import (
44
"context"
5+
"github.com/manusa/kubernetes-mcp-server/pkg/version"
56
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
7+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
68
"k8s.io/apimachinery/pkg/runtime/schema"
9+
"k8s.io/apimachinery/pkg/util/yaml"
710
"k8s.io/client-go/discovery"
811
memory "k8s.io/client-go/discovery/cached"
912
"k8s.io/client-go/dynamic"
1013
"k8s.io/client-go/restmapper"
14+
"regexp"
15+
"strings"
16+
)
17+
18+
const (
19+
AppKubernetesComponent = "app.kubernetes.io/component"
20+
AppKubernetesManagedBy = "app.kubernetes.io/managed-by"
21+
AppKubernetesName = "app.kubernetes.io/name"
22+
AppKubernetesPartOf = "app.kubernetes.io/part-of"
1123
)
1224

1325
// TODO: WIP
@@ -43,6 +55,46 @@ func (k *Kubernetes) ResourcesGet(ctx context.Context, gvk *schema.GroupVersionK
4355
return marshal(rg)
4456
}
4557

58+
func (k *Kubernetes) ResourcesCreateOrUpdate(ctx context.Context, resource string) (string, error) {
59+
separator := regexp.MustCompile(`\r?\n---\r?\n`)
60+
resources := separator.Split(resource, -1)
61+
var parsedResources []*unstructured.Unstructured
62+
for _, r := range resources {
63+
var obj unstructured.Unstructured
64+
if err := yaml.NewYAMLToJSONDecoder(strings.NewReader(r)).Decode(&obj); err != nil {
65+
return "", err
66+
}
67+
parsedResources = append(parsedResources, &obj)
68+
}
69+
return k.resourcesCreateOrUpdate(ctx, parsedResources)
70+
}
71+
72+
func (k *Kubernetes) resourcesCreateOrUpdate(ctx context.Context, resources []*unstructured.Unstructured) (string, error) {
73+
client, err := dynamic.NewForConfig(k.cfg)
74+
if err != nil {
75+
return "", err
76+
}
77+
for i, obj := range resources {
78+
gvk := obj.GroupVersionKind()
79+
gvr, rErr := k.resourceFor(&gvk)
80+
if rErr != nil {
81+
return "", rErr
82+
}
83+
namespace := obj.GetNamespace()
84+
// If it's a namespaced resource and namespace wasn't provided, try to use the default configured one
85+
if namespaced, nsErr := k.isNamespaced(&gvk); nsErr == nil && namespaced {
86+
namespace = namespaceOrDefault(namespace)
87+
}
88+
resources[i], rErr = client.Resource(*gvr).Namespace(namespace).Apply(ctx, obj.GetName(), obj, metav1.ApplyOptions{
89+
FieldManager: version.BinaryName,
90+
})
91+
if rErr != nil {
92+
return "", rErr
93+
}
94+
}
95+
return marshal(resources)
96+
}
97+
4698
func (k *Kubernetes) resourceFor(gvk *schema.GroupVersionKind) (*schema.GroupVersionResource, error) {
4799
if k.deferredDiscoveryRESTMapper == nil {
48100
d, err := discovery.NewDiscoveryClientForConfig(k.cfg)
@@ -57,3 +109,20 @@ func (k *Kubernetes) resourceFor(gvk *schema.GroupVersionKind) (*schema.GroupVer
57109
}
58110
return &m.Resource, nil
59111
}
112+
113+
func (k *Kubernetes) isNamespaced(gvk *schema.GroupVersionKind) (bool, error) {
114+
d, err := discovery.NewDiscoveryClientForConfig(k.cfg)
115+
if err != nil {
116+
return false, err
117+
}
118+
apiResourceList, err := d.ServerResourcesForGroupVersion(gvk.GroupVersion().String())
119+
if err != nil {
120+
return false, err
121+
}
122+
for _, apiResource := range apiResourceList.APIResources {
123+
if apiResource.Kind == gvk.Kind {
124+
return apiResource.Namespaced, nil
125+
}
126+
}
127+
return false, nil
128+
}

pkg/mcp/common_test.go

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -147,11 +147,7 @@ func (c *mcpContext) newKubernetesClient() *kubernetes.Clientset {
147147
c.withEnvTest()
148148
pathOptions := clientcmd.NewDefaultPathOptions()
149149
cfg, _ := clientcmd.BuildConfigFromFlags("", pathOptions.GetDefaultFilename())
150-
kubernetesClient, err := kubernetes.NewForConfig(cfg)
151-
if err != nil {
152-
panic(err)
153-
}
154-
return kubernetesClient
150+
return kubernetes.NewForConfigOrDie(cfg)
155151
}
156152

157153
// callTool helper function to call a tool by name with arguments

pkg/mcp/mcp.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ func NewSever() *Sever {
2222
}
2323
s.initConfiguration()
2424
s.initPods()
25+
s.initResources()
2526
return s
2627
}
2728

pkg/mcp/pods_test.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,17 @@ func TestPodsLog(t *testing.T) {
213213
return
214214
}
215215
})
216+
t.Run("pods_log with not found name returns error", func(t *testing.T) {
217+
toolResult, _ := c.callTool("pods_log", map[string]interface{}{"name": "not-found"})
218+
if toolResult.IsError != true {
219+
t.Fatalf("call tool should fail")
220+
return
221+
}
222+
if toolResult.Content[0].(map[string]interface{})["text"].(string) != "failed to get pod not-found log in namespace : pods \"not-found\" not found" {
223+
t.Fatalf("invalid error message, got %v", toolResult.Content[0].(map[string]interface{})["text"].(string))
224+
return
225+
}
226+
})
216227
podsLogNilNamespace, err := c.callTool("pods_log", map[string]interface{}{
217228
"name": "a-pod-in-default",
218229
})

pkg/mcp/resources.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package mcp
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"github.com/manusa/kubernetes-mcp-server/pkg/kubernetes"
8+
"github.com/mark3labs/mcp-go/mcp"
9+
)
10+
11+
func (s *Sever) initResources() {
12+
s.server.AddTool(mcp.NewTool(
13+
"resources_create_or_update",
14+
mcp.WithDescription("Create or update a Kubernetes resource in the current cluster by providing a YAML or JSON representation of the resource"),
15+
mcp.WithString("resource",
16+
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"),
17+
mcp.Required(),
18+
),
19+
), resourcesCreateOrUpdate)
20+
}
21+
22+
func resourcesCreateOrUpdate(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
23+
k, err := kubernetes.NewKubernetes()
24+
if err != nil {
25+
return NewTextResult("", fmt.Errorf("failed to create or update resources: %v", err)), nil
26+
}
27+
resource := ctr.Params.Arguments["resource"]
28+
if resource == nil || resource == "" {
29+
return NewTextResult("", errors.New("failed to create or update resources, missing argument resource")), nil
30+
}
31+
ret, err := k.ResourcesCreateOrUpdate(ctx, resource.(string))
32+
if err != nil {
33+
return NewTextResult("", fmt.Errorf("failed to create or update resources: %v", err)), nil
34+
}
35+
return NewTextResult(ret, err), nil
36+
}

pkg/mcp/resources_test.go

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
package mcp
2+
3+
import (
4+
v1 "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset/typed/apiextensions/v1"
5+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
6+
"k8s.io/apimachinery/pkg/runtime/schema"
7+
"k8s.io/client-go/dynamic"
8+
"testing"
9+
)
10+
11+
func TestResourcesCreateOrUpdate(t *testing.T) {
12+
testCase(t, func(c *mcpContext) {
13+
c.withEnvTest()
14+
t.Run("resources_create_or_update with nil resource returns error", func(t *testing.T) {
15+
toolResult, _ := c.callTool("resources_create_or_update", map[string]interface{}{})
16+
if toolResult.IsError != true {
17+
t.Fatalf("call tool should fail")
18+
return
19+
}
20+
if toolResult.Content[0].(map[string]interface{})["text"].(string) != "failed to create or update resources, missing argument resource" {
21+
t.Fatalf("invalid error message, got %v", toolResult.Content[0].(map[string]interface{})["text"].(string))
22+
return
23+
}
24+
})
25+
t.Run("resources_create_or_update with empty resource returns error", func(t *testing.T) {
26+
toolResult, _ := c.callTool("resources_create_or_update", map[string]interface{}{"resource": ""})
27+
if toolResult.IsError != true {
28+
t.Fatalf("call tool should fail")
29+
return
30+
}
31+
if toolResult.Content[0].(map[string]interface{})["text"].(string) != "failed to create or update resources, missing argument resource" {
32+
t.Fatalf("invalid error message, got %v", toolResult.Content[0].(map[string]interface{})["text"].(string))
33+
return
34+
}
35+
})
36+
client := c.newKubernetesClient()
37+
configMapYaml := "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: a-cm-created-or-updated\n namespace: default\n"
38+
resourcesCreateOrUpdateCm1, err := c.callTool("resources_create_or_update", map[string]interface{}{"resource": configMapYaml})
39+
t.Run("resources_create_or_update with valid namespaced yaml resource returns success", func(t *testing.T) {
40+
if err != nil {
41+
t.Fatalf("call tool failed %v", err)
42+
return
43+
}
44+
if resourcesCreateOrUpdateCm1.IsError {
45+
t.Fatalf("call tool failed")
46+
return
47+
}
48+
})
49+
t.Run("resources_create_or_update with valid namespaced yaml resource creates ConfigMap", func(t *testing.T) {
50+
cm, _ := client.CoreV1().ConfigMaps("default").Get(c.ctx, "a-cm-created-or-updated", metav1.GetOptions{})
51+
if cm == nil {
52+
t.Fatalf("ConfigMap not found")
53+
return
54+
}
55+
})
56+
configMapJson := "{\"apiVersion\": \"v1\", \"kind\": \"ConfigMap\", \"metadata\": {\"name\": \"a-cm-created-or-updated-2\", \"namespace\": \"default\"}}"
57+
resourcesCreateOrUpdateCm2, err := c.callTool("resources_create_or_update", map[string]interface{}{"resource": configMapJson})
58+
t.Run("resources_create_or_update with valid namespaced json resource returns success", func(t *testing.T) {
59+
if err != nil {
60+
t.Fatalf("call tool failed %v", err)
61+
return
62+
}
63+
if resourcesCreateOrUpdateCm2.IsError {
64+
t.Fatalf("call tool failed")
65+
return
66+
}
67+
})
68+
t.Run("resources_create_or_update with valid namespaced json resource creates config map", func(t *testing.T) {
69+
cm, _ := client.CoreV1().ConfigMaps("default").Get(c.ctx, "a-cm-created-or-updated-2", metav1.GetOptions{})
70+
if cm == nil {
71+
t.Fatalf("ConfigMap not found")
72+
return
73+
}
74+
})
75+
customResourceDefinitionJson := `
76+
{
77+
"apiVersion": "apiextensions.k8s.io/v1",
78+
"kind": "CustomResourceDefinition",
79+
"metadata": {"name": "customs.example.com"},
80+
"spec": {
81+
"group": "example.com",
82+
"versions": [{
83+
"name": "v1","served": true,"storage": true,
84+
"schema": {"openAPIV3Schema": {"type": "object"}}
85+
}],
86+
"scope": "Namespaced",
87+
"names": {"plural": "customs","singular": "custom","kind": "Custom"}
88+
}
89+
}`
90+
resourcesCreateOrUpdateCrd, err := c.callTool("resources_create_or_update", map[string]interface{}{"resource": customResourceDefinitionJson})
91+
t.Run("resources_create_or_update with valid cluster-scoped json resource returns success", func(t *testing.T) {
92+
if err != nil {
93+
t.Fatalf("call tool failed %v", err)
94+
return
95+
}
96+
if resourcesCreateOrUpdateCrd.IsError {
97+
t.Fatalf("call tool failed")
98+
return
99+
}
100+
})
101+
t.Run("resources_create_or_update with valid cluster-scoped json resource creates custom resource definition", func(t *testing.T) {
102+
apiExtensionsV1Client := v1.NewForConfigOrDie(envTestRestConfig)
103+
_, err = apiExtensionsV1Client.CustomResourceDefinitions().Get(c.ctx, "customs.example.com", metav1.GetOptions{})
104+
if err != nil {
105+
t.Fatalf("custom resource definition not found")
106+
return
107+
}
108+
})
109+
customJson := "{\"apiVersion\": \"example.com/v1\", \"kind\": \"Custom\", \"metadata\": {\"name\": \"a-custom-resource\"}}"
110+
resourcesCreateOrUpdateCustom, err := c.callTool("resources_create_or_update", map[string]interface{}{"resource": customJson})
111+
t.Run("resources_create_or_update with valid namespaced json resource returns success", func(t *testing.T) {
112+
if err != nil {
113+
t.Fatalf("call tool failed %v", err)
114+
return
115+
}
116+
if resourcesCreateOrUpdateCustom.IsError {
117+
t.Fatalf("call tool failed")
118+
return
119+
}
120+
})
121+
t.Run("resources_create_or_update with valid namespaced json resource creates custom resource", func(t *testing.T) {
122+
dynamicClient := dynamic.NewForConfigOrDie(envTestRestConfig)
123+
_, err = dynamicClient.
124+
Resource(schema.GroupVersionResource{Group: "example.com", Version: "v1", Resource: "customs"}).
125+
Namespace("default").
126+
Get(c.ctx, "a-custom-resource", metav1.GetOptions{})
127+
if err != nil {
128+
t.Fatalf("custom resource not found")
129+
return
130+
}
131+
})
132+
customJsonUpdated := "{\"apiVersion\": \"example.com/v1\", \"kind\": \"Custom\", \"metadata\": {\"name\": \"a-custom-resource\",\"annotations\": {\"updated\": \"true\"}}}"
133+
resourcesCreateOrUpdateCustomUpdated, err := c.callTool("resources_create_or_update", map[string]interface{}{"resource": customJsonUpdated})
134+
t.Run("resources_create_or_update with valid namespaced json resource updates custom resource", func(t *testing.T) {
135+
if err != nil {
136+
t.Fatalf("call tool failed %v", err)
137+
return
138+
}
139+
if resourcesCreateOrUpdateCustomUpdated.IsError {
140+
t.Fatalf("call tool failed")
141+
return
142+
}
143+
})
144+
t.Run("resources_create_or_update with valid namespaced json resource updates custom resource", func(t *testing.T) {
145+
dynamicClient := dynamic.NewForConfigOrDie(envTestRestConfig)
146+
customResource, _ := dynamicClient.
147+
Resource(schema.GroupVersionResource{Group: "example.com", Version: "v1", Resource: "customs"}).
148+
Namespace("default").
149+
Get(c.ctx, "a-custom-resource", metav1.GetOptions{})
150+
if customResource == nil {
151+
t.Fatalf("custom resource not found")
152+
return
153+
}
154+
annotations := customResource.GetAnnotations()
155+
if annotations == nil || annotations["updated"] != "true" {
156+
t.Fatalf("custom resource not updated")
157+
return
158+
}
159+
})
160+
})
161+
}

0 commit comments

Comments
 (0)