Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 12 additions & 32 deletions pkg/toolsets/netedge/coredns_test.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,6 @@
package netedge

import (
"context"
"testing"

"github.com/containers/kubernetes-mcp-server/pkg/api"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
Expand All @@ -15,17 +9,8 @@ import (
"sigs.k8s.io/controller-runtime/pkg/client/fake"
)

// mockKubernetesClient implements api.KubernetesClient for testing
type mockKubernetesClient struct {
api.KubernetesClient
restConfig *rest.Config
}

func (m *mockKubernetesClient) RESTConfig() *rest.Config {
return m.restConfig
}
func (s *NetEdgeTestSuite) TestGetCoreDNSConfig() {

func TestGetCoreDNSConfig(t *testing.T) {
tests := []struct {
name string
configMap *corev1.ConfigMap
Expand Down Expand Up @@ -72,7 +57,7 @@ func TestGetCoreDNSConfig(t *testing.T) {
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s.Run(tt.name, func() {
// Setup mock client
objs := []runtime.Object{}
if tt.configMap != nil {
Expand All @@ -87,24 +72,19 @@ func TestGetCoreDNSConfig(t *testing.T) {
return fake.NewClientBuilder().WithRuntimeObjects(objs...).Build(), nil
}

// Call handler
params := api.ToolHandlerParams{
Context: context.Background(),
KubernetesClient: &mockKubernetesClient{restConfig: &rest.Config{}},
}

result, err := getCoreDNSConfig(params)
// Call handler using suite params
result, err := getCoreDNSConfig(s.params)

if tt.expectError {
require.NoError(t, err) // Handler returns error in result, not as return value
require.NotNil(t, result)
require.Error(t, result.Error)
assert.Contains(t, result.Error.Error(), tt.errorContains)
s.Require().NoError(err) // Handler returns error in result, not as return value
s.Require().NotNil(result)
s.Require().Error(result.Error)
s.Assert().Contains(result.Error.Error(), tt.errorContains)
} else {
require.NoError(t, err)
require.NotNil(t, result)
require.NoError(t, result.Error)
assert.Equal(t, tt.expectedOutput, result.Content)
s.Require().NoError(err)
s.Require().NotNil(result)
s.Require().NoError(result.Error)
s.Assert().Equal(tt.expectedOutput, result.Content)
}
})
}
Expand Down
124 changes: 124 additions & 0 deletions pkg/toolsets/netedge/endpoints.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package netedge

import (
"fmt"

"github.com/containers/kubernetes-mcp-server/pkg/api"
"github.com/google/jsonschema-go/jsonschema"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/utils/ptr"
"sigs.k8s.io/yaml"
)

func initEndpoints() []api.ServerTool {
return []api.ServerTool{
{
Tool: api.Tool{
Name: "get_service_endpoints",
Description: "Return EndpointSlice objects for a Service to verify backend pod availability.",
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"namespace": {
Type: "string",
Description: "Service namespace",
},
"service": {
Type: "string",
Description: "Service name",
},
},
Required: []string{"namespace", "service"},
},
Annotations: api.ToolAnnotations{
Title: "Get Service Endpoints",
ReadOnlyHint: ptr.To(true),
DestructiveHint: ptr.To(false),
OpenWorldHint: ptr.To(true),
},
},
Handler: getServiceEndpoints,
},
}
}

func getServiceEndpoints(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
namespace, err := api.RequiredString(params, "namespace")
if err != nil {
return api.NewToolCallResult("", err), nil
}
serviceName, err := api.RequiredString(params, "service")
if err != nil {
return api.NewToolCallResult("", err), nil
}

gvr := schema.GroupVersionResource{
Group: "discovery.k8s.io",
Version: "v1",
Resource: "endpointslices",
}

// EndpointSlices are linked to a service via the "kubernetes.io/service-name" label
labelSelector := "kubernetes.io/service-name=" + serviceName

list, err := params.DynamicClient().Resource(gvr).Namespace(namespace).List(params.Context, metav1.ListOptions{
LabelSelector: labelSelector,
})
if err != nil {
return api.NewToolCallResult("", fmt.Errorf("failed to list EndpointSlices for service %s/%s: %w", namespace, serviceName, err)), nil
}

if len(list.Items) == 0 {
return api.NewToolCallResult("", fmt.Errorf("no EndpointSlices found for service %s/%s", namespace, serviceName)), nil
}

// Extract KeyFields from EndpointSlices
var keyFields []map[string]interface{}
for _, eps := range list.Items {
kf := map[string]interface{}{
"Name": eps.GetName(),
"Namespace": eps.GetNamespace(),
}

if endpoints, found, err := unstructured.NestedSlice(eps.Object, "endpoints"); found && err == nil {
var addresses []string
var nodeNames []string
for _, epRaw := range endpoints {
if ep, ok := epRaw.(map[string]interface{}); ok {
if addrs, ok := ep["addresses"].([]interface{}); ok {
for _, a := range addrs {
if addrStr, ok := a.(string); ok {
addresses = append(addresses, addrStr)
}
}
}
if nodeName, ok := ep["nodeName"].(string); ok {
nodeNames = append(nodeNames, nodeName)
}
}
}
kf["Addresses"] = addresses
kf["NodeNames"] = nodeNames
}

if ports, found, err := unstructured.NestedSlice(eps.Object, "ports"); found && err == nil {
kf["Ports"] = ports
}

keyFields = append(keyFields, kf)
}

resultObj := map[string]interface{}{
"KeyFields": keyFields,
"RawEndpointSlices": list.Items,
}

data, err := yaml.Marshal(resultObj)
if err != nil {
return api.NewToolCallResult("", fmt.Errorf("failed to marshal endpoint slices as yaml: %w", err)), nil
}

return api.NewToolCallResult(string(data), nil), nil
}
126 changes: 126 additions & 0 deletions pkg/toolsets/netedge/endpoints_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package netedge

import (
discoveryv1 "k8s.io/api/discovery/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/client-go/dynamic/fake"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
"k8s.io/utils/ptr"
"sigs.k8s.io/yaml"
)

Comment thread
bentito marked this conversation as resolved.
func (s *NetEdgeTestSuite) TestGetServiceEndpoints() {

tests := []struct {
name string
namespace string
service string
existingObjs []runtime.Object
expectedError string
validate func(result string)
}{
{
name: "successful retrieval",
namespace: "default",
service: "myservice",
existingObjs: []runtime.Object{
&discoveryv1.EndpointSlice{
ObjectMeta: metav1.ObjectMeta{
Name: "myservice-1",
Namespace: "default",
Labels: map[string]string{
"kubernetes.io/service-name": "myservice",
},
},
Endpoints: []discoveryv1.Endpoint{
{
Addresses: []string{"1.2.3.4"},
},
},
Ports: []discoveryv1.EndpointPort{
{
Port: ptr.To(int32(80)),
},
},
},
},
validate: func(result string) {
var r map[string]interface{}
err := yaml.Unmarshal([]byte(result), &r)
s.Require().NoError(err)

raw := r["RawEndpointSlices"].([]interface{})
s.Assert().Len(raw, 1)

keyFields := r["KeyFields"].([]interface{})
s.Assert().Len(keyFields, 1)

firstRaw := raw[0].(map[string]interface{})
firstKF := keyFields[0].(map[string]interface{})

s.Assert().Equal("myservice-1", firstRaw["metadata"].(map[string]interface{})["name"])
s.Assert().Equal("myservice-1", firstKF["Name"])
},
},
{
name: "endpoints not found",
namespace: "default",
service: "missing",
existingObjs: []runtime.Object{},
expectedError: "no EndpointSlices found",
},
{
name: "missing arguments",
namespace: "",
service: "",
expectedError: "parameter required",
},
}

for _, tt := range tests {
s.Run(tt.name, func() {
// Create fake dynamic client
scheme := runtime.NewScheme()
err := clientgoscheme.AddToScheme(scheme)
s.Require().NoError(err)
err = discoveryv1.AddToScheme(scheme)
s.Require().NoError(err)
dynClient := fake.NewSimpleDynamicClient(scheme, tt.existingObjs...)

// Create mock params
args := make(map[string]any)
if tt.namespace != "" {
args["namespace"] = tt.namespace
}
if tt.service != "" {
args["service"] = tt.service
}

s.SetArgs(args)
s.SetDynamicClient(dynClient)

result, err := getServiceEndpoints(s.params)

// If the handler returns an error in ToolCallResult (which is mostly what it does for logic errors),
// result.Error will be set. `err` return is usually nil unless panic/protocol error.

// However, our handler returns `api.NewToolCallResult("", err)`.
// So we check result.Error.

if tt.expectedError != "" {
s.Assert().NoError(err) // The handler doesn't return Go error
s.Require().NotNil(result)
s.Require().Error(result.Error)
s.Assert().Contains(result.Error.Error(), tt.expectedError)
} else {
s.Assert().NoError(err)
s.Require().NotNil(result)
s.Assert().NoError(result.Error)
if tt.validate != nil {
tt.validate(result.Content)
}
}
})
}
}
66 changes: 66 additions & 0 deletions pkg/toolsets/netedge/suite_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package netedge

import (
"context"
"testing"

"github.com/containers/kubernetes-mcp-server/pkg/api"
"github.com/stretchr/testify/suite"
"k8s.io/client-go/dynamic"
"k8s.io/client-go/rest"
)

// mockKubernetesClient implements api.KubernetesClient for testing
type mockKubernetesClient struct {
api.KubernetesClient
restConfig *rest.Config
dynamicClient dynamic.Interface
}

func (m *mockKubernetesClient) RESTConfig() *rest.Config {
return m.restConfig
}

func (m *mockKubernetesClient) DynamicClient() dynamic.Interface {
return m.dynamicClient
}

type NetEdgeTestSuite struct {
suite.Suite
params api.ToolHandlerParams
mockReq *mockToolCallRequest
mockClient *mockKubernetesClient
}

func (s *NetEdgeTestSuite) SetupTest() {
s.mockReq = &mockToolCallRequest{args: make(map[string]interface{})}
s.mockClient = &mockKubernetesClient{
restConfig: &rest.Config{},
}
s.params = api.ToolHandlerParams{
Context: context.Background(),
ToolCallRequest: s.mockReq,
KubernetesClient: s.mockClient,
}
}

func (s *NetEdgeTestSuite) SetArgs(args map[string]interface{}) {
s.mockReq.args = args
}

func (s *NetEdgeTestSuite) SetDynamicClient(dynClient dynamic.Interface) {
s.mockClient.dynamicClient = dynClient
s.params.KubernetesClient = s.mockClient
}

func TestNetEdgeSuite(t *testing.T) {
suite.Run(t, new(NetEdgeTestSuite))
}

type mockToolCallRequest struct {
args map[string]any
}

func (m *mockToolCallRequest) GetArguments() map[string]any {
return m.args
}
Loading