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 = ( + + ); + + 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)