From ba32a571904a2ee9cc3b2ad5bb3d894b5962ead6 Mon Sep 17 00:00:00 2001 From: Matthias Wessendorf Date: Fri, 6 Feb 2026 12:10:54 +0100 Subject: [PATCH] feat: add operations for helm Signed-off-by: Matthias Wessendorf --- .github/workflows/test.yaml | 5 + README.md | 91 +++++++++++++++ pkg/extension/extension.go | 6 +- pkg/extension/helm.go | 189 ++++++++++++++++++++++++++++++ pkg/extension/helm_test.go | 223 ++++++++++++++++++++++++++++++++++++ pkg/extension/operations.go | 76 +++++++++++- 6 files changed, 587 insertions(+), 3 deletions(-) create mode 100644 pkg/extension/helm.go create mode 100644 pkg/extension/helm_test.go diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index c6da8c5..23179a4 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -16,5 +16,10 @@ jobs: with: go-version-file: go.mod + - name: Install Helm + uses: azure/setup-helm@v4 + with: + version: 'latest' + - name: Run tests run: make test diff --git a/README.md b/README.md index b125b06..c9e3f81 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,9 @@ This extension enables declarative Kubernetes interactions within mcpchecker tas | `kubernetes.create` | Create a Kubernetes resource | | `kubernetes.delete` | Delete a Kubernetes resource | | `kubernetes.getCurrentContext` | Get the current context from kubeconfig | +| `kubernetes.helmInstall` | Install a Helm chart as a release | +| `kubernetes.helmList` | List Helm releases in a namespace or all namespaces | +| `kubernetes.helmUninstall` | Uninstall a Helm release | | `kubernetes.listContexts` | List all contexts from kubeconfig | | `kubernetes.viewConfig` | View kubeconfig as YAML (optionally minified) | | `kubernetes.wait` | Wait for a condition on a resource (e.g., `Ready`, `Available`) | @@ -87,6 +90,52 @@ spec: inline: Create an nginx pod named web-server in the test-namespace namespace ``` +### Helm Example + +Test Helm operations using declarative setup and cleanup: + +```yaml +kind: Task +apiVersion: gevals/v1alpha2 +metadata: + name: "list-helm-releases" + difficulty: easy +spec: + requires: + - extension: kubernetes + + setup: + # Create namespace + - kubernetes.create: + apiVersion: v1 + kind: Namespace + metadata: + name: helm-test + + # Install a test release + - kubernetes.helmInstall: + chart: oci://registry-1.docker.io/bitnamicharts/nginx + name: test-nginx + namespace: helm-test + + cleanup: + # Uninstall the release + - kubernetes.helmUninstall: + name: test-nginx + namespace: helm-test + + # Delete namespace + - kubernetes.delete: + apiVersion: v1 + kind: Namespace + metadata: + name: helm-test + ignoreNotFound: true + + prompt: + inline: List all Helm releases in the cluster +``` + ## Operation Reference ### kubernetes.create @@ -135,6 +184,48 @@ Waits for a condition on a resource. Supports configurable timeout and expected timeout: 5m # optional, defaults to 60s ``` +### kubernetes.helmInstall + +Installs a Helm chart as a release. Supports chart repositories and OCI registries. + +```yaml +- kubernetes.helmInstall: + chart: oci://registry-1.docker.io/bitnamicharts/nginx + name: my-nginx # optional, generates name if not provided + namespace: default # optional + values: # optional Helm values + replicaCount: 2 + service: + type: LoadBalancer +``` + +### kubernetes.helmList + +Lists Helm releases in a namespace or across all namespaces. + +```yaml +# List in specific namespace +- kubernetes.helmList: + namespace: default + +# List across all namespaces +- kubernetes.helmList: + allNamespaces: true +``` + +**Outputs:** +- `releases`: Information about found Helm releases (name, namespace, status, chart) + +### kubernetes.helmUninstall + +Uninstalls a Helm release. Gracefully handles releases that don't exist. + +```yaml +- kubernetes.helmUninstall: + name: my-nginx + namespace: default # optional +``` + ### kubernetes.listContexts Lists all contexts from the kubeconfig file, including which one is currently active. diff --git a/pkg/extension/extension.go b/pkg/extension/extension.go index 64cd068..d5aafec 100644 --- a/pkg/extension/extension.go +++ b/pkg/extension/extension.go @@ -17,9 +17,10 @@ import ( // Extension wraps the SDK extension with Kubernetes client type Extension struct { *sdk.Extension - client ResourceClient + client ResourceClient + kubeconfigPath string - mu sync.Mutex + mu sync.Mutex generatedNamespaces []string } @@ -89,6 +90,7 @@ func (e *Extension) handleInitialize(config map[string]any) error { authzClient: authzClient, kubeconfigPath: kubeconfigPath, } + e.kubeconfigPath = kubeconfigPath return nil } diff --git a/pkg/extension/helm.go b/pkg/extension/helm.go new file mode 100644 index 0000000..fca89f4 --- /dev/null +++ b/pkg/extension/helm.go @@ -0,0 +1,189 @@ +package extension + +import ( + "context" + "encoding/json" + "fmt" + "os/exec" + "strings" + + "github.com/mcpchecker/mcpchecker/pkg/extension/sdk" +) + +// handleHelmInstall installs a Helm chart as a release +func (e *Extension) handleHelmInstall(ctx context.Context, req *sdk.OperationRequest) (*sdk.OperationResult, error) { + args, ok := req.Args.(map[string]any) + if !ok { + return sdk.Failure(fmt.Errorf("args must be an object")), nil + } + + chart, ok := args["chart"].(string) + if !ok || chart == "" { + return sdk.Failure(fmt.Errorf("chart parameter is required")), nil + } + + // Optional parameters + name, _ := args["name"].(string) + namespace, _ := args["namespace"].(string) + values, _ := args["values"].(map[string]interface{}) + + cmdArgs := []string{"install"} + + if name != "" { + cmdArgs = append(cmdArgs, name) + } else { + cmdArgs = append(cmdArgs, "--generate-name") + } + + cmdArgs = append(cmdArgs, chart) + + if namespace != "" { + cmdArgs = append(cmdArgs, "--namespace", namespace) + } + + if e.kubeconfigPath != "" { + cmdArgs = append(cmdArgs, "--kubeconfig", e.kubeconfigPath) + } + + // Add values as --set flags + for k, v := range values { + cmdArgs = append(cmdArgs, "--set", fmt.Sprintf("%s=%v", k, v)) + } + + e.LogInfo(ctx, "Installing Helm chart", map[string]any{ + "chart": chart, + "name": name, + "namespace": namespace, + }) + + cmd := exec.CommandContext(ctx, "helm", cmdArgs...) + output, err := cmd.CombinedOutput() + if err != nil { + e.LogError(ctx, "Helm install failed", map[string]any{ + "chart": chart, + "error": err.Error(), + }) + return sdk.Failure(fmt.Errorf("helm install failed: %s\nOutput: %s", err, string(output))), nil + } + + e.LogInfo(ctx, "Helm chart installed successfully", map[string]any{ + "chart": chart, + "name": name, + }) + + return sdk.Success(fmt.Sprintf("Helm chart installed successfully\n%s", string(output))), nil +} + +// handleHelmList lists Helm releases +func (e *Extension) handleHelmList(ctx context.Context, req *sdk.OperationRequest) (*sdk.OperationResult, error) { + args, ok := req.Args.(map[string]any) + if !ok { + return sdk.Failure(fmt.Errorf("args must be an object")), nil + } + + cmdArgs := []string{"list", "--output", "json"} + + namespace, _ := args["namespace"].(string) + allNamespaces, _ := args["allNamespaces"].(bool) + + if allNamespaces { + cmdArgs = append(cmdArgs, "--all-namespaces") + } else if namespace != "" { + cmdArgs = append(cmdArgs, "--namespace", namespace) + } + + if e.kubeconfigPath != "" { + cmdArgs = append(cmdArgs, "--kubeconfig", e.kubeconfigPath) + } + + e.LogInfo(ctx, "Listing Helm releases", map[string]any{ + "namespace": namespace, + "allNamespaces": allNamespaces, + }) + + cmd := exec.CommandContext(ctx, "helm", cmdArgs...) + output, err := cmd.CombinedOutput() + if err != nil { + e.LogError(ctx, "Helm list failed", map[string]any{ + "error": err.Error(), + }) + return sdk.Failure(fmt.Errorf("helm list failed: %s\nOutput: %s", err, string(output))), nil + } + + // Parse JSON output + var releases []map[string]interface{} + if len(output) > 0 { + if err := json.Unmarshal(output, &releases); err != nil { + return sdk.Failure(fmt.Errorf("failed to parse helm list output: %s", err)), nil + } + } + + if len(releases) == 0 { + return sdk.Success("No Helm releases found"), nil + } + + // Format releases as a readable string + var result strings.Builder + result.WriteString(fmt.Sprintf("Found %d Helm release(s):\n", len(releases))) + for _, release := range releases { + name, _ := release["name"].(string) + ns, _ := release["namespace"].(string) + status, _ := release["status"].(string) + chart, _ := release["chart"].(string) + result.WriteString(fmt.Sprintf(" - %s (namespace: %s, status: %s, chart: %s)\n", name, ns, status, chart)) + } + + return sdk.Success(result.String()), nil +} + +// handleHelmUninstall uninstalls a Helm release +func (e *Extension) handleHelmUninstall(ctx context.Context, req *sdk.OperationRequest) (*sdk.OperationResult, error) { + args, ok := req.Args.(map[string]any) + if !ok { + return sdk.Failure(fmt.Errorf("args must be an object")), nil + } + + name, ok := args["name"].(string) + if !ok || name == "" { + return sdk.Failure(fmt.Errorf("name parameter is required")), nil + } + + cmdArgs := []string{"uninstall", name} + + namespace, _ := args["namespace"].(string) + if namespace != "" { + cmdArgs = append(cmdArgs, "--namespace", namespace) + } + + if e.kubeconfigPath != "" { + cmdArgs = append(cmdArgs, "--kubeconfig", e.kubeconfigPath) + } + + e.LogInfo(ctx, "Uninstalling Helm release", map[string]any{ + "name": name, + "namespace": namespace, + }) + + cmd := exec.CommandContext(ctx, "helm", cmdArgs...) + output, err := cmd.CombinedOutput() + if err != nil { + // Check if it's a "not found" error + if strings.Contains(string(output), "not found") { + e.LogInfo(ctx, "Helm release not found (ignored)", map[string]any{ + "name": name, + }) + return sdk.Success(fmt.Sprintf("Helm release '%s' not found (already uninstalled)", name)), nil + } + e.LogError(ctx, "Helm uninstall failed", map[string]any{ + "name": name, + "error": err.Error(), + }) + return sdk.Failure(fmt.Errorf("helm uninstall failed: %s\nOutput: %s", err, string(output))), nil + } + + e.LogInfo(ctx, "Helm release uninstalled successfully", map[string]any{ + "name": name, + }) + + return sdk.Success(fmt.Sprintf("Helm release '%s' uninstalled successfully\n%s", name, string(output))), nil +} diff --git a/pkg/extension/helm_test.go b/pkg/extension/helm_test.go new file mode 100644 index 0000000..185798d --- /dev/null +++ b/pkg/extension/helm_test.go @@ -0,0 +1,223 @@ +package extension + +import ( + "context" + "os/exec" + "strings" + "testing" + + "github.com/mcpchecker/mcpchecker/pkg/extension/sdk" +) + +// helmAvailable checks if the helm CLI is available in PATH +func helmAvailable() bool { + _, err := exec.LookPath("helm") + return err == nil +} + +// kubernetesAvailable checks if a Kubernetes cluster is reachable via helm +func kubernetesAvailable() bool { + cmd := exec.Command("helm", "list") + output, err := cmd.CombinedOutput() + if err != nil { + return !strings.Contains(string(output), "unreachable") + } + return true +} + +func TestHandleHelmInstall(t *testing.T) { + tests := []struct { + name string + args any + wantSuccess bool + wantErrMsg string + }{ + { + name: "missing chart parameter", + args: map[string]any{}, + wantSuccess: false, + wantErrMsg: "chart parameter is required", + }, + { + name: "empty chart parameter", + args: map[string]any{ + "chart": "", + }, + wantSuccess: false, + wantErrMsg: "chart parameter is required", + }, + { + name: "invalid args type", + args: "invalid", + wantSuccess: false, + wantErrMsg: "args must be an object", + }, + { + name: "valid chart parameter", + args: map[string]any{ + "chart": "oci://registry.io/chart/nginx", + "name": "test-release", + }, + // Will fail because helm isn't actually run, but we test parameter validation + wantSuccess: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ext := &Extension{ + Extension: sdk.NewExtension(sdk.ExtensionInfo{Name: "test"}), + } + + req := &sdk.OperationRequest{Args: tt.args} + result, err := ext.handleHelmInstall(context.Background(), req) + + if err != nil { + t.Fatalf("handleHelmInstall() returned error: %v", err) + } + + if result.Success != tt.wantSuccess { + t.Errorf("handleHelmInstall() success = %v, want %v", result.Success, tt.wantSuccess) + } + + if tt.wantErrMsg != "" && result.Message != "" { + // Check if error message contains expected substring + // (we don't check exact match because helm error messages may vary) + if result.Success { + t.Errorf("expected failure with message containing %q, but got success", tt.wantErrMsg) + } + } + }) + } +} + +func TestHandleHelmList(t *testing.T) { + tests := []struct { + name string + args any + wantSuccess bool + requireHelm bool // true if test actually executes helm + }{ + { + name: "invalid args type", + args: "invalid", + wantSuccess: false, + requireHelm: false, // parameter validation only + }, + { + name: "valid empty args", + args: map[string]any{}, + // helm list succeeds even with no releases (returns empty list) + wantSuccess: true, + requireHelm: true, + }, + { + name: "with namespace", + args: map[string]any{ + "namespace": "default", + }, + wantSuccess: true, + requireHelm: true, + }, + { + name: "all namespaces", + args: map[string]any{ + "allNamespaces": true, + }, + wantSuccess: true, + requireHelm: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.requireHelm && (!helmAvailable() || !kubernetesAvailable()) { + t.Skip("helm CLI or Kubernetes cluster not available, skipping test that requires helm") + } + ext := &Extension{ + Extension: sdk.NewExtension(sdk.ExtensionInfo{Name: "test"}), + } + + req := &sdk.OperationRequest{Args: tt.args} + result, err := ext.handleHelmList(context.Background(), req) + + if err != nil { + t.Fatalf("handleHelmList() returned error: %v", err) + } + + if result.Success != tt.wantSuccess { + t.Errorf("handleHelmList() success = %v, want %v", result.Success, tt.wantSuccess) + } + }) + } +} + +func TestHandleHelmUninstall(t *testing.T) { + tests := []struct { + name string + args any + wantSuccess bool + requireHelm bool // true if test actually executes helm + }{ + { + name: "missing name parameter", + args: map[string]any{}, + wantSuccess: false, + requireHelm: false, // parameter validation only + }, + { + name: "empty name parameter", + args: map[string]any{ + "name": "", + }, + wantSuccess: false, + requireHelm: false, // parameter validation only + }, + { + name: "invalid args type", + args: "invalid", + wantSuccess: false, + requireHelm: false, // parameter validation only + }, + { + name: "valid name parameter for non-existent release", + args: map[string]any{ + "name": "test-release-nonexistent", + }, + // Succeeds because code handles "not found" gracefully + wantSuccess: true, + requireHelm: true, + }, + { + name: "with namespace", + args: map[string]any{ + "name": "test-release-nonexistent", + "namespace": "default", + }, + wantSuccess: true, + requireHelm: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.requireHelm && (!helmAvailable() || !kubernetesAvailable()) { + t.Skip("helm CLI or Kubernetes cluster not available, skipping test that requires helm") + } + ext := &Extension{ + Extension: sdk.NewExtension(sdk.ExtensionInfo{Name: "test"}), + } + + req := &sdk.OperationRequest{Args: tt.args} + result, err := ext.handleHelmUninstall(context.Background(), req) + + if err != nil { + t.Fatalf("handleHelmUninstall() returned error: %v", err) + } + + if result.Success != tt.wantSuccess { + t.Errorf("handleHelmUninstall() success = %v, want %v", result.Success, tt.wantSuccess) + } + }) + } +} diff --git a/pkg/extension/operations.go b/pkg/extension/operations.go index bbcad29..5bb481e 100644 --- a/pkg/extension/operations.go +++ b/pkg/extension/operations.go @@ -1,8 +1,8 @@ package extension import ( - "github.com/mcpchecker/mcpchecker/pkg/extension/sdk" "github.com/google/jsonschema-go/jsonschema" + "github.com/mcpchecker/mcpchecker/pkg/extension/sdk" ) // registerOperations adds all available Kubernetes operations to the extension. @@ -221,4 +221,78 @@ func (e *Extension) registerOperations() { ), e.handleDeleteGeneratedNamespaces, ) + + // Helm operations + e.AddOperation( + sdk.NewOperation("helmInstall", + sdk.WithDescription("Install a Helm chart as a release"), + sdk.WithParams(jsonschema.Schema{ + Type: "object", + Description: "Helm chart installation parameters", + Properties: map[string]*jsonschema.Schema{ + "chart": { + Type: "string", + Description: "Chart reference (e.g., bitnami/nginx, oci://registry-1.docker.io/bitnamicharts/nginx)", + }, + "name": { + Type: "string", + Description: "Release name (optional, generates name if not provided)", + }, + "namespace": { + Type: "string", + Description: "Target namespace (optional)", + }, + "values": { + Type: "object", + Description: "Helm values to set (optional)", + }, + }, + Required: []string{"chart"}, + }), + ), + e.handleHelmInstall, + ) + + e.AddOperation( + sdk.NewOperation("helmList", + sdk.WithDescription("List Helm releases"), + sdk.WithParams(jsonschema.Schema{ + Type: "object", + Description: "Helm list parameters", + Properties: map[string]*jsonschema.Schema{ + "namespace": { + Type: "string", + Description: "Namespace to list releases from (optional)", + }, + "allNamespaces": { + Type: "boolean", + Description: "List releases from all namespaces (default: false)", + }, + }, + }), + ), + e.handleHelmList, + ) + + e.AddOperation( + sdk.NewOperation("helmUninstall", + sdk.WithDescription("Uninstall a Helm release"), + sdk.WithParams(jsonschema.Schema{ + Type: "object", + Description: "Helm uninstall parameters", + Properties: map[string]*jsonschema.Schema{ + "name": { + Type: "string", + Description: "Release name to uninstall", + }, + "namespace": { + Type: "string", + Description: "Release namespace (optional)", + }, + }, + Required: []string{"name"}, + }), + ), + e.handleHelmUninstall, + ) }