From c91c8a6467cbe16026cec465b832ef01e9b39b9e Mon Sep 17 00:00:00 2001 From: Ali <83188384+testA113@users.noreply.github.com> Date: Wed, 23 Apr 2025 08:58:34 +1200 Subject: [PATCH] feat(helm): rollback helm chart [r8s-287] (#660) --- api/http/handler/helm/handler.go | 4 + api/http/handler/helm/helm_rollback.go | 105 +++++++++++ .../ChartActions/ChartActions.tsx | 18 +- .../ChartActions/RollbackButton.test.tsx | 163 ++++++++++++++++++ .../ChartActions/RollbackButton.tsx | 71 ++++++++ .../HelmApplicationView.tsx | 51 +++--- .../queries/useHelmRollbackMutation.ts | 61 +++++++ pkg/libhelm/options/rollback_options.go | 19 ++ pkg/libhelm/sdk/rollback.go | 111 ++++++++++++ pkg/libhelm/sdk/rollback_test.go | 123 +++++++++++++ pkg/libhelm/sdk/search_repo_test.go | 1 - pkg/libhelm/test/mock.go | 5 + pkg/libhelm/types/types.go | 1 + 13 files changed, 701 insertions(+), 32 deletions(-) create mode 100644 api/http/handler/helm/helm_rollback.go create mode 100644 app/react/kubernetes/helm/HelmApplicationView/ChartActions/RollbackButton.test.tsx create mode 100644 app/react/kubernetes/helm/HelmApplicationView/ChartActions/RollbackButton.tsx create mode 100644 app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRollbackMutation.ts create mode 100644 pkg/libhelm/options/rollback_options.go create mode 100644 pkg/libhelm/sdk/rollback.go create mode 100644 pkg/libhelm/sdk/rollback_test.go 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 {