Skip to content

feat: implement helm history command #350

@manusa

Description

@manusa

Description

The helm history command is currently not implemented in helm-java. This command prints historical revisions for a given release, which is essential for auditing deployments and identifying which revision to rollback to.

Background

The helm history command displays a table with:

  • REVISION: Version number of the release
  • UPDATED: Timestamp of the change
  • STATUS: Current state (deployed, superseded, failed, etc.)
  • CHART: Chart name and version
  • APP VERSION: Application version
  • DESCRIPTION: Change description (e.g., "Initial install", "Upgraded successfully", "Rolled back to 2")

This command is commonly used in conjunction with helm rollback to identify target revisions. See the official documentation.

Related Issues

This addresses part of issue #97 which mentions missing history command but lacks implementation details.

Proposed API

Following the existing patterns in the codebase (similar to ListCommand), the implementation should provide a fluent API:

// Basic usage - returns list of release revisions
List<ReleaseHistory> history = Helm.history("my-release")
    .withKubeConfig(kubeConfigPath)
    .call();

// With namespace
List<ReleaseHistory> history = Helm.history("my-release")
    .withNamespace("my-namespace")
    .withKubeConfig(kubeConfigPath)
    .call();

// Limit number of revisions
List<ReleaseHistory> history = Helm.history("my-release")
    .withMax(10)
    .withKubeConfig(kubeConfigPath)
    .call();

// Using kubeconfig contents
List<ReleaseHistory> history = Helm.history("my-release")
    .withKubeConfigContents(kubeConfigYaml)
    .call();

Implementation Guide

1. Create ReleaseHistory result class (helm-java/src/main/java/com/marcnuri/helm/ReleaseHistory.java)

package com.marcnuri.helm;

import java.time.ZonedDateTime;

public class ReleaseHistory {
  private final int revision;
  private final ZonedDateTime updated;
  private final String status;
  private final String chart;
  private final String appVersion;
  private final String description;

  // Constructor, getters, and static parse methods
  // Similar pattern to Release.parseMultiple()
}

2. Create Go Options struct and function (native/internal/helm/history.go)

package helm

import (
    "bytes"
    "fmt"
    "net/url"
    "strconv"
    "time"

    "helm.sh/helm/v3/pkg/action"
)

type HistoryOptions struct {
    ReleaseName        string
    Max                int
    Namespace          string
    KubeConfig         string
    KubeConfigContents string
}

func History(options *HistoryOptions) (string, error) {
    cfg, err := NewCfg(&CfgOptions{
        KubeConfig:         options.KubeConfig,
        KubeConfigContents: options.KubeConfigContents,
        Namespace:          options.Namespace,
    })
    if err != nil {
        return "", err
    }
    
    client := action.NewHistory(cfg)
    releases, err := client.Run(options.ReleaseName)
    if err != nil {
        return "", err
    }

    // Apply Max filter manually since action.History.Run() does not honor the Max field.
    maxReleases := options.Max
    if maxReleases <= 0 {
        maxReleases = 256 // Default from Helm CLI
    }
    if len(releases) > maxReleases {
        releases = releases[len(releases)-maxReleases:]
    }
    
    // Format output using url.Values (consistent with list.go pattern)
    out := bytes.NewBuffer(make([]byte, 0))
    for _, rel := range releases {
        values := make(url.Values)
        values.Set("revision", strconv.Itoa(rel.Version))
        if tspb := rel.Info.LastDeployed; !tspb.IsZero() {
            values.Set("updated", tspb.Format(time.RFC1123Z))
        }
        values.Set("status", rel.Info.Status.String())
        values.Set("chart", formatChartname(rel.Chart))
        values.Set("appVersion", formatAppVersion(rel.Chart))
        values.Set("description", rel.Info.Description)
        _, _ = fmt.Fprintln(out, values.Encode())
    }
    return out.String(), nil
}

3. Add CGO export in native/main.go

Add the C struct definition:

struct HistoryOptions {
    char* releaseName;
    int max;
    char* namespace;
    char* kubeConfig;
    char* kubeConfigContents;
};

Add the export function:

//export History
func History(options *C.struct_HistoryOptions) C.Result {
    return runCommand(func() (string, error) {
        return helm.History(&helm.HistoryOptions{
            ReleaseName:        C.GoString(options.releaseName),
            Max:                int(options.max),
            Namespace:          C.GoString(options.namespace),
            KubeConfig:         C.GoString(options.kubeConfig),
            KubeConfigContents: C.GoString(options.kubeConfigContents),
        })
    })
}

