Skip to content

Commit e6ab757

Browse files
committed
feat(kubernetes): pods_run creates OpenShift routes
1 parent 4c5aa9a commit e6ab757

File tree

5 files changed

+153
-7
lines changed

5 files changed

+153
-7
lines changed

pkg/kubernetes/pods.go

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ func (k *Kubernetes) PodsGet(ctx context.Context, namespace, name string) (strin
3131
}, namespaceOrDefault(namespace), name)
3232
}
3333

34+
func (k *Kubernetes) PodsDelete(ctx context.Context, namespace, name string) (string, error) {
35+
// TODO
36+
return "", nil
37+
}
38+
3439
func (k *Kubernetes) PodsLog(ctx context.Context, namespace, name string) (string, error) {
3540
cs, err := kubernetes.NewForConfig(k.cfg)
3641
if err != nil {
@@ -75,16 +80,43 @@ func (k *Kubernetes) PodsRun(ctx context.Context, namespace, name, image string,
7580
resources = append(resources, pod)
7681
if port > 0 {
7782
pod.Spec.Containers[0].Ports = []v1.ContainerPort{{ContainerPort: port}}
78-
svc := &v1.Service{
83+
resources = append(resources, &v1.Service{
7984
TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Service"},
8085
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespaceOrDefault(namespace), Labels: labels},
8186
Spec: v1.ServiceSpec{
8287
Selector: labels,
8388
Type: v1.ServiceTypeClusterIP,
8489
Ports: []v1.ServicePort{{Port: port, TargetPort: intstr.FromInt32(port)}},
8590
},
86-
}
87-
resources = append(resources, svc)
91+
})
92+
}
93+
if port > 0 && k.supportsGroupVersion("route.openshift.io/v1") {
94+
resources = append(resources, &unstructured.Unstructured{
95+
Object: map[string]interface{}{
96+
"apiVersion": "route.openshift.io/v1",
97+
"kind": "Route",
98+
"metadata": map[string]interface{}{
99+
"name": name,
100+
"namespace": namespaceOrDefault(namespace),
101+
"labels": labels,
102+
},
103+
"spec": map[string]interface{}{
104+
"to": map[string]interface{}{
105+
"kind": "Service",
106+
"name": name,
107+
"weight": 100,
108+
},
109+
"port": map[string]interface{}{
110+
"targetPort": intstr.FromInt32(port),
111+
},
112+
"tls": map[string]interface{}{
113+
"termination": "edge",
114+
"insecureEdgeTerminationPolicy": "Redirect",
115+
},
116+
},
117+
},
118+
})
119+
88120
}
89121

90122
// Convert the objects to Unstructured and reuse resourcesCreateOrUpdate functionality

pkg/kubernetes/resources.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,3 +145,15 @@ func (k *Kubernetes) isNamespaced(gvk *schema.GroupVersionKind) (bool, error) {
145145
}
146146
return false, nil
147147
}
148+
149+
func (k *Kubernetes) supportsGroupVersion(groupVersion string) bool {
150+
d, err := discovery.NewDiscoveryClientForConfig(k.cfg)
151+
if err != nil {
152+
return false
153+
}
154+
_, err = d.ServerResourcesForGroupVersion(groupVersion)
155+
if err == nil {
156+
return true
157+
}
158+
return false
159+
}

pkg/mcp/common_test.go

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,22 @@ package mcp
22

33
import (
44
"context"
5+
"encoding/json"
6+
"fmt"
57
"github.com/mark3labs/mcp-go/client"
68
"github.com/mark3labs/mcp-go/mcp"
79
"github.com/mark3labs/mcp-go/server"
810
"github.com/spf13/afero"
911
corev1 "k8s.io/api/core/v1"
12+
apiextensionsv1spec "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
13+
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset/typed/apiextensions/v1"
1014
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
15+
"k8s.io/apimachinery/pkg/watch"
1116
"k8s.io/client-go/kubernetes"
1217
"k8s.io/client-go/rest"
1318
"k8s.io/client-go/tools/clientcmd"
1419
"k8s.io/client-go/tools/clientcmd/api"
20+
toolswatch "k8s.io/client-go/tools/watch"
1521
"net/http/httptest"
1622
"os"
1723
"path/filepath"
@@ -142,14 +148,74 @@ func (c *mcpContext) withEnvTest() {
142148
c.withKubeConfig(envTestRestConfig)
143149
}
144150

151+
// inOpenShift sets up the kubernetes environment to seem to be running OpenShift
152+
func (c *mcpContext) inOpenShift() func() {
153+
c.withKubeConfig(envTestRestConfig)
154+
return c.crdApply(`
155+
{
156+
"apiVersion": "apiextensions.k8s.io/v1",
157+
"kind": "CustomResourceDefinition",
158+
"metadata": {"name": "routes.route.openshift.io"},
159+
"spec": {
160+
"group": "route.openshift.io",
161+
"versions": [{
162+
"name": "v1","served": true,"storage": true,
163+
"schema": {"openAPIV3Schema": {"type": "object","x-kubernetes-preserve-unknown-fields": true}}
164+
}],
165+
"scope": "Namespaced",
166+
"names": {"plural": "routes","singular": "route","kind": "Route"}
167+
}
168+
}`)
169+
}
170+
145171
// newKubernetesClient creates a new Kubernetes client with the current kubeconfig
146172
func (c *mcpContext) newKubernetesClient() *kubernetes.Clientset {
147173
c.withEnvTest()
148-
pathOptions := clientcmd.NewDefaultPathOptions()
149-
cfg, _ := clientcmd.BuildConfigFromFlags("", pathOptions.GetDefaultFilename())
174+
cfg, _ := clientcmd.BuildConfigFromFlags("", clientcmd.NewDefaultPathOptions().GetDefaultFilename())
150175
return kubernetes.NewForConfigOrDie(cfg)
151176
}
152177

178+
// newApiExtensionsClient creates a new ApiExtensions client with the envTest kubeconfig
179+
func (c *mcpContext) newApiExtensionsClient() *apiextensionsv1.ApiextensionsV1Client {
180+
return apiextensionsv1.NewForConfigOrDie(envTestRestConfig)
181+
}
182+
183+
// crdApply creates a CRD from the provided resource string and waits for it to be established, returns a cleanup function
184+
func (c *mcpContext) crdApply(resource string) func() {
185+
apiExtensionsV1Client := c.newApiExtensionsClient()
186+
var crd = &apiextensionsv1spec.CustomResourceDefinition{}
187+
err := json.Unmarshal([]byte(resource), crd)
188+
_, err = apiExtensionsV1Client.CustomResourceDefinitions().Create(c.ctx, crd, metav1.CreateOptions{})
189+
if err != nil {
190+
panic(fmt.Errorf("failed to create CRD %v", err))
191+
}
192+
c.crdWaitUntilReady(crd.Name)
193+
return func() {
194+
err = apiExtensionsV1Client.CustomResourceDefinitions().Delete(c.ctx, "routes.route.openshift.io", metav1.DeleteOptions{})
195+
if err != nil {
196+
panic(fmt.Errorf("failed to delete CRD %v", err))
197+
}
198+
}
199+
}
200+
201+
// crdWaitUntilReady waits for a CRD to be established
202+
func (c *mcpContext) crdWaitUntilReady(name string) {
203+
watcher, err := c.newApiExtensionsClient().CustomResourceDefinitions().Watch(c.ctx, metav1.ListOptions{
204+
FieldSelector: "metadata.name=" + name,
205+
})
206+
_, err = toolswatch.UntilWithoutRetry(c.ctx, watcher, func(event watch.Event) (bool, error) {
207+
for _, c := range event.Object.(*apiextensionsv1spec.CustomResourceDefinition).Status.Conditions {
208+
if c.Type == apiextensionsv1spec.Established && c.Status == apiextensionsv1spec.ConditionTrue {
209+
return true, nil
210+
}
211+
}
212+
return false, nil
213+
})
214+
if err != nil {
215+
panic(fmt.Errorf("failed to wait for CRD %v", err))
216+
}
217+
}
218+
153219
// callTool helper function to call a tool by name with arguments
154220
func (c *mcpContext) callTool(name string, args map[string]interface{}) (*mcp.CallToolResult, error) {
155221
callToolRequest := mcp.CallToolRequest{}

pkg/mcp/pods_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,3 +404,39 @@ func TestPodsRun(t *testing.T) {
404404
})
405405
})
406406
}
407+
408+
func TestPodsRunInOpenShift(t *testing.T) {
409+
testCase(t, func(c *mcpContext) {
410+
defer c.inOpenShift()() // n.b. two sets of parentheses to invoke the first function
411+
t.Run("pods_run with image, namespace, and port returns route with port", func(t *testing.T) {
412+
podsRunInOpenShift, err := c.callTool("pods_run", map[string]interface{}{"image": "nginx", "port": 80})
413+
if err != nil {
414+
t.Errorf("call tool failed %v", err)
415+
return
416+
}
417+
if podsRunInOpenShift.IsError {
418+
t.Errorf("call tool failed")
419+
return
420+
}
421+
var decodedPodServiceRoute []unstructured.Unstructured
422+
err = yaml.Unmarshal([]byte(podsRunInOpenShift.Content[0].(map[string]interface{})["text"].(string)), &decodedPodServiceRoute)
423+
if err != nil {
424+
t.Errorf("invalid tool result content %v", err)
425+
return
426+
}
427+
if len(decodedPodServiceRoute) != 3 {
428+
t.Errorf("invalid pods count, expected 3, got %v", len(decodedPodServiceRoute))
429+
return
430+
}
431+
if decodedPodServiceRoute[2].GetKind() != "Route" {
432+
t.Errorf("invalid route kind, expected Route, got %v", decodedPodServiceRoute[2].GetKind())
433+
return
434+
}
435+
targetPort := decodedPodServiceRoute[2].Object["spec"].(map[string]interface{})["port"].(map[string]interface{})["targetPort"].(int64)
436+
if targetPort != 80 {
437+
t.Errorf("invalid route target port, expected 80, got %v", targetPort)
438+
return
439+
}
440+
})
441+
})
442+
}

pkg/mcp/resources_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package mcp
22

33
import (
4-
v1 "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset/typed/apiextensions/v1"
54
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
65
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
76
"k8s.io/apimachinery/pkg/runtime/schema"
@@ -262,13 +261,14 @@ func TestResourcesCreateOrUpdate(t *testing.T) {
262261
}
263262
})
264263
t.Run("resources_create_or_update with valid cluster-scoped json resource creates custom resource definition", func(t *testing.T) {
265-
apiExtensionsV1Client := v1.NewForConfigOrDie(envTestRestConfig)
264+
apiExtensionsV1Client := c.newApiExtensionsClient()
266265
_, err = apiExtensionsV1Client.CustomResourceDefinitions().Get(c.ctx, "customs.example.com", metav1.GetOptions{})
267266
if err != nil {
268267
t.Fatalf("custom resource definition not found")
269268
return
270269
}
271270
})
271+
c.crdWaitUntilReady("customs.example.com")
272272
customJson := "{\"apiVersion\": \"example.com/v1\", \"kind\": \"Custom\", \"metadata\": {\"name\": \"a-custom-resource\"}}"
273273
resourcesCreateOrUpdateCustom, err := c.callTool("resources_create_or_update", map[string]interface{}{"resource": customJson})
274274
t.Run("resources_create_or_update with valid namespaced json resource returns success", func(t *testing.T) {

0 commit comments

Comments
 (0)