mirror of
https://github.com/openshift/openshift-mcp-server.git
synced 2025-10-17 14:27:48 +03:00
feat(kubernetes): pods_run creates OpenShift routes
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user