diff --git a/src/Components/CreateImageWizard/steps/ImageOutput/components/ImageSourceSelect/OnPrem/OfficialImageSource.tsx b/src/Components/CreateImageWizard/steps/ImageOutput/components/ImageSourceSelect/OnPrem/OfficialImageSource.tsx
index 260495aee3..257ae50ef1 100644
--- a/src/Components/CreateImageWizard/steps/ImageOutput/components/ImageSourceSelect/OnPrem/OfficialImageSource.tsx
+++ b/src/Components/CreateImageWizard/steps/ImageOutput/components/ImageSourceSelect/OnPrem/OfficialImageSource.tsx
@@ -8,6 +8,7 @@ import {
HelperText,
HelperTextItem,
Spinner,
+ Tooltip,
} from '@patternfly/react-core';
import {
@@ -16,7 +17,10 @@ import {
usePullImageMutation,
} from '@/store/api/backend';
import { Distributions } from '@/store/api/backend/hosted';
-import { KNOWN_IMAGES } from '@/store/api/backend/onprem/constants';
+import {
+ IMAGE_REGISTRY_HOST,
+ KNOWN_IMAGES,
+} from '@/store/api/backend/onprem/constants';
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import {
changeDistribution,
@@ -33,6 +37,42 @@ import {
import ImageSelect from './ImageSelect';
import RegistryAuth from './RegistryAuth';
+type PullButtonProps = {
+ onPull: () => void;
+ isPulling: boolean;
+ isAuthenticated: boolean;
+ isDisabled?: boolean;
+};
+
+const PullButton = ({
+ onPull,
+ isPulling,
+ isAuthenticated,
+ isDisabled,
+}: PullButtonProps) => {
+ const button = (
+ : undefined}
+ >
+ {isPulling ? 'Pulling image...' : 'Pull latest image'}
+
+ );
+
+ if (isAuthenticated) {
+ return button;
+ }
+
+ return (
+
+ {button}
+
+ );
+};
+
const OfficialImageSource = () => {
const dispatch = useAppDispatch();
const arch = useAppSelector(selectArchitecture);
@@ -42,24 +82,30 @@ const OfficialImageSource = () => {
const hasOfficialSelection = useAppSelector(selectIsOfficialImage);
const { data: authStatus, isLoading: isAuthLoading } =
- useGetRegistryAuthStatusQuery();
+ useGetRegistryAuthStatusQuery(undefined, {
+ refetchOnMountOrArgChange: true,
+ });
const isAuthenticated = authStatus?.status === 'authenticated';
- const images = useMemo(() => {
- if (!isAuthenticated) {
- return [];
- }
-
- return KNOWN_IMAGES.map((known) => ({ ...known, arch }));
- }, [isAuthenticated, arch]);
+ const images = useMemo(
+ () => KNOWN_IMAGES.map((known) => ({ ...known, arch })),
+ [arch],
+ );
+ // Local images can be removed outside the wizard (e.g. podman rmi),
+ // so bypass the cache and re-check whenever this section mounts.
const { data: imageExists } = useGetImageExistsQuery(
{ reference: selectedRef! },
- { skip: !selectedRef },
+ { skip: !selectedRef, refetchOnMountOrArgChange: true },
);
- const [pullImage, { isLoading: isPulling, isError: isPullError }] =
- usePullImageMutation();
+ // The mutation state is scoped to the reference it was started with,
+ // so switching to another image doesn't show its busy/error state.
+ const [pullImage, pullState] = usePullImageMutation();
+ const isPulling =
+ pullState.isLoading && pullState.originalArgs?.reference === selectedRef;
+ const isPullError =
+ pullState.isError && pullState.originalArgs?.reference === selectedRef;
const showSelectionError = forceShowErrors && !hasOfficialSelection;
const showPullValidation = hasOfficialSelection && imageExists === false;
@@ -77,48 +123,42 @@ const OfficialImageSource = () => {
return (
<>
- {isAuthenticated && (
-
-
- {
- const selected = images.find(
- (img) => img.reference === selection,
+
+
+ {
+ const selected = images.find(
+ (img) => img.reference === selection,
+ );
+ if (selected) {
+ dispatch(changeImageSource(selected.reference));
+ dispatch(changeDistribution(selected.distro as Distributions));
+ dispatch(
+ changeImageTypes([selected.type as SupportedImageTypes]),
);
- if (selected) {
- dispatch(changeImageSource(selected.reference));
- dispatch(
- changeDistribution(selected.distro as Distributions),
- );
- dispatch(
- changeImageTypes([selected.type as SupportedImageTypes]),
- );
- }
- }}
- getLabel={(item) => item.name}
- placeholder={'Select an official image'}
+ }
+ }}
+ getLabel={(item) => item.name}
+ placeholder={'Select an official image'}
+ />
+
+ {hasOfficialSelection && (
+
+ pullImage({ reference: selectedRef! })}
+ isPulling={isPulling}
+ isAuthenticated={isAuthenticated}
+ isDisabled={isAuthLoading}
/>
- {hasOfficialSelection && (
-
-
-
- )}
-
- )}
+ )}
+
{showSelectionError && (
@@ -134,7 +174,9 @@ const OfficialImageSource = () => {
{isPullError
? 'Failed to pull image. Please try again.'
- : 'Image must be pulled before proceeding.'}
+ : isAuthenticated
+ ? 'Bootc container must be pulled before proceeding.'
+ : `Bootc container is not in local storage. Log in to ${IMAGE_REGISTRY_HOST} to pull it.`}
diff --git a/src/Components/CreateImageWizard/steps/ImageOutput/components/ImageSourceSelect/OnPrem/RegistryAuth.tsx b/src/Components/CreateImageWizard/steps/ImageOutput/components/ImageSourceSelect/OnPrem/RegistryAuth.tsx
index 597f51fc4a..4fb5c87b42 100644
--- a/src/Components/CreateImageWizard/steps/ImageOutput/components/ImageSourceSelect/OnPrem/RegistryAuth.tsx
+++ b/src/Components/CreateImageWizard/steps/ImageOutput/components/ImageSourceSelect/OnPrem/RegistryAuth.tsx
@@ -1,6 +1,8 @@
import React, { useState } from 'react';
import {
+ Alert,
+ AlertActionLink,
Button,
Card,
CardBody,
@@ -9,10 +11,6 @@ import {
CardTitle,
Content,
ContentVariants,
- EmptyState,
- EmptyStateActions,
- EmptyStateBody,
- EmptyStateFooter,
Flex,
FlexItem,
FormGroup,
@@ -31,23 +29,21 @@ import {
import { IMAGE_REGISTRY_HOST } from '@/store/api/backend/onprem/constants';
import { OnPremError } from '@/store/api/shared';
-const EmptyCard = ({ openForm }: { openForm: (arg0: boolean) => void }) => {
+const LoginPrompt = ({ openForm }: { openForm: (arg0: boolean) => void }) => {
return (
-
-
-
- Registry images come from {IMAGE_REGISTRY_HOST}.
-
- Sign in to browse available images for this release.
-
-
-
-
-
-
-
-
-
+ openForm(true)}>Log in
+ }
+ >
+ You can build from images already on this system without logging in.
+ Pulling the latest images from {IMAGE_REGISTRY_HOST} requires a Red Hat
+ login.
+
);
};
@@ -210,7 +206,11 @@ const RegistryStatus = ({ username }: { username: string }) => {
const RegistryAuth = () => {
const [isFormVisible, setIsFormVisible] = useState(false);
- const { data, isLoading } = useGetRegistryAuthStatusQuery();
+ // The login can change outside the wizard (e.g. podman logout), so
+ // bypass the cache and re-check whenever this section mounts.
+ const { data, isLoading } = useGetRegistryAuthStatusQuery(undefined, {
+ refetchOnMountOrArgChange: true,
+ });
if (isLoading) {
return (
@@ -226,7 +226,7 @@ const RegistryAuth = () => {
}
if (!isFormVisible) {
- return ;
+ return ;
}
return ;
diff --git a/src/Components/CreateImageWizard/steps/ImageOutput/components/ImageSourceSelect/OnPrem/index.tsx b/src/Components/CreateImageWizard/steps/ImageOutput/components/ImageSourceSelect/OnPrem/index.tsx
index 81c51da2c7..403c860fb1 100644
--- a/src/Components/CreateImageWizard/steps/ImageOutput/components/ImageSourceSelect/OnPrem/index.tsx
+++ b/src/Components/CreateImageWizard/steps/ImageOutput/components/ImageSourceSelect/OnPrem/index.tsx
@@ -7,7 +7,6 @@ import {
CardTitle,
FormGroup,
Gallery,
- Label,
} from '@patternfly/react-core';
import { IMAGE_REGISTRY_HOST } from '@/store/api/backend/onprem/constants';
@@ -47,7 +46,7 @@ const OnPremImageSourceSelect = () => {
}}
>
- Official Red Hat images
+ Official Red Hat images
Remote images from {IMAGE_REGISTRY_HOST}
diff --git a/src/Components/CreateImageWizard/steps/ImageOutput/tests/ImageSourceSelect.test.tsx b/src/Components/CreateImageWizard/steps/ImageOutput/tests/ImageSourceSelect.test.tsx
index 0faa147c22..888151e099 100644
--- a/src/Components/CreateImageWizard/steps/ImageOutput/tests/ImageSourceSelect.test.tsx
+++ b/src/Components/CreateImageWizard/steps/ImageOutput/tests/ImageSourceSelect.test.tsx
@@ -16,6 +16,9 @@ import {
import ImageSourceSelect from '../components/ImageSourceSelect';
+const KVM_REF = 'registry.redhat.io/rhel10/rhel-bootc-kvm:latest';
+const AWS_REF = 'registry.redhat.io/rhel10/rhel-bootc-aws:latest';
+
const mockRefetch = vi.fn();
const mockUseGetDistributionsQuery = vi.fn();
const mockUseGetImageExistsQuery = vi.fn();
@@ -47,6 +50,19 @@ const renderHostedImageSourceSelect = () => {
});
};
+// Tests preset the selected image in state rather than driving the
+// dropdown: the dropdown is on its way out as a selection mechanism.
+const renderWithGuestImage = () => {
+ return renderImageSourceSelect({
+ output: {
+ ...initialState.output,
+ imageSourceType: 'official',
+ imageTypes: ['guest-image'],
+ imageSource: KVM_REF,
+ },
+ });
+};
+
describe('ImageSourceSelect', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -92,6 +108,7 @@ describe('ImageSourceSelect', () => {
expect(screen.getByText('Local images')).toBeInTheDocument();
expect(screen.queryByText('Custom images')).not.toBeInTheDocument();
expect(screen.queryByText('No login')).not.toBeInTheDocument();
+ expect(screen.queryByText('Login required')).not.toBeInTheDocument();
});
test('does not auto-select an image on-prem', async () => {
@@ -103,6 +120,146 @@ describe('ImageSourceSelect', () => {
});
});
+ describe('Official images', () => {
+ test('pulls the selected container', async () => {
+ renderWithGuestImage();
+ const user = createUser();
+
+ const pullButton = await screen.findByRole('button', {
+ name: /pull latest image/i,
+ });
+ await clickWithWait(user, pullButton);
+
+ expect(mockPullImage).toHaveBeenCalledWith({ reference: KVM_REF });
+ });
+
+ test('shows the pulling state for the selected image', async () => {
+ mockUsePullImageMutation.mockReturnValue([
+ mockPullImage,
+ {
+ isLoading: true,
+ isError: false,
+ originalArgs: { reference: KVM_REF },
+ },
+ ]);
+
+ renderWithGuestImage();
+
+ expect(
+ await screen.findByRole('button', { name: /pulling image/i }),
+ ).toBeInTheDocument();
+ });
+
+ test("does not show another image's pull as busy", async () => {
+ // A pull of the guest image is in flight while AWS is selected
+ mockUsePullImageMutation.mockReturnValue([
+ mockPullImage,
+ {
+ isLoading: true,
+ isError: false,
+ originalArgs: { reference: KVM_REF },
+ },
+ ]);
+
+ renderImageSourceSelect({
+ output: {
+ ...initialState.output,
+ imageSourceType: 'official',
+ imageTypes: ['aws'],
+ imageSource: AWS_REF,
+ },
+ });
+
+ expect(
+ await screen.findByRole('button', { name: /pull latest image/i }),
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole('button', { name: /pulling image/i }),
+ ).not.toBeInTheDocument();
+ });
+
+ test('requires the bootc container to be pulled', async () => {
+ mockUseGetImageExistsQuery.mockReturnValue({
+ data: false,
+ isLoading: false,
+ isError: false,
+ });
+
+ renderWithGuestImage();
+
+ expect(
+ await screen.findByText(
+ /bootc container must be pulled before proceeding/i,
+ ),
+ ).toBeInTheDocument();
+ });
+ });
+
+ describe('Not logged in', () => {
+ beforeEach(() => {
+ mockUseGetRegistryAuthStatusQuery.mockReturnValue({
+ data: { status: 'unauthenticated' },
+ isLoading: false,
+ isError: false,
+ error: undefined,
+ });
+ });
+
+ test('displays the login prompt instead of an empty state', async () => {
+ renderImageSourceSelect();
+
+ expect(
+ await screen.findByText(/log in to pull the latest images/i),
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByText(/login to select an image/i),
+ ).not.toBeInTheDocument();
+ });
+
+ test('disables the pull button', async () => {
+ renderWithGuestImage();
+ const user = createUser();
+
+ const pullButton = await screen.findByRole('button', {
+ name: /pull latest image/i,
+ });
+ expect(pullButton).toHaveAttribute('aria-disabled', 'true');
+
+ await clickWithWait(user, pullButton);
+ expect(mockPullImage).not.toHaveBeenCalled();
+ });
+
+ test('points at the login when a missing image cannot be pulled', async () => {
+ mockUseGetImageExistsQuery.mockReturnValue({
+ data: false,
+ isLoading: false,
+ isError: false,
+ });
+
+ renderWithGuestImage();
+
+ expect(
+ await screen.findByText(/log in to registry\.redhat\.io to pull it/i),
+ ).toBeInTheDocument();
+ });
+
+ test('login action opens the login form', async () => {
+ renderImageSourceSelect();
+ const user = createUser();
+
+ const loginButton = await screen.findByRole('button', {
+ name: /log in/i,
+ });
+ await clickWithWait(user, loginButton);
+
+ expect(
+ await screen.findByText(/log in to registry\.redhat\.io/i),
+ ).toBeInTheDocument();
+ expect(screen.getByLabelText(/username/i)).toBeInTheDocument();
+ expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
+ });
+ });
+
describe('Local images', () => {
const renderLocalImageSource = () => {
return renderImageSourceSelect({
diff --git a/src/Components/CreateImageWizard/utilities/useValidation.tsx b/src/Components/CreateImageWizard/utilities/useValidation.tsx
index 4618d065e7..fd6aa396c2 100644
--- a/src/Components/CreateImageWizard/utilities/useValidation.tsx
+++ b/src/Components/CreateImageWizard/utilities/useValidation.tsx
@@ -12,8 +12,10 @@ import {
BlueprintsResponse,
useGetImageExistsQuery,
useGetOscapCustomizationsQuery,
+ useGetRegistryAuthStatusQuery,
useLazyGetBlueprintsQuery,
} from '@/store/api/backend';
+import { IMAGE_REGISTRY_HOST } from '@/store/api/backend/onprem/constants';
import { useShowActivationKeyQuery } from '@/store/api/rhsm';
import { useAppSelector } from '@/store/hooks';
import { selectIsOnPremise } from '@/store/slices/env';
@@ -1281,6 +1283,10 @@ export const useImagePullValidation = (): StepValidation => {
{ reference: imageSource! },
{ skip: !isOnPremise || !isOfficialImage },
);
+ const { data: authStatus } = useGetRegistryAuthStatusQuery(undefined, {
+ skip: !isOnPremise || !isOfficialImage,
+ });
+ const isAuthenticated = authStatus?.status === 'authenticated';
if (!isOnPremise || !isOfficialImage) {
return { errors: {}, disabledNext: false };
@@ -1292,7 +1298,11 @@ export const useImagePullValidation = (): StepValidation => {
if (imageExists !== true) {
return {
- errors: { imagePull: 'Image must be pulled before proceeding' },
+ errors: {
+ imagePull: isAuthenticated
+ ? 'Bootc container must be pulled before proceeding'
+ : `Bootc container is not in local storage. Log in to ${IMAGE_REGISTRY_HOST} to pull it.`,
+ },
disabledNext: true,
};
}
diff --git a/src/store/api/backend/index.ts b/src/store/api/backend/index.ts
index 83d319d3da..0fe6e8e8d7 100644
--- a/src/store/api/backend/index.ts
+++ b/src/store/api/backend/index.ts
@@ -109,7 +109,6 @@ export {
getHostArch,
getHostDistro,
useExportBlueprintCockpitQuery,
- useGetRegistryAuthStatusQuery,
useLazyGetImageExistsQuery,
useGetUploadConfigQuery,
useLazyExportBlueprintCockpitQuery,
@@ -133,6 +132,18 @@ export const useGetImageExistsQuery = (
})
) as typeof composerQueries.useGetImageExistsQuery;
+// Same conditional export, for the same caller.
+export const useGetRegistryAuthStatusQuery = (
+ process.env.IS_ON_PREMISE
+ ? composerQueries.useGetRegistryAuthStatusQuery
+ : () => ({
+ data: undefined,
+ isLoading: false,
+ isError: false,
+ isFetching: false,
+ })
+) as typeof composerQueries.useGetRegistryAuthStatusQuery;
+
export { composerApi, errorMessage, imageBuilderApi };
// Re-export all types from hosted API (primary/canonical types)