diff --git a/api/http/handler/helm/handler.go b/api/http/handler/helm/handler.go
index e87e98410..f7f14a1c1 100644
--- a/api/http/handler/helm/handler.go
+++ b/api/http/handler/helm/handler.go
@@ -62,6 +62,10 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor
h.Handle("/{id}/kubernetes/helm/{release}/history",
httperror.LoggerHandler(h.helmGetHistory)).Methods(http.MethodGet)
+ // `helm rollback [RELEASE_NAME] [REVISION]`
+ h.Handle("/{id}/kubernetes/helm/{release}/rollback",
+ httperror.LoggerHandler(h.helmRollback)).Methods(http.MethodPost)
+
return h
}
diff --git a/api/http/handler/helm/helm_rollback.go b/api/http/handler/helm/helm_rollback.go
new file mode 100644
index 000000000..d98f7a47a
--- /dev/null
+++ b/api/http/handler/helm/helm_rollback.go
@@ -0,0 +1,105 @@
+package helm
+
+import (
+ "net/http"
+ "time"
+
+ "github.com/portainer/portainer/pkg/libhelm/options"
+ _ "github.com/portainer/portainer/pkg/libhelm/release"
+ httperror "github.com/portainer/portainer/pkg/libhttp/error"
+ "github.com/portainer/portainer/pkg/libhttp/request"
+ "github.com/portainer/portainer/pkg/libhttp/response"
+)
+
+// @id HelmRollback
+// @summary Rollback a helm release
+// @description Rollback a helm release to a previous revision
+// @description **Access policy**: authenticated
+// @tags helm
+// @security ApiKeyAuth || jwt
+// @produce json
+// @param id path int true "Environment(Endpoint) identifier"
+// @param release path string true "Helm release name"
+// @param namespace query string false "specify an optional namespace"
+// @param revision query int false "specify the revision to rollback to (defaults to previous revision if not specified)"
+// @param wait query boolean false "wait for resources to be ready (default: false)"
+// @param waitForJobs query boolean false "wait for jobs to complete before marking the release as successful (default: false)"
+// @param recreate query boolean false "performs pods restart for the resource if applicable (default: true)"
+// @param force query boolean false "force resource update through delete/recreate if needed (default: false)"
+// @param timeout query int false "time to wait for any individual Kubernetes operation in seconds (default: 300)"
+// @success 200 {object} release.Release "Success"
+// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
+// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions."
+// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions."
+// @failure 404 "Unable to find an environment with the specified identifier or release name."
+// @failure 500 "Server error occurred while attempting to rollback the release."
+// @router /endpoints/{id}/kubernetes/helm/{release}/rollback [post]
+func (handler *Handler) helmRollback(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
+ release, err := request.RetrieveRouteVariableValue(r, "release")
+ if err != nil {
+ return httperror.BadRequest("No release specified", err)
+ }
+
+ clusterAccess, httperr := handler.getHelmClusterAccess(r)
+ if httperr != nil {
+ return httperr
+ }
+
+ // build the rollback options
+ rollbackOpts := options.RollbackOptions{
+ KubernetesClusterAccess: clusterAccess,
+ Name: release,
+ // Set default values
+ Recreate: true, // Default to recreate pods (restart)
+ Timeout: 5 * time.Minute, // Default timeout of 5 minutes
+ }
+
+ namespace, _ := request.RetrieveQueryParameter(r, "namespace", true)
+ // optional namespace. The library defaults to "default"
+ if namespace != "" {
+ rollbackOpts.Namespace = namespace
+ }
+
+ revision, _ := request.RetrieveNumericQueryParameter(r, "revision", true)
+ // optional revision. If not specified, it will rollback to the previous revision
+ if revision > 0 {
+ rollbackOpts.Version = revision
+ }
+
+ // Default for wait is false, only set to true if explicitly requested
+ wait, err := request.RetrieveBooleanQueryParameter(r, "wait", true)
+ if err == nil {
+ rollbackOpts.Wait = wait
+ }
+
+ // Default for waitForJobs is false, only set to true if explicitly requested
+ waitForJobs, err := request.RetrieveBooleanQueryParameter(r, "waitForJobs", true)
+ if err == nil {
+ rollbackOpts.WaitForJobs = waitForJobs
+ }
+
+ // Default for recreate is true (set above), override if specified
+ recreate, err := request.RetrieveBooleanQueryParameter(r, "recreate", true)
+ if err == nil {
+ rollbackOpts.Recreate = recreate
+ }
+
+ // Default for force is false, only set to true if explicitly requested
+ force, err := request.RetrieveBooleanQueryParameter(r, "force", true)
+ if err == nil {
+ rollbackOpts.Force = force
+ }
+
+ timeout, _ := request.RetrieveNumericQueryParameter(r, "timeout", true)
+ // Override default timeout if specified
+ if timeout > 0 {
+ rollbackOpts.Timeout = time.Duration(timeout) * time.Second
+ }
+
+ releaseInfo, err := handler.helmPackageManager.Rollback(rollbackOpts)
+ if err != nil {
+ return httperror.InternalServerError("Failed to rollback helm release", err)
+ }
+
+ return response.JSON(w, releaseInfo)
+}
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ChartActions/ChartActions.tsx b/app/react/kubernetes/helm/HelmApplicationView/ChartActions/ChartActions.tsx
index c67984952..59e0fd2dc 100644
--- a/app/react/kubernetes/helm/HelmApplicationView/ChartActions/ChartActions.tsx
+++ b/app/react/kubernetes/helm/HelmApplicationView/ChartActions/ChartActions.tsx
@@ -1,22 +1,20 @@
import { EnvironmentId } from '@/react/portainer/environments/types';
-import { useAuthorizations } from '@/react/hooks/useUser';
+import { RollbackButton } from './RollbackButton';
import { UninstallButton } from './UninstallButton';
export function ChartActions({
environmentId,
releaseName,
namespace,
+ currentRevision,
}: {
environmentId: EnvironmentId;
releaseName: string;
namespace?: string;
+ currentRevision?: number;
}) {
- const { authorized } = useAuthorizations('K8sApplicationsW');
-
- if (!authorized) {
- return null;
- }
+ const hasPreviousRevision = currentRevision && currentRevision >= 2;
return (
@@ -25,6 +23,14 @@ export function ChartActions({
releaseName={releaseName}
namespace={namespace}
/>
+ {hasPreviousRevision && (
+
+ )}
);
}
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ChartActions/RollbackButton.test.tsx b/app/react/kubernetes/helm/HelmApplicationView/ChartActions/RollbackButton.test.tsx
new file mode 100644
index 000000000..1d7b3f340
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmApplicationView/ChartActions/RollbackButton.test.tsx
@@ -0,0 +1,163 @@
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { HttpResponse, http } from 'msw';
+import { vi, type Mock } from 'vitest';
+
+import { server } from '@/setup-tests/server';
+import { notifySuccess } from '@/portainer/services/notifications';
+import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
+
+import { confirm } from '@@/modals/confirm';
+
+import { RollbackButton } from './RollbackButton';
+
+// Mock the confirm modal function
+vi.mock('@@/modals/confirm', () => ({
+ confirm: vi.fn(() => Promise.resolve(false)),
+ buildConfirmButton: vi.fn((label) => ({ label })),
+}));
+
+// Mock the notifications service
+vi.mock('@/portainer/services/notifications', () => ({
+ notifySuccess: vi.fn(),
+}));
+
+function renderButton(props = {}) {
+ const defaultProps = {
+ latestRevision: 3, // So we're rolling back to revision 2
+ environmentId: 1,
+ releaseName: 'test-release',
+ namespace: 'default',
+ ...props,
+ };
+
+ const Wrapped = withTestQueryProvider(RollbackButton);
+ return render();
+}
+
+describe('RollbackButton', () => {
+ test('should display the revision to rollback to', () => {
+ renderButton();
+
+ const button = screen.getByRole('button', { name: /Rollback to #2/i });
+ expect(button).toBeInTheDocument();
+ });
+
+ test('should be disabled when the rollback mutation is loading', async () => {
+ const resolveRequest = vi.fn();
+ const requestPromise = new Promise((resolve) => {
+ resolveRequest.mockImplementation(() => resolve());
+ });
+
+ server.use(
+ http.post(
+ '/api/endpoints/1/kubernetes/helm/test-release/rollback',
+ () =>
+ new Promise((resolve) => {
+ // Keep request pending to simulate loading state
+ requestPromise
+ .then(() => {
+ resolve(HttpResponse.json({}));
+ return null;
+ })
+ .catch(() => {});
+ })
+ )
+ );
+
+ renderButton();
+
+ const user = userEvent.setup();
+ const button = screen.getByRole('button', { name: /Rollback to #2/i });
+
+ (confirm as Mock).mockResolvedValueOnce(true);
+ await user.click(button);
+
+ await waitFor(() => {
+ expect(screen.getByText('Rolling back...')).toBeInTheDocument();
+ });
+
+ resolveRequest();
+ });
+
+ test('should show a confirmation modal before executing the rollback', async () => {
+ renderButton();
+
+ const user = userEvent.setup();
+ const button = screen.getByRole('button', { name: /Rollback to #2/i });
+
+ await user.click(button);
+
+ expect(confirm).toHaveBeenCalledWith(
+ expect.objectContaining({
+ title: 'Are you sure?',
+ message: expect.stringContaining(
+ 'Rolling back will restore the application to revision #2'
+ ),
+ })
+ );
+ });
+
+ test('should execute the rollback mutation with correct query params when confirmed', async () => {
+ let requestParams: Record = {};
+
+ server.use(
+ http.post(
+ '/api/endpoints/1/kubernetes/helm/test-release/rollback',
+ ({ request }) => {
+ const url = new URL(request.url);
+ requestParams = Object.fromEntries(url.searchParams.entries());
+ return HttpResponse.json({});
+ }
+ )
+ );
+
+ renderButton();
+
+ const user = userEvent.setup();
+ const button = screen.getByRole('button', { name: /Rollback to #2/i });
+
+ (confirm as Mock).mockResolvedValueOnce(true);
+ await user.click(button);
+
+ await waitFor(() => {
+ expect(Object.keys(requestParams).length).toBeGreaterThan(0);
+ });
+
+ expect(requestParams.namespace).toBe('default');
+ expect(requestParams.revision).toBe('2');
+
+ expect(notifySuccess).toHaveBeenCalledWith(
+ 'Success',
+ 'Application rolled back to revision #2 successfully.'
+ );
+ });
+
+ test('should not execute the rollback if confirmation is cancelled', async () => {
+ let wasRequestMade = false;
+
+ server.use(
+ http.post(
+ '/api/endpoints/1/kubernetes/helm/test-release/rollback',
+ () => {
+ wasRequestMade = true;
+ return HttpResponse.json({});
+ }
+ )
+ );
+
+ renderButton();
+
+ const user = userEvent.setup();
+ const button = screen.getByRole('button', { name: /Rollback to #2/i });
+
+ (confirm as Mock).mockResolvedValueOnce(false);
+ await user.click(button);
+
+ await waitFor(() => {
+ expect(confirm).toHaveBeenCalled();
+ });
+
+ expect(wasRequestMade).toBe(false);
+ });
+});
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ChartActions/RollbackButton.tsx b/app/react/kubernetes/helm/HelmApplicationView/ChartActions/RollbackButton.tsx
new file mode 100644
index 000000000..3d870d3a8
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmApplicationView/ChartActions/RollbackButton.tsx
@@ -0,0 +1,71 @@
+import { RotateCcw } from 'lucide-react';
+
+import { EnvironmentId } from '@/react/portainer/environments/types';
+import { notifySuccess } from '@/portainer/services/notifications';
+
+import { LoadingButton } from '@@/buttons';
+import { buildConfirmButton } from '@@/modals/utils';
+import { confirm } from '@@/modals/confirm';
+import { ModalType } from '@@/modals';
+
+import { useHelmRollbackMutation } from '../queries/useHelmRollbackMutation';
+
+type Props = {
+ latestRevision: number;
+ environmentId: EnvironmentId;
+ releaseName: string;
+ namespace?: string;
+};
+
+export function RollbackButton({
+ latestRevision,
+ environmentId,
+ releaseName,
+ namespace,
+}: Props) {
+ // the selectedRevision can be a prop when selecting a revision is implemented
+ const selectedRevision = latestRevision ? latestRevision - 1 : undefined;
+
+ const rollbackMutation = useHelmRollbackMutation(environmentId);
+
+ return (
+
+ Rollback to #{selectedRevision}
+
+ );
+
+ async function handleClick() {
+ const confirmed = await confirm({
+ title: 'Are you sure?',
+ modalType: ModalType.Warn,
+ confirmButton: buildConfirmButton('Rollback'),
+ message: `Rolling back will restore the application to revision #${selectedRevision}, which will cause service interruption. Do you wish to continue?`,
+ });
+ if (!confirmed) {
+ return;
+ }
+
+ rollbackMutation.mutate(
+ {
+ releaseName,
+ params: { namespace, revision: selectedRevision },
+ },
+ {
+ onSuccess: () => {
+ notifySuccess(
+ 'Success',
+ `Application rolled back to revision #${selectedRevision} successfully.`
+ );
+ },
+ }
+ );
+ }
+}
diff --git a/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.tsx b/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.tsx
index 6aec1d50a..164943223 100644
--- a/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.tsx
+++ b/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.tsx
@@ -3,12 +3,14 @@ import { useCurrentStateAndParams } from '@uirouter/react';
import helm from '@/assets/ico/vendor/helm.svg?c';
import { PageHeader } from '@/react/components/PageHeader';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
-import { EnvironmentId } from '@/react/portainer/environments/types';
+import { Authorized } from '@/react/hooks/useUser';
import { WidgetTitle, WidgetBody, Widget, Loading } from '@@/Widget';
import { Card } from '@@/Card';
import { Alert } from '@@/Alert';
+import { HelmRelease } from '../types';
+
import { HelmSummary } from './HelmSummary';
import { ReleaseTabs } from './ReleaseDetails/ReleaseTabs';
import { useHelmRelease } from './queries/useHelmRelease';
@@ -19,6 +21,10 @@ export function HelmApplicationView() {
const { params } = useCurrentStateAndParams();
const { name, namespace } = params;
+ const helmReleaseQuery = useHelmRelease(environmentId, name, namespace, {
+ showResources: true,
+ });
+
return (
<>
{name && (
-
+
+
+
)}
@@ -57,21 +66,13 @@ export function HelmApplicationView() {
}
type HelmDetailsProps = {
- name: string;
- namespace: string;
- environmentId: EnvironmentId;
+ isLoading: boolean;
+ isError: boolean;
+ release: HelmRelease | undefined;
};
-function HelmDetails({ name, namespace, environmentId }: HelmDetailsProps) {
- const {
- data: release,
- isInitialLoading,
- isError,
- } = useHelmRelease(environmentId, name, namespace, {
- showResources: true,
- });
-
- if (isInitialLoading) {
+function HelmDetails({ isLoading, isError, release: data }: HelmDetailsProps) {
+ if (isLoading) {
return ;
}
@@ -81,16 +82,16 @@ function HelmDetails({ name, namespace, environmentId }: HelmDetailsProps) {
);
}
- if (!release) {
+ if (!data) {
return ;
}
return (
<>
-
+
-
+
>
);
diff --git a/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRollbackMutation.ts b/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRollbackMutation.ts
new file mode 100644
index 000000000..6aea3ad29
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRollbackMutation.ts
@@ -0,0 +1,61 @@
+import { useMutation } from '@tanstack/react-query';
+
+import { EnvironmentId } from '@/react/portainer/environments/types';
+import {
+ queryClient,
+ withInvalidate,
+ withGlobalError,
+} from '@/react-tools/react-query';
+import axios from '@/portainer/services/axios';
+import { queryKeys } from '@/react/kubernetes/applications/queries/query-keys';
+
+/**
+ * Parameters for helm rollback operation
+ *
+ * @see https://helm.sh/docs/helm/helm_rollback/
+ */
+interface RollbackQueryParams {
+ /** Optional namespace for the release (defaults to "default" if not specified) */
+ namespace?: string;
+ /** Revision to rollback to (if omitted or set to 0, rolls back to the previous release) */
+ revision?: number;
+ /** If set, waits until resources are in a ready state before marking the release as successful (default: false) */
+ wait?: boolean;
+ /** If set and --wait enabled, waits until all Jobs have been completed before marking the release as successful (default: false) */
+ waitForJobs?: boolean;
+ /** Performs pods restart for the resources if applicable (default: true) */
+ recreate?: boolean;
+ /** Force resource update through delete/recreate if needed (default: false) */
+ force?: boolean;
+ /** Time to wait for any individual Kubernetes operation in seconds (default: 300) */
+ timeout?: number;
+}
+
+interface RollbackPayload {
+ releaseName: string;
+ params: RollbackQueryParams;
+}
+
+async function rollbackRelease({
+ releaseName,
+ params,
+ environmentId,
+}: RollbackPayload & { environmentId: EnvironmentId }) {
+ return axios.post>(
+ `/endpoints/${environmentId}/kubernetes/helm/${releaseName}/rollback`,
+ null,
+ { params }
+ );
+}
+
+export function useHelmRollbackMutation(environmentId: EnvironmentId) {
+ return useMutation({
+ mutationFn: ({ releaseName, params }: RollbackPayload) =>
+ rollbackRelease({ releaseName, params, environmentId }),
+ ...withGlobalError('Unable to rollback Helm release'),
+ ...withInvalidate(queryClient, [
+ [environmentId, 'helm', 'releases'],
+ queryKeys.applications(environmentId),
+ ]),
+ });
+}
diff --git a/pkg/libhelm/options/rollback_options.go b/pkg/libhelm/options/rollback_options.go
new file mode 100644
index 000000000..1c220f223
--- /dev/null
+++ b/pkg/libhelm/options/rollback_options.go
@@ -0,0 +1,19 @@
+package options
+
+import "time"
+
+// RollbackOptions defines options for rollback.
+type RollbackOptions struct {
+ // Required
+ Name string
+ Namespace string
+ KubernetesClusterAccess *KubernetesClusterAccess
+
+ // Optional with defaults
+ Version int // Target revision to rollback to (0 means previous revision)
+ Timeout time.Duration // Default: 5 minutes
+ Wait bool // Default: false
+ WaitForJobs bool // Default: false
+ Recreate bool // Default: false - whether to recreate pods
+ Force bool // Default: false - whether to force recreation
+}
diff --git a/pkg/libhelm/sdk/rollback.go b/pkg/libhelm/sdk/rollback.go
new file mode 100644
index 000000000..9b5caac8e
--- /dev/null
+++ b/pkg/libhelm/sdk/rollback.go
@@ -0,0 +1,111 @@
+package sdk
+
+import (
+ "time"
+
+ "github.com/pkg/errors"
+ "github.com/portainer/portainer/pkg/libhelm/options"
+ "github.com/portainer/portainer/pkg/libhelm/release"
+ "github.com/rs/zerolog/log"
+ "helm.sh/helm/v3/pkg/action"
+)
+
+// Rollback would implement the HelmPackageManager interface by using the Helm SDK to rollback a release to a previous revision.
+func (hspm *HelmSDKPackageManager) Rollback(rollbackOpts options.RollbackOptions) (*release.Release, error) {
+ log.Debug().
+ Str("context", "HelmClient").
+ Str("name", rollbackOpts.Name).
+ Str("namespace", rollbackOpts.Namespace).
+ Int("revision", rollbackOpts.Version).
+ Bool("wait", rollbackOpts.Wait).
+ Msg("Rolling back Helm release")
+
+ if rollbackOpts.Name == "" {
+ log.Error().
+ Str("context", "HelmClient").
+ Msg("Name is required for helm release rollback")
+ return nil, errors.New("name is required for helm release rollback")
+ }
+
+ // Initialize action configuration with kubernetes config
+ actionConfig := new(action.Configuration)
+ err := hspm.initActionConfig(actionConfig, rollbackOpts.Namespace, rollbackOpts.KubernetesClusterAccess)
+ if err != nil {
+ return nil, errors.Wrap(err, "failed to initialize helm configuration for helm release rollback")
+ }
+
+ rollbackClient := initRollbackClient(actionConfig, rollbackOpts)
+
+ // Run the rollback
+ err = rollbackClient.Run(rollbackOpts.Name)
+ if err != nil {
+ log.Error().
+ Str("context", "HelmClient").
+ Str("name", rollbackOpts.Name).
+ Str("namespace", rollbackOpts.Namespace).
+ Int("revision", rollbackOpts.Version).
+ Err(err).
+ Msg("Failed to rollback helm release")
+ return nil, errors.Wrap(err, "helm was not able to rollback the release")
+ }
+
+ // Get the release info after rollback
+ statusClient := action.NewStatus(actionConfig)
+ rel, err := statusClient.Run(rollbackOpts.Name)
+ if err != nil {
+ log.Error().
+ Str("context", "HelmClient").
+ Str("name", rollbackOpts.Name).
+ Str("namespace", rollbackOpts.Namespace).
+ Int("revision", rollbackOpts.Version).
+ Err(err).
+ Msg("Failed to get status after rollback")
+ return nil, errors.Wrap(err, "failed to get status after rollback")
+ }
+
+ return &release.Release{
+ Name: rel.Name,
+ Namespace: rel.Namespace,
+ Version: rel.Version,
+ Info: &release.Info{
+ Status: release.Status(rel.Info.Status),
+ Notes: rel.Info.Notes,
+ Description: rel.Info.Description,
+ },
+ Manifest: rel.Manifest,
+ Chart: release.Chart{
+ Metadata: &release.Metadata{
+ Name: rel.Chart.Metadata.Name,
+ Version: rel.Chart.Metadata.Version,
+ AppVersion: rel.Chart.Metadata.AppVersion,
+ },
+ },
+ Labels: rel.Labels,
+ }, nil
+}
+
+// initRollbackClient initializes the rollback client with the given options
+// and returns the rollback client.
+func initRollbackClient(actionConfig *action.Configuration, rollbackOpts options.RollbackOptions) *action.Rollback {
+ rollbackClient := action.NewRollback(actionConfig)
+
+ // Set version to rollback to (if specified)
+ if rollbackOpts.Version > 0 {
+ rollbackClient.Version = rollbackOpts.Version
+ }
+
+ rollbackClient.Wait = rollbackOpts.Wait
+ rollbackClient.WaitForJobs = rollbackOpts.WaitForJobs
+ rollbackClient.CleanupOnFail = true // Sane default to clean up on failure
+ rollbackClient.Recreate = rollbackOpts.Recreate
+ rollbackClient.Force = rollbackOpts.Force
+
+ // Set default values if not specified
+ if rollbackOpts.Timeout == 0 {
+ rollbackClient.Timeout = 5 * time.Minute // Sane default of 5 minutes
+ } else {
+ rollbackClient.Timeout = rollbackOpts.Timeout
+ }
+
+ return rollbackClient
+}
diff --git a/pkg/libhelm/sdk/rollback_test.go b/pkg/libhelm/sdk/rollback_test.go
new file mode 100644
index 000000000..31932ffc7
--- /dev/null
+++ b/pkg/libhelm/sdk/rollback_test.go
@@ -0,0 +1,123 @@
+package sdk
+
+import (
+ "testing"
+
+ "github.com/portainer/portainer/pkg/libhelm/options"
+ "github.com/portainer/portainer/pkg/libhelm/test"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestRollback(t *testing.T) {
+ test.EnsureIntegrationTest(t)
+ is := assert.New(t)
+
+ // Create a new SDK package manager
+ hspm := NewHelmSDKPackageManager()
+
+ t.Run("should return error when name is not provided", func(t *testing.T) {
+ rollbackOpts := options.RollbackOptions{
+ Namespace: "default",
+ }
+
+ _, err := hspm.Rollback(rollbackOpts)
+
+ is.Error(err, "should return an error when name is not provided")
+ is.Equal("name is required for helm release rollback", err.Error(), "should return correct error message")
+ })
+
+ t.Run("should return error when release doesn't exist", func(t *testing.T) {
+ rollbackOpts := options.RollbackOptions{
+ Name: "non-existent-release",
+ Namespace: "default",
+ }
+
+ _, err := hspm.Rollback(rollbackOpts)
+
+ is.Error(err, "should return an error when release doesn't exist")
+ })
+
+ t.Run("should successfully rollback to previous revision", func(t *testing.T) {
+ // First install a release
+ installOpts := options.InstallOptions{
+ Name: "hello-world",
+ Chart: "hello-world",
+ Namespace: "default",
+ Repo: "https://helm.github.io/examples",
+ }
+
+ // Ensure the release doesn't exist before test
+ hspm.Uninstall(options.UninstallOptions{
+ Name: installOpts.Name,
+ })
+
+ // Install first version
+ release, err := hspm.Upgrade(installOpts)
+ is.NoError(err, "should successfully install release")
+ is.Equal(1, release.Version, "first version should be 1")
+
+ // Upgrade to second version
+ _, err = hspm.Upgrade(installOpts)
+ is.NoError(err, "should successfully upgrade release")
+
+ // Rollback to first version
+ rollbackOpts := options.RollbackOptions{
+ Name: installOpts.Name,
+ Namespace: "default",
+ Version: 0, // Previous revision
+ }
+
+ rolledBackRelease, err := hspm.Rollback(rollbackOpts)
+ defer hspm.Uninstall(options.UninstallOptions{
+ Name: installOpts.Name,
+ })
+
+ is.NoError(err, "should successfully rollback release")
+ is.NotNil(rolledBackRelease, "should return non-nil release")
+ is.Equal(3, rolledBackRelease.Version, "version should be incremented to 3")
+ })
+
+ t.Run("should successfully rollback to specific revision", func(t *testing.T) {
+ // First install a release
+ installOpts := options.InstallOptions{
+ Name: "hello-world",
+ Chart: "hello-world",
+ Namespace: "default",
+ Repo: "https://helm.github.io/examples",
+ }
+
+ // Ensure the release doesn't exist before test
+ hspm.Uninstall(options.UninstallOptions{
+ Name: installOpts.Name,
+ })
+
+ // Install first version
+ release, err := hspm.Upgrade(installOpts)
+ is.NoError(err, "should successfully install release")
+ is.Equal(1, release.Version, "first version should be 1")
+
+ // Upgrade to second version
+ _, err = hspm.Upgrade(installOpts)
+ is.NoError(err, "should successfully upgrade release")
+
+ // Upgrade to third version
+ _, err = hspm.Upgrade(installOpts)
+ is.NoError(err, "should successfully upgrade release again")
+
+ // Rollback to first version
+ rollbackOpts := options.RollbackOptions{
+ Name: installOpts.Name,
+ Namespace: "default",
+ Version: 1, // Specific revision
+ }
+
+ rolledBackRelease, err := hspm.Rollback(rollbackOpts)
+ defer hspm.Uninstall(options.UninstallOptions{
+ Name: installOpts.Name,
+ })
+
+ is.NoError(err, "should successfully rollback to specific revision")
+ is.NotNil(rolledBackRelease, "should return non-nil release")
+ is.Equal(4, rolledBackRelease.Version, "version should be incremented to 4")
+ })
+}
diff --git a/pkg/libhelm/sdk/search_repo_test.go b/pkg/libhelm/sdk/search_repo_test.go
index d4cfd9b46..bb30b8468 100644
--- a/pkg/libhelm/sdk/search_repo_test.go
+++ b/pkg/libhelm/sdk/search_repo_test.go
@@ -19,7 +19,6 @@ var tests = []testCase{
{"ingress helm repo", "https://kubernetes.github.io/ingress-nginx", false},
{"portainer helm repo", "https://portainer.github.io/k8s/", false},
{"elastic helm repo with trailing slash", "https://helm.elastic.co/", false},
- {"lensesio helm repo without trailing slash", "https://lensesio.github.io/kafka-helm-charts", false},
}
func Test_SearchRepo(t *testing.T) {
diff --git a/pkg/libhelm/test/mock.go b/pkg/libhelm/test/mock.go
index 518b53fbd..cf8eefd17 100644
--- a/pkg/libhelm/test/mock.go
+++ b/pkg/libhelm/test/mock.go
@@ -79,6 +79,11 @@ func (hpm *helmMockPackageManager) Upgrade(upgradeOpts options.InstallOptions) (
return hpm.Install(upgradeOpts)
}
+// Rollback a helm chart (not thread safe)
+func (hpm *helmMockPackageManager) Rollback(rollbackOpts options.RollbackOptions) (*release.Release, error) {
+ return hpm.Rollback(rollbackOpts)
+}
+
// Show values/readme/chart etc
func (hpm *helmMockPackageManager) Show(showOpts options.ShowOptions) ([]byte, error) {
switch showOpts.OutputFormat {
diff --git a/pkg/libhelm/types/types.go b/pkg/libhelm/types/types.go
index 7c78cbe88..4204dbb3d 100644
--- a/pkg/libhelm/types/types.go
+++ b/pkg/libhelm/types/types.go
@@ -16,6 +16,7 @@ type HelmPackageManager interface {
Uninstall(uninstallOpts options.UninstallOptions) error
Get(getOpts options.GetOptions) (*release.Release, error)
GetHistory(historyOpts options.HistoryOptions) ([]*release.Release, error)
+ Rollback(rollbackOpts options.RollbackOptions) (*release.Release, error)
}
type Repository interface {