diff --git a/README.md b/README.md index ee592bd5..a84abca1 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ A powerful and flexible Kubernetes [Model Context Protocol (MCP)](https://blog.m - **Install** a Helm chart in the current or provided namespace. - **List** Helm releases in all namespaces or in a specific namespace. - **Uninstall** a Helm release in the current or provided namespace. + - **History** - View revision history for a Helm release. Unlike other Kubernetes MCP server implementations, this **IS NOT** just a wrapper around `kubectl` or `helm` command-line tools. It is a **Go-based native implementation** that interacts directly with the Kubernetes API server. @@ -341,6 +342,11 @@ In case multi-cluster support is enabled (default) and you have access to multip - `name` (`string`) **(required)** - Name of the Helm release to uninstall - `namespace` (`string`) - Namespace to uninstall the Helm release from (Optional, current namespace if not provided) +- **helm_history** - Retrieve the revision history for a given Helm release + - `max` (`integer`) - Maximum number of revisions to retrieve (Optional, all revisions if not provided) + - `name` (`string`) **(required)** - Name of the Helm release to retrieve history for + - `namespace` (`string`) - Namespace of the Helm release (Optional, current namespace if not provided) + diff --git a/pkg/helm/helm.go b/pkg/helm/helm.go index 186b50df..7f6cb261 100644 --- a/pkg/helm/helm.go +++ b/pkg/helm/helm.go @@ -3,15 +3,16 @@ package helm import ( "context" "fmt" + "log" + "time" + "helm.sh/helm/v3/pkg/action" "helm.sh/helm/v3/pkg/chart/loader" "helm.sh/helm/v3/pkg/cli" "helm.sh/helm/v3/pkg/registry" "helm.sh/helm/v3/pkg/release" "k8s.io/cli-runtime/pkg/genericclioptions" - "log" "sigs.k8s.io/yaml" - "time" ) type Kubernetes interface { @@ -104,6 +105,30 @@ func (h *Helm) Uninstall(name string, namespace string) (string, error) { return fmt.Sprintf("Uninstalled release %s %s", uninstalledRelease.Release.Name, uninstalledRelease.Info), nil } +// History retrieves the revision history for a given Helm release +func (h *Helm) History(name string, namespace string, max int) (string, error) { + cfg, err := h.newAction(h.kubernetes.NamespaceOrDefault(namespace), false) + if err != nil { + return "", err + } + history := action.NewHistory(cfg) + releases, err := history.Run(name) + if err != nil { + return "", err + } + if len(releases) == 0 { + return fmt.Sprintf("No history found for release %s", name), nil + } + if max > 0 && len(releases) > max { + releases = releases[len(releases)-max:] + } + ret, err := yaml.Marshal(simplifyHistory(releases...)) + if err != nil { + return "", err + } + return string(ret), nil +} + func (h *Helm) newAction(namespace string, allNamespaces bool) (*action.Configuration, error) { cfg := new(action.Configuration) applicableNamespace := "" @@ -140,3 +165,20 @@ func simplify(release ...*release.Release) []map[string]interface{} { } return ret } + +func simplifyHistory(releases ...*release.Release) []map[string]interface{} { + ret := make([]map[string]interface{}, len(releases)) + for i, r := range releases { + ret[i] = map[string]interface{}{ + "revision": r.Version, + "updated": r.Info.LastDeployed.Format(time.RFC1123Z), + "status": r.Info.Status.String(), + "chart": fmt.Sprintf("%s-%s", r.Chart.Metadata.Name, r.Chart.Metadata.Version), + "appVersion": r.Chart.Metadata.AppVersion, + } + if r.Info.Description != "" { + ret[i]["description"] = r.Info.Description + } + } + return ret +} diff --git a/pkg/mcp/helm_test.go b/pkg/mcp/helm_test.go index 5d52c79c..ba8d9443 100644 --- a/pkg/mcp/helm_test.go +++ b/pkg/mcp/helm_test.go @@ -266,6 +266,106 @@ func (s *HelmSuite) TestHelmUninstallDenied() { }) } +func (s *HelmSuite) TestHelmHistoryNoReleases() { + s.InitMcpClient() + s.Run("helm_history(name=non-existent-release) with no releases", func() { + toolResult, err := s.CallTool("helm_history", map[string]interface{}{ + "name": "non-existent-release", + }) + s.Run("has error", func() { + s.Truef(toolResult.IsError, "call tool should fail for non-existent release") + s.Nilf(err, "call tool should not return error object") + }) + s.Run("describes error", func() { + s.Truef(strings.Contains(toolResult.Content[0].(mcp.TextContent).Text, "failed to retrieve helm history"), "expected descriptive error, got %v", toolResult.Content[0].(mcp.TextContent).Text) + }) + }) +} + +func (s *HelmSuite) TestHelmHistory() { + kc := kubernetes.NewForConfigOrDie(envTestRestConfig) + // Create multiple revisions of a release + for i := 1; i <= 3; i++ { + _, err := kc.CoreV1().Secrets("default").Create(s.T().Context(), &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "sh.helm.release.v1.release-with-history.v" + string(rune('0'+i)), + Labels: map[string]string{"owner": "helm", "name": "release-with-history", "version": string(rune('0' + i))}, + }, + Data: map[string][]byte{ + "release": []byte(base64.StdEncoding.EncodeToString([]byte("{" + + "\"name\":\"release-with-history\"," + + "\"version\":" + string(rune('0'+i)) + "," + + "\"info\":{\"status\":\"superseded\",\"last_deployed\":\"2024-01-01T00:00:00Z\",\"description\":\"Upgrade complete\"}," + + "\"chart\":{\"metadata\":{\"name\":\"test-chart\",\"version\":\"1.0.0\",\"appVersion\":\"1.0.0\"}}" + + "}"))), + }, + }, metav1.CreateOptions{}) + s.Require().NoError(err) + } + s.InitMcpClient() + s.Run("helm_history(name=release-with-history) with multiple revisions", func() { + toolResult, err := s.CallTool("helm_history", map[string]interface{}{ + "name": "release-with-history", + }) + s.Run("no error", func() { + s.Nilf(err, "call tool failed %v", err) + s.Falsef(toolResult.IsError, "call tool failed") + }) + s.Run("returns history", func() { + var decoded []map[string]interface{} + err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded) + s.Run("has yaml content", func() { + s.Nilf(err, "invalid tool result content %v", err) + }) + s.Run("has 3 items", func() { + s.Lenf(decoded, 3, "invalid helm history count, expected 3, got %v", len(decoded)) + }) + s.Run("has valid revision numbers", func() { + for i, item := range decoded { + expectedRevision := float64(i + 1) + s.Equalf(expectedRevision, item["revision"], "invalid revision for item %d, expected %v, got %v", i, expectedRevision, item["revision"]) + } + }) + s.Run("has valid status", func() { + s.Equalf("superseded", decoded[0]["status"], "invalid status, expected superseded, got %v", decoded[0]["status"]) + }) + s.Run("has valid chart", func() { + s.Equalf("test-chart-1.0.0", decoded[0]["chart"], "invalid chart, expected test-chart-1.0.0, got %v", decoded[0]["chart"]) + }) + s.Run("has valid appVersion", func() { + s.Equalf("1.0.0", decoded[0]["appVersion"], "invalid appVersion, expected 1.0.0, got %v", decoded[0]["appVersion"]) + }) + s.Run("has valid description", func() { + s.Equalf("Upgrade complete", decoded[0]["description"], "invalid description, expected 'Upgrade complete', got %v", decoded[0]["description"]) + }) + }) + }) + s.Run("helm_history(name=release-with-history, max=2) with max limit", func() { + toolResult, err := s.CallTool("helm_history", map[string]interface{}{ + "name": "release-with-history", + "max": 2, + }) + s.Run("no error", func() { + s.Nilf(err, "call tool failed %v", err) + s.Falsef(toolResult.IsError, "call tool failed") + }) + s.Run("returns limited history", func() { + var decoded []map[string]interface{} + err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded) + s.Run("has yaml content", func() { + s.Nilf(err, "invalid tool result content %v", err) + }) + s.Run("has 2 items", func() { + s.Lenf(decoded, 2, "invalid helm history count with max=2, expected 2, got %v", len(decoded)) + }) + s.Run("returns most recent revisions", func() { + s.Equalf(float64(2), decoded[0]["revision"], "expected revision 2, got %v", decoded[0]["revision"]) + s.Equalf(float64(3), decoded[1]["revision"], "expected revision 3, got %v", decoded[1]["revision"]) + }) + }) + }) +} + func clearHelmReleases(ctx context.Context, kc *kubernetes.Clientset) { secrets, _ := kc.CoreV1().Secrets("default").List(ctx, metav1.ListOptions{}) for _, secret := range secrets.Items { diff --git a/pkg/mcp/testdata/toolsets-full-tools-multicluster-enum.json b/pkg/mcp/testdata/toolsets-full-tools-multicluster-enum.json index 7831c054..a7f183a2 100644 --- a/pkg/mcp/testdata/toolsets-full-tools-multicluster-enum.json +++ b/pkg/mcp/testdata/toolsets-full-tools-multicluster-enum.json @@ -59,6 +59,45 @@ }, "name": "events_list" }, + { + "annotations": { + "title": "Helm: History", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + }, + "description": "Retrieve the revision history for a given Helm release", + "inputSchema": { + "type": "object", + "properties": { + "context": { + "description": "Optional parameter selecting which context to run the tool in. Defaults to fake-context if not set", + "enum": [ + "extra-cluster", + "fake-context" + ], + "type": "string" + }, + "max": { + "description": "Maximum number of revisions to retrieve (Optional, all revisions if not provided)", + "type": "integer" + }, + "name": { + "description": "Name of the Helm release to retrieve history for", + "type": "string" + }, + "namespace": { + "description": "Namespace of the Helm release (Optional, current namespace if not provided)", + "type": "string" + } + }, + "required": [ + "name" + ] + }, + "name": "helm_history" + }, { "annotations": { "title": "Helm: Install", diff --git a/pkg/mcp/testdata/toolsets-full-tools-multicluster.json b/pkg/mcp/testdata/toolsets-full-tools-multicluster.json index b95f179c..38704d90 100644 --- a/pkg/mcp/testdata/toolsets-full-tools-multicluster.json +++ b/pkg/mcp/testdata/toolsets-full-tools-multicluster.json @@ -55,6 +55,41 @@ }, "name": "events_list" }, + { + "annotations": { + "title": "Helm: History", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + }, + "description": "Retrieve the revision history for a given Helm release", + "inputSchema": { + "type": "object", + "properties": { + "context": { + "description": "Optional parameter selecting which context to run the tool in. Defaults to fake-context if not set", + "type": "string" + }, + "max": { + "description": "Maximum number of revisions to retrieve (Optional, all revisions if not provided)", + "type": "integer" + }, + "name": { + "description": "Name of the Helm release to retrieve history for", + "type": "string" + }, + "namespace": { + "description": "Namespace of the Helm release (Optional, current namespace if not provided)", + "type": "string" + } + }, + "required": [ + "name" + ] + }, + "name": "helm_history" + }, { "annotations": { "title": "Helm: Install", diff --git a/pkg/mcp/testdata/toolsets-full-tools-openshift.json b/pkg/mcp/testdata/toolsets-full-tools-openshift.json index e4488b0a..0f25b114 100644 --- a/pkg/mcp/testdata/toolsets-full-tools-openshift.json +++ b/pkg/mcp/testdata/toolsets-full-tools-openshift.json @@ -37,6 +37,37 @@ }, "name": "events_list" }, + { + "annotations": { + "title": "Helm: History", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + }, + "description": "Retrieve the revision history for a given Helm release", + "inputSchema": { + "type": "object", + "properties": { + "max": { + "description": "Maximum number of revisions to retrieve (Optional, all revisions if not provided)", + "type": "integer" + }, + "name": { + "description": "Name of the Helm release to retrieve history for", + "type": "string" + }, + "namespace": { + "description": "Namespace of the Helm release (Optional, current namespace if not provided)", + "type": "string" + } + }, + "required": [ + "name" + ] + }, + "name": "helm_history" + }, { "annotations": { "title": "Helm: Install", diff --git a/pkg/mcp/testdata/toolsets-full-tools.json b/pkg/mcp/testdata/toolsets-full-tools.json index ca270027..707c0d3e 100644 --- a/pkg/mcp/testdata/toolsets-full-tools.json +++ b/pkg/mcp/testdata/toolsets-full-tools.json @@ -36,6 +36,37 @@ } }, "name": "events_list" + }, + { + "annotations": { + "title": "Helm: History", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + }, + "description": "Retrieve the revision history for a given Helm release", + "inputSchema": { + "type": "object", + "properties": { + "max": { + "description": "Maximum number of revisions to retrieve (Optional, all revisions if not provided)", + "type": "integer" + }, + "name": { + "description": "Name of the Helm release to retrieve history for", + "type": "string" + }, + "namespace": { + "description": "Namespace of the Helm release (Optional, current namespace if not provided)", + "type": "string" + } + }, + "required": [ + "name" + ] + }, + "name": "helm_history" }, { "annotations": { diff --git a/pkg/mcp/testdata/toolsets-helm-tools.json b/pkg/mcp/testdata/toolsets-helm-tools.json index 6afd3f33..17462f02 100644 --- a/pkg/mcp/testdata/toolsets-helm-tools.json +++ b/pkg/mcp/testdata/toolsets-helm-tools.json @@ -1,4 +1,35 @@ [ + { + "annotations": { + "title": "Helm: History", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + }, + "description": "Retrieve the revision history for a given Helm release", + "inputSchema": { + "type": "object", + "properties": { + "max": { + "description": "Maximum number of revisions to retrieve (Optional, all revisions if not provided)", + "type": "integer" + }, + "name": { + "description": "Name of the Helm release to retrieve history for", + "type": "string" + }, + "namespace": { + "description": "Namespace of the Helm release (Optional, current namespace if not provided)", + "type": "string" + } + }, + "required": [ + "name" + ] + }, + "name": "helm_history" + }, { "annotations": { "title": "Helm: Install", diff --git a/pkg/toolsets/helm/helm.go b/pkg/toolsets/helm/helm.go index 646941f1..f64f3385 100644 --- a/pkg/toolsets/helm/helm.go +++ b/pkg/toolsets/helm/helm.go @@ -91,6 +91,35 @@ func initHelm() []api.ServerTool { OpenWorldHint: ptr.To(true), }, }, Handler: helmUninstall}, + {Tool: api.Tool{ + Name: "helm_history", + Description: "Retrieve the revision history for a given Helm release", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "name": { + Type: "string", + Description: "Name of the Helm release to retrieve history for", + }, + "namespace": { + Type: "string", + Description: "Namespace of the Helm release (Optional, current namespace if not provided)", + }, + "max": { + Type: "integer", + Description: "Maximum number of revisions to retrieve (Optional, all revisions if not provided)", + }, + }, + Required: []string{"name"}, + }, + Annotations: api.ToolAnnotations{ + Title: "Helm: History", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + IdempotentHint: ptr.To(true), + OpenWorldHint: ptr.To(true), + }, + }, Handler: helmHistory}, } } @@ -151,3 +180,24 @@ func helmUninstall(params api.ToolHandlerParams) (*api.ToolCallResult, error) { } return api.NewToolCallResult(ret, err), nil } + +func helmHistory(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + var name string + ok := false + if name, ok = params.GetArguments()["name"].(string); !ok { + return api.NewToolCallResult("", fmt.Errorf("failed to retrieve helm history, missing argument name")), nil + } + namespace := "" + if v, ok := params.GetArguments()["namespace"].(string); ok { + namespace = v + } + max := 0 + if v, ok := params.GetArguments()["max"].(float64); ok { + max = int(v) + } + ret, err := params.NewHelm().History(name, namespace, max) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to retrieve helm history for release '%s': %w", name, err)), nil + } + return api.NewToolCallResult(ret, err), nil +}