mirror of
https://github.com/portainer/portainer.git
synced 2025-07-19 13:29:41 +02:00
feat(helm): rollback helm chart [r8s-287] (#660)
This commit is contained in:
parent
61d6ac035d
commit
c91c8a6467
13 changed files with 701 additions and 32 deletions
|
@ -62,6 +62,10 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor
|
||||||
h.Handle("/{id}/kubernetes/helm/{release}/history",
|
h.Handle("/{id}/kubernetes/helm/{release}/history",
|
||||||
httperror.LoggerHandler(h.helmGetHistory)).Methods(http.MethodGet)
|
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
|
return h
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
105
api/http/handler/helm/helm_rollback.go
Normal file
105
api/http/handler/helm/helm_rollback.go
Normal file
|
@ -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)
|
||||||
|
}
|
|
@ -1,22 +1,20 @@
|
||||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
import { useAuthorizations } from '@/react/hooks/useUser';
|
|
||||||
|
|
||||||
|
import { RollbackButton } from './RollbackButton';
|
||||||
import { UninstallButton } from './UninstallButton';
|
import { UninstallButton } from './UninstallButton';
|
||||||
|
|
||||||
export function ChartActions({
|
export function ChartActions({
|
||||||
environmentId,
|
environmentId,
|
||||||
releaseName,
|
releaseName,
|
||||||
namespace,
|
namespace,
|
||||||
|
currentRevision,
|
||||||
}: {
|
}: {
|
||||||
environmentId: EnvironmentId;
|
environmentId: EnvironmentId;
|
||||||
releaseName: string;
|
releaseName: string;
|
||||||
namespace?: string;
|
namespace?: string;
|
||||||
|
currentRevision?: number;
|
||||||
}) {
|
}) {
|
||||||
const { authorized } = useAuthorizations('K8sApplicationsW');
|
const hasPreviousRevision = currentRevision && currentRevision >= 2;
|
||||||
|
|
||||||
if (!authorized) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="inline-flex gap-x-2">
|
<div className="inline-flex gap-x-2">
|
||||||
|
@ -25,6 +23,14 @@ export function ChartActions({
|
||||||
releaseName={releaseName}
|
releaseName={releaseName}
|
||||||
namespace={namespace}
|
namespace={namespace}
|
||||||
/>
|
/>
|
||||||
|
{hasPreviousRevision && (
|
||||||
|
<RollbackButton
|
||||||
|
latestRevision={currentRevision}
|
||||||
|
environmentId={environmentId}
|
||||||
|
releaseName={releaseName}
|
||||||
|
namespace={namespace}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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(<Wrapped {...defaultProps} />);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<void>((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<string, string> = {};
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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 (
|
||||||
|
<LoadingButton
|
||||||
|
onClick={handleClick}
|
||||||
|
isLoading={rollbackMutation.isLoading}
|
||||||
|
loadingText="Rolling back..."
|
||||||
|
data-cy="rollback-button"
|
||||||
|
icon={RotateCcw}
|
||||||
|
color="default"
|
||||||
|
size="medium"
|
||||||
|
>
|
||||||
|
Rollback to #{selectedRevision}
|
||||||
|
</LoadingButton>
|
||||||
|
);
|
||||||
|
|
||||||
|
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.`
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,12 +3,14 @@ import { useCurrentStateAndParams } from '@uirouter/react';
|
||||||
import helm from '@/assets/ico/vendor/helm.svg?c';
|
import helm from '@/assets/ico/vendor/helm.svg?c';
|
||||||
import { PageHeader } from '@/react/components/PageHeader';
|
import { PageHeader } from '@/react/components/PageHeader';
|
||||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
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 { WidgetTitle, WidgetBody, Widget, Loading } from '@@/Widget';
|
||||||
import { Card } from '@@/Card';
|
import { Card } from '@@/Card';
|
||||||
import { Alert } from '@@/Alert';
|
import { Alert } from '@@/Alert';
|
||||||
|
|
||||||
|
import { HelmRelease } from '../types';
|
||||||
|
|
||||||
import { HelmSummary } from './HelmSummary';
|
import { HelmSummary } from './HelmSummary';
|
||||||
import { ReleaseTabs } from './ReleaseDetails/ReleaseTabs';
|
import { ReleaseTabs } from './ReleaseDetails/ReleaseTabs';
|
||||||
import { useHelmRelease } from './queries/useHelmRelease';
|
import { useHelmRelease } from './queries/useHelmRelease';
|
||||||
|
@ -19,6 +21,10 @@ export function HelmApplicationView() {
|
||||||
const { params } = useCurrentStateAndParams();
|
const { params } = useCurrentStateAndParams();
|
||||||
const { name, namespace } = params;
|
const { name, namespace } = params;
|
||||||
|
|
||||||
|
const helmReleaseQuery = useHelmRelease(environmentId, name, namespace, {
|
||||||
|
showResources: true,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
|
@ -35,18 +41,21 @@ export function HelmApplicationView() {
|
||||||
<Widget>
|
<Widget>
|
||||||
{name && (
|
{name && (
|
||||||
<WidgetTitle icon={helm} title={name}>
|
<WidgetTitle icon={helm} title={name}>
|
||||||
|
<Authorized authorizations="K8sApplicationsW">
|
||||||
<ChartActions
|
<ChartActions
|
||||||
environmentId={environmentId}
|
environmentId={environmentId}
|
||||||
releaseName={name}
|
releaseName={name}
|
||||||
namespace={namespace}
|
namespace={namespace}
|
||||||
|
currentRevision={helmReleaseQuery.data?.version}
|
||||||
/>
|
/>
|
||||||
|
</Authorized>
|
||||||
</WidgetTitle>
|
</WidgetTitle>
|
||||||
)}
|
)}
|
||||||
<WidgetBody>
|
<WidgetBody>
|
||||||
<HelmDetails
|
<HelmDetails
|
||||||
name={name}
|
isLoading={helmReleaseQuery.isInitialLoading}
|
||||||
namespace={namespace}
|
isError={helmReleaseQuery.isError}
|
||||||
environmentId={environmentId}
|
release={helmReleaseQuery.data}
|
||||||
/>
|
/>
|
||||||
</WidgetBody>
|
</WidgetBody>
|
||||||
</Widget>
|
</Widget>
|
||||||
|
@ -57,21 +66,13 @@ export function HelmApplicationView() {
|
||||||
}
|
}
|
||||||
|
|
||||||
type HelmDetailsProps = {
|
type HelmDetailsProps = {
|
||||||
name: string;
|
isLoading: boolean;
|
||||||
namespace: string;
|
isError: boolean;
|
||||||
environmentId: EnvironmentId;
|
release: HelmRelease | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
function HelmDetails({ name, namespace, environmentId }: HelmDetailsProps) {
|
function HelmDetails({ isLoading, isError, release: data }: HelmDetailsProps) {
|
||||||
const {
|
if (isLoading) {
|
||||||
data: release,
|
|
||||||
isInitialLoading,
|
|
||||||
isError,
|
|
||||||
} = useHelmRelease(environmentId, name, namespace, {
|
|
||||||
showResources: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isInitialLoading) {
|
|
||||||
return <Loading />;
|
return <Loading />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -81,16 +82,16 @@ function HelmDetails({ name, namespace, environmentId }: HelmDetailsProps) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!release) {
|
if (!data) {
|
||||||
return <Alert color="error" title="No Helm application details found" />;
|
return <Alert color="error" title="No Helm application details found" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<HelmSummary release={release} />
|
<HelmSummary release={data} />
|
||||||
<div className="my-6 h-[1px] w-full bg-gray-5 th-dark:bg-gray-7 th-highcontrast:bg-white" />
|
<div className="my-6 h-[1px] w-full bg-gray-5 th-dark:bg-gray-7 th-highcontrast:bg-white" />
|
||||||
<Card className="bg-inherit">
|
<Card className="bg-inherit">
|
||||||
<ReleaseTabs release={release} />
|
<ReleaseTabs release={data} />
|
||||||
</Card>
|
</Card>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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<Record<string, unknown>>(
|
||||||
|
`/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),
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
}
|
19
pkg/libhelm/options/rollback_options.go
Normal file
19
pkg/libhelm/options/rollback_options.go
Normal file
|
@ -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
|
||||||
|
}
|
111
pkg/libhelm/sdk/rollback.go
Normal file
111
pkg/libhelm/sdk/rollback.go
Normal file
|
@ -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
|
||||||
|
}
|
123
pkg/libhelm/sdk/rollback_test.go
Normal file
123
pkg/libhelm/sdk/rollback_test.go
Normal file
|
@ -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")
|
||||||
|
})
|
||||||
|
}
|
|
@ -19,7 +19,6 @@ var tests = []testCase{
|
||||||
{"ingress helm repo", "https://kubernetes.github.io/ingress-nginx", false},
|
{"ingress helm repo", "https://kubernetes.github.io/ingress-nginx", false},
|
||||||
{"portainer helm repo", "https://portainer.github.io/k8s/", false},
|
{"portainer helm repo", "https://portainer.github.io/k8s/", false},
|
||||||
{"elastic helm repo with trailing slash", "https://helm.elastic.co/", 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) {
|
func Test_SearchRepo(t *testing.T) {
|
||||||
|
|
|
@ -79,6 +79,11 @@ func (hpm *helmMockPackageManager) Upgrade(upgradeOpts options.InstallOptions) (
|
||||||
return hpm.Install(upgradeOpts)
|
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
|
// Show values/readme/chart etc
|
||||||
func (hpm *helmMockPackageManager) Show(showOpts options.ShowOptions) ([]byte, error) {
|
func (hpm *helmMockPackageManager) Show(showOpts options.ShowOptions) ([]byte, error) {
|
||||||
switch showOpts.OutputFormat {
|
switch showOpts.OutputFormat {
|
||||||
|
|
|
@ -16,6 +16,7 @@ type HelmPackageManager interface {
|
||||||
Uninstall(uninstallOpts options.UninstallOptions) error
|
Uninstall(uninstallOpts options.UninstallOptions) error
|
||||||
Get(getOpts options.GetOptions) (*release.Release, error)
|
Get(getOpts options.GetOptions) (*release.Release, error)
|
||||||
GetHistory(historyOpts options.HistoryOptions) ([]*release.Release, error)
|
GetHistory(historyOpts options.HistoryOptions) ([]*release.Release, error)
|
||||||
|
Rollback(rollbackOpts options.RollbackOptions) (*release.Release, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type Repository interface {
|
type Repository interface {
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue