diff --git a/pkg/k8s/client.go b/pkg/k8s/client.go index d427ab2..666895f 100644 --- a/pkg/k8s/client.go +++ b/pkg/k8s/client.go @@ -29,13 +29,13 @@ type Client struct { getPodLogs PodLogsFunc kubeconfigPath string mu sync.RWMutex // Protects access to client components - + // For periodic refresh - refreshCtx context.Context - refreshCancel context.CancelFunc - refreshInterval time.Duration - refreshing bool - refreshMu sync.Mutex // Protects refreshing state + refreshCtx context.Context + refreshCancel context.CancelFunc + refreshInterval time.Duration + refreshing bool + refreshMu sync.Mutex // Protects refreshing state } // NewClient creates a new Kubernetes client @@ -69,10 +69,10 @@ func NewClient(kubeconfigPath string) (*Client, error) { clientset: clientset, kubeconfigPath: kubeconfigPath, } - + // Set the default implementation for getPodLogs client.getPodLogs = client.defaultGetPodLogs - + return client, nil } @@ -105,7 +105,7 @@ var getConfig ConfigGetter = defaultConfigGetter func (c *Client) ListAPIResources(ctx context.Context) ([]*metav1.APIResourceList, error) { c.mu.RLock() defer c.mu.RUnlock() - + _, resourcesList, err := c.discoveryClient.ServerGroupsAndResources() if err != nil { return nil, fmt.Errorf("failed to get server resources: %w", err) @@ -114,28 +114,32 @@ func (c *Client) ListAPIResources(ctx context.Context) ([]*metav1.APIResourceLis } // ListClusteredResources returns all clustered resources of the specified group/version/kind -func (c *Client) ListClusteredResources(ctx context.Context, gvr schema.GroupVersionResource) (*unstructured.UnstructuredList, error) { +func (c *Client) ListClusteredResources(ctx context.Context, gvr schema.GroupVersionResource, labelSelector string) (*unstructured.UnstructuredList, error) { c.mu.RLock() defer c.mu.RUnlock() - - return c.dynamicClient.Resource(gvr).List(ctx, metav1.ListOptions{}) + + return c.dynamicClient.Resource(gvr).List(ctx, metav1.ListOptions{ + LabelSelector: labelSelector, + }) } // ListNamespacedResources returns all namespaced resources of the specified group/version/kind in the given namespace -func (c *Client) ListNamespacedResources(ctx context.Context, gvr schema.GroupVersionResource, namespace string) (*unstructured.UnstructuredList, error) { +func (c *Client) ListNamespacedResources(ctx context.Context, gvr schema.GroupVersionResource, namespace string, labelSelector string) (*unstructured.UnstructuredList, error) { c.mu.RLock() defer c.mu.RUnlock() - - return c.dynamicClient.Resource(gvr).Namespace(namespace).List(ctx, metav1.ListOptions{}) + + return c.dynamicClient.Resource(gvr).Namespace(namespace).List(ctx, metav1.ListOptions{ + LabelSelector: labelSelector, + }) } // ApplyClusteredResource creates or updates a clustered resource func (c *Client) ApplyClusteredResource(ctx context.Context, gvr schema.GroupVersionResource, obj *unstructured.Unstructured) (*unstructured.Unstructured, error) { c.mu.RLock() defer c.mu.RUnlock() - + name := obj.GetName() - + // Check if resource exists existing, err := c.dynamicClient.Resource(gvr).Get(ctx, name, metav1.GetOptions{}) if err == nil { @@ -144,8 +148,7 @@ func (c *Client) ApplyClusteredResource(ctx context.Context, gvr schema.GroupVer obj.SetResourceVersion(existing.GetResourceVersion()) return c.dynamicClient.Resource(gvr).Update(ctx, obj, metav1.UpdateOptions{}) } - - + // Resource doesn't exist or error occurred, create it return c.dynamicClient.Resource(gvr).Create(ctx, obj, metav1.CreateOptions{}) } @@ -154,7 +157,7 @@ func (c *Client) ApplyClusteredResource(ctx context.Context, gvr schema.GroupVer func (c *Client) GetClusteredResource(ctx context.Context, gvr schema.GroupVersionResource, name string) (interface{}, error) { c.mu.RLock() defer c.mu.RUnlock() - + return c.dynamicClient.Resource(gvr).Get(ctx, name, metav1.GetOptions{}) } @@ -162,7 +165,7 @@ func (c *Client) GetClusteredResource(ctx context.Context, gvr schema.GroupVersi func (c *Client) GetNamespacedResource(ctx context.Context, gvr schema.GroupVersionResource, namespace, name string) (interface{}, error) { c.mu.RLock() defer c.mu.RUnlock() - + return c.dynamicClient.Resource(gvr).Namespace(namespace).Get(ctx, name, metav1.GetOptions{}) } @@ -170,9 +173,9 @@ func (c *Client) GetNamespacedResource(ctx context.Context, gvr schema.GroupVers func (c *Client) ApplyNamespacedResource(ctx context.Context, gvr schema.GroupVersionResource, namespace string, obj *unstructured.Unstructured) (*unstructured.Unstructured, error) { c.mu.RLock() defer c.mu.RUnlock() - + name := obj.GetName() - + // Check if resource exists existing, err := c.dynamicClient.Resource(gvr).Namespace(namespace).Get(ctx, name, metav1.GetOptions{}) if err == nil { @@ -181,7 +184,7 @@ func (c *Client) ApplyNamespacedResource(ctx context.Context, gvr schema.GroupVe obj.SetResourceVersion(existing.GetResourceVersion()) return c.dynamicClient.Resource(gvr).Namespace(namespace).Update(ctx, obj, metav1.UpdateOptions{}) } - + // Resource doesn't exist or error occurred, create it return c.dynamicClient.Resource(gvr).Namespace(namespace).Create(ctx, obj, metav1.CreateOptions{}) } @@ -190,7 +193,7 @@ func (c *Client) ApplyNamespacedResource(ctx context.Context, gvr schema.GroupVe func (c *Client) DeleteClusteredResource(ctx context.Context, gvr schema.GroupVersionResource, name string) error { c.mu.RLock() defer c.mu.RUnlock() - + return c.dynamicClient.Resource(gvr).Delete(ctx, name, metav1.DeleteOptions{}) } @@ -198,7 +201,7 @@ func (c *Client) DeleteClusteredResource(ctx context.Context, gvr schema.GroupVe func (c *Client) DeleteNamespacedResource(ctx context.Context, gvr schema.GroupVersionResource, namespace, name string) error { c.mu.RLock() defer c.mu.RUnlock() - + return c.dynamicClient.Resource(gvr).Namespace(namespace).Delete(ctx, name, metav1.DeleteOptions{}) } @@ -206,7 +209,7 @@ func (c *Client) DeleteNamespacedResource(ctx context.Context, gvr schema.GroupV func (c *Client) SetDynamicClient(dynamicClient dynamic.Interface) { c.mu.Lock() defer c.mu.Unlock() - + c.dynamicClient = dynamicClient } @@ -214,7 +217,7 @@ func (c *Client) SetDynamicClient(dynamicClient dynamic.Interface) { func (c *Client) SetDiscoveryClient(discoveryClient discovery.DiscoveryInterface) { c.mu.Lock() defer c.mu.Unlock() - + c.discoveryClient = discoveryClient } @@ -222,7 +225,7 @@ func (c *Client) SetDiscoveryClient(discoveryClient discovery.DiscoveryInterface func (c *Client) SetClientset(clientset kubernetes.Interface) { c.mu.Lock() defer c.mu.Unlock() - + // Store the interface directly, we'll use it through the interface methods c.clientset = clientset } @@ -231,7 +234,7 @@ func (c *Client) SetClientset(clientset kubernetes.Interface) { func (c *Client) SetPodLogsFunc(getPodLogs PodLogsFunc) { c.mu.Lock() defer c.mu.Unlock() - + c.getPodLogs = getPodLogs } @@ -239,7 +242,7 @@ func (c *Client) SetPodLogsFunc(getPodLogs PodLogsFunc) { func (c *Client) GetPodLogs() PodLogsFunc { c.mu.RLock() defer c.mu.RUnlock() - + return c.getPodLogs } @@ -247,7 +250,7 @@ func (c *Client) GetPodLogs() PodLogsFunc { func (c *Client) IsReady() bool { c.mu.RLock() defer c.mu.RUnlock() - + return c.discoveryClient != nil && c.dynamicClient != nil && c.clientset != nil } @@ -280,7 +283,7 @@ func (c *Client) RefreshClient() error { // Update the client's components with proper locking c.mu.Lock() defer c.mu.Unlock() - + c.discoveryClient = discoveryClient c.dynamicClient = dynamicClient c.clientset = clientset @@ -294,23 +297,23 @@ func (c *Client) RefreshClient() error { func (c *Client) StartPeriodicRefresh(interval time.Duration) error { c.refreshMu.Lock() defer c.refreshMu.Unlock() - + if c.refreshing { return fmt.Errorf("periodic refresh is already running") } - + // Create a cancellable context for the refresh goroutine ctx, cancel := context.WithCancel(context.Background()) c.refreshCtx = ctx c.refreshCancel = cancel c.refreshInterval = interval c.refreshing = true - + // Start the refresh goroutine go func() { ticker := time.NewTicker(interval) defer ticker.Stop() - + for { select { case <-ticker.C: @@ -325,7 +328,7 @@ func (c *Client) StartPeriodicRefresh(interval time.Duration) error { } } }() - + return nil } @@ -334,15 +337,15 @@ func (c *Client) StartPeriodicRefresh(interval time.Duration) error { func (c *Client) StopPeriodicRefresh() error { c.refreshMu.Lock() defer c.refreshMu.Unlock() - + if !c.refreshing { return fmt.Errorf("periodic refresh is not running") } - + // Cancel the refresh context to stop the goroutine c.refreshCancel() c.refreshing = false - + return nil } @@ -350,7 +353,7 @@ func (c *Client) StopPeriodicRefresh() error { func (c *Client) IsRefreshing() bool { c.refreshMu.Lock() defer c.refreshMu.Unlock() - + return c.refreshing } @@ -359,10 +362,10 @@ func (c *Client) IsRefreshing() bool { func (c *Client) GetRefreshInterval() time.Duration { c.refreshMu.Lock() defer c.refreshMu.Unlock() - + if !c.refreshing { return 0 } - + return c.refreshInterval -} \ No newline at end of file +} diff --git a/pkg/k8s/client_test.go b/pkg/k8s/client_test.go index 2ec9ef3..de381a8 100644 --- a/pkg/k8s/client_test.go +++ b/pkg/k8s/client_test.go @@ -19,12 +19,12 @@ import ( func TestListClusteredResources(t *testing.T) { // Create a fake dynamic client scheme := runtime.NewScheme() - + // Register list kinds for the resources we'll be testing listKinds := map[schema.GroupVersionResource]string{ {Group: "rbac.authorization.k8s.io", Version: "v1", Resource: "clusterroles"}: "ClusterRoleList", } - + client := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, listKinds) // Create a test client with the fake dynamic client @@ -59,6 +59,25 @@ func TestListClusteredResources(t *testing.T) { }, }, }, + { + Object: map[string]interface{}{ + "apiVersion": "rbac.authorization.k8s.io/v1", + "kind": "ClusterRole", + "metadata": map[string]interface{}{ + "name": "test-cluster-role-2", + "labels": map[string]interface{}{ + "app": "test-app", + }, + }, + "rules": []interface{}{ + map[string]interface{}{ + "apiGroups": []interface{}{""}, + "resources": []interface{}{"pods"}, + "verbs": []interface{}{"get", "list", "watch"}, + }, + }, + }, + }, }, } return true, list, nil @@ -66,25 +85,35 @@ func TestListClusteredResources(t *testing.T) { // Test ListClusteredResources ctx := context.Background() - list, err := testClient.ListClusteredResources(ctx, gvr) - + list, err := testClient.ListClusteredResources(ctx, gvr, "") + // Verify there was no error assert.NoError(t, err, "ListClusteredResources should not return an error") - + // Verify the result - assert.Len(t, list.Items, 1, "Expected 1 item") + assert.Len(t, list.Items, 2, "Expected 2 items") assert.Equal(t, "test-cluster-role", list.Items[0].GetName(), "Expected name 'test-cluster-role'") + + // Test ListClusteredResources with label selector + list, err = testClient.ListClusteredResources(ctx, gvr, "app=test-app") + + // Verify there was no error + assert.NoError(t, err, "ListClusteredResources should not return an error") + + // Verify the result + assert.Len(t, list.Items, 1, "Expected 1 item") + assert.Equal(t, "test-cluster-role-2", list.Items[0].GetName(), "Expected name 'test-cluster-role-2'") } func TestListNamespacedResources(t *testing.T) { // Create a fake dynamic client scheme := runtime.NewScheme() - + // Register list kinds for the resources we'll be testing listKinds := map[schema.GroupVersionResource]string{ {Group: "", Version: "v1", Resource: "services"}: "ServiceList", } - + client := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, listKinds) // Create a test client with the fake dynamic client @@ -121,6 +150,27 @@ func TestListNamespacedResources(t *testing.T) { }, }, }, + { + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Service", + "metadata": map[string]interface{}{ + "name": "test-service-2", + "namespace": "default", + "labels": map[string]interface{}{ + "app": "test-app", + }, + }, + "spec": map[string]interface{}{ + "ports": []interface{}{ + map[string]interface{}{ + "port": int64(80), + "protocol": "TCP", + }, + }, + }, + }, + }, }, } return true, list, nil @@ -128,14 +178,24 @@ func TestListNamespacedResources(t *testing.T) { // Test ListNamespacedResources ctx := context.Background() - list, err := testClient.ListNamespacedResources(ctx, gvr, "default") - + list, err := testClient.ListNamespacedResources(ctx, gvr, "default", "") + // Verify there was no error assert.NoError(t, err, "ListNamespacedResources should not return an error") - + // Verify the result - assert.Len(t, list.Items, 1, "Expected 1 item") + assert.Len(t, list.Items, 2, "Expected 2 items") assert.Equal(t, "test-service", list.Items[0].GetName(), "Expected name 'test-service'") + + // Test ListNamespacedResources with label selector + list, err = testClient.ListNamespacedResources(ctx, gvr, "default", "app=test-app") + + // Verify there was no error + assert.NoError(t, err, "ListNamespacedResources should not return an error") + + // Verify the result + assert.Len(t, list.Items, 1, "Expected 1 item") + assert.Equal(t, "test-service-2", list.Items[0].GetName(), "Expected name 'test-service-2'") } func TestApplyClusteredResource(t *testing.T) { @@ -186,12 +246,13 @@ func TestApplyClusteredResource(t *testing.T) { // Test ApplyClusteredResource ctx := context.Background() result, err := testClient.ApplyClusteredResource(ctx, gvr, obj) - + // Verify there was no error assert.NoError(t, err, "ApplyClusteredResource should not return an error") - + // Verify the result assert.Equal(t, "test-cluster-role", result.GetName(), "Expected name 'test-cluster-role'") + } func TestApplyNamespacedResource(t *testing.T) { @@ -244,10 +305,10 @@ func TestApplyNamespacedResource(t *testing.T) { // Test ApplyNamespacedResource ctx := context.Background() result, err := testClient.ApplyNamespacedResource(ctx, gvr, "default", obj) - + // Verify there was no error assert.NoError(t, err, "ApplyNamespacedResource should not return an error") - + // Verify the result assert.Equal(t, "test-service", result.GetName(), "Expected name 'test-service'") } @@ -295,10 +356,10 @@ func TestGetClusteredResource(t *testing.T) { // Test GetClusteredResource ctx := context.Background() result, err := testClient.GetClusteredResource(ctx, gvr, "test-cluster-role") - + // Verify there was no error assert.NoError(t, err, "GetClusteredResource should not return an error") - + // Verify the result unstructuredResult, ok := result.(*unstructured.Unstructured) assert.True(t, ok, "Expected *unstructured.Unstructured") @@ -350,10 +411,10 @@ func TestGetNamespacedResource(t *testing.T) { // Test GetNamespacedResource ctx := context.Background() result, err := testClient.GetNamespacedResource(ctx, gvr, "default", "test-service") - + // Verify there was no error assert.NoError(t, err, "GetNamespacedResource should not return an error") - + // Verify the result unstructuredResult, ok := result.(*unstructured.Unstructured) assert.True(t, ok, "Expected *unstructured.Unstructured") @@ -363,14 +424,14 @@ func TestGetNamespacedResource(t *testing.T) { func TestSetDynamicClient(t *testing.T) { // Create a test client testClient := &Client{} - + // Create a fake dynamic client scheme := runtime.NewScheme() fakeDynamicClient := dynamicfake.NewSimpleDynamicClient(scheme) - + // Set the dynamic client testClient.SetDynamicClient(fakeDynamicClient) - + // Verify the dynamic client was set assert.NotNil(t, testClient.dynamicClient, "Expected dynamicClient to be set") } @@ -378,13 +439,13 @@ func TestSetDynamicClient(t *testing.T) { func TestSetDiscoveryClient(t *testing.T) { // Create a test client testClient := &Client{} - + // Create a fake discovery client fakeDiscoveryClient := &discoveryfake.FakeDiscovery{Fake: &ktesting.Fake{}} - + // Set the discovery client testClient.SetDiscoveryClient(fakeDiscoveryClient) - + // Verify the discovery client was set assert.NotNil(t, testClient.discoveryClient, "Expected discoveryClient to be set") } @@ -393,26 +454,26 @@ func TestIsReady(t *testing.T) { // Test with all clients nil testClient := &Client{} assert.False(t, testClient.IsReady(), "Expected IsReady to return false when all clients are nil") - + // Test with only dynamic client set testClient = &Client{} scheme := runtime.NewScheme() fakeDynamicClient := dynamicfake.NewSimpleDynamicClient(scheme) testClient.SetDynamicClient(fakeDynamicClient) assert.False(t, testClient.IsReady(), "Expected IsReady to return false when some clients are nil") - + // Test with only discovery client set testClient = &Client{} fakeDiscoveryClient := &discoveryfake.FakeDiscovery{Fake: &ktesting.Fake{}} testClient.SetDiscoveryClient(fakeDiscoveryClient) assert.False(t, testClient.IsReady(), "Expected IsReady to return false when some clients are nil") - + // Test with only clientset set testClient = &Client{} fakeClientset := kubefake.NewSimpleClientset() testClient.SetClientset(fakeClientset) assert.False(t, testClient.IsReady(), "Expected IsReady to return false when some clients are nil") - + // Test with all clients set testClient = &Client{} testClient.SetDynamicClient(fakeDynamicClient) @@ -424,7 +485,7 @@ func TestIsReady(t *testing.T) { func TestListAPIResources(t *testing.T) { // Create a fake discovery client fakeDiscoveryClient := &discoveryfake.FakeDiscovery{Fake: &ktesting.Fake{}} - + // Add some fake API resources fakeDiscoveryClient.Resources = []*metav1.APIResourceList{ { @@ -458,27 +519,27 @@ func TestListAPIResources(t *testing.T) { }, }, } - + // Create a test client with the fake discovery client testClient := &Client{ discoveryClient: fakeDiscoveryClient, } - + // Test ListAPIResources ctx := context.Background() resources, err := testClient.ListAPIResources(ctx) - + // Verify there was no error assert.NoError(t, err, "ListAPIResources should not return an error") - + // Verify the result assert.Len(t, resources, 2, "Expected 2 resource lists") - + // Check the first resource list assert.Equal(t, "v1", resources[0].GroupVersion, "Expected GroupVersion 'v1'") assert.Len(t, resources[0].APIResources, 2, "Expected 2 API resources in the first list") - + // Check the second resource list assert.Equal(t, "apps/v1", resources[1].GroupVersion, "Expected GroupVersion 'apps/v1'") assert.Len(t, resources[1].APIResources, 2, "Expected 2 API resources in the second list") -} \ No newline at end of file +} diff --git a/pkg/mcp/list_resources.go b/pkg/mcp/list_resources.go index 5d9bc63..c42064d 100644 --- a/pkg/mcp/list_resources.go +++ b/pkg/mcp/list_resources.go @@ -9,10 +9,19 @@ import ( "github.com/mark3labs/mcp-go/mcp" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime/schema" ) // HandleListResources handles the list_resources tool +// Parameters: +// - resource_type: Type of the resource ("clustered" or "namespaced") +// - group: API group of the resource +// - version: API version of the resource +// - resource: Resource type (e.g., "pods", "services") +// - namespace: Namespace for namespaced resources +// - label_selector: Kubernetes label selector for filtering resources (optional) +// Label selector format: "key1=value1,key2=value2" for equality or "key1 in (value1, value2),!key3" for set-based func (m *Implementation) HandleListResources(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { // Parse parameters resourceType := mcp.ParseString(request, "resource_type", "") @@ -20,6 +29,7 @@ func (m *Implementation) HandleListResources(ctx context.Context, request mcp.Ca version := mcp.ParseString(request, "version", "") resource := mcp.ParseString(request, "resource", "") namespace := mcp.ParseString(request, "namespace", "") + labelSelector := mcp.ParseString(request, "label_selector", "") // Validate parameters if resourceType == "" { @@ -34,6 +44,12 @@ func (m *Implementation) HandleListResources(ctx context.Context, request mcp.Ca if resourceType == "namespaced" && namespace == "" { return mcp.NewToolResultError("namespace is required for namespaced resources"), nil } + if labelSelector != "" { + _, err := labels.Parse(labelSelector) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("invalid label selector: %v", err)), nil + } + } // Create GVR gvr := schema.GroupVersionResource{ @@ -47,9 +63,9 @@ func (m *Implementation) HandleListResources(ctx context.Context, request mcp.Ca var err error switch resourceType { case "clustered": - list, err = m.k8sClient.ListClusteredResources(ctx, gvr) + list, err = m.k8sClient.ListClusteredResources(ctx, gvr, labelSelector) case "namespaced": - list, err = m.k8sClient.ListNamespacedResources(ctx, gvr, namespace) + list, err = m.k8sClient.ListNamespacedResources(ctx, gvr, namespace, labelSelector) default: return mcp.NewToolResultError(fmt.Sprintf("Invalid resource_type: %s", resourceType)), nil }