diff --git a/helm-java/src/main/java/com/marcnuri/helm/Helm.java b/helm-java/src/main/java/com/marcnuri/helm/Helm.java index 2a29a2d4..a2282764 100644 --- a/helm-java/src/main/java/com/marcnuri/helm/Helm.java +++ b/helm-java/src/main/java/com/marcnuri/helm/Helm.java @@ -60,6 +60,16 @@ public DependencyCommand dependency() { return new DependencyCommand(HelmLibHolder.INSTANCE, path); } + /** + * 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); + } + /** * This commands installs the referenced chart archive. * diff --git a/helm-java/src/main/java/com/marcnuri/helm/HistoryCommand.java b/helm-java/src/main/java/com/marcnuri/helm/HistoryCommand.java new file mode 100644 index 00000000..fdf65fbd --- /dev/null +++ b/helm-java/src/main/java/com/marcnuri/helm/HistoryCommand.java @@ -0,0 +1,95 @@ +/* + * Copyright 2024 Marc Nuri + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +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; + +/** + * @author Giuseppe Cardaropoli + */ +public class HistoryCommand extends HelmCommand>{ + + 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 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; + } +} diff --git a/helm-java/src/main/java/com/marcnuri/helm/ReleaseHistory.java b/helm-java/src/main/java/com/marcnuri/helm/ReleaseHistory.java new file mode 100644 index 00000000..84e1197a --- /dev/null +++ b/helm-java/src/main/java/com/marcnuri/helm/ReleaseHistory.java @@ -0,0 +1,105 @@ +/* + * Copyright 2024 Marc Nuri + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.marcnuri.helm; + +import static com.marcnuri.helm.HelmCommand.parseUrlEncodedLines; + +import com.marcnuri.helm.jni.Result; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * @author Giuseppe Cardaropoli + */ +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; + + @SuppressWarnings("java:S107") + private ReleaseHistory(int revision, ZonedDateTime updated, String status, String chart, String appVersion, String description) { + this.revision = revision; + this.updated = updated; + this.status = status; + this.chart = chart; + this.appVersion = appVersion; + this.description = description; + } + + public int getRevision() { + return revision; + } + + public ZonedDateTime getUpdated() { + return updated; + } + + public String getStatus() { + return status; + } + + public String getChart() { + return chart; + } + + public String getAppVersion() { + return appVersion; + } + + public String getDescription() { + return description; + } + + static List parseMultiple(Result result) { + if (result == null) { + throw new IllegalArgumentException("Result cannot be null"); + } + final List releases = new ArrayList<>(); + for (Map entries : parseUrlEncodedLines(result.out)) { + releases.add(new ReleaseHistory( + parseInt(entries.get("revision")), + parseDate(entries.get("updated")), + entries.get("status"), + entries.get("chart"), + entries.get("appVersion"), + entries.get("description") + )); + } + return releases; + } + + private static ZonedDateTime parseDate(String date) { + if (date == null || date.isEmpty()) { + return null; + } + return ZonedDateTime.parse(date, DateTimeFormatter.RFC_1123_DATE_TIME); + } + + private static int parseInt(String number) { + if (number == null || number.isEmpty()) { + return 0; + } + return Integer.parseInt(number); + } +} diff --git a/helm-java/src/test/java/com/marcnuri/helm/HelmKubernetesTest.java b/helm-java/src/test/java/com/marcnuri/helm/HelmKubernetesTest.java index 1aec2338..a385d608 100644 --- a/helm-java/src/test/java/com/marcnuri/helm/HelmKubernetesTest.java +++ b/helm-java/src/test/java/com/marcnuri/helm/HelmKubernetesTest.java @@ -18,6 +18,7 @@ import com.dajudge.kindcontainer.KindContainer; import com.dajudge.kindcontainer.KindContainerVersion; +import org.assertj.core.api.AssertionsForClassTypes; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; @@ -879,4 +880,141 @@ void missingRelease() { } } } + + @Nested + class History { + + @Nested + class Valid { + + @Test + void afterInstall() { + helm.install() + .withKubeConfig(kubeConfigFile) + .withName("test-history-after-install") + .call(); + + List releaseHistories = Helm.history("test-history-after-install") + .withKubeConfig(kubeConfigFile) + .call(); + + assertThat(releaseHistories) + .hasSize(1) + .first() + .returns(1, ReleaseHistory::getRevision) + .extracting(ReleaseHistory::getDescription).asString() + .containsIgnoringCase("Install complete"); + } + + @Test + void afterUpgrade() { + helm.install() + .withKubeConfig(kubeConfigFile) + .withName("test-history-after-install-and-upgrade") + .call(); + + helm.upgrade() + .withKubeConfig(kubeConfigFile) + .withName("test-history-after-install-and-upgrade") + .set("image.tag", "latest") + .call(); + + List releaseHistories = Helm.history("test-history-after-install-and-upgrade") + .withKubeConfig(kubeConfigFile) + .call(); + + assertThat(releaseHistories) + .hasSize(2) + .satisfiesExactly( + first -> assertThat(first.getDescription()).containsIgnoringCase("Install complete"), + second -> assertThat(second.getDescription()).containsIgnoringCase("Upgrade complete") + ); + } + + @Test + void withMax() { + helm.install() + .withKubeConfig(kubeConfigFile) + .withName("test-history-with-max") + .call(); + + helm.upgrade() + .withKubeConfig(kubeConfigFile) + .withName("test-history-with-max") + .set("image.tag", "v1") + .call(); + + helm.upgrade() + .withKubeConfig(kubeConfigFile) + .withName("test-history-with-max") + .set("image.tag", "v2") + .call(); + + List releaseHistories = Helm.history("test-history-with-max") + .withKubeConfig(kubeConfigFile) + .withMax(2) + .call(); + + assertThat(releaseHistories) + .hasSize(2) + .satisfiesExactly( + first -> assertThat(first.getRevision()).isEqualTo(2), + second -> assertThat(second.getRevision()).isEqualTo(3) + ); + } + + @Test + void withNamespace() { + helm.install() + .withKubeConfig(kubeConfigFile) + .withName("test-history-with-namespace") + .withNamespace("history-namespace") + .createNamespace() + .call(); + + List releaseHistories = Helm.history("test-history-with-namespace") + .withKubeConfig(kubeConfigFile) + .withNamespace("history-namespace") + .call(); + + assertThat(releaseHistories) + .hasSize(1) + .first() + .returns(1, ReleaseHistory::getRevision); + } + + @Test + void withKubeConfigContents() { + helm.install() + .withKubeConfig(kubeConfigFile) + .withName("test-history-with-kube-config-contents") + .call(); + + List releaseHistories = Helm.history("test-history-with-kube-config-contents") + .withKubeConfigContents(kubeConfigContents) + .call(); + + assertThat(releaseHistories) + .hasSize(1) + .first() + .returns(1, ReleaseHistory::getRevision); + } + + } + + @Nested + class Invalid { + + @Test + void nonExistentRelease() { + AssertionsForClassTypes.assertThatThrownBy(() -> + Helm.history("non-existent-release") + .withKubeConfig(kubeConfigFile) + .call() + ) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("release: not found"); + } + } + } } diff --git a/lib/api/src/main/java/com/marcnuri/helm/jni/HelmLib.java b/lib/api/src/main/java/com/marcnuri/helm/jni/HelmLib.java index e16b9dbe..3fcfc142 100644 --- a/lib/api/src/main/java/com/marcnuri/helm/jni/HelmLib.java +++ b/lib/api/src/main/java/com/marcnuri/helm/jni/HelmLib.java @@ -33,6 +33,8 @@ public interface HelmLib extends Library { Result DependencyUpdate(DependencyOptions options); + Result History(HistoryOptions options); + Result Install(InstallOptions options); Result Lint(LintOptions options); diff --git a/lib/api/src/main/java/com/marcnuri/helm/jni/HistoryOptions.java b/lib/api/src/main/java/com/marcnuri/helm/jni/HistoryOptions.java new file mode 100644 index 00000000..5c9d450c --- /dev/null +++ b/lib/api/src/main/java/com/marcnuri/helm/jni/HistoryOptions.java @@ -0,0 +1,47 @@ +/* + * Copyright 2026 Marc Nuri + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.marcnuri.helm.jni; + +import com.sun.jna.Structure; + +/** + * @author Giuseppe Cardaropoli + */ +@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; + } + +} diff --git a/native/internal/helm/history.go b/native/internal/helm/history.go new file mode 100644 index 00000000..39f5f988 --- /dev/null +++ b/native/internal/helm/history.go @@ -0,0 +1,82 @@ +/* + * Copyright 2024 Marc Nuri + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +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. + // The Run() method returns all revisions for a release, and the Max limit is expected + // to be applied by the caller (as done in the Helm CLI). We keep only the most recent + // 'maxReleases' revisions by slicing from the end of the list. + maxReleases := options.Max + if maxReleases <= 0 { + maxReleases = 256 // Default from Helm CLI + } + if len(releases) > maxReleases { + releases = releases[len(releases)-maxReleases:] + } + + 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 +} diff --git a/native/main.go b/native/main.go index 339fd621..ffa3c97f 100644 --- a/native/main.go +++ b/native/main.go @@ -40,6 +40,14 @@ struct DependencyOptions { int debug; }; +struct HistoryOptions { + char* releaseName; + int max; + char* namespace; + char* kubeConfig; + char* kubeConfigContents; +}; + struct InstallOptions { char* name; int generateName; @@ -260,12 +268,13 @@ struct UpgradeOptions { import "C" import ( "fmt" - "github.com/manusa/helm-java/native/internal/helm" "io" "os" "strings" "time" "unsafe" + + "github.com/manusa/helm-java/native/internal/helm" ) // Run the given function and return the result as a C struct. @@ -347,6 +356,19 @@ func DependencyUpdate(options *C.struct_DependencyOptions) C.Result { }) } +//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), + }) + }) +} + //export Install func Install(options *C.struct_InstallOptions) C.Result { var timeout time.Duration