From e6ab757915814d8664974ee0bf09f9c7ac05fb9d Mon Sep 17 00:00:00 2001 From: Marc Nuri Date: Tue, 18 Feb 2025 05:34:27 +0100 Subject: [PATCH] feat(kubernetes): pods_run creates OpenShift routes --- pkg/kubernetes/pods.go | 38 ++++++++++++++++++-- pkg/kubernetes/resources.go | 12 +++++++ pkg/mcp/common_test.go | 70 +++++++++++++++++++++++++++++++++++-- pkg/mcp/pods_test.go | 36 +++++++++++++++++++ pkg/mcp/resources_test.go | 4 +-- 5 files changed, 153 insertions(+), 7 deletions(-) diff --git a/pkg/kubernetes/pods.go b/pkg/kubernetes/pods.go index 0f08e42..0079ef9 100644 --- a/pkg/kubernetes/pods.go +++ b/pkg/kubernetes/pods.go @@ -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 diff --git a/pkg/kubernetes/resources.go b/pkg/kubernetes/resources.go index b33fb20..76b1fce 100644 --- a/pkg/kubernetes/resources.go +++ b/pkg/kubernetes/resources.go @@ -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 +} diff --git a/pkg/mcp/common_test.go b/pkg/mcp/common_test.go index b6036fb..0414722 100644 --- a/pkg/mcp/common_test.go +++ b/pkg/mcp/common_test.go @@ -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{} diff --git a/pkg/mcp/pods_test.go b/pkg/mcp/pods_test.go index a47af34..8adb193 100644 --- a/pkg/mcp/pods_test.go +++ b/pkg/mcp/pods_test.go @@ -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 + } + }) + }) +} diff --git a/pkg/mcp/resources_test.go b/pkg/mcp/resources_test.go index 9815cce..55c835c 100644 --- a/pkg/mcp/resources_test.go +++ b/pkg/mcp/resources_test.go @@ -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) {