1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-07-19 13:29:41 +02:00

feat(helm): helm actions [r8s-259] (#715)

Co-authored-by: James Player <james.player@portainer.io>
Co-authored-by: Cara Ryan <cara.ryan@portainer.io>
Co-authored-by: stevensbkang <skan070@gmail.com>
This commit is contained in:
Ali 2025-05-13 22:15:04 +12:00 committed by GitHub
parent dfa32b6755
commit 4ee349bd6b
117 changed files with 4161 additions and 696 deletions

View file

@ -1,31 +1,53 @@
import { EnvironmentId } from '@/react/portainer/environments/types';
import { HelmRelease } from '../../types';
import { RollbackButton } from './RollbackButton';
import { UninstallButton } from './UninstallButton';
import { UpgradeButton } from './UpgradeButton';
type Props = {
environmentId: EnvironmentId;
releaseName: string;
namespace: string;
latestRevision?: number;
earlistRevision?: number;
selectedRevision?: number;
release?: HelmRelease;
updateRelease: (release: HelmRelease) => void;
};
export function ChartActions({
environmentId,
releaseName,
namespace,
currentRevision,
}: {
environmentId: EnvironmentId;
releaseName: string;
namespace?: string;
currentRevision?: number;
}) {
const hasPreviousRevision = currentRevision && currentRevision >= 2;
latestRevision,
earlistRevision,
selectedRevision,
release,
updateRelease,
}: Props) {
const showRollbackButton =
latestRevision && earlistRevision && latestRevision > earlistRevision;
return (
<div className="inline-flex gap-x-2">
<div className="inline-flex gap-2 flex-wrap">
<UpgradeButton
environmentId={environmentId}
releaseName={releaseName}
namespace={namespace}
release={release}
updateRelease={updateRelease}
/>
<UninstallButton
environmentId={environmentId}
releaseName={releaseName}
namespace={namespace}
/>
{hasPreviousRevision && (
{showRollbackButton && (
<RollbackButton
latestRevision={currentRevision}
latestRevision={latestRevision}
selectedRevision={selectedRevision}
environmentId={environmentId}
releaseName={releaseName}
namespace={namespace}

View file

@ -6,6 +6,7 @@ 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 { withTestRouter } from '@/react/test-utils/withRouter';
import { confirm } from '@@/modals/confirm';
@ -25,13 +26,18 @@ vi.mock('@/portainer/services/notifications', () => ({
function renderButton(props = {}) {
const defaultProps = {
latestRevision: 3, // So we're rolling back to revision 2
selectedRevision: 3, // This simulates the selectedRevision from URL params
environmentId: 1,
releaseName: 'test-release',
namespace: 'default',
...props,
};
const Wrapped = withTestQueryProvider(RollbackButton);
const Wrapped = withTestQueryProvider(
withTestRouter(RollbackButton, {
route: '/?revision=3',
})
);
return render(<Wrapped {...defaultProps} />);
}

View file

@ -1,4 +1,5 @@
import { RotateCcw } from 'lucide-react';
import { useRouter } from '@uirouter/react';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { notifySuccess } from '@/portainer/services/notifications';
@ -12,6 +13,7 @@ import { useHelmRollbackMutation } from '../queries/useHelmRollbackMutation';
type Props = {
latestRevision: number;
selectedRevision?: number;
environmentId: EnvironmentId;
releaseName: string;
namespace?: string;
@ -19,13 +21,16 @@ type Props = {
export function RollbackButton({
latestRevision,
selectedRevision,
environmentId,
releaseName,
namespace,
}: Props) {
// the selectedRevision can be a prop when selecting a revision is implemented
const selectedRevision = latestRevision ? latestRevision - 1 : undefined;
// when the latest revision is selected, rollback to the previous revision
// otherwise, rollback to the selected revision
const rollbackRevision =
selectedRevision === latestRevision ? latestRevision - 1 : selectedRevision;
const router = useRouter();
const rollbackMutation = useHelmRollbackMutation(environmentId);
return (
@ -38,7 +43,7 @@ export function RollbackButton({
color="default"
size="medium"
>
Rollback to #{selectedRevision}
Rollback to #{rollbackRevision}
</LoadingButton>
);
@ -47,7 +52,7 @@ export function RollbackButton({
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?`,
message: `Rolling back will restore the application to revision #${rollbackRevision}, which could cause service interruption. Do you wish to continue?`,
});
if (!confirmed) {
return;
@ -56,14 +61,20 @@ export function RollbackButton({
rollbackMutation.mutate(
{
releaseName,
params: { namespace, revision: selectedRevision },
params: { namespace, revision: rollbackRevision },
},
{
onSuccess: () => {
notifySuccess(
'Success',
`Application rolled back to revision #${selectedRevision} successfully.`
`Application rolled back to revision #${rollbackRevision} successfully.`
);
// set the revision url param to undefined to refresh the page at the latest revision
router.stateService.go('kubernetes.helm', {
namespace,
name: releaseName,
revision: undefined,
});
},
}
);

View file

@ -0,0 +1,182 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { withTestRouter } from '@/react/test-utils/withRouter';
import {
useHelmRepoVersions,
ChartVersion,
} from '../queries/useHelmRepositories';
import { HelmRelease } from '../../types';
import { openUpgradeHelmModal } from './UpgradeHelmModal';
import { UpgradeButton } from './UpgradeButton';
// Mock the upgrade modal function
vi.mock('./UpgradeHelmModal', () => ({
openUpgradeHelmModal: vi.fn(() => Promise.resolve(undefined)),
}));
// Mock the notifications service
vi.mock('@/portainer/services/notifications', () => ({
notifySuccess: vi.fn(),
}));
// Mock the useHelmRepoVersions and useHelmRepositories hooks
vi.mock('../queries/useHelmRepositories', () => ({
useHelmRepoVersions: vi.fn(() => ({
data: [
{ Version: '1.0.0', Repo: 'stable' },
{ Version: '1.1.0', Repo: 'stable' },
],
isInitialLoading: false,
isError: false,
})),
useHelmRepositories: vi.fn(() => ({
data: ['repo1', 'repo2'],
isInitialLoading: false,
isError: false,
})),
}));
function renderButton(props = {}) {
const defaultProps = {
environmentId: 1,
releaseName: 'test-release',
namespace: 'default',
release: {
name: 'test-release',
chart: {
metadata: {
name: 'test-chart',
version: '1.0.0',
},
},
values: {
userSuppliedValues: '{}',
},
manifest: '',
} as HelmRelease,
updateRelease: vi.fn(),
...props,
};
const Wrapped = withTestQueryProvider(withTestRouter(UpgradeButton));
return render(<Wrapped {...defaultProps} />);
}
describe('UpgradeButton', () => {
test('should display the upgrade button', () => {
renderButton();
const button = screen.getByRole('button', { name: /Upgrade/i });
expect(button).toBeInTheDocument();
});
test('should be disabled when no versions are available', () => {
const data: ChartVersion[] = [];
vi.mocked(useHelmRepoVersions).mockReturnValue({
data,
isInitialLoading: false,
isError: false,
});
renderButton();
const button = screen.getByRole('button', { name: /Upgrade/i });
expect(button).toBeDisabled();
});
test('should show loading state when checking for versions', () => {
vi.mocked(useHelmRepoVersions).mockReturnValue({
data: [],
isInitialLoading: true,
isError: false,
});
renderButton();
expect(
screen.getByText('Checking for new versions...')
).toBeInTheDocument();
});
test('should show "No versions available" when no versions are found', () => {
const data: ChartVersion[] = [];
vi.mocked(useHelmRepoVersions).mockReturnValue({
data,
isInitialLoading: false,
isError: false,
});
renderButton();
expect(screen.getByText('No versions available')).toBeInTheDocument();
});
test('should open upgrade modal when clicked', async () => {
const user = userEvent.setup();
const mockRelease = {
name: 'test-release',
chart: {
metadata: {
name: 'test-chart',
version: '1.0.0',
},
},
values: {
userSuppliedValues: '{}',
},
manifest: '',
} as HelmRelease;
vi.mocked(useHelmRepoVersions).mockReturnValue({
data: [
{ Version: '1.0.0', Repo: 'stable' },
{ Version: '1.1.0', Repo: 'stable' },
],
isInitialLoading: false,
isError: false,
});
renderButton({ release: mockRelease });
const button = screen.getByRole('button', { name: /Upgrade/i });
await user.click(button);
await waitFor(() => {
expect(openUpgradeHelmModal).toHaveBeenCalledWith(
expect.objectContaining({
name: 'test-release',
chart: 'test-chart',
namespace: 'default',
values: '{}',
version: '1.0.0',
}),
expect.arrayContaining([
{ Version: '1.0.0', Repo: 'stable' },
{ Version: '1.1.0', Repo: 'stable' },
])
);
});
});
test('should not execute the upgrade if modal is cancelled', async () => {
const mockUpdateRelease = vi.fn();
vi.mocked(openUpgradeHelmModal).mockResolvedValueOnce(undefined);
const user = userEvent.setup();
renderButton({ updateRelease: mockUpdateRelease });
const button = screen.getByRole('button', { name: /Upgrade/i });
await user.click(button);
await waitFor(() => {
expect(openUpgradeHelmModal).toHaveBeenCalled();
});
expect(mockUpdateRelease).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,167 @@
import { ArrowUp } from 'lucide-react';
import { useRouter } from '@uirouter/react';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { notifySuccess } from '@/portainer/services/notifications';
import { semverCompare } from '@/react/common/semver-utils';
import { LoadingButton } from '@@/buttons';
import { InlineLoader } from '@@/InlineLoader';
import { Tooltip } from '@@/Tip/Tooltip';
import { Link } from '@@/Link';
import { HelmRelease } from '../../types';
import {
useUpdateHelmReleaseMutation,
UpdateHelmReleasePayload,
} from '../queries/useUpdateHelmReleaseMutation';
import {
ChartVersion,
useHelmRepoVersions,
useHelmRepositories,
} from '../queries/useHelmRepositories';
import { useHelmRelease } from '../queries/useHelmRelease';
import { openUpgradeHelmModal } from './UpgradeHelmModal';
export function UpgradeButton({
environmentId,
releaseName,
namespace,
release,
updateRelease,
}: {
environmentId: EnvironmentId;
releaseName: string;
namespace: string;
release?: HelmRelease;
updateRelease: (release: HelmRelease) => void;
}) {
const router = useRouter();
const updateHelmReleaseMutation = useUpdateHelmReleaseMutation(environmentId);
const repositoriesQuery = useHelmRepositories();
const helmRepoVersionsQuery = useHelmRepoVersions(
release?.chart.metadata?.name || '',
60 * 60 * 1000, // 1 hour
repositoriesQuery.data
);
const versions = helmRepoVersionsQuery.data;
// Combined loading state
const isInitialLoading =
repositoriesQuery.isInitialLoading ||
helmRepoVersionsQuery.isInitialLoading;
const isError = repositoriesQuery.isError || helmRepoVersionsQuery.isError;
const latestVersion = useHelmRelease(environmentId, releaseName, namespace, {
select: (data) => data.chart.metadata?.version,
});
const latestVersionAvailable = versions[0]?.Version ?? '';
const isNewVersionAvailable =
latestVersion?.data &&
semverCompare(latestVersionAvailable, latestVersion?.data) === 1;
const editableHelmRelease: UpdateHelmReleasePayload = {
name: releaseName,
namespace: namespace || '',
values: release?.values?.userSuppliedValues,
chart: release?.chart.metadata?.name || '',
version: release?.chart.metadata?.version,
};
return (
<div className="relative">
<LoadingButton
color="secondary"
data-cy="k8sApp-upgradeHelmChartButton"
onClick={() => openUpgradeForm(versions, release)}
disabled={
versions.length === 0 ||
isInitialLoading ||
isError ||
release?.info?.status?.startsWith('pending')
}
loadingText="Upgrading..."
isLoading={updateHelmReleaseMutation.isLoading}
icon={ArrowUp}
size="medium"
>
Upgrade
</LoadingButton>
{versions.length === 0 && isInitialLoading && (
<InlineLoader
size="xs"
className="absolute -bottom-5 left-0 right-0 whitespace-nowrap"
>
Checking for new versions...
</InlineLoader>
)}
{versions.length === 0 && !isInitialLoading && !isError && (
<span className="absolute flex items-center -bottom-5 left-0 right-0 text-xs text-muted text-center whitespace-nowrap">
No versions available
<Tooltip
message={
<div>
Portainer is unable to find any versions for this chart in the
repositories saved. Try adding a new repository which contains
the chart in the{' '}
<Link
to="portainer.account"
params={{ '#': 'helm-repositories' }}
data-cy="user-settings-link"
>
Helm repositories settings
</Link>
</div>
}
/>
</span>
)}
{isNewVersionAvailable && (
<span className="absolute -bottom-5 left-0 right-0 text-xs text-muted text-center whitespace-nowrap">
New version available ({latestVersionAvailable})
</span>
)}
</div>
);
async function openUpgradeForm(
versions: ChartVersion[],
release?: HelmRelease
) {
const result = await openUpgradeHelmModal(editableHelmRelease, versions);
if (result) {
handleUpgrade(result, release);
}
}
function handleUpgrade(
payload: UpdateHelmReleasePayload,
release?: HelmRelease
) {
if (release?.info) {
const updatedRelease = {
...release,
info: {
...release.info,
status: 'pending-upgrade',
description: 'Preparing upgrade',
},
};
updateRelease(updatedRelease);
}
updateHelmReleaseMutation.mutate(payload, {
onSuccess: () => {
notifySuccess('Success', 'Helm chart upgraded successfully');
// set the revision url param to undefined to refresh the page at the latest revision
router.stateService.go('kubernetes.helm', {
namespace,
name: releaseName,
revision: undefined,
});
},
});
}
}

View file

@ -0,0 +1,151 @@
import { useState } from 'react';
import { ArrowUp } from 'lucide-react';
import { withReactQuery } from '@/react-tools/withReactQuery';
import { withCurrentUser } from '@/react-tools/withCurrentUser';
import { Modal, OnSubmit, openModal } from '@@/modals';
import { Button } from '@@/buttons';
import { Option, PortainerSelect } from '@@/form-components/PortainerSelect';
import { Input } from '@@/form-components/Input';
import { CodeEditor } from '@@/CodeEditor';
import { FormControl } from '@@/form-components/FormControl';
import { WidgetTitle } from '@@/Widget';
import { UpdateHelmReleasePayload } from '../queries/useUpdateHelmReleaseMutation';
import { ChartVersion } from '../queries/useHelmRepositories';
interface Props {
onSubmit: OnSubmit<UpdateHelmReleasePayload>;
values: UpdateHelmReleasePayload;
versions: ChartVersion[];
}
export function UpgradeHelmModal({ values, versions, onSubmit }: Props) {
const versionOptions: Option<ChartVersion>[] = versions.map((version) => {
const isCurrentVersion = version.Version === values.version;
const label = `${version.Repo}@${version.Version}${
isCurrentVersion ? ' (current)' : ''
}`;
return {
label,
value: version,
};
});
const defaultVersion =
versionOptions.find((v) => v.value.Version === values.version)?.value ||
versionOptions[0]?.value;
const [version, setVersion] = useState<ChartVersion>(defaultVersion);
const [userValues, setUserValues] = useState<string>(values.values || '');
return (
<Modal
onDismiss={() => onSubmit()}
size="lg"
className="flex flex-col h-[80vh] px-0"
aria-label="upgrade-helm"
>
<Modal.Header
title={<WidgetTitle className="px-5" title="Upgrade" icon={ArrowUp} />}
/>
<div className="flex-1 overflow-y-auto px-5">
<Modal.Body>
<FormControl label="Version" inputId="version-input" size="vertical">
<PortainerSelect<ChartVersion>
value={version}
options={versionOptions}
onChange={(version) => {
if (version) {
setVersion(version);
}
}}
data-cy="helm-version-input"
/>
</FormControl>
<FormControl
label="Release name"
inputId="release-name-input"
size="vertical"
>
<Input
id="release-name-input"
value={values.name}
readOnly
disabled
data-cy="helm-release-name-input"
/>
</FormControl>
<FormControl
label="Namespace"
inputId="namespace-input"
size="vertical"
>
<Input
id="namespace-input"
value={values.namespace}
readOnly
disabled
data-cy="helm-namespace-input"
/>
</FormControl>
<FormControl
label="User-defined values"
inputId="user-values-editor"
size="vertical"
>
<CodeEditor
id="user-values-editor"
value={userValues}
onChange={(value) => setUserValues(value)}
height="50vh"
type="yaml"
data-cy="helm-user-values-editor"
placeholder="Define or paste the content of your values yaml file here"
/>
</FormControl>
</Modal.Body>
</div>
<div className="px-5 border-solid border-0 border-t border-gray-5 th-dark:border-gray-7 th-highcontrast:border-white">
<Modal.Footer>
<Button
onClick={() => onSubmit()}
color="secondary"
key="cancel-button"
size="medium"
data-cy="cancel-button-cy"
>
Cancel
</Button>
<Button
onClick={() =>
onSubmit({
name: values.name,
values: userValues,
namespace: values.namespace,
chart: values.chart,
repo: version.Repo,
version: version.Version,
})
}
color="primary"
key="update-button"
size="medium"
data-cy="update-button-cy"
>
Upgrade
</Button>
</Modal.Footer>
</div>
</Modal>
);
}
export async function openUpgradeHelmModal(
values: UpdateHelmReleasePayload,
versions: ChartVersion[]
) {
return openModal(withReactQuery(withCurrentUser(UpgradeHelmModal)), {
values,
versions,
});
}