4. Create JNA Options class (lib/api/src/main/java/com/marcnuri/helm/jni/HistoryOptions.java)

package com.marcnuri.helm.jni;

import com.sun.jna.Structure;

@Structure.FieldOrder({
  "releaseName",
  "max",
  "namespace",
  "kubeConfig",
  "kubeConfigContents"
})
public class HistoryOptions extends Structure {
  public String releaseName;
  public int max;
  public String namespace;
  public String kubeConfig;
  public String kubeConfigContents;

  public HistoryOptions(String releaseName, int max, String namespace, 
                        String kubeConfig, String kubeConfigContents) {
    this.releaseName = releaseName;
    this.max = max;
    this.namespace = namespace;
    this.kubeConfig = kubeConfig;
    this.kubeConfigContents = kubeConfigContents;
  }
}

5. Add method to HelmLib interface (lib/api/src/main/java/com/marcnuri/helm/jni/HelmLib.java)

Result History(HistoryOptions options);

6. Create HistoryCommand class (helm-java/src/main/java/com/marcnuri/helm/HistoryCommand.java)

package com.marcnuri.helm;

import com.marcnuri.helm.jni.HelmLib;
import com.marcnuri.helm.jni.HistoryOptions;
import java.nio.file.Path;
import java.util.List;

public class HistoryCommand extends HelmCommand<List<ReleaseHistory>> {

  private final String releaseName;
  private int max;
  private String namespace;
  private Path kubeConfig;
  private String kubeConfigContents;

  public HistoryCommand(HelmLib helmLib, String releaseName) {
    super(helmLib);
    this.releaseName = releaseName;
  }

  @Override
  public List<ReleaseHistory> call() {
    return ReleaseHistory.parseMultiple(run(hl -> hl.History(new HistoryOptions(
      releaseName,
      max,
      namespace,
      toString(kubeConfig),
      kubeConfigContents
    ))));
  }

  /**
   * Maximum number of revisions to include in history.
   * Default is 256.
   *
   * @param max maximum number of revisions.
   * @return this {@link HistoryCommand} instance.
   */
  public HistoryCommand withMax(int max) {
    this.max = max;
    return this;
  }

  /**
   * Kubernetes namespace scope for this request.
   *
   * @param namespace the Kubernetes namespace for this request.
   * @return this {@link HistoryCommand} instance.
   */
  public HistoryCommand withNamespace(String namespace) {
    this.namespace = namespace;
    return this;
  }

  /**
   * Set the path to the ~/.kube/config file to use.
   *
   * @param kubeConfig the path to kube config file.
   * @return this {@link HistoryCommand} instance.
   */
  public HistoryCommand withKubeConfig(Path kubeConfig) {
    this.kubeConfig = kubeConfig;
    return this;
  }

  /**
   * Set the kube config to use.
   *
   * @param kubeConfigContents the contents of the kube config file.
   * @return this {@link HistoryCommand} instance.
   */
  public HistoryCommand withKubeConfigContents(String kubeConfigContents) {
    this.kubeConfigContents = kubeConfigContents;
    return this;
  }
}

7. Add factory method in Helm.java

/**
 * Fetch release history.
 *
 * @param releaseName name of the release.
 * @return a new {@link HistoryCommand} instance.
 */
public static HistoryCommand history(String releaseName) {
  return new HistoryCommand(HelmLibHolder.INSTANCE, releaseName);
}

8. Add tests as nested class in HelmKubernetesTest

IMPORTANT: Tests that require a KinD container must be added as a nested class within HelmKubernetesTest, not as a separate test file. This is for performance reasons - all Kubernetes integration tests share a single KinD container instance that is started once in @BeforeAll and stopped in @AfterAll.

Add the following nested class to HelmKubernetesTest:

@Nested
class History {

  @Nested
  class Valid {

    @Test
    void afterInstall() {
      helm.install()
        .withKubeConfig(kubeConfigFile)
        .withName("history-after-install")
        .call();
      final List<ReleaseHistory> result = Helm.history("history-after-install")
        .withKubeConfig(kubeConfigFile)
        .call();
      assertThat(result)
        .hasSize(1)
        .first()
        .returns(1, ReleaseHistory::getRevision)
        .extracting(ReleaseHistory::getDescription).asString()
        .containsIgnoringCase("Install complete");
    }

