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:
parent
caac45b834
commit
a9061e5258
29 changed files with 864 additions and 562 deletions
|
@ -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();
|
||||
|
||||
|
|
|
@ -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';
|
||||
}
|
||||
|
|
|
@ -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 ?? '',
|
||||
});
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue