feat(kubernetes): pods_run creates OpenShift routes

This commit is contained in:
Marc Nuri
2025-02-18 05:34:27 +01:00
parent 4c5aa9ab38
commit e6ab757915
5 changed files with 153 additions and 7 deletions

View File

@@ -31,6 +31,11 @@ func (k *Kubernetes) PodsGet(ctx context.Context, namespace, name string) (strin
}, namespaceOrDefault(namespace), name)
}
func (k *Kubernetes) PodsDelete(ctx context.Context, namespace, name string) (string, error) {
// TODO
return "", nil
}
func (k *Kubernetes) PodsLog(ctx context.Context, namespace, name string) (string, error) {
cs, err := kubernetes.NewForConfig(k.cfg)
if err != nil {
@@ -75,7 +80,7 @@ func (k *Kubernetes) PodsRun(ctx context.Context, namespace, name, image string,
resources = append(resources, pod)
if port > 0 {
pod.Spec.Containers[0].Ports = []v1.ContainerPort{{ContainerPort: port}}
svc := &v1.Service{
resources = append(resources, &v1.Service{
TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Service"},
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespaceOrDefault(namespace), Labels: labels},
Spec: v1.ServiceSpec{
@@ -83,8 +88,35 @@ func (k *Kubernetes) PodsRun(ctx context.Context, namespace, name, image string,
Type: v1.ServiceTypeClusterIP,
Ports: []v1.ServicePort{{Port: port, TargetPort: intstr.FromInt32(port)}},
},
}
resources = append(resources, svc)
})
}
if port > 0 && k.supportsGroupVersion("route.openshift.io/v1") {
resources = append(resources, &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "route.openshift.io/v1",
"kind": "Route",
"metadata": map[string]interface{}{
"name": name,
"namespace": namespaceOrDefault(namespace),
"labels": labels,
},
"spec": map[string]interface{}{
"to": map[string]interface{}{
"kind": "Service",
"name": name,
"weight": 100,
},
"port": map[string]interface{}{
"targetPort": intstr.FromInt32(port),
},
"tls": map[string]interface{}{
"termination": "edge",
"insecureEdgeTerminationPolicy": "Redirect",
},
},
},
})
}
// Convert the objects to Unstructured and reuse resourcesCreateOrUpdate functionality

View File

@@ -145,3 +145,15 @@ func (k *Kubernetes) isNamespaced(gvk *schema.GroupVersionKind) (bool, error) {
}
return false, nil
}
func (k *Kubernetes) supportsGroupVersion(groupVersion string) bool {
d, err := discovery.NewDiscoveryClientForConfig(k.cfg)
if err != nil {
return false
}
_, err = d.ServerResourcesForGroupVersion(groupVersion)
if err == nil {
return true
}
return false
}

View File

@@ -2,16 +2,22 @@ package mcp
import (
"context"
"encoding/json"
"fmt"
"github.com/mark3labs/mcp-go/client"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/spf13/afero"
corev1 "k8s.io/api/core/v1"
apiextensionsv1spec "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset/typed/apiextensions/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/watch"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
"k8s.io/client-go/tools/clientcmd/api"
toolswatch "k8s.io/client-go/tools/watch"
"net/http/httptest"
"os"
"path/filepath"
@@ -142,14 +148,74 @@ func (c *mcpContext) withEnvTest() {
c.withKubeConfig(envTestRestConfig)
}
// inOpenShift sets up the kubernetes environment to seem to be running OpenShift
func (c *mcpContext) inOpenShift() func() {
c.withKubeConfig(envTestRestConfig)
return c.crdApply(`
{
"apiVersion": "apiextensions.k8s.io/v1",
"kind": "CustomResourceDefinition",
"metadata": {"name": "routes.route.openshift.io"},
"spec": {
"group": "route.openshift.io",
"versions": [{
"name": "v1","served": true,"storage": true,
"schema": {"openAPIV3Schema": {"type": "object","x-kubernetes-preserve-unknown-fields": true}}
}],
"scope": "Namespaced",
"names": {"plural": "routes","singular": "route","kind": "Route"}
}
}`)
}
// newKubernetesClient creates a new Kubernetes client with the current kubeconfig
func (c *mcpContext) newKubernetesClient() *kubernetes.Clientset {
c.withEnvTest()
pathOptions := clientcmd.NewDefaultPathOptions()
cfg, _ := clientcmd.BuildConfigFromFlags("", pathOptions.GetDefaultFilename())
cfg, _ := clientcmd.BuildConfigFromFlags("", clientcmd.NewDefaultPathOptions().GetDefaultFilename())
return kubernetes.NewForConfigOrDie(cfg)
}
// newApiExtensionsClient creates a new ApiExtensions client with the envTest kubeconfig
func (c *mcpContext) newApiExtensionsClient() *apiextensionsv1.ApiextensionsV1Client {
return apiextensionsv1.NewForConfigOrDie(envTestRestConfig)
}
// crdApply creates a CRD from the provided resource string and waits for it to be established, returns a cleanup function
func (c *mcpContext) crdApply(resource string) func() {
apiExtensionsV1Client := c.newApiExtensionsClient()
var crd = &apiextensionsv1spec.CustomResourceDefinition{}
err := json.Unmarshal([]byte(resource), crd)
_, err = apiExtensionsV1Client.CustomResourceDefinitions().Create(c.ctx, crd, metav1.CreateOptions{})
if err != nil {
panic(fmt.Errorf("failed to create CRD %v", err))
}
c.crdWaitUntilReady(crd.Name)
return func() {
err = apiExtensionsV1Client.CustomResourceDefinitions().Delete(c.ctx, "routes.route.openshift.io", metav1.DeleteOptions{})
if err != nil {
panic(fmt.Errorf("failed to delete CRD %v", err))
}
}
}
// crdWaitUntilReady waits for a CRD to be established
func (c *mcpContext) crdWaitUntilReady(name string) {
watcher, err := c.newApiExtensionsClient().CustomResourceDefinitions().Watch(c.ctx, metav1.ListOptions{
FieldSelector: "metadata.name=" + name,
})
_, err = toolswatch.UntilWithoutRetry(c.ctx, watcher, func(event watch.Event) (bool, error) {
for _, c := range event.Object.(*apiextensionsv1spec.CustomResourceDefinition).Status.Conditions {
if c.Type == apiextensionsv1spec.Established && c.Status == apiextensionsv1spec.ConditionTrue {
return true, nil
}
}
return false, nil
})
if err != nil {
panic(fmt.Errorf("failed to wait for CRD %v", err))
}
}
// callTool helper function to call a tool by name with arguments
func (c *mcpContext) callTool(name string, args map[string]interface{}) (*mcp.CallToolResult, error) {
callToolRequest := mcp.CallToolRequest{}

View File

@@ -404,3 +404,39 @@ func TestPodsRun(t *testing.T) {
})
})
}
func TestPodsRunInOpenShift(t *testing.T) {
testCase(t, func(c *mcpContext) {
defer c.inOpenShift()() // n.b. two sets of parentheses to invoke the first function
t.Run("pods_run with image, namespace, and port returns route with port", func(t *testing.T) {
podsRunInOpenShift, err := c.callTool("pods_run", map[string]interface{}{"image": "nginx", "port": 80})
if err != nil {
t.Errorf("call tool failed %v", err)
return
}
if podsRunInOpenShift.IsError {
t.Errorf("call tool failed")
return
}
var decodedPodServiceRoute []unstructured.Unstructured
err = yaml.Unmarshal([]byte(podsRunInOpenShift.Content[0].(map[string]interface{})["text"].(string)), &decodedPodServiceRoute)
if err != nil {
t.Errorf("invalid tool result content %v", err)
return
}
if len(decodedPodServiceRoute) != 3 {
t.Errorf("invalid pods count, expected 3, got %v", len(decodedPodServiceRoute))
return
}
if decodedPodServiceRoute[2].GetKind() != "Route" {
t.Errorf("invalid route kind, expected Route, got %v", decodedPodServiceRoute[2].GetKind())
return
}
targetPort := decodedPodServiceRoute[2].Object["spec"].(map[string]interface{})["port"].(map[string]interface{})["targetPort"].(int64)
if targetPort != 80 {
t.Errorf("invalid route target port, expected 80, got %v", targetPort)
return
}
})
})
}

View File

@@ -1,7 +1,6 @@
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/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
@@ -262,13 +261,14 @@ func TestResourcesCreateOrUpdate(t *testing.T) {
}
})
t.Run("resources_create_or_update with valid cluster-scoped json resource creates custom resource definition", func(t *testing.T) {
apiExtensionsV1Client := v1.NewForConfigOrDie(envTestRestConfig)
apiExtensionsV1Client := c.newApiExtensionsClient()
_, err = apiExtensionsV1Client.CustomResourceDefinitions().Get(c.ctx, "customs.example.com", metav1.GetOptions{})
if err != nil {
t.Fatalf("custom resource definition not found")
return
}
})
c.crdWaitUntilReady("customs.example.com")
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) {