diff --git a/.github/workflows/ci.yml b/.github/workflows/build.yml similarity index 57% rename from .github/workflows/ci.yml rename to .github/workflows/build.yml index 7da8926..a97ff58 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/build.yml @@ -1,39 +1,22 @@ -name: CI +name: Build artifacts on: - push: - branches: [ main ] - pull_request: - branches: [ main ] + workflow_call: -jobs: - lint: - name: Lint - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 +permissions: + contents: read - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version-file: 'go.mod' - cache: true - - - name: golangci-lint - uses: golangci/golangci-lint-action@v8 - with: - version: latest +jobs: build: name: Build and Test runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5 with: go-version-file: 'go.mod' cache: true diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..50ff9c2 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,25 @@ +name: Linting + +on: + workflow_call: + +permissions: + contents: read + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + + - name: Set up Go + uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5 + with: + go-version-file: 'go.mod' + cache: true + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0 + with: + args: --timeout=5m diff --git a/.github/workflows/run-on-main.yml b/.github/workflows/run-on-main.yml new file mode 100644 index 0000000..d12c2cb --- /dev/null +++ b/.github/workflows/run-on-main.yml @@ -0,0 +1,20 @@ +# These set of workflows run on every push to the main branch +name: Main build +permissions: + contents: read + +on: + workflow_dispatch: + push: + branches: [ main ] + +jobs: + linting: + name: Linting + uses: ./.github/workflows/lint.yml + tests: + name: Tests + uses: ./.github/workflows/test.yml + build: + name: Build + uses: ./.github/workflows/build.yml diff --git a/.github/workflows/run-on-pr.yml b/.github/workflows/run-on-pr.yml new file mode 100644 index 0000000..719bf1e --- /dev/null +++ b/.github/workflows/run-on-pr.yml @@ -0,0 +1,16 @@ +# These set of workflows run on every push to the main branch +name: PR Checks +permissions: + contents: read + +on: + workflow_dispatch: + pull_request: + +jobs: + linting: + name: Linting + uses: ./.github/workflows/lint.yml + tests: + name: Tests + uses: ./.github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..43c546b --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,29 @@ +name: Tests + +on: + workflow_call: + +permissions: + contents: read + +jobs: + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + + - name: Set up Go + uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5 + with: + go-version-file: 'go.mod' + cache: true + + - name: Install Task + uses: arduino/setup-task@v2 + with: + version: '3.x' + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Test + run: task test \ No newline at end of file diff --git a/.gitignore b/.gitignore index 3668d1a..4e05646 100644 --- a/.gitignore +++ b/.gitignore @@ -32,4 +32,5 @@ Thumbs.db # Kubeconfig files kubeconfig -.kubeconfig \ No newline at end of file +.kubeconfig +**/.claude/settings.local.json diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..5aaba55 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,106 @@ +version: "2" +run: + issues-exit-code: 1 +output: + formats: + text: + path: stdout + print-linter-name: true + print-issued-lines: true +linters: + default: none + enable: + - depguard + - exhaustive + - goconst + - gocyclo + - gosec + - govet + - ineffassign + - lll + - paralleltest + - promlinter + - revive + - staticcheck + - thelper + - tparallel + - unparam + - unused + settings: + depguard: + rules: + prevent_unmaintained_packages: + list-mode: lax + files: + - $all + - '!$test' + deny: + - pkg: io/ioutil + desc: this is deprecated + gocyclo: + min-complexity: 15 + gosec: + excludes: + - G601 + lll: + line-length: 130 + revive: + severity: warning + rules: + - name: blank-imports + severity: warning + - name: context-as-argument + - name: context-keys-type + - name: duplicated-imports + - name: error-naming + - name: error-return + - name: exported + severity: error + - name: if-return + - name: identical-branches + - name: indent-error-flow + - name: import-shadowing + - name: package-comments + - name: redefines-builtin-id + - name: struct-tag + - name: unconditional-recursion + - name: unnecessary-stmt + - name: unreachable-code + - name: unused-parameter + - name: unused-receiver + - name: unhandled-error + disabled: true + exclusions: + generated: lax + rules: + - linters: + - lll + - gocyclo + - errcheck + - dupl + - gosec + - paralleltest + path: (.+)_test\.go + - linters: + - lll + path: .golangci.yml + paths: + - third_party$ + - builtin$ + - examples$ +formatters: + enable: + - gci + - gofmt + settings: + gci: + sections: + - standard + - default + - prefix(github.com/StacklokLabs/mkp) + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/Taskfile.yml b/Taskfile.yml index 39e4734..2246295 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -22,6 +22,17 @@ tasks: cmds: - ./{{.BUILD_DIR}}/{{.BINARY_NAME}} + lint: + desc: Run linting tools + cmds: + - golangci-lint run ./... + - go vet ./... + + lint-fix: + desc: Run linting tools, and apply fixes. + cmds: + - golangci-lint run --fix ./... + test: desc: Run tests cmds: diff --git a/cmd/server/main.go b/cmd/server/main.go index 71b5fe4..2544fd5 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -1,3 +1,4 @@ +// Package main provides the entry point for the mkp server application package main import ( @@ -17,9 +18,12 @@ func main() { // Parse command line flags kubeconfig := flag.String("kubeconfig", "", "Path to kubeconfig file. If not provided, in-cluster config will be used") addr := flag.String("addr", ":8080", "Address to listen on") - serveResources := flag.Bool("serve-resources", false, "Whether to serve cluster resources as MCP resources. Setting to false can reduce context size for LLMs when working with large clusters") - readWrite := flag.Bool("read-write", false, "Whether to allow write operations on the cluster. When false, the server operates in read-only mode") - kubeconfigRefreshInterval := flag.Duration("kubeconfig-refresh-interval", 0, "Interval to periodically re-read the kubeconfig (e.g., 5m for 5 minutes). If 0, no refresh will be performed") + serveResources := flag.Bool("serve-resources", false, + "Whether to serve cluster resources as MCP resources. Setting to false reduces context size for LLMs with large clusters") + readWrite := flag.Bool("read-write", false, + "Whether to allow write operations on the cluster. When false, the server operates in read-only mode") + kubeconfigRefreshInterval := flag.Duration("kubeconfig-refresh-interval", 0, + "Interval to periodically re-read the kubeconfig (e.g., 5m for 5 minutes). If 0, no refresh will be performed") flag.Parse() // Create a context that can be cancelled @@ -40,7 +44,7 @@ func main() { if err != nil { log.Fatalf("Failed to create Kubernetes client: %v", err) } - + // Start periodic refresh if interval is set if *kubeconfigRefreshInterval > 0 { log.Printf("Starting periodic kubeconfig refresh every %v", *kubeconfigRefreshInterval) @@ -66,10 +70,10 @@ func main() { // Create SSE server sseServer := mcp.CreateSSEServer(mcpServer) - + // Channel to receive server errors serverErrCh := make(chan error, 1) - + // Start the server in a goroutine go func() { log.Printf("Starting MCP server on %s", *addr) @@ -78,7 +82,7 @@ func main() { serverErrCh <- err } }() - + // Wait for either a server error or a shutdown signal select { case err := <-serverErrCh: @@ -86,11 +90,11 @@ func main() { case <-ctx.Done(): log.Println("Shutting down server...") } - + // Create a context with timeout for shutdown shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) defer shutdownCancel() - + // Attempt to shut down the server gracefully shutdownCh := make(chan error, 1) go func() { @@ -102,7 +106,7 @@ func main() { shutdownCh <- err close(shutdownCh) }() - + // Wait for shutdown to complete or timeout select { case err, ok := <-shutdownCh: @@ -118,7 +122,7 @@ func main() { // Force exit after timeout os.Exit(1) } - + log.Println("Server shutdown complete, exiting...") // Ensure we exit the program os.Exit(0) diff --git a/pkg/k8s/client.go b/pkg/k8s/client.go index 666895f..924e351 100644 --- a/pkg/k8s/client.go +++ b/pkg/k8s/client.go @@ -1,3 +1,4 @@ +// Package k8s provides Kubernetes client functionality package k8s import ( @@ -18,8 +19,12 @@ import ( "k8s.io/client-go/util/homedir" ) -// PodLogsFunc is a function type for getting pod logs -type PodLogsFunc func(ctx context.Context, namespace, name string, parameters map[string]string) (*unstructured.Unstructured, error) +// PodLogsFunc is a function type for retrieving pod logs +type PodLogsFunc func( + ctx context.Context, + namespace, name string, + parameters map[string]string, +) (*unstructured.Unstructured, error) // Client represents a Kubernetes client with discovery and dynamic capabilities type Client struct { @@ -102,7 +107,7 @@ func defaultConfigGetter(kubeconfigPath string) (*rest.Config, error) { var getConfig ConfigGetter = defaultConfigGetter // ListAPIResources returns all API resources supported by the Kubernetes API server -func (c *Client) ListAPIResources(ctx context.Context) ([]*metav1.APIResourceList, error) { +func (c *Client) ListAPIResources(_ context.Context) ([]*metav1.APIResourceList, error) { c.mu.RLock() defer c.mu.RUnlock() @@ -114,7 +119,11 @@ 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, labelSelector string) (*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() @@ -124,7 +133,12 @@ func (c *Client) ListClusteredResources(ctx context.Context, gvr schema.GroupVer } // 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, labelSelector 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() @@ -134,7 +148,11 @@ func (c *Client) ListNamespacedResources(ctx context.Context, gvr schema.GroupVe } // ApplyClusteredResource creates or updates a clustered resource -func (c *Client) ApplyClusteredResource(ctx context.Context, gvr schema.GroupVersionResource, obj *unstructured.Unstructured) (*unstructured.Unstructured, error) { +func (c *Client) ApplyClusteredResource( + ctx context.Context, + gvr schema.GroupVersionResource, + obj *unstructured.Unstructured, +) (*unstructured.Unstructured, error) { c.mu.RLock() defer c.mu.RUnlock() @@ -162,7 +180,11 @@ func (c *Client) GetClusteredResource(ctx context.Context, gvr schema.GroupVersi } // GetNamespacedResource gets a namespaced resource -func (c *Client) GetNamespacedResource(ctx context.Context, gvr schema.GroupVersionResource, namespace, name string) (interface{}, error) { +func (c *Client) GetNamespacedResource( + ctx context.Context, + gvr schema.GroupVersionResource, + namespace, name string, +) (interface{}, error) { c.mu.RLock() defer c.mu.RUnlock() @@ -170,7 +192,12 @@ func (c *Client) GetNamespacedResource(ctx context.Context, gvr schema.GroupVers } // ApplyNamespacedResource creates or updates a namespaced resource -func (c *Client) ApplyNamespacedResource(ctx context.Context, gvr schema.GroupVersionResource, namespace string, obj *unstructured.Unstructured) (*unstructured.Unstructured, error) { +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() diff --git a/pkg/k8s/client_test.go b/pkg/k8s/client_test.go index de381a8..a7b3d70 100644 --- a/pkg/k8s/client_test.go +++ b/pkg/k8s/client_test.go @@ -40,7 +40,7 @@ func TestListClusteredResources(t *testing.T) { } // Add a fake list response - client.PrependReactor("list", "clusterroles", func(action ktesting.Action) (handled bool, ret runtime.Object, err error) { + client.PrependReactor("list", "clusterroles", func(_ ktesting.Action) (handled bool, ret runtime.Object, err error) { list := &unstructured.UnstructuredList{ Items: []unstructured.Unstructured{ { @@ -129,7 +129,7 @@ func TestListNamespacedResources(t *testing.T) { } // Add a fake list response - client.PrependReactor("list", "services", func(action ktesting.Action) (handled bool, ret runtime.Object, err error) { + client.PrependReactor("list", "services", func(_ ktesting.Action) (handled bool, ret runtime.Object, err error) { list := &unstructured.UnstructuredList{ Items: []unstructured.Unstructured{ { @@ -234,12 +234,12 @@ func TestApplyClusteredResource(t *testing.T) { } // Add a fake get response (resource not found) - client.PrependReactor("get", "clusterroles", func(action ktesting.Action) (handled bool, ret runtime.Object, err error) { + client.PrependReactor("get", "clusterroles", func(_ ktesting.Action) (handled bool, ret runtime.Object, err error) { return true, nil, fmt.Errorf("not found: clusterroles \"test-cluster-role\" not found") }) // Add a fake create response - client.PrependReactor("create", "clusterroles", func(action ktesting.Action) (handled bool, ret runtime.Object, err error) { + client.PrependReactor("create", "clusterroles", func(_ ktesting.Action) (handled bool, ret runtime.Object, err error) { return true, obj, nil }) @@ -293,12 +293,12 @@ func TestApplyNamespacedResource(t *testing.T) { } // Add a fake get response (resource not found) - client.PrependReactor("get", "services", func(action ktesting.Action) (handled bool, ret runtime.Object, err error) { + client.PrependReactor("get", "services", func(_ ktesting.Action) (handled bool, ret runtime.Object, err error) { return true, nil, fmt.Errorf("not found: services \"test-service\" not found") }) // Add a fake create response - client.PrependReactor("create", "services", func(action ktesting.Action) (handled bool, ret runtime.Object, err error) { + client.PrependReactor("create", "services", func(_ ktesting.Action) (handled bool, ret runtime.Object, err error) { return true, obj, nil }) @@ -349,7 +349,7 @@ func TestGetClusteredResource(t *testing.T) { } // Add a fake get response - client.PrependReactor("get", "clusterroles", func(action ktesting.Action) (handled bool, ret runtime.Object, err error) { + client.PrependReactor("get", "clusterroles", func(_ ktesting.Action) (handled bool, ret runtime.Object, err error) { return true, obj, nil }) @@ -404,7 +404,7 @@ func TestGetNamespacedResource(t *testing.T) { } // Add a fake get response - client.PrependReactor("get", "services", func(action ktesting.Action) (handled bool, ret runtime.Object, err error) { + client.PrependReactor("get", "services", func(_ ktesting.Action) (handled bool, ret runtime.Object, err error) { return true, obj, nil }) diff --git a/pkg/k8s/refresh_test.go b/pkg/k8s/refresh_test.go index a61ae34..73d5fd5 100644 --- a/pkg/k8s/refresh_test.go +++ b/pkg/k8s/refresh_test.go @@ -16,7 +16,7 @@ import ( ) // mockGetConfig is a mock function for getConfig -func mockGetConfig(kubeconfigPath string) (*rest.Config, error) { +func mockGetConfig(_ string) (*rest.Config, error) { return &rest.Config{ Host: "https://mock-server", }, nil @@ -121,7 +121,7 @@ func TestPeriodicRefreshActuallyRefreshes(t *testing.T) { // Create a counter to track how many times getConfig is called refreshCount := 0 - getConfig = func(kubeconfigPath string) (*rest.Config, error) { + getConfig = func(_ string) (*rest.Config, error) { refreshCount++ return &rest.Config{ Host: "https://mock-server", @@ -186,4 +186,4 @@ func TestRefreshClientWithRealClients(t *testing.T) { ctx := context.Background() _, err = client.ListAPIResources(ctx) assert.NoError(t, err, "ListAPIResources should not return an error after refresh") -} \ No newline at end of file +} diff --git a/pkg/k8s/subresource.go b/pkg/k8s/subresource.go index 8df1d8c..3424286 100644 --- a/pkg/k8s/subresource.go +++ b/pkg/k8s/subresource.go @@ -17,7 +17,10 @@ import ( // GetResource gets a resource or its subresource // If subresource is empty, the main resource is returned // parameters is a map of string key-value pairs that can be used to customize the request -func (c *Client) GetResource(ctx context.Context, gvr schema.GroupVersionResource, namespace, name, subresource string, parameters map[string]string) (*unstructured.Unstructured, error) { +func (c *Client) GetResource(ctx context.Context, + gvr schema.GroupVersionResource, + namespace, name, subresource string, + parameters map[string]string) (*unstructured.Unstructured, error) { if name == "" { return nil, fmt.Errorf("resource name cannot be empty") } @@ -32,7 +35,7 @@ func (c *Client) GetResource(ctx context.Context, gvr schema.GroupVersionResourc // Create GetOptions with parameters getOptions := metav1.GetOptions{} - + // Apply parameters to GetOptions if parameters != nil { // ResourceVersion - when specified with a watch call, shows changes that occur after that particular version of a resource @@ -69,75 +72,32 @@ func (c *Client) GetResource(ctx context.Context, gvr schema.GroupVersionResourc } // defaultGetPodLogs retrieves logs from a pod and returns them as an unstructured object -func (c *Client) defaultGetPodLogs(ctx context.Context, namespace, name string, parameters map[string]string) (*unstructured.Unstructured, error) { +func (c *Client) defaultGetPodLogs( + ctx context.Context, + namespace, name string, + parameters map[string]string) (*unstructured.Unstructured, error) { // We need to use the CoreV1 client for logs, as the dynamic client doesn't handle logs properly - + // Set reasonable defaults for LLM context window // Default to last 100 lines and 32KB limit to avoid overwhelming the LLM context defaultTailLines := int64(100) defaultLimitBytes := int64(32 * 1024) // 32KB - + podLogOpts := corev1.PodLogOptions{ - TailLines: &defaultTailLines, + TailLines: &defaultTailLines, LimitBytes: &defaultLimitBytes, } - + // Apply parameters to PodLogOptions // Note we don't follow nor tail the logs since we are not using a watcher, // this is an MCP tool call after all. if parameters != nil { - // Container name - if container, ok := parameters["container"]; ok { - podLogOpts.Container = container - } - - // Previous container logs - if previous, ok := parameters["previous"]; ok { - previousBool, _ := strconv.ParseBool(previous) - podLogOpts.Previous = previousBool - } - - // Since seconds (overrides default tail lines) - if sinceSeconds, ok := parameters["sinceSeconds"]; ok { - if seconds, err := strconv.ParseInt(sinceSeconds, 10, 64); err == nil { - podLogOpts.SinceSeconds = &seconds - // If sinceSeconds is specified, don't use tail lines - podLogOpts.TailLines = nil - } - } - - // Since time - if sinceTime, ok := parameters["sinceTime"]; ok { - if t, err := time.Parse(time.RFC3339, sinceTime); err == nil { - metaTime := metav1.NewTime(t) - podLogOpts.SinceTime = &metaTime - } - } - - // Timestamps - if timestamps, ok := parameters["timestamps"]; ok { - timestampsBool, _ := strconv.ParseBool(timestamps) - podLogOpts.Timestamps = timestampsBool - } - - // Limit bytes (overrides default limit) - if limitBytes, ok := parameters["limitBytes"]; ok { - if bytes, err := strconv.ParseInt(limitBytes, 10, 64); err == nil { - podLogOpts.LimitBytes = &bytes - } - } - - // Tail lines (overrides default tail lines) - if tailLines, ok := parameters["tailLines"]; ok { - if lines, err := strconv.ParseInt(tailLines, 10, 64); err == nil { - podLogOpts.TailLines = &lines - } - } + podLogOpts = buildPodLogOpts(&podLogOpts, parameters) } - + // Get the REST client for pods req := c.clientset.CoreV1().Pods(namespace).GetLogs(name, &podLogOpts) - + // Execute the request podLogs, err := req.Stream(ctx) if err != nil { @@ -171,4 +131,55 @@ func (c *Client) defaultGetPodLogs(ctx context.Context, namespace, name string, } return result, nil -} \ No newline at end of file +} + +func buildPodLogOpts(podLogOpts *corev1.PodLogOptions, parameters map[string]string) corev1.PodLogOptions { + if container, ok := parameters["container"]; ok { + podLogOpts.Container = container + } + + // Previous container logs + if previous, ok := parameters["previous"]; ok { + previousBool, _ := strconv.ParseBool(previous) + podLogOpts.Previous = previousBool + } + + // Since seconds (overrides default tail lines) + if sinceSeconds, ok := parameters["sinceSeconds"]; ok { + if seconds, err := strconv.ParseInt(sinceSeconds, 10, 64); err == nil { + podLogOpts.SinceSeconds = &seconds + // If sinceSeconds is specified, don't use tail lines + podLogOpts.TailLines = nil + } + } + + // Since time + if sinceTime, ok := parameters["sinceTime"]; ok { + if t, err := time.Parse(time.RFC3339, sinceTime); err == nil { + metaTime := metav1.NewTime(t) + podLogOpts.SinceTime = &metaTime + } + } + + // Timestamps + if timestamps, ok := parameters["timestamps"]; ok { + timestampsBool, _ := strconv.ParseBool(timestamps) + podLogOpts.Timestamps = timestampsBool + } + + // Limit bytes (overrides default limit) + if limitBytes, ok := parameters["limitBytes"]; ok { + if b, err := strconv.ParseInt(limitBytes, 10, 64); err == nil { + podLogOpts.LimitBytes = &b + } + } + + // Tail lines (overrides default tail lines) + if tailLines, ok := parameters["tailLines"]; ok { + if lines, err := strconv.ParseInt(tailLines, 10, 64); err == nil { + podLogOpts.TailLines = &lines + } + } + + return *podLogOpts +} diff --git a/pkg/k8s/subresource_test.go b/pkg/k8s/subresource_test.go index a32cee9..da33e0a 100644 --- a/pkg/k8s/subresource_test.go +++ b/pkg/k8s/subresource_test.go @@ -20,7 +20,7 @@ func TestGetResource(t *testing.T) { // Create test resources deploymentGVR := schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"} - + // Create a clustered deployment clusteredDeployment := &unstructured.Unstructured{ Object: map[string]interface{}{ @@ -34,7 +34,7 @@ func TestGetResource(t *testing.T) { }, }, } - + // Create a namespaced deployment namespacedDeployment := &unstructured.Unstructured{ Object: map[string]interface{}{ @@ -49,24 +49,24 @@ func TestGetResource(t *testing.T) { }, }, } - + // Add the resources to the fake client _, err := fakeDynamic.Resource(deploymentGVR).Create(context.Background(), clusteredDeployment, metav1.CreateOptions{}) assert.NoError(t, err) - + _, err = fakeDynamic.Resource(deploymentGVR).Namespace("default").Create(context.Background(), namespacedDeployment, metav1.CreateOptions{}) assert.NoError(t, err) - + // Create a test client client := &Client{} client.SetDynamicClient(fakeDynamic) - + // Create a fake clientset for pod logs test fakeClientset := kubefake.NewSimpleClientset() client.SetClientset(fakeClientset) // Mock the getPodLogs method - getPodLogsMock := func(ctx context.Context, namespace, name string, parameters map[string]string) (*unstructured.Unstructured, error) { + getPodLogsMock := func(_ context.Context, namespace, name string, _ map[string]string) (*unstructured.Unstructured, error) { return &unstructured.Unstructured{ Object: map[string]interface{}{ "apiVersion": "v1", @@ -79,13 +79,13 @@ func TestGetResource(t *testing.T) { }, }, nil } - + // Store the original implementation originalGetPodLogs := client.getPodLogs - + // Replace with our mock client.getPodLogs = getPodLogsMock - + // Restore the original after the test defer func() { client.getPodLogs = originalGetPodLogs @@ -147,14 +147,14 @@ func TestGetResource(t *testing.T) { parameters: map[string]string{"resourceVersion": "12345"}, }, } - + // Run test cases for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { // Call the method ctx := context.Background() result, err := client.GetResource(ctx, tc.gvr, tc.namespace, tc.resourceName, tc.subresource, tc.parameters) - + // Assert expectations if tc.expectError { assert.Error(t, err) @@ -164,15 +164,15 @@ func TestGetResource(t *testing.T) { } else { assert.NoError(t, err) assert.NotNil(t, result) - + // Verify the resource name assert.Equal(t, tc.resourceName, result.GetName()) - + // Verify the namespace if applicable if tc.namespace != "" { assert.Equal(t, tc.namespace, result.GetNamespace()) } - + // Check for logs if this is a pod logs test if tc.checkLogs { logs, found, err := unstructured.NestedString(result.Object, "logs") @@ -183,7 +183,7 @@ func TestGetResource(t *testing.T) { } }) } - + // Note: The fake client doesn't fully support subresources in the same way as the real client, // so we're not testing subresource functionality here. In a real environment, the subresource // parameter would be passed to the Get method and the appropriate subresource would be returned. @@ -192,9 +192,9 @@ func TestGetResource(t *testing.T) { func TestGetPodLogs(t *testing.T) { // Create a test client client := &Client{} - + // Create a mock implementation of getPodLogs - mockGetPodLogs := func(ctx context.Context, namespace, name string, parameters map[string]string) (*unstructured.Unstructured, error) { + mockGetPodLogs := func(_ context.Context, namespace, name string, _ map[string]string) (*unstructured.Unstructured, error) { return &unstructured.Unstructured{ Object: map[string]interface{}{ "apiVersion": "v1", @@ -207,24 +207,24 @@ func TestGetPodLogs(t *testing.T) { }, }, nil } - + // Set the mock implementation client.getPodLogs = mockGetPodLogs - + // Call the method through the GetResource method ctx := context.Background() podGVR := schema.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"} result, err := client.GetResource(ctx, podGVR, "default", "test-pod", "logs", nil) - + // Assert expectations assert.NoError(t, err) assert.NotNil(t, result) assert.Equal(t, "test-pod", result.GetName()) assert.Equal(t, "default", result.GetNamespace()) - + // Verify the logs field logs, found, err := unstructured.NestedString(result.Object, "logs") assert.NoError(t, err) assert.True(t, found) assert.Equal(t, "test log output", logs) -} \ No newline at end of file +} diff --git a/pkg/mcp/apply_resource.go b/pkg/mcp/apply_resource.go index f6a6a88..1915ea7 100644 --- a/pkg/mcp/apply_resource.go +++ b/pkg/mcp/apply_resource.go @@ -1,3 +1,4 @@ +// Package mcp implements the Model, Channel, Prompt (MCP) protocol services package mcp import ( @@ -30,7 +31,7 @@ func (m *Implementation) HandleApplyResource(ctx context.Context, request mcp.Ca if resource == "" { return mcp.NewToolResultError("resource is required"), nil } - if resourceType == "namespaced" && namespace == "" { + if resourceType == ResourceTypeNamespaced && namespace == "" { return mcp.NewToolResultError("namespace is required for namespaced resources"), nil } if manifestMap == nil { @@ -51,9 +52,9 @@ func (m *Implementation) HandleApplyResource(ctx context.Context, request mcp.Ca var result *unstructured.Unstructured var err error switch resourceType { - case "clustered": + case ResourceTypeClustered: result, err = m.k8sClient.ApplyClusteredResource(ctx, gvr, obj) - case "namespaced": + case ResourceTypeNamespaced: result, err = m.k8sClient.ApplyNamespacedResource(ctx, gvr, namespace, obj) default: return mcp.NewToolResultError(fmt.Sprintf("Invalid resource_type: %s", resourceType)), nil diff --git a/pkg/mcp/apply_resource_test.go b/pkg/mcp/apply_resource_test.go index 7f1e69b..7c1919c 100644 --- a/pkg/mcp/apply_resource_test.go +++ b/pkg/mcp/apply_resource_test.go @@ -18,11 +18,11 @@ import ( func TestHandleApplyResourceClusteredSuccess(t *testing.T) { // Create a mock k8s client mockClient := &k8s.Client{} - + // Create a fake dynamic client scheme := runtime.NewScheme() fakeDynamicClient := fake.NewSimpleDynamicClient(scheme) - + // Create a test resource obj := &unstructured.Unstructured{ Object: map[string]interface{}{ @@ -40,26 +40,26 @@ func TestHandleApplyResourceClusteredSuccess(t *testing.T) { }, }, } - + // Add a fake get response (resource not found) - fakeDynamicClient.PrependReactor("get", "clusterroles", func(action ktesting.Action) (handled bool, ret runtime.Object, err error) { + fakeDynamicClient.PrependReactor("get", "clusterroles", func(_ ktesting.Action) (handled bool, ret runtime.Object, err error) { return true, nil, fmt.Errorf("not found: clusterroles \"test-cluster-role\" not found") }) - + // Add a fake create response - fakeDynamicClient.PrependReactor("create", "clusterroles", func(action ktesting.Action) (handled bool, ret runtime.Object, err error) { + fakeDynamicClient.PrependReactor("create", "clusterroles", func(_ ktesting.Action) (handled bool, ret runtime.Object, err error) { return true, obj, nil }) - + // Set the dynamic client mockClient.SetDynamicClient(fakeDynamicClient) - + // Create an implementation impl := NewImplementation(mockClient) - + // Create a test request request := mcp.CallToolRequest{} - request.Params.Name = "apply_resource" + request.Params.Name = ApplyResourceToolName request.Params.Arguments = map[string]interface{}{ "resource_type": "clustered", "group": "rbac.authorization.k8s.io", @@ -80,20 +80,20 @@ func TestHandleApplyResourceClusteredSuccess(t *testing.T) { }, }, } - + // Test HandleApplyResource ctx := context.Background() result, err := impl.HandleApplyResource(ctx, request) - + // Verify there was no error assert.NoError(t, err, "HandleApplyResource should not return an error") - + // Verify the result is not nil assert.NotNil(t, result, "Result should not be nil") - + // Verify the result is successful assert.False(t, result.IsError, "Result should not be an error") - + // Verify the result contains the resource name textContent, ok := mcp.AsTextContent(result.Content[0]) assert.True(t, ok, "Content should be TextContent") @@ -103,11 +103,11 @@ func TestHandleApplyResourceClusteredSuccess(t *testing.T) { func TestHandleApplyResourceNamespacedSuccess(t *testing.T) { // Create a mock k8s client mockClient := &k8s.Client{} - + // Create a fake dynamic client scheme := runtime.NewScheme() fakeDynamicClient := fake.NewSimpleDynamicClient(scheme) - + // Create a test resource obj := &unstructured.Unstructured{ Object: map[string]interface{}{ @@ -127,28 +127,28 @@ func TestHandleApplyResourceNamespacedSuccess(t *testing.T) { }, }, } - + // Add a fake get response (resource not found) - fakeDynamicClient.PrependReactor("get", "services", func(action ktesting.Action) (handled bool, ret runtime.Object, err error) { + fakeDynamicClient.PrependReactor("get", "services", func(_ ktesting.Action) (handled bool, ret runtime.Object, err error) { return true, nil, fmt.Errorf("not found: services \"test-service\" not found") }) - + // Add a fake create response - fakeDynamicClient.PrependReactor("create", "services", func(action ktesting.Action) (handled bool, ret runtime.Object, err error) { + fakeDynamicClient.PrependReactor("create", "services", func(_ ktesting.Action) (handled bool, ret runtime.Object, err error) { return true, obj, nil }) - + // Set the dynamic client mockClient.SetDynamicClient(fakeDynamicClient) - + // Create an implementation impl := NewImplementation(mockClient) - + // Create a test request request := mcp.CallToolRequest{} - request.Params.Name = "apply_resource" + request.Params.Name = ApplyResourceToolName request.Params.Arguments = map[string]interface{}{ - "resource_type": "namespaced", + "resource_type": ResourceTypeNamespaced, "group": "", "version": "v1", "resource": "services", @@ -170,20 +170,20 @@ func TestHandleApplyResourceNamespacedSuccess(t *testing.T) { }, }, } - + // Test HandleApplyResource ctx := context.Background() result, err := impl.HandleApplyResource(ctx, request) - + // Verify there was no error assert.NoError(t, err, "HandleApplyResource should not return an error") - + // Verify the result is not nil assert.NotNil(t, result, "Result should not be nil") - + // Verify the result is successful assert.False(t, result.IsError, "Result should not be an error") - + // Verify the result contains the resource name textContent, ok := mcp.AsTextContent(result.Content[0]) assert.True(t, ok, "Content should be TextContent") @@ -193,10 +193,10 @@ func TestHandleApplyResourceNamespacedSuccess(t *testing.T) { func TestHandleApplyResourceMissingParameters(t *testing.T) { // Create a mock k8s client mockClient := &k8s.Client{} - + // Create an implementation impl := NewImplementation(mockClient) - + // Test cases for missing parameters testCases := []struct { name string @@ -204,7 +204,7 @@ func TestHandleApplyResourceMissingParameters(t *testing.T) { errorMsg string }{ { - name: "Missing resource_type", + name: "Missing resource_type", arguments: map[string]interface{}{ "group": "apps", "version": "v1", @@ -214,7 +214,7 @@ func TestHandleApplyResourceMissingParameters(t *testing.T) { errorMsg: "resource_type is required", }, { - name: "Missing version", + name: "Missing version", arguments: map[string]interface{}{ "resource_type": "clustered", "group": "apps", @@ -224,7 +224,7 @@ func TestHandleApplyResourceMissingParameters(t *testing.T) { errorMsg: "version is required", }, { - name: "Missing resource", + name: "Missing resource", arguments: map[string]interface{}{ "resource_type": "clustered", "group": "apps", @@ -234,9 +234,9 @@ func TestHandleApplyResourceMissingParameters(t *testing.T) { errorMsg: "resource is required", }, { - name: "Missing namespace for namespaced resource", + name: "Missing namespace for namespaced resource", arguments: map[string]interface{}{ - "resource_type": "namespaced", + "resource_type": ResourceTypeNamespaced, "group": "apps", "version": "v1", "resource": "deployments", @@ -245,7 +245,7 @@ func TestHandleApplyResourceMissingParameters(t *testing.T) { errorMsg: "namespace is required for namespaced resources", }, { - name: "Missing manifest", + name: "Missing manifest", arguments: map[string]interface{}{ "resource_type": "clustered", "group": "apps", @@ -255,27 +255,27 @@ func TestHandleApplyResourceMissingParameters(t *testing.T) { errorMsg: "manifest is required", }, } - + for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { // Create a test request request := mcp.CallToolRequest{} - request.Params.Name = "apply_resource" + request.Params.Name = ApplyResourceToolName request.Params.Arguments = tc.arguments - + // Test HandleApplyResource ctx := context.Background() result, err := impl.HandleApplyResource(ctx, request) - + // Verify there was no error assert.NoError(t, err, "HandleApplyResource should not return an error") - + // Verify the result is not nil assert.NotNil(t, result, "Result should not be nil") - + // Verify the result is an error assert.True(t, result.IsError, "Result should be an error") - + // Verify the error message textContent, ok := mcp.AsTextContent(result.Content[0]) assert.True(t, ok, "Content should be TextContent") @@ -287,13 +287,13 @@ func TestHandleApplyResourceMissingParameters(t *testing.T) { func TestHandleApplyResourceInvalidResourceType(t *testing.T) { // Create a mock k8s client mockClient := &k8s.Client{} - + // Create an implementation impl := NewImplementation(mockClient) - + // Create a test request with invalid resource_type request := mcp.CallToolRequest{} - request.Params.Name = "apply_resource" + request.Params.Name = ApplyResourceToolName request.Params.Arguments = map[string]interface{}{ "resource_type": "invalid", "group": "apps", @@ -301,20 +301,20 @@ func TestHandleApplyResourceInvalidResourceType(t *testing.T) { "resource": "deployments", "manifest": map[string]interface{}{}, } - + // Test HandleApplyResource ctx := context.Background() result, err := impl.HandleApplyResource(ctx, request) - + // Verify there was no error assert.NoError(t, err, "HandleApplyResource should not return an error") - + // Verify the result is not nil assert.NotNil(t, result, "Result should not be nil") - + // Verify the result is an error assert.True(t, result.IsError, "Result should be an error") - + // Verify the error message textContent, ok := mcp.AsTextContent(result.Content[0]) assert.True(t, ok, "Content should be TextContent") @@ -324,30 +324,30 @@ func TestHandleApplyResourceInvalidResourceType(t *testing.T) { func TestHandleApplyResourceApplyError(t *testing.T) { // Create a mock k8s client mockClient := &k8s.Client{} - + // Create a fake dynamic client scheme := runtime.NewScheme() fakeDynamicClient := fake.NewSimpleDynamicClient(scheme) - + // Add a fake get response (resource not found) - fakeDynamicClient.PrependReactor("get", "clusterroles", func(action ktesting.Action) (handled bool, ret runtime.Object, err error) { + fakeDynamicClient.PrependReactor("get", "clusterroles", func(_ ktesting.Action) (handled bool, ret runtime.Object, err error) { return true, nil, fmt.Errorf("not found: clusterroles \"test-cluster-role\" not found") }) - + // Add a fake create response with error - fakeDynamicClient.PrependReactor("create", "clusterroles", func(action ktesting.Action) (handled bool, ret runtime.Object, err error) { + fakeDynamicClient.PrependReactor("create", "clusterroles", func(_ ktesting.Action) (handled bool, ret runtime.Object, err error) { return true, nil, fmt.Errorf("failed to create resource") }) - + // Set the dynamic client mockClient.SetDynamicClient(fakeDynamicClient) - + // Create an implementation impl := NewImplementation(mockClient) - + // Create a test request request := mcp.CallToolRequest{} - request.Params.Name = "apply_resource" + request.Params.Name = ApplyResourceToolName request.Params.Arguments = map[string]interface{}{ "resource_type": "clustered", "group": "rbac.authorization.k8s.io", @@ -361,22 +361,22 @@ func TestHandleApplyResourceApplyError(t *testing.T) { }, }, } - + // Test HandleApplyResource ctx := context.Background() result, err := impl.HandleApplyResource(ctx, request) - + // Verify there was no error assert.NoError(t, err, "HandleApplyResource should not return an error") - + // Verify the result is not nil assert.NotNil(t, result, "Result should not be nil") - + // Verify the result is an error assert.True(t, result.IsError, "Result should be an error") - + // Verify the error message textContent, ok := mcp.AsTextContent(result.Content[0]) assert.True(t, ok, "Content should be TextContent") assert.Contains(t, textContent.Text, "Failed to apply resource", "Error message should contain 'Failed to apply resource'") -} \ No newline at end of file +} diff --git a/pkg/mcp/delete_resource.go b/pkg/mcp/delete_resource.go index 8e0bbec..fe6c266 100644 --- a/pkg/mcp/delete_resource.go +++ b/pkg/mcp/delete_resource.go @@ -1,3 +1,4 @@ +// Package mcp implements the Model, Channel, Prompt (MCP) protocol services package mcp import ( @@ -31,13 +32,13 @@ func (m *Implementation) HandleDeleteResource(ctx context.Context, request mcp.C if name == "" { return mcp.NewToolResultError("name is required"), nil } - if resourceType == "namespaced" && namespace == "" { + if resourceType == ResourceTypeNamespaced && namespace == "" { return mcp.NewToolResultError("namespace is required for namespaced resources"), nil } // Create GVR // Validate resource_type - if resourceType != "clustered" && resourceType != "namespaced" { + if resourceType != ResourceTypeClustered && resourceType != ResourceTypeNamespaced { return mcp.NewToolResultError("Invalid resource_type: " + resourceType), nil } @@ -50,9 +51,9 @@ func (m *Implementation) HandleDeleteResource(ctx context.Context, request mcp.C // Delete resource var err error switch resourceType { - case "clustered": + case ResourceTypeClustered: err = m.k8sClient.DeleteClusteredResource(ctx, gvr, name) - case "namespaced": + case ResourceTypeNamespaced: err = m.k8sClient.DeleteNamespacedResource(ctx, gvr, namespace, name) default: return mcp.NewToolResultError(fmt.Sprintf("Invalid resource_type: %s", resourceType)), nil diff --git a/pkg/mcp/delete_resource_test.go b/pkg/mcp/delete_resource_test.go index b95d864..bee677b 100644 --- a/pkg/mcp/delete_resource_test.go +++ b/pkg/mcp/delete_resource_test.go @@ -17,25 +17,25 @@ import ( func TestHandleDeleteResourceClusteredSuccess(t *testing.T) { // Create a mock k8s client mockClient := &k8s.Client{} - + // Create a fake dynamic client scheme := runtime.NewScheme() fakeDynamicClient := fake.NewSimpleDynamicClient(scheme) - + // Add a fake delete response - fakeDynamicClient.PrependReactor("delete", "clusterroles", func(action ktesting.Action) (handled bool, ret runtime.Object, err error) { + fakeDynamicClient.PrependReactor("delete", "clusterroles", func(_ ktesting.Action) (handled bool, ret runtime.Object, err error) { return true, nil, nil }) - + // Set the dynamic client mockClient.SetDynamicClient(fakeDynamicClient) - + // Create an implementation impl := NewImplementation(mockClient) - + // Create a test request request := mcp.CallToolRequest{} - request.Params.Name = "delete_resource" + request.Params.Name = DeleteResourceToolName request.Params.Arguments = map[string]interface{}{ "resource_type": "clustered", "group": "rbac.authorization.k8s.io", @@ -43,20 +43,20 @@ func TestHandleDeleteResourceClusteredSuccess(t *testing.T) { "resource": "clusterroles", "name": "test-cluster-role", } - + // Test HandleDeleteResource ctx := context.Background() result, err := impl.HandleDeleteResource(ctx, request) - + // Verify there was no error assert.NoError(t, err, "HandleDeleteResource should not return an error") - + // Verify the result is not nil assert.NotNil(t, result, "Result should not be nil") - + // Verify the result is successful assert.False(t, result.IsError, "Result should not be an error") - + // Verify the result contains the resource name textContent, ok := mcp.AsTextContent(result.Content[0]) assert.True(t, ok, "Content should be TextContent") @@ -66,47 +66,47 @@ func TestHandleDeleteResourceClusteredSuccess(t *testing.T) { func TestHandleDeleteResourceNamespacedSuccess(t *testing.T) { // Create a mock k8s client mockClient := &k8s.Client{} - + // Create a fake dynamic client scheme := runtime.NewScheme() fakeDynamicClient := fake.NewSimpleDynamicClient(scheme) - + // Add a fake delete response - fakeDynamicClient.PrependReactor("delete", "services", func(action ktesting.Action) (handled bool, ret runtime.Object, err error) { + fakeDynamicClient.PrependReactor("delete", "services", func(_ ktesting.Action) (handled bool, ret runtime.Object, err error) { return true, nil, nil }) - + // Set the dynamic client mockClient.SetDynamicClient(fakeDynamicClient) - + // Create an implementation impl := NewImplementation(mockClient) - + // Create a test request request := mcp.CallToolRequest{} - request.Params.Name = "delete_resource" + request.Params.Name = DeleteResourceToolName request.Params.Arguments = map[string]interface{}{ - "resource_type": "namespaced", + "resource_type": ResourceTypeNamespaced, "group": "", "version": "v1", "resource": "services", "namespace": "default", "name": "test-service", } - + // Test HandleDeleteResource ctx := context.Background() result, err := impl.HandleDeleteResource(ctx, request) - + // Verify there was no error assert.NoError(t, err, "HandleDeleteResource should not return an error") - + // Verify the result is not nil assert.NotNil(t, result, "Result should not be nil") - + // Verify the result is successful assert.False(t, result.IsError, "Result should not be an error") - + // Verify the result contains the resource name textContent, ok := mcp.AsTextContent(result.Content[0]) assert.True(t, ok, "Content should be TextContent") @@ -116,10 +116,10 @@ func TestHandleDeleteResourceNamespacedSuccess(t *testing.T) { func TestHandleDeleteResourceMissingParameters(t *testing.T) { // Create a mock k8s client mockClient := &k8s.Client{} - + // Create an implementation impl := NewImplementation(mockClient) - + // Test cases for missing parameters testCases := []struct { name string @@ -127,7 +127,7 @@ func TestHandleDeleteResourceMissingParameters(t *testing.T) { errorMsg string }{ { - name: "Missing resource_type", + name: "Missing resource_type", arguments: map[string]interface{}{ "group": "apps", "version": "v1", @@ -137,7 +137,7 @@ func TestHandleDeleteResourceMissingParameters(t *testing.T) { errorMsg: "resource_type is required", }, { - name: "Missing version", + name: "Missing version", arguments: map[string]interface{}{ "resource_type": "clustered", "group": "apps", @@ -147,7 +147,7 @@ func TestHandleDeleteResourceMissingParameters(t *testing.T) { errorMsg: "version is required", }, { - name: "Missing resource", + name: "Missing resource", arguments: map[string]interface{}{ "resource_type": "clustered", "group": "apps", @@ -157,7 +157,7 @@ func TestHandleDeleteResourceMissingParameters(t *testing.T) { errorMsg: "resource is required", }, { - name: "Missing name", + name: "Missing name", arguments: map[string]interface{}{ "resource_type": "clustered", "group": "apps", @@ -167,9 +167,9 @@ func TestHandleDeleteResourceMissingParameters(t *testing.T) { errorMsg: "name is required", }, { - name: "Missing namespace for namespaced resource", + name: "Missing namespace for namespaced resource", arguments: map[string]interface{}{ - "resource_type": "namespaced", + "resource_type": ResourceTypeNamespaced, "group": "apps", "version": "v1", "resource": "deployments", @@ -178,27 +178,27 @@ func TestHandleDeleteResourceMissingParameters(t *testing.T) { errorMsg: "namespace is required for namespaced resources", }, } - + for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { // Create a test request request := mcp.CallToolRequest{} - request.Params.Name = "delete_resource" + request.Params.Name = DeleteResourceToolName request.Params.Arguments = tc.arguments - + // Test HandleDeleteResource ctx := context.Background() result, err := impl.HandleDeleteResource(ctx, request) - + // Verify there was no error assert.NoError(t, err, "HandleDeleteResource should not return an error") - + // Verify the result is not nil assert.NotNil(t, result, "Result should not be nil") - + // Verify the result is an error assert.True(t, result.IsError, "Result should be an error") - + // Verify the error message textContent, ok := mcp.AsTextContent(result.Content[0]) assert.True(t, ok, "Content should be TextContent") @@ -210,13 +210,13 @@ func TestHandleDeleteResourceMissingParameters(t *testing.T) { func TestHandleDeleteResourceInvalidResourceType(t *testing.T) { // Create a mock k8s client mockClient := &k8s.Client{} - + // Create an implementation impl := NewImplementation(mockClient) - + // Create a test request with invalid resource_type request := mcp.CallToolRequest{} - request.Params.Name = "delete_resource" + request.Params.Name = DeleteResourceToolName request.Params.Arguments = map[string]interface{}{ "resource_type": "invalid", "group": "apps", @@ -224,20 +224,20 @@ func TestHandleDeleteResourceInvalidResourceType(t *testing.T) { "resource": "deployments", "name": "test-deployment", } - + // Test HandleDeleteResource ctx := context.Background() result, err := impl.HandleDeleteResource(ctx, request) - + // Verify there was no error assert.NoError(t, err, "HandleDeleteResource should not return an error") - + // Verify the result is not nil assert.NotNil(t, result, "Result should not be nil") - + // Verify the result is an error assert.True(t, result.IsError, "Result should be an error") - + // Verify the error message textContent, ok := mcp.AsTextContent(result.Content[0]) assert.True(t, ok, "Content should be TextContent") @@ -247,25 +247,25 @@ func TestHandleDeleteResourceInvalidResourceType(t *testing.T) { func TestHandleDeleteResourceDeleteError(t *testing.T) { // Create a mock k8s client mockClient := &k8s.Client{} - + // Create a fake dynamic client scheme := runtime.NewScheme() fakeDynamicClient := fake.NewSimpleDynamicClient(scheme) - + // Add a fake delete response with error - fakeDynamicClient.PrependReactor("delete", "clusterroles", func(action ktesting.Action) (handled bool, ret runtime.Object, err error) { + fakeDynamicClient.PrependReactor("delete", "clusterroles", func(_ ktesting.Action) (handled bool, ret runtime.Object, err error) { return true, nil, fmt.Errorf("failed to delete resource") }) - + // Set the dynamic client mockClient.SetDynamicClient(fakeDynamicClient) - + // Create an implementation impl := NewImplementation(mockClient) - + // Create a test request request := mcp.CallToolRequest{} - request.Params.Name = "delete_resource" + request.Params.Name = DeleteResourceToolName request.Params.Arguments = map[string]interface{}{ "resource_type": "clustered", "group": "rbac.authorization.k8s.io", @@ -273,22 +273,22 @@ func TestHandleDeleteResourceDeleteError(t *testing.T) { "resource": "clusterroles", "name": "test-cluster-role", } - + // Test HandleDeleteResource ctx := context.Background() result, err := impl.HandleDeleteResource(ctx, request) - + // Verify there was no error assert.NoError(t, err, "HandleDeleteResource should not return an error") - + // Verify the result is not nil assert.NotNil(t, result, "Result should not be nil") - + // Verify the result is an error assert.True(t, result.IsError, "Result should be an error") - + // Verify the error message textContent, ok := mcp.AsTextContent(result.Content[0]) assert.True(t, ok, "Content should be TextContent") assert.Contains(t, textContent.Text, "Failed to delete resource", "Error message should contain 'Failed to delete resource'") -} \ No newline at end of file +} diff --git a/pkg/mcp/get_resource.go b/pkg/mcp/get_resource.go index a5fcb91..ea65113 100644 --- a/pkg/mcp/get_resource.go +++ b/pkg/mcp/get_resource.go @@ -10,6 +10,8 @@ import ( ) // HandleGetResource handles the get_resource tool +// +//nolint:gocyclo // This is deemed a complex function, but realistically it's not too bad func (m *Implementation) HandleGetResource(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { // Parse parameters resourceType := mcp.ParseString(request, "resource_type", "") @@ -48,13 +50,13 @@ func (m *Implementation) HandleGetResource(ctx context.Context, request mcp.Call if name == "" { return mcp.NewToolResultError("name is required"), nil } - if resourceType == "namespaced" && namespace == "" { + if resourceType == ResourceTypeNamespaced && namespace == "" { return mcp.NewToolResultError("namespace is required for namespaced resources"), nil } // Create GVR // Validate resource_type - if resourceType != "clustered" && resourceType != "namespaced" { + if resourceType != "clustered" && resourceType != ResourceTypeNamespaced { return mcp.NewToolResultError("Invalid resource_type: " + resourceType), nil } @@ -102,7 +104,8 @@ func NewGetResourceTool() mcp.Tool { mcp.WithString("subresource", mcp.Description("Subresource to get (e.g., status, scale, logs)")), mcp.WithObject("parameters", - mcp.Description("Optional parameters for the request. For regular resources: resourceVersion. For pod logs: container, previous, sinceSeconds, sinceTime, timestamps, limitBytes, tailLines")), + mcp.Description(`Optional parameters for the request. For regular resources: resourceVersion. + For pod logs: container, previous, sinceSeconds, sinceTime, timestamps, limitBytes, tailLines`)), mcp.WithToolAnnotation(mcp.ToolAnnotation{ Title: "Get Kubernetes resource", ReadOnlyHint: true, diff --git a/pkg/mcp/get_resource_test.go b/pkg/mcp/get_resource_test.go index 6dd0293..2b8f743 100644 --- a/pkg/mcp/get_resource_test.go +++ b/pkg/mcp/get_resource_test.go @@ -19,16 +19,16 @@ import ( func TestHandleGetResourceClusteredSuccess(t *testing.T) { // Create a mock k8s client mockClient := &k8s.Client{} - + // Create a fake dynamic client scheme := runtime.NewScheme() - + fakeDynamicClient := dynamicfake.NewSimpleDynamicClient(scheme) - + // Add a fake get response fakeDynamicClient.PrependReactor("get", "deployments", func(action ktesting.Action) (handled bool, ret runtime.Object, err error) { getAction := action.(ktesting.GetAction) - if getAction.GetName() == "test-deployment" { + if getAction.GetName() == TestDeploymentName { return true, &unstructured.Unstructured{ Object: map[string]interface{}{ "apiVersion": "apps/v1", @@ -44,16 +44,16 @@ func TestHandleGetResourceClusteredSuccess(t *testing.T) { } return false, nil, fmt.Errorf("deployment not found") }) - + // Set the dynamic client mockClient.SetDynamicClient(fakeDynamicClient) - + // Create an implementation impl := NewImplementation(mockClient) - + // Create a test request request := mcp.CallToolRequest{} - request.Params.Name = "get_resource" + request.Params.Name = GetResourceToolName request.Params.Arguments = map[string]interface{}{ "resource_type": "clustered", "group": "apps", @@ -61,20 +61,20 @@ func TestHandleGetResourceClusteredSuccess(t *testing.T) { "resource": "deployments", "name": "test-deployment", } - + // Test HandleGetResource ctx := context.Background() result, err := impl.HandleGetResource(ctx, request) - + // Verify there was no error assert.NoError(t, err, "HandleGetResource should not return an error") - + // Verify the result is not nil assert.NotNil(t, result, "Result should not be nil") - + // Verify the result is successful assert.False(t, result.IsError, "Result should not be an error") - + // Verify the result contains the resource name textContent, ok := mcp.AsTextContent(result.Content[0]) assert.True(t, ok, "Content should be TextContent") @@ -84,16 +84,16 @@ func TestHandleGetResourceClusteredSuccess(t *testing.T) { func TestHandleGetResourceNamespacedSuccess(t *testing.T) { // Create a mock k8s client mockClient := &k8s.Client{} - + // Create a fake dynamic client scheme := runtime.NewScheme() - + fakeDynamicClient := dynamicfake.NewSimpleDynamicClient(scheme) - + // Add a fake get response fakeDynamicClient.PrependReactor("get", "deployments", func(action ktesting.Action) (handled bool, ret runtime.Object, err error) { getAction := action.(ktesting.GetAction) - if getAction.GetName() == "test-deployment" && getAction.GetNamespace() == "default" { + if getAction.GetName() == TestDeploymentName && getAction.GetNamespace() == "default" { return true, &unstructured.Unstructured{ Object: map[string]interface{}{ "apiVersion": "apps/v1", @@ -110,38 +110,38 @@ func TestHandleGetResourceNamespacedSuccess(t *testing.T) { } return false, nil, fmt.Errorf("deployment not found") }) - + // Set the dynamic client mockClient.SetDynamicClient(fakeDynamicClient) - + // Create an implementation impl := NewImplementation(mockClient) - + // Create a test request request := mcp.CallToolRequest{} - request.Params.Name = "get_resource" + request.Params.Name = GetResourceToolName request.Params.Arguments = map[string]interface{}{ - "resource_type": "namespaced", + "resource_type": ResourceTypeNamespaced, "group": "apps", "version": "v1", "resource": "deployments", "namespace": "default", "name": "test-deployment", } - + // Test HandleGetResource ctx := context.Background() result, err := impl.HandleGetResource(ctx, request) - + // Verify there was no error assert.NoError(t, err, "HandleGetResource should not return an error") - + // Verify the result is not nil assert.NotNil(t, result, "Result should not be nil") - + // Verify the result is successful assert.False(t, result.IsError, "Result should not be an error") - + // Verify the result contains the resource name and namespace textContent, ok := mcp.AsTextContent(result.Content[0]) assert.True(t, ok, "Content should be TextContent") @@ -152,30 +152,30 @@ func TestHandleGetResourceNamespacedSuccess(t *testing.T) { func TestHandleGetResourceWithParameters(t *testing.T) { // Create a mock k8s client mockClient := &k8s.Client{} - + // Create a fake dynamic client scheme := runtime.NewScheme() fakeDynamicClient := dynamicfake.NewSimpleDynamicClient(scheme) - + // Set the dynamic client mockClient.SetDynamicClient(fakeDynamicClient) - + // Create a mock implementation for getPodLogs that verifies parameters - mockGetPodLogs := func(ctx context.Context, namespace, name string, parameters map[string]string) (*unstructured.Unstructured, error) { + mockGetPodLogs := func(_ context.Context, namespace, name string, parameters map[string]string) (*unstructured.Unstructured, error) { // Verify parameters were passed correctly assert.Equal(t, "test-pod", name) assert.Equal(t, "default", namespace) assert.NotNil(t, parameters) - + // Check specific parameters container, hasContainer := parameters["container"] assert.True(t, hasContainer) assert.Equal(t, "my-container", container) - + sinceSeconds, hasSinceSeconds := parameters["sinceSeconds"] assert.True(t, hasSinceSeconds) assert.Equal(t, "3600", sinceSeconds) - + // Return mock logs return &unstructured.Unstructured{ Object: map[string]interface{}{ @@ -189,18 +189,18 @@ func TestHandleGetResourceWithParameters(t *testing.T) { }, }, nil } - + // Set our mock implementation mockClient.SetPodLogsFunc(mockGetPodLogs) - + // Create an implementation impl := NewImplementation(mockClient) - + // Create a test request with parameters request := mcp.CallToolRequest{} - request.Params.Name = "get_resource" + request.Params.Name = GetResourceToolName request.Params.Arguments = map[string]interface{}{ - "resource_type": "namespaced", + "resource_type": ResourceTypeNamespaced, "group": "", "version": "v1", "resource": "pods", @@ -212,20 +212,20 @@ func TestHandleGetResourceWithParameters(t *testing.T) { "sinceSeconds": "3600", }, } - + // Test HandleGetResource ctx := context.Background() result, err := impl.HandleGetResource(ctx, request) - + // Verify there was no error assert.NoError(t, err, "HandleGetResource should not return an error") - + // Verify the result is not nil assert.NotNil(t, result, "Result should not be nil") - + // Verify the result is successful assert.False(t, result.IsError, "Result should not be an error") - + // Verify the result contains the logs textContent, ok := mcp.AsTextContent(result.Content[0]) assert.True(t, ok, "Content should be TextContent") @@ -235,17 +235,17 @@ func TestHandleGetResourceWithParameters(t *testing.T) { func TestHandleGetResourceWithSubresource(t *testing.T) { // Create a mock k8s client mockClient := &k8s.Client{} - + // Create a fake dynamic client scheme := runtime.NewScheme() - + fakeDynamicClient := dynamicfake.NewSimpleDynamicClient(scheme) - + // Add a fake get response for subresource // Note: The fake client doesn't fully support subresources, so we're simulating it fakeDynamicClient.PrependReactor("get", "deployments/status", func(action ktesting.Action) (handled bool, ret runtime.Object, err error) { getAction := action.(ktesting.GetAction) - if getAction.GetName() == "test-deployment" && getAction.GetNamespace() == "default" { + if getAction.GetName() == TestDeploymentName && getAction.GetNamespace() == "default" { return true, &unstructured.Unstructured{ Object: map[string]interface{}{ "apiVersion": "apps/v1", @@ -263,18 +263,18 @@ func TestHandleGetResourceWithSubresource(t *testing.T) { } return false, nil, fmt.Errorf("deployment status not found") }) - + // Set the dynamic client mockClient.SetDynamicClient(fakeDynamicClient) - + // Create an implementation impl := NewImplementation(mockClient) - + // Create a test request request := mcp.CallToolRequest{} - request.Params.Name = "get_resource" + request.Params.Name = GetResourceToolName request.Params.Arguments = map[string]interface{}{ - "resource_type": "namespaced", + "resource_type": ResourceTypeNamespaced, "group": "apps", "version": "v1", "resource": "deployments", @@ -282,29 +282,29 @@ func TestHandleGetResourceWithSubresource(t *testing.T) { "name": "test-deployment", "subresource": "status", } - + // Test HandleGetResource ctx := context.Background() result, err := impl.HandleGetResource(ctx, request) - + // Verify there was no error assert.NoError(t, err, "HandleGetResource should not return an error") - + // Verify the result is not nil assert.NotNil(t, result, "Result should not be nil") - + // Verify the result is successful assert.False(t, result.IsError, "Result should not be an error") - + // Verify the result contains the status information textContent, ok := mcp.AsTextContent(result.Content[0]) assert.True(t, ok, "Content should be TextContent") - + // Parse the JSON to verify the content var resultObj map[string]interface{} err = json.Unmarshal([]byte(textContent.Text), &resultObj) assert.NoError(t, err, "Should be able to parse the JSON result") - + // Check for status field status, ok := resultObj["status"].(map[string]interface{}) assert.True(t, ok, "Result should contain status field") @@ -315,10 +315,10 @@ func TestHandleGetResourceWithSubresource(t *testing.T) { func TestHandleGetResourceMissingParameters(t *testing.T) { // Create a mock k8s client mockClient := &k8s.Client{} - + // Create an implementation impl := NewImplementation(mockClient) - + // Test cases for missing parameters testCases := []struct { name string @@ -326,7 +326,7 @@ func TestHandleGetResourceMissingParameters(t *testing.T) { errorMsg string }{ { - name: "Missing resource_type", + name: "Missing resource_type", arguments: map[string]interface{}{ "group": "apps", "version": "v1", @@ -336,7 +336,7 @@ func TestHandleGetResourceMissingParameters(t *testing.T) { errorMsg: "resource_type is required", }, { - name: "Missing version", + name: "Missing version", arguments: map[string]interface{}{ "resource_type": "clustered", "group": "apps", @@ -346,7 +346,7 @@ func TestHandleGetResourceMissingParameters(t *testing.T) { errorMsg: "version is required", }, { - name: "Missing resource", + name: "Missing resource", arguments: map[string]interface{}{ "resource_type": "clustered", "group": "apps", @@ -356,7 +356,7 @@ func TestHandleGetResourceMissingParameters(t *testing.T) { errorMsg: "resource is required", }, { - name: "Missing name", + name: "Missing name", arguments: map[string]interface{}{ "resource_type": "clustered", "group": "apps", @@ -366,9 +366,9 @@ func TestHandleGetResourceMissingParameters(t *testing.T) { errorMsg: "name is required", }, { - name: "Missing namespace for namespaced resource", + name: "Missing namespace for namespaced resource", arguments: map[string]interface{}{ - "resource_type": "namespaced", + "resource_type": ResourceTypeNamespaced, "group": "apps", "version": "v1", "resource": "deployments", @@ -377,27 +377,27 @@ func TestHandleGetResourceMissingParameters(t *testing.T) { errorMsg: "namespace is required for namespaced resources", }, } - + for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { // Create a test request request := mcp.CallToolRequest{} - request.Params.Name = "get_resource" + request.Params.Name = GetResourceToolName request.Params.Arguments = tc.arguments - + // Test HandleGetResource ctx := context.Background() result, err := impl.HandleGetResource(ctx, request) - + // Verify there was no error assert.NoError(t, err, "HandleGetResource should not return an error") - + // Verify the result is not nil assert.NotNil(t, result, "Result should not be nil") - + // Verify the result is an error assert.True(t, result.IsError, "Result should be an error") - + // Verify the error message textContent, ok := mcp.AsTextContent(result.Content[0]) assert.True(t, ok, "Content should be TextContent") @@ -409,13 +409,13 @@ func TestHandleGetResourceMissingParameters(t *testing.T) { func TestHandleGetResourceInvalidResourceType(t *testing.T) { // Create a mock k8s client mockClient := &k8s.Client{} - + // Create an implementation impl := NewImplementation(mockClient) - + // Create a test request with invalid resource_type request := mcp.CallToolRequest{} - request.Params.Name = "get_resource" + request.Params.Name = GetResourceToolName request.Params.Arguments = map[string]interface{}{ "resource_type": "invalid", "group": "apps", @@ -423,20 +423,20 @@ func TestHandleGetResourceInvalidResourceType(t *testing.T) { "resource": "deployments", "name": "test-deployment", } - + // Test HandleGetResource ctx := context.Background() result, err := impl.HandleGetResource(ctx, request) - + // Verify there was no error assert.NoError(t, err, "HandleGetResource should not return an error") - + // Verify the result is not nil assert.NotNil(t, result, "Result should not be nil") - + // Verify the result is an error assert.True(t, result.IsError, "Result should be an error") - + // Verify the error message textContent, ok := mcp.AsTextContent(result.Content[0]) assert.True(t, ok, "Content should be TextContent") @@ -445,10 +445,10 @@ func TestHandleGetResourceInvalidResourceType(t *testing.T) { func TestNewGetResourceTool(t *testing.T) { tool := NewGetResourceTool() - + assert.Equal(t, "get_resource", tool.Name) assert.Equal(t, "Get a Kubernetes resource or its subresource", tool.Description) - + // Verify the tool exists assert.NotNil(t, tool, "Tool should not be nil") -} \ No newline at end of file +} diff --git a/pkg/mcp/implementation.go b/pkg/mcp/implementation.go index 7bcf57a..00761cd 100644 --- a/pkg/mcp/implementation.go +++ b/pkg/mcp/implementation.go @@ -14,4 +14,4 @@ func NewImplementation(k8sClient *k8s.Client) *Implementation { return &Implementation{ k8sClient: k8sClient, } -} \ No newline at end of file +} diff --git a/pkg/mcp/implementation_test.go b/pkg/mcp/implementation_test.go index 27628fb..19c26eb 100644 --- a/pkg/mcp/implementation_test.go +++ b/pkg/mcp/implementation_test.go @@ -23,16 +23,16 @@ type mockK8sClient struct { func newMockK8sClient() *mockK8sClient { scheme := runtime.NewScheme() - + // Register list kinds for the resources we'll be testing listKinds := map[schema.GroupVersionResource]string{ {Group: "apps", Version: "v1", Resource: "deployments"}: "DeploymentList", } - + dynamicClient := fake.NewSimpleDynamicClientWithCustomListKinds(scheme, listKinds) - + return &mockK8sClient{ - Client: &k8s.Client{}, + Client: &k8s.Client{}, dynamicClient: dynamicClient, } } @@ -55,7 +55,7 @@ func TestHandleListResources(t *testing.T) { } // Add a fake list response - mockClient.dynamicClient.PrependReactor("list", "deployments", func(action ktesting.Action) (handled bool, ret runtime.Object, err error) { + mockClient.dynamicClient.PrependReactor("list", "deployments", func(_ ktesting.Action) (handled bool, ret runtime.Object, err error) { list := &unstructured.UnstructuredList{ Items: []unstructured.Unstructured{ { @@ -132,12 +132,12 @@ func TestHandleApplyResource(t *testing.T) { } // Add a fake get response (resource not found) - mockClient.dynamicClient.PrependReactor("get", "deployments", func(action ktesting.Action) (handled bool, ret runtime.Object, err error) { + mockClient.dynamicClient.PrependReactor("get", "deployments", func(_ ktesting.Action) (handled bool, ret runtime.Object, err error) { return true, nil, fmt.Errorf("not found: deployments \"test-deployment\" not found") }) // Add a fake create response - mockClient.dynamicClient.PrependReactor("create", "deployments", func(action ktesting.Action) (handled bool, ret runtime.Object, err error) { + mockClient.dynamicClient.PrependReactor("create", "deployments", func(_ ktesting.Action) (handled bool, ret runtime.Object, err error) { return true, obj, nil }) @@ -196,7 +196,7 @@ func TestCallTool(t *testing.T) { mockClient.SetDynamicClient(mockClient.dynamicClient) // Add a fake list response - mockClient.dynamicClient.PrependReactor("list", "deployments", func(action ktesting.Action) (handled bool, ret runtime.Object, err error) { + mockClient.dynamicClient.PrependReactor("list", "deployments", func(_ ktesting.Action) (handled bool, ret runtime.Object, err error) { list := &unstructured.UnstructuredList{ Items: []unstructured.Unstructured{ { @@ -228,7 +228,7 @@ func TestCallTool(t *testing.T) { ctx := context.Background() // Create a CallToolRequest callToolRequest := mcp.CallToolRequest{} - callToolRequest.Params.Name = "list_resources" + callToolRequest.Params.Name = ListResourcesToolName callToolRequest.Params.Arguments = requestParams["arguments"].(map[string]interface{}) // Call the appropriate handler directly diff --git a/pkg/mcp/list_resources.go b/pkg/mcp/list_resources.go index c42064d..644ec9b 100644 --- a/pkg/mcp/list_resources.go +++ b/pkg/mcp/list_resources.go @@ -41,7 +41,7 @@ func (m *Implementation) HandleListResources(ctx context.Context, request mcp.Ca if resource == "" { return mcp.NewToolResultError("resource is required"), nil } - if resourceType == "namespaced" && namespace == "" { + if resourceType == ResourceTypeNamespaced && namespace == "" { return mcp.NewToolResultError("namespace is required for namespaced resources"), nil } if labelSelector != "" { @@ -64,7 +64,7 @@ func (m *Implementation) HandleListResources(ctx context.Context, request mcp.Ca switch resourceType { case "clustered": list, err = m.k8sClient.ListClusteredResources(ctx, gvr, labelSelector) - case "namespaced": + case ResourceTypeNamespaced: list, err = m.k8sClient.ListNamespacedResources(ctx, gvr, namespace, labelSelector) default: return mcp.NewToolResultError(fmt.Sprintf("Invalid resource_type: %s", resourceType)), nil diff --git a/pkg/mcp/list_resources_test.go b/pkg/mcp/list_resources_test.go index ba3bab2..1b87e93 100644 --- a/pkg/mcp/list_resources_test.go +++ b/pkg/mcp/list_resources_test.go @@ -19,22 +19,25 @@ import ( "github.com/StacklokLabs/mkp/pkg/k8s" ) +// Import tools constants +var _ = ListResourcesToolName + func TestHandleListResourcesClusteredSuccess(t *testing.T) { // Create a mock k8s client mockClient := &k8s.Client{} - + // 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", } - + fakeDynamicClient := fake.NewSimpleDynamicClientWithCustomListKinds(scheme, listKinds) - + // Add a fake list response - fakeDynamicClient.PrependReactor("list", "clusterroles", func(action ktesting.Action) (handled bool, ret runtime.Object, err error) { + fakeDynamicClient.PrependReactor("list", "clusterroles", func(_ ktesting.Action) (handled bool, ret runtime.Object, err error) { list := &unstructured.UnstructuredList{ Items: []unstructured.Unstructured{ { @@ -57,36 +60,36 @@ func TestHandleListResourcesClusteredSuccess(t *testing.T) { } return true, list, nil }) - + // Set the dynamic client mockClient.SetDynamicClient(fakeDynamicClient) - + // Create an implementation impl := NewImplementation(mockClient) - + // Create a test request request := mcp.CallToolRequest{} - request.Params.Name = "list_resources" + request.Params.Name = ListResourcesToolName request.Params.Arguments = map[string]interface{}{ "resource_type": "clustered", "group": "rbac.authorization.k8s.io", "version": "v1", "resource": "clusterroles", } - + // Test HandleListResources ctx := context.Background() result, err := impl.HandleListResources(ctx, request) - + // Verify there was no error assert.NoError(t, err, "HandleListResources should not return an error") - + // Verify the result is not nil assert.NotNil(t, result, "Result should not be nil") - + // Verify the result is successful assert.False(t, result.IsError, "Result should not be an error") - + // Verify the result contains the resource name in a PartialObjectMetadataList textContent, ok := mcp.AsTextContent(result.Content[0]) assert.True(t, ok, "Content should be TextContent") @@ -99,19 +102,19 @@ func TestHandleListResourcesClusteredSuccess(t *testing.T) { func TestHandleListResourcesNamespacedSuccess(t *testing.T) { // Create a mock k8s client mockClient := &k8s.Client{} - + // 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", } - + fakeDynamicClient := fake.NewSimpleDynamicClientWithCustomListKinds(scheme, listKinds) - + // Add a fake list response - fakeDynamicClient.PrependReactor("list", "services", func(action ktesting.Action) (handled bool, ret runtime.Object, err error) { + fakeDynamicClient.PrependReactor("list", "services", func(_ ktesting.Action) (handled bool, ret runtime.Object, err error) { list := &unstructured.UnstructuredList{ Items: []unstructured.Unstructured{ { @@ -136,37 +139,37 @@ func TestHandleListResourcesNamespacedSuccess(t *testing.T) { } return true, list, nil }) - + // Set the dynamic client mockClient.SetDynamicClient(fakeDynamicClient) - + // Create an implementation impl := NewImplementation(mockClient) - + // Create a test request request := mcp.CallToolRequest{} - request.Params.Name = "list_resources" + request.Params.Name = ListResourcesToolName request.Params.Arguments = map[string]interface{}{ - "resource_type": "namespaced", + "resource_type": ResourceTypeNamespaced, "group": "", "version": "v1", "resource": "services", "namespace": "default", } - + // Test HandleListResources ctx := context.Background() result, err := impl.HandleListResources(ctx, request) - + // Verify there was no error assert.NoError(t, err, "HandleListResources should not return an error") - + // Verify the result is not nil assert.NotNil(t, result, "Result should not be nil") - + // Verify the result is successful assert.False(t, result.IsError, "Result should not be an error") - + // Verify the result contains the resource name in a PartialObjectMetadataList textContent, ok := mcp.AsTextContent(result.Content[0]) assert.True(t, ok, "Content should be TextContent") @@ -179,10 +182,10 @@ func TestHandleListResourcesNamespacedSuccess(t *testing.T) { func TestHandleListResourcesMissingParameters(t *testing.T) { // Create a mock k8s client mockClient := &k8s.Client{} - + // Create an implementation impl := NewImplementation(mockClient) - + // Test cases for missing parameters testCases := []struct { name string @@ -190,7 +193,7 @@ func TestHandleListResourcesMissingParameters(t *testing.T) { errorMsg string }{ { - name: "Missing resource_type", + name: "Missing resource_type", arguments: map[string]interface{}{ "group": "apps", "version": "v1", @@ -199,7 +202,7 @@ func TestHandleListResourcesMissingParameters(t *testing.T) { errorMsg: "resource_type is required", }, { - name: "Missing version", + name: "Missing version", arguments: map[string]interface{}{ "resource_type": "clustered", "group": "apps", @@ -208,7 +211,7 @@ func TestHandleListResourcesMissingParameters(t *testing.T) { errorMsg: "version is required", }, { - name: "Missing resource", + name: "Missing resource", arguments: map[string]interface{}{ "resource_type": "clustered", "group": "apps", @@ -217,9 +220,9 @@ func TestHandleListResourcesMissingParameters(t *testing.T) { errorMsg: "resource is required", }, { - name: "Missing namespace for namespaced resource", + name: "Missing namespace for namespaced resource", arguments: map[string]interface{}{ - "resource_type": "namespaced", + "resource_type": ResourceTypeNamespaced, "group": "apps", "version": "v1", "resource": "deployments", @@ -227,27 +230,27 @@ func TestHandleListResourcesMissingParameters(t *testing.T) { errorMsg: "namespace is required for namespaced resources", }, } - + for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { // Create a test request request := mcp.CallToolRequest{} - request.Params.Name = "list_resources" + request.Params.Name = ListResourcesToolName request.Params.Arguments = tc.arguments - + // Test HandleListResources ctx := context.Background() result, err := impl.HandleListResources(ctx, request) - + // Verify there was no error assert.NoError(t, err, "HandleListResources should not return an error") - + // Verify the result is not nil assert.NotNil(t, result, "Result should not be nil") - + // Verify the result is an error assert.True(t, result.IsError, "Result should be an error") - + // Verify the error message textContent, ok := mcp.AsTextContent(result.Content[0]) assert.True(t, ok, "Content should be TextContent") @@ -259,33 +262,33 @@ func TestHandleListResourcesMissingParameters(t *testing.T) { func TestHandleListResourcesInvalidResourceType(t *testing.T) { // Create a mock k8s client mockClient := &k8s.Client{} - + // Create an implementation impl := NewImplementation(mockClient) - + // Create a test request with invalid resource_type request := mcp.CallToolRequest{} - request.Params.Name = "list_resources" + request.Params.Name = ListResourcesToolName request.Params.Arguments = map[string]interface{}{ "resource_type": "invalid", "group": "apps", "version": "v1", "resource": "deployments", } - + // Test HandleListResources ctx := context.Background() result, err := impl.HandleListResources(ctx, request) - + // Verify there was no error assert.NoError(t, err, "HandleListResources should not return an error") - + // Verify the result is not nil assert.NotNil(t, result, "Result should not be nil") - + // Verify the result is an error assert.True(t, result.IsError, "Result should be an error") - + // Verify the error message textContent, ok := mcp.AsTextContent(result.Content[0]) assert.True(t, ok, "Content should be TextContent") @@ -295,51 +298,51 @@ func TestHandleListResourcesInvalidResourceType(t *testing.T) { func TestHandleListResourcesListError(t *testing.T) { // Create a mock k8s client mockClient := &k8s.Client{} - + // 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", } - + fakeDynamicClient := fake.NewSimpleDynamicClientWithCustomListKinds(scheme, listKinds) - + // Add a fake list response with error - fakeDynamicClient.PrependReactor("list", "clusterroles", func(action ktesting.Action) (handled bool, ret runtime.Object, err error) { + fakeDynamicClient.PrependReactor("list", "clusterroles", func(_ ktesting.Action) (handled bool, ret runtime.Object, err error) { return true, nil, fmt.Errorf("failed to list resources") }) - + // Set the dynamic client mockClient.SetDynamicClient(fakeDynamicClient) - + // Create an implementation impl := NewImplementation(mockClient) - + // Create a test request request := mcp.CallToolRequest{} - request.Params.Name = "list_resources" + request.Params.Name = ListResourcesToolName request.Params.Arguments = map[string]interface{}{ "resource_type": "clustered", "group": "rbac.authorization.k8s.io", "version": "v1", "resource": "clusterroles", } - + // Test HandleListResources ctx := context.Background() result, err := impl.HandleListResources(ctx, request) - + // Verify there was no error assert.NoError(t, err, "HandleListResources should not return an error") - + // Verify the result is not nil assert.NotNil(t, result, "Result should not be nil") - + // Verify the result is an error assert.True(t, result.IsError, "Result should be an error") - + // Verify the error message textContent, ok := mcp.AsTextContent(result.Content[0]) assert.True(t, ok, "Content should be TextContent") @@ -349,10 +352,10 @@ func TestHandleListResourcesListError(t *testing.T) { func TestHandleListAllResourcesSuccess(t *testing.T) { // Create a mock k8s client mockClient := &k8s.Client{} - + // Create a fake discovery client fakeDiscoveryClient := &discoveryfake.FakeDiscovery{Fake: &ktesting.Fake{}} - + // Add some fake API resources fakeDiscoveryClient.Resources = []*metav1.APIResourceList{ { @@ -401,26 +404,26 @@ func TestHandleListAllResourcesSuccess(t *testing.T) { }, }, } - + // Set the discovery client mockClient.SetDiscoveryClient(fakeDiscoveryClient) - + // Create an implementation impl := NewImplementation(mockClient) - + // Test HandleListAllResources ctx := context.Background() resources, err := impl.HandleListAllResources(ctx) - + // Verify there was no error assert.NoError(t, err, "HandleListAllResources should not return an error") - + // Verify the result is not nil assert.NotNil(t, resources, "Resources should not be nil") - + // Verify the number of resources (5 resources, excluding subresources) assert.Equal(t, 5, len(resources), "Should have 5 resources") - + // Verify the resources include both namespaced and clustered resources var hasNamespaced, hasClustered bool for _, resource := range resources { @@ -433,7 +436,7 @@ func TestHandleListAllResourcesSuccess(t *testing.T) { } assert.True(t, hasNamespaced, "Should include namespaced resources") assert.True(t, hasClustered, "Should include clustered resources") - + // Verify the URIs are correctly formatted for _, resource := range resources { if strings.HasPrefix(resource.Name, "Namespaced") { @@ -447,29 +450,29 @@ func TestHandleListAllResourcesSuccess(t *testing.T) { func TestHandleListAllResourcesError(t *testing.T) { // Create a mock k8s client mockClient := &k8s.Client{} - + // Create a fake discovery client that returns an error fakeDiscoveryClient := &discoveryfake.FakeDiscovery{Fake: &ktesting.Fake{}} - fakeDiscoveryClient.AddReactor("*", "*", func(action ktesting.Action) (handled bool, ret runtime.Object, err error) { + fakeDiscoveryClient.AddReactor("*", "*", func(_ ktesting.Action) (handled bool, ret runtime.Object, err error) { return true, nil, fmt.Errorf("failed to list API resources") }) - + // Set the discovery client mockClient.SetDiscoveryClient(fakeDiscoveryClient) - + // Create an implementation impl := NewImplementation(mockClient) - + // Test HandleListAllResources ctx := context.Background() resources, err := impl.HandleListAllResources(ctx) - + // Verify there was an error assert.Error(t, err, "HandleListAllResources should return an error") - + // Verify the error message assert.Contains(t, err.Error(), "failed to list API resources", "Error message should contain 'failed to list API resources'") - + // Verify the result is nil assert.Nil(t, resources, "Resources should be nil") } @@ -477,22 +480,22 @@ func TestHandleListAllResourcesError(t *testing.T) { func TestHandleListResourcesWithLastAppliedConfig(t *testing.T) { // Create a mock k8s client mockClient := &k8s.Client{} - + // Create a fake dynamic client scheme := runtime.NewScheme() - + // Register list kinds for the resources we'll be testing listKinds := map[schema.GroupVersionResource]string{ {Group: "apps", Version: "v1", Resource: "deployments"}: "DeploymentList", } - + fakeDynamicClient := fake.NewSimpleDynamicClientWithCustomListKinds(scheme, listKinds) - + // Add a fake list response with the last-applied-configuration annotation - fakeDynamicClient.PrependReactor("list", "deployments", func(action ktesting.Action) (handled bool, ret runtime.Object, err error) { + fakeDynamicClient.PrependReactor("list", "deployments", func(_ ktesting.Action) (handled bool, ret runtime.Object, err error) { // Create a large last-applied-configuration annotation lastAppliedConfig := `{"apiVersion":"apps/v1","kind":"Deployment","metadata":{"name":"test-deployment","namespace":"default"},"spec":{"replicas":3,"selector":{"matchLabels":{"app":"test"}},"template":{"metadata":{"labels":{"app":"test"}},"spec":{"containers":[{"name":"test-container","image":"nginx:latest","ports":[{"containerPort":80}]}]}}}}` - + list := &unstructured.UnstructuredList{ Items: []unstructured.Unstructured{ { @@ -504,7 +507,7 @@ func TestHandleListResourcesWithLastAppliedConfig(t *testing.T) { "namespace": "default", "annotations": map[string]interface{}{ "kubectl.kubernetes.io/last-applied-configuration": lastAppliedConfig, - "deployment.kubernetes.io/revision": "1", + "deployment.kubernetes.io/revision": "1", }, }, "spec": map[string]interface{}{ @@ -541,51 +544,51 @@ func TestHandleListResourcesWithLastAppliedConfig(t *testing.T) { } return true, list, nil }) - + // Set the dynamic client mockClient.SetDynamicClient(fakeDynamicClient) - + // Create an implementation impl := NewImplementation(mockClient) - + // Create a test request request := mcp.CallToolRequest{} - request.Params.Name = "list_resources" + request.Params.Name = ListResourcesToolName request.Params.Arguments = map[string]interface{}{ - "resource_type": "namespaced", + "resource_type": ResourceTypeNamespaced, "group": "apps", "version": "v1", "resource": "deployments", "namespace": "default", } - + // Test HandleListResources ctx := context.Background() result, err := impl.HandleListResources(ctx, request) - + // Verify there was no error assert.NoError(t, err, "HandleListResources should not return an error") - + // Verify the result is not nil assert.NotNil(t, result, "Result should not be nil") - + // Verify the result is successful assert.False(t, result.IsError, "Result should not be an error") - + // Get the text content textContent, ok := mcp.AsTextContent(result.Content[0]) assert.True(t, ok, "Content should be TextContent") - + // Verify the result contains the deployment name assert.Contains(t, textContent.Text, "test-deployment", "Result should contain the deployment name") - + // Verify the result contains the other annotation assert.Contains(t, textContent.Text, "deployment.kubernetes.io/revision", "Result should contain other annotations") - + // Verify the result does not contain the last-applied-configuration annotation assert.NotContains(t, textContent.Text, "kubectl.kubernetes.io/last-applied-configuration", "Result should not contain the kubectl.kubernetes.io/last-applied-configuration annotation") - + // Verify the result does not contain the spec field assert.NotContains(t, textContent.Text, "spec", "Result should not contain the spec field") -} \ No newline at end of file +} diff --git a/pkg/mcp/middleware.go b/pkg/mcp/middleware.go index ee3594a..759f3df 100644 --- a/pkg/mcp/middleware.go +++ b/pkg/mcp/middleware.go @@ -12,7 +12,7 @@ import ( // This helps prevent context cancellation errors from the k8s client func WithTimeoutContext(timeout time.Duration) server.ServerOption { return server.WithToolHandlerMiddleware(func(next server.ToolHandlerFunc) server.ToolHandlerFunc { - return func(ctx context.Context, request mcp.CallToolRequest) (result *mcp.CallToolResult, err error) { + return func(_ context.Context, request mcp.CallToolRequest) (result *mcp.CallToolResult, err error) { // Create a new context with the specified timeout timeoutCtx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() diff --git a/pkg/mcp/read_resource.go b/pkg/mcp/read_resource.go index c1e511c..ae89cb4 100644 --- a/pkg/mcp/read_resource.go +++ b/pkg/mcp/read_resource.go @@ -26,13 +26,13 @@ func parseURI(uri, prefix string) ([]string, error) { if !strings.HasPrefix(uri, prefix) { return nil, fmt.Errorf("invalid URI format: missing prefix %s", prefix) } - + // Get the path after the prefix path := uri[len(prefix):] - + // Split the path parts := strings.Split(path, "/") - + // Filter out empty parts (handles double slashes for empty group) filteredParts := []string{} for _, part := range parts { @@ -40,7 +40,7 @@ func parseURI(uri, prefix string) ([]string, error) { filteredParts = append(filteredParts, part) } } - + return filteredParts, nil } @@ -53,14 +53,14 @@ func parseClusteredResourceURI(uri string) (ResourceURIComponents, error) { if err != nil { return ResourceURIComponents{}, err } - + // Check if we have enough parts if len(parts) < 3 { return ResourceURIComponents{}, fmt.Errorf("invalid URI format: expected at least 3 parts after prefix, got %d", len(parts)) } - + components := ResourceURIComponents{} - + // Handle the case where the group is empty (core API group) if len(parts) == 3 { // Assume the group is empty (core API group) @@ -75,7 +75,7 @@ func parseClusteredResourceURI(uri string) (ResourceURIComponents, error) { components.Resource = parts[2] components.Name = parts[3] } - + return components, nil } @@ -88,15 +88,15 @@ func parseNamespacedResourceURI(uri string) (ResourceURIComponents, error) { if err != nil { return ResourceURIComponents{}, err } - + // Check if we have enough parts if len(parts) < 4 { return ResourceURIComponents{}, fmt.Errorf("invalid URI format: expected at least 4 parts after prefix, got %d", len(parts)) } - + components := ResourceURIComponents{} components.Namespace = parts[0] - + // Handle the case where the group is empty (core API group) if len(parts) == 4 { // Assume the group is empty (core API group) @@ -111,18 +111,21 @@ func parseNamespacedResourceURI(uri string) (ResourceURIComponents, error) { components.Resource = parts[3] components.Name = parts[4] } - + return components, nil } // HandleClusteredResource handles the clustered resource template -func (m *Implementation) HandleClusteredResource(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { +func (m *Implementation) HandleClusteredResource( + ctx context.Context, + request mcp.ReadResourceRequest, +) ([]mcp.ResourceContents, error) { // Parse the URI components, err := parseClusteredResourceURI(request.Params.URI) if err != nil { return nil, err } - + // Create GVR gvr := schema.GroupVersionResource{ Group: components.Group, @@ -152,7 +155,10 @@ func (m *Implementation) HandleClusteredResource(ctx context.Context, request mc } // HandleNamespacedResource handles the namespaced resource template -func (m *Implementation) HandleNamespacedResource(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { +func (m *Implementation) HandleNamespacedResource( + ctx context.Context, + request mcp.ReadResourceRequest, +) ([]mcp.ResourceContents, error) { // Parse the URI components, err := parseNamespacedResourceURI(request.Params.URI) if err != nil { @@ -185,4 +191,4 @@ func (m *Implementation) HandleNamespacedResource(ctx context.Context, request m Text: string(result), }, }, nil -} \ No newline at end of file +} diff --git a/pkg/mcp/read_resource_test.go b/pkg/mcp/read_resource_test.go index e337ab4..b5b21aa 100644 --- a/pkg/mcp/read_resource_test.go +++ b/pkg/mcp/read_resource_test.go @@ -20,10 +20,10 @@ import ( func TestHandleClusteredResource(t *testing.T) { // Create a mock k8s client with a fake discovery client mockClient := &k8s.Client{} - + // Create a fake discovery client fakeDiscoveryClient := &discoveryfake.FakeDiscovery{Fake: &testingfake.Fake{}} - + // Add some fake API resources fakeDiscoveryClient.Resources = []*metav1.APIResourceList{ { @@ -37,14 +37,14 @@ func TestHandleClusteredResource(t *testing.T) { }, }, } - + // Set the discovery client mockClient.SetDiscoveryClient(fakeDiscoveryClient) - + // Create a fake dynamic client scheme := runtime.NewScheme() fakeDynamicClient := dynamicfake.NewSimpleDynamicClient(scheme) - + // Create a test ClusterRole clusterRole := &unstructured.Unstructured{ Object: map[string]interface{}{ @@ -62,41 +62,41 @@ func TestHandleClusteredResource(t *testing.T) { }, }, } - + // Add a fake get response - fakeDynamicClient.PrependReactor("get", "clusterroles", func(action testingfake.Action) (handled bool, ret runtime.Object, err error) { + fakeDynamicClient.PrependReactor("get", "clusterroles", func(_ testingfake.Action) (handled bool, ret runtime.Object, err error) { return true, clusterRole, nil }) - + // Set the dynamic client mockClient.SetDynamicClient(fakeDynamicClient) - + // Create an implementation impl := NewImplementation(mockClient) - + // Create a test request request := mcp.ReadResourceRequest{} // URI format: k8s://clustered/{group}/{version}/{resource}/{name} request.Params.URI = "k8s://clustered/rbac.authorization.k8s.io/v1/clusterroles/test-cluster-role" - + // Test HandleClusteredResource result, err := impl.HandleClusteredResource(context.Background(), request) - + // Verify there was no error assert.NoError(t, err, "HandleClusteredResource should not return an error") - + // Verify the result is not nil assert.NotNil(t, result, "Result should not be nil") - + // Verify the result has the correct length assert.Len(t, result, 1, "Result should have 1 item") - + // Verify the result has the correct URI assert.Equal(t, request.Params.URI, result[0].(mcp.TextResourceContents).URI, "Result URI should match request URI") - + // Verify the result has the correct MIME type assert.Equal(t, "application/json", result[0].(mcp.TextResourceContents).MIMEType, "Result MIME type should be application/json") - + // Verify the result contains the ClusterRole name assert.Contains(t, result[0].(mcp.TextResourceContents).Text, "test-cluster-role", "Result should contain the ClusterRole name") } @@ -104,10 +104,10 @@ func TestHandleClusteredResource(t *testing.T) { func TestHandleNamespacedResource(t *testing.T) { // Create a mock k8s client with a fake discovery client mockClient := &k8s.Client{} - + // Create a fake discovery client fakeDiscoveryClient := &discoveryfake.FakeDiscovery{Fake: &testingfake.Fake{}} - + // Add some fake API resources fakeDiscoveryClient.Resources = []*metav1.APIResourceList{ { @@ -121,14 +121,14 @@ func TestHandleNamespacedResource(t *testing.T) { }, }, } - + // Set the discovery client mockClient.SetDiscoveryClient(fakeDiscoveryClient) - + // Create a fake dynamic client scheme := runtime.NewScheme() fakeDynamicClient := dynamicfake.NewSimpleDynamicClient(scheme) - + // Create a test service service := &unstructured.Unstructured{ Object: map[string]interface{}{ @@ -148,42 +148,42 @@ func TestHandleNamespacedResource(t *testing.T) { }, }, } - + // Add a fake get response - fakeDynamicClient.PrependReactor("get", "services", func(action testingfake.Action) (handled bool, ret runtime.Object, err error) { + fakeDynamicClient.PrependReactor("get", "services", func(_ testingfake.Action) (handled bool, ret runtime.Object, err error) { return true, service, nil }) - + // Set the dynamic client mockClient.SetDynamicClient(fakeDynamicClient) - + // Create an implementation impl := NewImplementation(mockClient) - + // Create a test request request := mcp.ReadResourceRequest{} // URI format: k8s://namespaced/{namespace}/{group}/{version}/{resource}/{name} // For core API group, the group is empty, but we need to include the slash request.Params.URI = "k8s://namespaced/default//v1/services/test-service" - + // Test HandleNamespacedResource result, err := impl.HandleNamespacedResource(context.Background(), request) - + // Verify there was no error assert.NoError(t, err, "HandleNamespacedResource should not return an error") - + // Verify the result is not nil assert.NotNil(t, result, "Result should not be nil") - + // Verify the result has the correct length assert.Len(t, result, 1, "Result should have 1 item") - + // Verify the result has the correct URI assert.Equal(t, request.Params.URI, result[0].(mcp.TextResourceContents).URI, "Result URI should match request URI") - + // Verify the result has the correct MIME type assert.Equal(t, "application/json", result[0].(mcp.TextResourceContents).MIMEType, "Result MIME type should be application/json") - + // Verify the result contains the service name assert.Contains(t, result[0].(mcp.TextResourceContents).Text, "test-service", "Result should contain the service name") } @@ -191,10 +191,10 @@ func TestHandleNamespacedResource(t *testing.T) { func TestHandleCoreClusteredResource(t *testing.T) { // Create a mock k8s client with a fake discovery client mockClient := &k8s.Client{} - + // Create a fake discovery client fakeDiscoveryClient := &discoveryfake.FakeDiscovery{Fake: &testingfake.Fake{}} - + // Add some fake API resources fakeDiscoveryClient.Resources = []*metav1.APIResourceList{ { @@ -208,14 +208,14 @@ func TestHandleCoreClusteredResource(t *testing.T) { }, }, } - + // Set the discovery client mockClient.SetDiscoveryClient(fakeDiscoveryClient) - + // Create a fake dynamic client scheme := runtime.NewScheme() fakeDynamicClient := dynamicfake.NewSimpleDynamicClient(scheme) - + // Create a test PersistentVolume pv := &unstructured.Unstructured{ Object: map[string]interface{}{ @@ -235,42 +235,42 @@ func TestHandleCoreClusteredResource(t *testing.T) { }, }, } - + // Add a fake get response - fakeDynamicClient.PrependReactor("get", "persistentvolumes", func(action testingfake.Action) (handled bool, ret runtime.Object, err error) { + fakeDynamicClient.PrependReactor("get", "persistentvolumes", func(_ testingfake.Action) (handled bool, ret runtime.Object, err error) { return true, pv, nil }) - + // Set the dynamic client mockClient.SetDynamicClient(fakeDynamicClient) - + // Create an implementation impl := NewImplementation(mockClient) - + // Create a test request request := mcp.ReadResourceRequest{} // URI format: k8s://clustered/{group}/{version}/{resource}/{name} // For core API group, the group is empty, but we need to include the slash request.Params.URI = "k8s://clustered//v1/persistentvolumes/test-pv" - + // Test HandleClusteredResource result, err := impl.HandleClusteredResource(context.Background(), request) - + // Verify there was no error assert.NoError(t, err, "HandleClusteredResource should not return an error") - + // Verify the result is not nil assert.NotNil(t, result, "Result should not be nil") - + // Verify the result has the correct length assert.Len(t, result, 1, "Result should have 1 item") - + // Verify the result has the correct URI assert.Equal(t, request.Params.URI, result[0].(mcp.TextResourceContents).URI, "Result URI should match request URI") - + // Verify the result has the correct MIME type assert.Equal(t, "application/json", result[0].(mcp.TextResourceContents).MIMEType, "Result MIME type should be application/json") - + // Verify the result contains the PV name assert.Contains(t, result[0].(mcp.TextResourceContents).Text, "test-pv", "Result should contain the PV name") } @@ -278,10 +278,10 @@ func TestHandleCoreClusteredResource(t *testing.T) { func TestHandleNamespacedResourceSingleSlash(t *testing.T) { // Create a mock k8s client with a fake discovery client mockClient := &k8s.Client{} - + // Create a fake discovery client fakeDiscoveryClient := &discoveryfake.FakeDiscovery{Fake: &testingfake.Fake{}} - + // Add some fake API resources fakeDiscoveryClient.Resources = []*metav1.APIResourceList{ { @@ -295,14 +295,14 @@ func TestHandleNamespacedResourceSingleSlash(t *testing.T) { }, }, } - + // Set the discovery client mockClient.SetDiscoveryClient(fakeDiscoveryClient) - + // Create a fake dynamic client scheme := runtime.NewScheme() fakeDynamicClient := dynamicfake.NewSimpleDynamicClient(scheme) - + // Create a test deployment deployment := &unstructured.Unstructured{ Object: map[string]interface{}{ @@ -322,42 +322,42 @@ func TestHandleNamespacedResourceSingleSlash(t *testing.T) { }, }, } - + // Add a fake get response - fakeDynamicClient.PrependReactor("get", "deployments", func(action testingfake.Action) (handled bool, ret runtime.Object, err error) { + fakeDynamicClient.PrependReactor("get", "deployments", func(_ testingfake.Action) (handled bool, ret runtime.Object, err error) { return true, deployment, nil }) - + // Set the dynamic client mockClient.SetDynamicClient(fakeDynamicClient) - + // Create an implementation impl := NewImplementation(mockClient) - + // Create a test request request := mcp.ReadResourceRequest{} // URI format: k8s://namespaced/{namespace}/{group}/{version}/{resource}/{name} // Using a single slash for the group/version request.Params.URI = "k8s://namespaced/default/apps/v1/deployments/test-deployment" - + // Test HandleNamespacedResource result, err := impl.HandleNamespacedResource(context.Background(), request) - + // Verify there was no error assert.NoError(t, err, "HandleNamespacedResource should not return an error") - + // Verify the result is not nil assert.NotNil(t, result, "Result should not be nil") - + // Verify the result has the correct length assert.Len(t, result, 1, "Result should have 1 item") - + // Verify the result has the correct URI assert.Equal(t, request.Params.URI, result[0].(mcp.TextResourceContents).URI, "Result URI should match request URI") - + // Verify the result has the correct MIME type assert.Equal(t, "application/json", result[0].(mcp.TextResourceContents).MIMEType, "Result MIME type should be application/json") - + // Verify the result contains the deployment name assert.Contains(t, result[0].(mcp.TextResourceContents).Text, "test-deployment", "Result should contain the deployment name") } @@ -393,11 +393,11 @@ func TestParseURI(t *testing.T) { errorMessage: "invalid URI format: missing prefix k8s://clustered/", }, } - + for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { parts, err := parseURI(tc.uri, tc.prefix) - + if tc.expectError { assert.Error(t, err, "parseURI should return an error") assert.Equal(t, tc.errorMessage, err.Error(), "Error message should match") @@ -412,11 +412,11 @@ func TestParseURI(t *testing.T) { func TestParseClusteredResourceURI(t *testing.T) { // Test cases for parseClusteredResourceURI testCases := []struct { - name string - uri string + name string + uri string expectedComponents ResourceURIComponents - expectError bool - errorMessage string + expectError bool + errorMessage string }{ { name: "Valid URI with group", @@ -455,11 +455,11 @@ func TestParseClusteredResourceURI(t *testing.T) { errorMessage: "invalid URI format: expected at least 3 parts after prefix, got 2", }, } - + for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { components, err := parseClusteredResourceURI(tc.uri) - + if tc.expectError { assert.Error(t, err, "parseClusteredResourceURI should return an error") assert.Equal(t, tc.errorMessage, err.Error(), "Error message should match") @@ -478,11 +478,11 @@ func TestParseClusteredResourceURI(t *testing.T) { func TestParseNamespacedResourceURI(t *testing.T) { // Test cases for parseNamespacedResourceURI testCases := []struct { - name string - uri string + name string + uri string expectedComponents ResourceURIComponents - expectError bool - errorMessage string + expectError bool + errorMessage string }{ { name: "Valid URI with group", @@ -521,11 +521,11 @@ func TestParseNamespacedResourceURI(t *testing.T) { errorMessage: "invalid URI format: expected at least 4 parts after prefix, got 3", }, } - + for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { components, err := parseNamespacedResourceURI(tc.uri) - + if tc.expectError { assert.Error(t, err, "parseNamespacedResourceURI should return an error") assert.Equal(t, tc.errorMessage, err.Error(), "Error message should match") @@ -553,7 +553,7 @@ func TestHandleClusteredResourceErrors(t *testing.T) { { name: "Invalid URI format", uri: "invalid://clustered/rbac.authorization.k8s.io/v1/clusterroles/test-cluster-role", - setupMock: func(mockClient *k8s.Client) { + setupMock: func(_ *k8s.Client) { // No setup needed }, expectError: true, @@ -566,12 +566,12 @@ func TestHandleClusteredResourceErrors(t *testing.T) { // Create a fake dynamic client scheme := runtime.NewScheme() fakeDynamicClient := dynamicfake.NewSimpleDynamicClient(scheme) - + // Add a fake get response with error - fakeDynamicClient.PrependReactor("get", "clusterroles", func(action testingfake.Action) (handled bool, ret runtime.Object, err error) { + fakeDynamicClient.PrependReactor("get", "clusterroles", func(_ testingfake.Action) (handled bool, ret runtime.Object, err error) { return true, nil, fmt.Errorf("resource not found") }) - + // Set the dynamic client mockClient.SetDynamicClient(fakeDynamicClient) }, @@ -579,25 +579,25 @@ func TestHandleClusteredResourceErrors(t *testing.T) { errorMessage: "failed to get resource: resource not found", }, } - + for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { // Create a mock k8s client mockClient := &k8s.Client{} - + // Setup the mock tc.setupMock(mockClient) - + // Create an implementation impl := NewImplementation(mockClient) - + // Create a test request request := mcp.ReadResourceRequest{} request.Params.URI = tc.uri - + // Test HandleClusteredResource result, err := impl.HandleClusteredResource(context.Background(), request) - + if tc.expectError { assert.Error(t, err, "HandleClusteredResource should return an error") assert.Contains(t, err.Error(), tc.errorMessage, "Error message should contain expected text") @@ -622,7 +622,7 @@ func TestHandleNamespacedResourceErrors(t *testing.T) { { name: "Invalid URI format", uri: "invalid://namespaced/default/apps/v1/deployments/test-deployment", - setupMock: func(mockClient *k8s.Client) { + setupMock: func(_ *k8s.Client) { // No setup needed }, expectError: true, @@ -635,12 +635,12 @@ func TestHandleNamespacedResourceErrors(t *testing.T) { // Create a fake dynamic client scheme := runtime.NewScheme() fakeDynamicClient := dynamicfake.NewSimpleDynamicClient(scheme) - + // Add a fake get response with error - fakeDynamicClient.PrependReactor("get", "deployments", func(action testingfake.Action) (handled bool, ret runtime.Object, err error) { + fakeDynamicClient.PrependReactor("get", "deployments", func(_ testingfake.Action) (handled bool, ret runtime.Object, err error) { return true, nil, fmt.Errorf("resource not found") }) - + // Set the dynamic client mockClient.SetDynamicClient(fakeDynamicClient) }, @@ -648,25 +648,25 @@ func TestHandleNamespacedResourceErrors(t *testing.T) { errorMessage: "failed to get resource: resource not found", }, } - + for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { // Create a mock k8s client mockClient := &k8s.Client{} - + // Setup the mock tc.setupMock(mockClient) - + // Create an implementation impl := NewImplementation(mockClient) - + // Create a test request request := mcp.ReadResourceRequest{} request.Params.URI = tc.uri - + // Test HandleNamespacedResource result, err := impl.HandleNamespacedResource(context.Background(), request) - + if tc.expectError { assert.Error(t, err, "HandleNamespacedResource should return an error") assert.Contains(t, err.Error(), tc.errorMessage, "Error message should contain expected text") @@ -677,4 +677,4 @@ func TestHandleNamespacedResourceErrors(t *testing.T) { } }) } -} \ No newline at end of file +} diff --git a/pkg/mcp/server_test.go b/pkg/mcp/server_test.go index 122a59f..3f330fb 100644 --- a/pkg/mcp/server_test.go +++ b/pkg/mcp/server_test.go @@ -16,10 +16,10 @@ import ( func TestCreateSSEServer(t *testing.T) { // Create a mock k8s client with a fake discovery client mockClient := &k8s.Client{} - + // Create a fake discovery client fakeDiscoveryClient := &discoveryfake.FakeDiscovery{Fake: &testingfake.Fake{}} - + // Add some fake API resources fakeDiscoveryClient.Resources = []*metav1.APIResourceList{ { @@ -33,14 +33,14 @@ func TestCreateSSEServer(t *testing.T) { }, }, } - + // Set the discovery client mockClient.SetDiscoveryClient(fakeDiscoveryClient) - + // Create a fake dynamic client scheme := runtime.NewScheme() fakeDynamicClient := dynamicfake.NewSimpleDynamicClient(scheme) - + // Set the dynamic client mockClient.SetDynamicClient(fakeDynamicClient) @@ -54,4 +54,4 @@ func TestCreateSSEServer(t *testing.T) { // Verify the server is not nil assert.NotNil(t, sseServer, "SSE server should not be nil") -} \ No newline at end of file +} diff --git a/pkg/mcp/tools_test.go b/pkg/mcp/tools_test.go index 769a666..dbe9a2d 100644 --- a/pkg/mcp/tools_test.go +++ b/pkg/mcp/tools_test.go @@ -14,22 +14,22 @@ func TestNewListResourcesTool(t *testing.T) { // Verify the tool has the required parameters schema := tool.InputSchema - + // Check that the schema has the correct type assert.Equal(t, "object", schema.Type, "Schema type should be 'object'") - + // Check that the required parameters are in the Required field assert.Contains(t, schema.Required, "resource_type", "resource_type should be required") assert.Contains(t, schema.Required, "version", "version should be required") assert.Contains(t, schema.Required, "resource", "resource should be required") - + // Check that the properties exist _, ok := schema.Properties["resource_type"] assert.True(t, ok, "Should have 'resource_type' parameter") - + _, ok = schema.Properties["version"] assert.True(t, ok, "Should have 'version' parameter") - + _, ok = schema.Properties["resource"] assert.True(t, ok, "Should have 'resource' parameter") } @@ -42,22 +42,22 @@ func TestNewApplyResourceTool(t *testing.T) { // Verify the tool has the required parameters schema := tool.InputSchema - + // Check that the schema has the correct type assert.Equal(t, "object", schema.Type, "Schema type should be 'object'") - + // Check that the required parameters are in the Required field assert.Contains(t, schema.Required, "resource_type", "resource_type should be required") assert.Contains(t, schema.Required, "version", "version should be required") assert.Contains(t, schema.Required, "resource", "resource should be required") - + // Check that the properties exist _, ok := schema.Properties["resource_type"] assert.True(t, ok, "Should have 'resource_type' parameter") - + _, ok = schema.Properties["version"] assert.True(t, ok, "Should have 'version' parameter") - + _, ok = schema.Properties["resource"] assert.True(t, ok, "Should have 'resource' parameter") @@ -105,4 +105,4 @@ func TestNewNamespacedResourceTemplate(t *testing.T) { // Verify the template description expectedDescription := "A Kubernetes namespaced resource" assert.Equal(t, expectedDescription, template.Description, "Description should match") -} \ No newline at end of file +} diff --git a/pkg/mcp/types.go b/pkg/mcp/types.go new file mode 100644 index 0000000..0e3b059 --- /dev/null +++ b/pkg/mcp/types.go @@ -0,0 +1,22 @@ +package mcp + +// Constants for resource types +const ( + ResourceTypeNamespaced = "namespaced" + ResourceTypeClustered = "clustered" + + // ApplyResourceToolName is the name of the apply_resource tool for tests + ApplyResourceToolName = "apply_resource" + + // DeleteResourceToolName is the name of the delete_resource tool for tests + DeleteResourceToolName = "delete_resource" + + // GetResourceToolName is the name of the get_resource tool for tests + GetResourceToolName = "get_resource" + + // ListResourcesToolName is the name of the list_resources tool for tests + ListResourcesToolName = "list_resources" + + // TestDeploymentName is the name of the test deployment + TestDeploymentName = "test-deployment" +)