mirror of
https://github.com/containers/kubernetes-mcp-server.git
synced 2025-10-23 01:22:57 +03:00
feat(kubernetes): pods_run can get any resource in the cluster
This commit is contained in:
@@ -2,8 +2,14 @@ package kubernetes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/manusa/kubernetes-mcp-server/pkg/version"
|
||||
v1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/apimachinery/pkg/util/intstr"
|
||||
"k8s.io/apimachinery/pkg/util/rand"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
)
|
||||
|
||||
@@ -44,3 +50,56 @@ func (k *Kubernetes) PodsLog(ctx context.Context, namespace, name string) (strin
|
||||
}
|
||||
return string(rawData), nil
|
||||
}
|
||||
|
||||
func (k *Kubernetes) PodsRun(ctx context.Context, namespace, name, image string, port int32) (string, error) {
|
||||
if name == "" {
|
||||
name = version.BinaryName + "-run-" + rand.String(5)
|
||||
}
|
||||
labels := map[string]string{
|
||||
AppKubernetesName: name,
|
||||
AppKubernetesComponent: name,
|
||||
AppKubernetesManagedBy: version.BinaryName,
|
||||
AppKubernetesPartOf: version.BinaryName + "-run-sandbox",
|
||||
}
|
||||
// NewPod
|
||||
var resources []any
|
||||
pod := &v1.Pod{
|
||||
TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Pod"},
|
||||
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespaceOrDefault(namespace), Labels: labels},
|
||||
Spec: v1.PodSpec{Containers: []v1.Container{{
|
||||
Name: name,
|
||||
Image: image,
|
||||
ImagePullPolicy: v1.PullAlways,
|
||||
}}},
|
||||
}
|
||||
resources = append(resources, pod)
|
||||
if port > 0 {
|
||||
pod.Spec.Containers[0].Ports = []v1.ContainerPort{{ContainerPort: port}}
|
||||
svc := &v1.Service{
|
||||
TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Service"},
|
||||
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespaceOrDefault(namespace), Labels: labels},
|
||||
Spec: v1.ServiceSpec{
|
||||
Selector: labels,
|
||||
Type: v1.ServiceTypeClusterIP,
|
||||
Ports: []v1.ServicePort{{Port: port, TargetPort: intstr.FromInt32(port)}},
|
||||
},
|
||||
}
|
||||
resources = append(resources, svc)
|
||||
}
|
||||
|
||||
// Convert the objects to Unstructured and reuse resourcesCreateOrUpdate functionality
|
||||
converter := runtime.DefaultUnstructuredConverter
|
||||
var toCreate []*unstructured.Unstructured
|
||||
for _, obj := range resources {
|
||||
m, err := converter.ToUnstructured(obj)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
u := &unstructured.Unstructured{}
|
||||
if err = converter.FromUnstructured(m, u); err != nil {
|
||||
return "", err
|
||||
}
|
||||
toCreate = append(toCreate, u)
|
||||
}
|
||||
return k.resourcesCreateOrUpdate(ctx, toCreate)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,18 @@ import (
|
||||
)
|
||||
|
||||
func TestTools(t *testing.T) {
|
||||
expectedNames := []string{"pods_list", "pods_list_in_namespace", "configuration_view"}
|
||||
expectedNames := []string{
|
||||
"configuration_view",
|
||||
"pods_list",
|
||||
"pods_list_in_namespace",
|
||||
"pods_get",
|
||||
"pods_log",
|
||||
"pods_run",
|
||||
"resources_list",
|
||||
"resources_get",
|
||||
"resources_create_or_update",
|
||||
"resources_delete",
|
||||
}
|
||||
testCase(t, func(c *mcpContext) {
|
||||
tools, err := c.mcpClient.ListTools(c.ctx, mcp.ListToolsRequest{})
|
||||
t.Run("ListTools returns tools", func(t *testing.T) {
|
||||
|
||||
@@ -43,6 +43,23 @@ func (s *Sever) initPods() {
|
||||
mcp.Required(),
|
||||
),
|
||||
), podsLog)
|
||||
s.server.AddTool(mcp.NewTool(
|
||||
"pods_run",
|
||||
mcp.WithDescription("Run a Kubernetes Pod in the current or provided namespace with the provided container image and optional name"),
|
||||
mcp.WithString("namespace",
|
||||
mcp.Description("Namespace to run the Pod in"),
|
||||
),
|
||||
mcp.WithString("name",
|
||||
mcp.Description("Name of the Pod (Optional, random name if not provided)"),
|
||||
),
|
||||
mcp.WithString("image",
|
||||
mcp.Description("Container Image to run in the Pod"),
|
||||
mcp.Required(),
|
||||
),
|
||||
mcp.WithNumber("port",
|
||||
mcp.Description("TCP/IP port to expose from the Pod container (Optional, no port exposed if not provided)"),
|
||||
),
|
||||
), podsRun)
|
||||
}
|
||||
|
||||
func podsListInAllNamespaces(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
@@ -112,3 +129,31 @@ func podsLog(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult,
|
||||
}
|
||||
return NewTextResult(ret, err), nil
|
||||
}
|
||||
|
||||
func podsRun(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
k, err := kubernetes.NewKubernetes()
|
||||
if err != nil {
|
||||
return NewTextResult("", fmt.Errorf("failed to run pod: %v", err)), nil
|
||||
}
|
||||
ns := ctr.Params.Arguments["namespace"]
|
||||
if ns == nil {
|
||||
ns = ""
|
||||
}
|
||||
name := ctr.Params.Arguments["name"]
|
||||
if name == nil {
|
||||
name = ""
|
||||
}
|
||||
image := ctr.Params.Arguments["image"]
|
||||
if image == nil {
|
||||
return NewTextResult("", errors.New("failed to run pod, missing argument image")), nil
|
||||
}
|
||||
port := ctr.Params.Arguments["port"]
|
||||
if port == nil {
|
||||
port = float64(0)
|
||||
}
|
||||
ret, err := k.PodsRun(ctx, ns.(string), name.(string), image.(string), int32(port.(float64)))
|
||||
if err != nil {
|
||||
return NewTextResult("", fmt.Errorf("failed to get pod %s log in namespace %s: %v", name, ns, err)), nil
|
||||
}
|
||||
return NewTextResult(ret, err), nil
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package mcp
|
||||
import (
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"sigs.k8s.io/yaml"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -253,3 +254,153 @@ func TestPodsLog(t *testing.T) {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestPodsRun(t *testing.T) {
|
||||
testCase(t, func(c *mcpContext) {
|
||||
c.withEnvTest()
|
||||
t.Run("pods_run with nil image returns error", func(t *testing.T) {
|
||||
toolResult, _ := c.callTool("pods_run", map[string]interface{}{})
|
||||
if toolResult.IsError != true {
|
||||
t.Errorf("call tool should fail")
|
||||
return
|
||||
}
|
||||
if toolResult.Content[0].(map[string]interface{})["text"].(string) != "failed to run pod, missing argument image" {
|
||||
t.Errorf("invalid error message, got %v", toolResult.Content[0].(map[string]interface{})["text"].(string))
|
||||
return
|
||||
}
|
||||
})
|
||||
podsRunNilNamespace, err := c.callTool("pods_run", map[string]interface{}{"image": "nginx"})
|
||||
t.Run("pods_run with image and nil namespace runs pod", func(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Errorf("call tool failed %v", err)
|
||||
return
|
||||
}
|
||||
if podsRunNilNamespace.IsError {
|
||||
t.Errorf("call tool failed")
|
||||
return
|
||||
}
|
||||
})
|
||||
var decodedNilNamespace []unstructured.Unstructured
|
||||
err = yaml.Unmarshal([]byte(podsRunNilNamespace.Content[0].(map[string]interface{})["text"].(string)), &decodedNilNamespace)
|
||||
t.Run("pods_run with image and nil namespace has yaml content", func(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Errorf("invalid tool result content %v", err)
|
||||
return
|
||||
}
|
||||
})
|
||||
t.Run("pods_run with image and nil namespace returns 1 item (Pod)", func(t *testing.T) {
|
||||
if len(decodedNilNamespace) != 1 {
|
||||
t.Errorf("invalid pods count, expected 1, got %v", len(decodedNilNamespace))
|
||||
return
|
||||
}
|
||||
if decodedNilNamespace[0].GetKind() != "Pod" {
|
||||
t.Errorf("invalid pod kind, expected Pod, got %v", decodedNilNamespace[0].GetKind())
|
||||
return
|
||||
}
|
||||
})
|
||||
t.Run("pods_run with image and nil namespace returns pod in default", func(t *testing.T) {
|
||||
if decodedNilNamespace[0].GetNamespace() != "default" {
|
||||
t.Errorf("invalid pod namespace, expected default, got %v", decodedNilNamespace[0].GetNamespace())
|
||||
return
|
||||
}
|
||||
})
|
||||
t.Run("pods_run with image and nil namespace returns pod with random name", func(t *testing.T) {
|
||||
if !strings.HasPrefix(decodedNilNamespace[0].GetName(), "kubernetes-mcp-server-run-") {
|
||||
t.Errorf("invalid pod name, expected random, got %v", decodedNilNamespace[0].GetName())
|
||||
return
|
||||
}
|
||||
})
|
||||
t.Run("pods_run with image and nil namespace returns pod with labels", func(t *testing.T) {
|
||||
labels := decodedNilNamespace[0].Object["metadata"].(map[string]interface{})["labels"].(map[string]interface{})
|
||||
if labels["app.kubernetes.io/name"] == "" {
|
||||
t.Errorf("invalid labels, expected app.kubernetes.io/name, got %v", labels)
|
||||
return
|
||||
}
|
||||
if labels["app.kubernetes.io/component"] == "" {
|
||||
t.Errorf("invalid labels, expected app.kubernetes.io/component, got %v", labels)
|
||||
return
|
||||
}
|
||||
if labels["app.kubernetes.io/managed-by"] != "kubernetes-mcp-server" {
|
||||
t.Errorf("invalid labels, expected app.kubernetes.io/managed-by, got %v", labels)
|
||||
return
|
||||
}
|
||||
if labels["app.kubernetes.io/part-of"] != "kubernetes-mcp-server-run-sandbox" {
|
||||
t.Errorf("invalid labels, expected app.kubernetes.io/part-of, got %v", labels)
|
||||
return
|
||||
}
|
||||
})
|
||||
t.Run("pods_run with image and nil namespace returns pod with nginx container", func(t *testing.T) {
|
||||
containers := decodedNilNamespace[0].Object["spec"].(map[string]interface{})["containers"].([]interface{})
|
||||
if containers[0].(map[string]interface{})["image"] != "nginx" {
|
||||
t.Errorf("invalid container name, expected nginx, got %v", containers[0].(map[string]interface{})["image"])
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
podsRunNamespaceAndPort, err := c.callTool("pods_run", map[string]interface{}{"image": "nginx", "port": 80})
|
||||
t.Run("pods_run with image, namespace, and port runs pod", func(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Errorf("call tool failed %v", err)
|
||||
return
|
||||
}
|
||||
if podsRunNamespaceAndPort.IsError {
|
||||
t.Errorf("call tool failed")
|
||||
return
|
||||
}
|
||||
})
|
||||
var decodedNamespaceAndPort []unstructured.Unstructured
|
||||
err = yaml.Unmarshal([]byte(podsRunNamespaceAndPort.Content[0].(map[string]interface{})["text"].(string)), &decodedNamespaceAndPort)
|
||||
t.Run("pods_run with image, namespace, and port has yaml content", func(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Errorf("invalid tool result content %v", err)
|
||||
return
|
||||
}
|
||||
})
|
||||
t.Run("pods_run with image, namespace, and port returns 2 items (Pod + Service)", func(t *testing.T) {
|
||||
if len(decodedNamespaceAndPort) != 2 {
|
||||
t.Errorf("invalid pods count, expected 2, got %v", len(decodedNamespaceAndPort))
|
||||
return
|
||||
}
|
||||
if decodedNamespaceAndPort[0].GetKind() != "Pod" {
|
||||
t.Errorf("invalid pod kind, expected Pod, got %v", decodedNamespaceAndPort[0].GetKind())
|
||||
return
|
||||
}
|
||||
if decodedNamespaceAndPort[1].GetKind() != "Service" {
|
||||
t.Errorf("invalid service kind, expected Service, got %v", decodedNamespaceAndPort[1].GetKind())
|
||||
return
|
||||
}
|
||||
})
|
||||
t.Run("pods_run with image, namespace, and port returns pod with port", func(t *testing.T) {
|
||||
containers := decodedNamespaceAndPort[0].Object["spec"].(map[string]interface{})["containers"].([]interface{})
|
||||
ports := containers[0].(map[string]interface{})["ports"].([]interface{})
|
||||
if ports[0].(map[string]interface{})["containerPort"] != int64(80) {
|
||||
t.Errorf("invalid container port, expected 80, got %v", ports[0].(map[string]interface{})["containerPort"])
|
||||
return
|
||||
}
|
||||
})
|
||||
t.Run("pods_run with image, namespace, and port returns service with port and selector", func(t *testing.T) {
|
||||
ports := decodedNamespaceAndPort[1].Object["spec"].(map[string]interface{})["ports"].([]interface{})
|
||||
if ports[0].(map[string]interface{})["port"] != int64(80) {
|
||||
t.Errorf("invalid service port, expected 80, got %v", ports[0].(map[string]interface{})["port"])
|
||||
return
|
||||
}
|
||||
if ports[0].(map[string]interface{})["targetPort"] != int64(80) {
|
||||
t.Errorf("invalid service target port, expected 80, got %v", ports[0].(map[string]interface{})["targetPort"])
|
||||
return
|
||||
}
|
||||
selector := decodedNamespaceAndPort[1].Object["spec"].(map[string]interface{})["selector"].(map[string]interface{})
|
||||
if selector["app.kubernetes.io/name"] == "" {
|
||||
t.Errorf("invalid service selector, expected app.kubernetes.io/name, got %v", selector)
|
||||
return
|
||||
}
|
||||
if selector["app.kubernetes.io/managed-by"] != "kubernetes-mcp-server" {
|
||||
t.Errorf("invalid service selector, expected app.kubernetes.io/managed-by, got %v", selector)
|
||||
return
|
||||
}
|
||||
if selector["app.kubernetes.io/part-of"] != "kubernetes-mcp-server-run-sandbox" {
|
||||
t.Errorf("invalid service selector, expected app.kubernetes.io/part-of, got %v", selector)
|
||||
return
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -277,7 +277,7 @@ func TestResourcesCreateOrUpdate(t *testing.T) {
|
||||
return
|
||||
}
|
||||
if resourcesCreateOrUpdateCustom.IsError {
|
||||
t.Fatalf("call tool failed")
|
||||
t.Fatalf("call tool failed, got: %v", resourcesCreateOrUpdateCustom.Content)
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user