    @Test
    void afterUpgrade() {
      helm.install()
        .withKubeConfig(kubeConfigFile)
        .withName("history-after-upgrade")
        .call();
      helm.upgrade()
        .withKubeConfig(kubeConfigFile)
        .withName("history-after-upgrade")
        .set("image.tag", "latest")
        .call();
      final List<ReleaseHistory> result = Helm.history("history-after-upgrade")
        .withKubeConfig(kubeConfigFile)
        .call();
      assertThat(result).hasSize(2);
      assertThat(result.get(0).getDescription()).containsIgnoringCase("Install complete");
      assertThat(result.get(1).getDescription()).containsIgnoringCase("Upgrade complete");
    }

    @Test
    void withMax() {
      helm.install()
        .withKubeConfig(kubeConfigFile)
        .withName("history-with-max")
        .call();
      helm.upgrade()
        .withKubeConfig(kubeConfigFile)
        .withName("history-with-max")
        .set("image.tag", "v1")
        .call();
      helm.upgrade()
        .withKubeConfig(kubeConfigFile)
        .withName("history-with-max")
        .set("image.tag", "v2")
        .call();
      final List<ReleaseHistory> result = Helm.history("history-with-max")
        .withKubeConfig(kubeConfigFile)
        .withMax(2)
        .call();
      assertThat(result)
        .hasSize(2)
        .extracting(ReleaseHistory::getRevision)
        .containsExactly(2, 3);
    }

    @Test
    void withNamespace() {
      helm.install()
        .withKubeConfig(kubeConfigFile)
        .withName("history-with-namespace")
        .withNamespace("history-namespace")
        .createNamespace()
        .call();
      final List<ReleaseHistory> result = Helm.history("history-with-namespace")
        .withKubeConfig(kubeConfigFile)
        .withNamespace("history-namespace")
        .call();
      assertThat(result)
        .hasSize(1)
        .first()
        .returns(1, ReleaseHistory::getRevision);
    }

    @Test
    void withKubeConfigContents() {
      helm.install()
        .withKubeConfig(kubeConfigFile)
        .withName("history-with-kube-config-contents")
        .call();
      final List<ReleaseHistory> result = Helm.history("history-with-kube-config-contents")
        .withKubeConfigContents(kubeConfigContents)
        .call();
      assertThat(result)
        .hasSize(1)
        .first()
        .returns(1, ReleaseHistory::getRevision);
    }
  }

  @Nested
  class Invalid {

    @Test
    void nonExistentRelease() {
      final HistoryCommand historyCommand = Helm.history("non-existent-release")
        .withKubeConfig(kubeConfigFile);
      assertThatThrownBy(historyCommand::call)
        .isInstanceOf(IllegalStateException.class)
        .hasMessageContaining("release: not found");
    }
  }
}

Acceptance Criteria

  • Create ReleaseHistory result class in helm-java module (with Apache License header)
  • Create HistoryOptions Go struct in native/internal/helm/history.go
  • Implement History function in Go using action.NewHistory and url.Values pattern
  • Add CGO export History in native/main.go
  • Create HistoryOptions.java JNA structure in lib/api (with Apache License header)
  • Add History method to HelmLib interface
  • Create HistoryCommand.java in helm-java module (with Apache License header)
  • Add history(String releaseName) factory method to Helm.java
  • Add integration tests as nested class in HelmKubernetesTest (NOT as a separate test file)

Tests

Following the project's testing philosophy (black-box, no mocks, nested structure):

Tests should be added as a nested History class within HelmKubernetesTest:

  • HelmKubernetesTest.History
    • Valid
      • afterInstall - History shows single revision after fresh install
      • afterUpgrade - History shows multiple revisions after upgrade
      • withMax - Limit number of returned revisions
      • withNamespace - Get history with explicit namespace
      • withKubeConfigContents - Use inline kubeconfig
    • Invalid
      • nonExistentRelease - Should throw appropriate exception

Additional Information

Example CLI Output

$ helm history angry-bird
REVISION    UPDATED                     STATUS          CHART             APP VERSION     DESCRIPTION
1           Mon Oct 3 10:15:13 2016     superseded      alpine-0.1.0      1.0             Initial install
2           Mon Oct 3 10:15:13 2016     superseded      alpine-0.1.0      1.0             Upgraded successfully
3           Mon Oct 3 10:15:13 2016     superseded      alpine-0.1.0      1.0             Rolled back to 2
4           Mon Oct 3 10:15:13 2016     deployed        alpine-0.1.0      1.0             Upgraded successfully

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions