1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-07-25 00:09:40 +02:00

feat(helm): enhance helm chart install [r8s-341] (#766)

This commit is contained in:
Ali 2025-06-05 13:13:45 +12:00 committed by GitHub
parent caac45b834
commit a9061e5258
29 changed files with 864 additions and 562 deletions

View file

@ -4,11 +4,12 @@ import { vi } from 'vitest';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { withTestRouter } from '@/react/test-utils/withRouter';
import { withUserProvider } from '@/react/test-utils/withUserProvider';
import {
useHelmRepoVersions,
ChartVersion,
} from '../queries/useHelmRepositories';
} from '../../queries/useHelmRepositories';
import { HelmRelease } from '../../types';
import { openUpgradeHelmModal } from './UpgradeHelmModal';
@ -25,17 +26,8 @@ vi.mock('@/portainer/services/notifications', () => ({
}));
// 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,
isFetching: false,
refetch: vi.fn(() => Promise.resolve([])),
})),
vi.mock('../../queries/useHelmRepositories', () => ({
useHelmRepoVersions: vi.fn(),
useHelmRepositories: vi.fn(() => ({
data: ['repo1', 'repo2'],
isInitialLoading: false,
@ -43,6 +35,21 @@ vi.mock('../queries/useHelmRepositories', () => ({
})),
}));
// Mock the useHelmRelease hook
vi.mock('../queries/useHelmRelease', () => ({
useHelmRelease: vi.fn(() => ({
data: '1.0.0',
})),
}));
// Mock the useUpdateHelmReleaseMutation hook
vi.mock('../../queries/useUpdateHelmReleaseMutation', () => ({
useUpdateHelmReleaseMutation: vi.fn(() => ({
mutate: vi.fn(),
isLoading: false,
})),
}));
function renderButton(props = {}) {
const defaultProps = {
environmentId: 1,
@ -65,11 +72,27 @@ function renderButton(props = {}) {
...props,
};
const Wrapped = withTestQueryProvider(withTestRouter(UpgradeButton));
const Wrapped = withTestQueryProvider(
withUserProvider(withTestRouter(UpgradeButton))
);
return render(<Wrapped {...defaultProps} />);
}
describe('UpgradeButton', () => {
beforeEach(() => {
// Set up default mock return values
vi.mocked(useHelmRepoVersions).mockReturnValue({
data: [
{ Version: '1.0.0', Repo: 'stable' },
{ Version: '1.1.0', Repo: 'stable' },
],
isInitialLoading: false,
isError: false,
isFetching: false,
refetch: vi.fn(() => Promise.resolve([])),
});
});
test('should display the upgrade button', () => {
renderButton();

View file

@ -6,21 +6,17 @@ 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 { Button, LoadingButton } from '@@/buttons';
import { InlineLoader } from '@@/InlineLoader';
import { Tooltip } from '@@/Tip/Tooltip';
import { Link } from '@@/Link';
import { HelmRelease } from '../../types';
import { HelmRelease, UpdateHelmReleasePayload } from '../../types';
import { useUpdateHelmReleaseMutation } from '../../queries/useUpdateHelmReleaseMutation';
import {
useUpdateHelmReleaseMutation,
UpdateHelmReleasePayload,
} from '../queries/useUpdateHelmReleaseMutation';
import {
ChartVersion,
useHelmRepoVersions,
useHelmRepositories,
} from '../queries/useHelmRepositories';
} from '../../queries/useHelmRepositories';
import { useHelmRelease } from '../queries/useHelmRelease';
import { openUpgradeHelmModal } from './UpgradeHelmModal';
@ -39,10 +35,10 @@ export function UpgradeButton({
updateRelease: (release: HelmRelease) => void;
}) {
const router = useRouter();
const [useCache, setUseCache] = useState(true);
const updateHelmReleaseMutation = useUpdateHelmReleaseMutation(environmentId);
const repositoriesQuery = useHelmRepositories();
const [useCache, setUseCache] = useState(true);
const helmRepoVersionsQuery = useHelmRepoVersions(
release?.chart.metadata?.name || '',
60 * 60 * 1000, // 1 hour
@ -50,43 +46,42 @@ export function UpgradeButton({
useCache
);
const versions = helmRepoVersionsQuery.data;
const repo = versions?.[0]?.Repo;
// Combined loading state
const isLoading =
repositoriesQuery.isInitialLoading || helmRepoVersionsQuery.isFetching;
repositoriesQuery.isInitialLoading || helmRepoVersionsQuery.isFetching; // use 'isFetching' for helmRepoVersionsQuery because we want to show when it's refetching
const isError = repositoriesQuery.isError || helmRepoVersionsQuery.isError;
const latestVersion = useHelmRelease(environmentId, releaseName, namespace, {
select: (data) => data.chart.metadata?.version,
});
const latestVersionQuery = useHelmRelease(
environmentId,
releaseName,
namespace,
{
select: (data) => data.chart.metadata?.version,
}
);
const latestVersionAvailable = versions[0]?.Version ?? '';
const isNewVersionAvailable = Boolean(
latestVersion?.data &&
semverCompare(latestVersionAvailable, latestVersion?.data) === 1
latestVersionQuery?.data &&
semverCompare(latestVersionAvailable, latestVersionQuery?.data) === 1
);
const currentVersion = release?.chart.metadata?.version;
const editableHelmRelease: UpdateHelmReleasePayload = {
name: releaseName,
namespace: namespace || '',
values: release?.values?.userSuppliedValues,
chart: release?.chart.metadata?.name || '',
version: release?.chart.metadata?.version,
version: currentVersion,
repo,
};
function handleRefreshVersions() {
if (!useCache) {
helmRepoVersionsQuery.refetch();
} else {
setUseCache(false);
}
}
return (
<div className="relative">
<LoadingButton
color="secondary"
data-cy="k8sApp-upgradeHelmChartButton"
onClick={() => openUpgradeForm(versions, release)}
onClick={handleUpgrade}
disabled={
versions.length === 0 ||
isLoading ||
@ -133,68 +128,75 @@ export function UpgradeButton({
}
/>
)}
<button
<Button
data-cy="k8sApp-refreshHelmChartVersionsButton"
color="link"
size="xsmall"
onClick={handleRefreshVersions}
className="text-primary hover:text-primary-light cursor-pointer bg-transparent border-0 pl-1 p-0"
type="button"
>
Refresh versions
</button>
Refresh
</Button>
</span>
)}
</div>
);
async function openUpgradeForm(
versions: ChartVersion[],
release?: HelmRelease
) {
const result = await openUpgradeHelmModal(editableHelmRelease, versions);
if (result) {
handleUpgrade(result, release);
function handleRefreshVersions() {
if (useCache) {
// clicking 'refresh versions' should get the latest versions from the repo, not the cached versions
setUseCache(false);
}
helmRepoVersionsQuery.refetch();
}
function handleUpgrade(
payload: UpdateHelmReleasePayload,
release?: HelmRelease
) {
if (release?.info) {
const updatedRelease = {
...release,
info: {
...release.info,
status: 'pending-upgrade',
description: 'Preparing upgrade',
async function handleUpgrade() {
const submittedUpgradeValues = await openUpgradeHelmModal(
editableHelmRelease,
versions
);
if (submittedUpgradeValues) {
upgrade(submittedUpgradeValues, release);
}
function upgrade(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,
});
},
};
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,
});
},
});
}
function getStatusMessage(
hasNoAvailableVersions: boolean,
latestVersionAvailable: string,
isNewVersionAvailable: boolean
): string {
if (hasNoAvailableVersions) {
return 'No versions available ';
}
if (isNewVersionAvailable) {
return `New version available (${latestVersionAvailable}) `;
}
return '';
}
}
function getStatusMessage(
hasNoAvailableVersions: boolean,
latestVersionAvailable: string,
isNewVersionAvailable: boolean
) {
if (hasNoAvailableVersions) {
return 'No versions available ';
}
if (isNewVersionAvailable) {
return `New version available (${latestVersionAvailable}) `;
}
return 'Latest version installed';
}

View file

@ -3,26 +3,35 @@ import { ArrowUp } from 'lucide-react';
import { withReactQuery } from '@/react-tools/withReactQuery';
import { withCurrentUser } from '@/react-tools/withCurrentUser';
import { ChartVersion } from '@/react/kubernetes/helm/queries/useHelmRepositories';
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 { Checkbox } from '@@/form-components/Checkbox';
import { Option, PortainerSelect } from '@@/form-components/PortainerSelect';
import { UpdateHelmReleasePayload } from '../queries/useUpdateHelmReleaseMutation';
import { ChartVersion } from '../queries/useHelmRepositories';
import { UpdateHelmReleasePayload } from '../../types';
import { HelmValuesInput } from '../../components/HelmValuesInput';
import { useHelmChartValues } from '../../queries/useHelmChartValues';
interface Props {
onSubmit: OnSubmit<UpdateHelmReleasePayload>;
values: UpdateHelmReleasePayload;
versions: ChartVersion[];
chartName: string;
repo: string;
}
export function UpgradeHelmModal({ values, versions, onSubmit }: Props) {
export function UpgradeHelmModal({
values,
versions,
onSubmit,
chartName,
repo,
}: Props) {
const versionOptions: Option<ChartVersion>[] = versions.map((version) => {
const isCurrentVersion = version.Version === values.version;
const label = `${version.Repo}@${version.Version}${
@ -38,11 +47,18 @@ export function UpgradeHelmModal({ values, versions, onSubmit }: Props) {
versionOptions[0]?.value;
const [version, setVersion] = useState<ChartVersion>(defaultVersion);
const [userValues, setUserValues] = useState<string>(values.values || '');
const [atomic, setAtomic] = useState<boolean>(false);
const [atomic, setAtomic] = useState<boolean>(true);
const chartValuesRefQuery = useHelmChartValues({
chart: chartName,
repo,
version: version.Version,
});
return (
<Modal
onDismiss={() => onSubmit()}
size="lg"
size="xl"
className="flex flex-col h-[80vh] px-0"
aria-label="upgrade-helm"
>
@ -51,73 +67,65 @@ export function UpgradeHelmModal({ values, versions, onSubmit }: Props) {
/>
<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"
<div className="form-horizontal">
<FormControl
label="Release name"
inputId="release-name-input"
size="medium"
>
<Input
id="release-name-input"
value={values.name}
readOnly
disabled
data-cy="helm-release-name-input"
/>
</FormControl>
<FormControl
label="Namespace"
inputId="namespace-input"
size="medium"
>
<Input
id="namespace-input"
value={values.namespace}
readOnly
disabled
data-cy="helm-namespace-input"
/>
</FormControl>
<FormControl label="Version" inputId="version-input" size="medium">
<PortainerSelect<ChartVersion>
value={version}
options={versionOptions}
onChange={(version) => {
if (version) {
setVersion(version);
}
}}
data-cy="helm-version-input"
/>
</FormControl>
<FormControl
label="Rollback on failure"
tooltip="Enables automatic rollback on failure (equivalent to the helm --atomic flag). It may increase the time to upgrade."
inputId="atomic-input"
size="medium"
>
<Checkbox
id="atomic-input"
checked={atomic}
data-cy="atomic-checkbox"
onChange={(e) => setAtomic(e.target.checked)}
/>
</FormControl>
<HelmValuesInput
values={userValues}
setValues={setUserValues}
valuesRef={chartValuesRefQuery.data?.values ?? ''}
isValuesRefLoading={chartValuesRefQuery.isInitialLoading}
/>
</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="Rollback on failure"
tooltip="Enables automatic rollback on failure (equivalent to the helm --atomic flag). It may increase the time to upgrade."
inputId="atomic-input"
className="[&>label]:!pl-0"
size="medium"
>
<Checkbox
id="atomic-input"
checked={atomic}
data-cy="atomic-checkbox"
onChange={(e) => setAtomic(e.target.checked)}
/>
</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>
</div>
</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">
@ -163,5 +171,7 @@ export async function openUpgradeHelmModal(
return openModal(withReactQuery(withCurrentUser(UpgradeHelmModal)), {
values,
versions,
chartName: values.chart,
repo: values.repo ?? '',
});
}