Launch: implement guidance for GCP (HMS-9004)

This commit adds launch modal for guiding users through launching a GCP
instance from their image. This commit also adds unique image name in
the command in the clipboard. That way, users can rebuild the image more
times without worrying about duplicate names. This guidance should be as
helpful to users as possible, so even if they are able to create their
own image name here, we chose it for them for the sake of simplicity.
This commit is contained in:
Anna Vítová 2025-08-11 16:06:21 +02:00
parent af19251f17
commit f98f95ca65
4 changed files with 213 additions and 1 deletions

View file

@ -119,7 +119,7 @@ const AwsSourceName = ({ id }: AwsSourceNamePropTypes) => {
return <SourceNotFoundPopover />;
};
const parseGcpSharedWith = (
export const parseGcpSharedWith = (
sharedWith: GcpUploadRequestOptions['share_with_accounts'],
) => {
if (sharedWith) {

View file

@ -57,6 +57,7 @@ import { resolveRelPath } from '../../Utilities/path';
import { useFlag } from '../../Utilities/useGetEnvironment';
import useProvisioningPermissions from '../../Utilities/useProvisioningPermissions';
import { AWSLaunchModal } from '../Launch/AWSLaunchModal';
import { GcpLaunchModal } from '../Launch/GcpLaunchModal';
type CloudInstancePropTypes = {
compose: ComposesResponseItem;
@ -224,6 +225,14 @@ const ProvisioningLink = ({
composeStatus={composeStatus}
/>
)}
{launchEofFlag && isModalOpen && provider === 'gcp' && (
<GcpLaunchModal
isOpen={isModalOpen}
handleModalToggle={handleModalToggle}
compose={compose}
composeStatus={composeStatus}
/>
)}
{!launchEofFlag && isModalOpen && (
<Modal
isOpen

View file

@ -0,0 +1,169 @@
import React, { useState } from 'react';
import {
Button,
ClipboardCopy,
ClipboardCopyVariant,
List,
ListComponent,
ListItem,
Modal,
ModalBody,
ModalFooter,
ModalHeader,
ModalVariant,
OrderType,
TextInput,
} from '@patternfly/react-core';
import { ExternalLinkAltIcon } from '@patternfly/react-icons';
import { generateDefaultName } from './useGenerateDefaultName';
import {
ComposesResponseItem,
ComposeStatus,
} from '../../store/imageBuilderApi';
import {
isGcpUploadRequestOptions,
isGcpUploadStatus,
} from '../../store/typeGuards';
import { parseGcpSharedWith } from '../ImagesTable/ImageDetails';
type LaunchProps = {
isOpen: boolean;
handleModalToggle: (event: KeyboardEvent | React.MouseEvent) => void;
compose: ComposesResponseItem;
composeStatus: ComposeStatus | undefined;
};
export const GcpLaunchModal = ({
isOpen,
handleModalToggle,
compose,
composeStatus,
}: LaunchProps) => {
const [customerProjectId, setCustomerProjectId] = useState('');
const statusOptions = composeStatus?.image_status.upload_status?.options;
const composeOptions =
compose.request.image_requests[0].upload_request.options;
if (
(statusOptions && !isGcpUploadStatus(statusOptions)) ||
!isGcpUploadRequestOptions(composeOptions)
) {
throw TypeError(
`Error: options must be of type GcpUploadRequestOptions, not ${typeof statusOptions}.`,
);
}
const imageName = statusOptions?.image_name;
const projectId = statusOptions?.project_id;
if (!imageName || !projectId) {
throw TypeError(
`Error: Image name not found, unable to generate a command to copy ${typeof statusOptions}.`,
);
}
const uniqueImageName = generateDefaultName(imageName);
const authorizeString =
composeOptions.share_with_accounts &&
composeOptions.share_with_accounts.length === 1
? `Authorize gcloud CLI to the following
account: ${parseGcpSharedWith(composeOptions.share_with_accounts)}.`
: composeOptions.share_with_accounts
? `Authorize gcloud CLI to use one of the following
accounts: ${parseGcpSharedWith(composeOptions.share_with_accounts)}.`
: 'Authorize gcloud CLI to use the account that the image is shared with.';
const installationCommand = `sudo dnf install google-cloud-cli`;
const createImage = `gcloud compute images create ${uniqueImageName} --source-image=${imageName} --source-image-project=${projectId} --project=${
customerProjectId || '<your_project_id>'
}`;
const createInstance = `gcloud compute instances create ${uniqueImageName} --image=${uniqueImageName} --project=${
customerProjectId || '<your_project_id>'
}`;
return (
<Modal
isOpen={isOpen}
onClose={handleModalToggle}
variant={ModalVariant.large}
aria-label='Open launch guide modal'
>
<ModalHeader
title={'Launch with Google Cloud Platform'}
labelId='modal-title'
description={compose.image_name}
/>
<ModalBody id='modal-box-body-basic'>
<List component={ListComponent.ol} type={OrderType.number}>
<ListItem>
Install the gcloud CLI. See the{' '}
<Button
component='a'
target='_blank'
variant='link'
icon={<ExternalLinkAltIcon />}
iconPosition='right'
href={`https://cloud.google.com/sdk/docs/install`}
className='pf-v6-u-pl-0'
>
Install gcloud CLI
</Button>
documentation.
<ClipboardCopy isReadOnly hoverTip='Copy' clickTip='Copied'>
{installationCommand}
</ClipboardCopy>
</ListItem>
<ListItem>{authorizeString}</ListItem>
<ListItem>
Enter your GCP project ID, and run the command to create the image
in your project.
<TextInput
className='pf-v6-u-mt-sm pf-v6-u-mb-md'
value={customerProjectId}
type='text'
onChange={(_event, value) => setCustomerProjectId(value)}
aria-label='Project ID input'
placeholder='Project ID'
/>
<ClipboardCopy
isReadOnly
hoverTip='Copy'
clickTip='Copied'
variant={ClipboardCopyVariant.expansion}
>
{createImage}
</ClipboardCopy>
</ListItem>
<ListItem>
Create an instance of your image by either accessing the{' '}
<Button
component='a'
target='_blank'
variant='link'
icon={<ExternalLinkAltIcon />}
iconPosition='right'
href={`https://console.cloud.google.com/compute/images`}
className='pf-v6-u-pl-0'
>
GCP console
</Button>{' '}
or by running the following command:
<ClipboardCopy
isReadOnly
hoverTip='Copy'
clickTip='Copied'
variant={ClipboardCopyVariant.expansion}
>
{createInstance}
</ClipboardCopy>
</ListItem>
</List>
</ModalBody>
<ModalFooter>
<Button key='close' variant='primary' onClick={handleModalToggle}>
Close
</Button>
</ModalFooter>
</Modal>
);
};

View file

@ -0,0 +1,34 @@
export const generateDefaultName = (imageName: string) => {
const date = new Date();
const day = date.getDate().toString().padStart(2, '0');
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const year = date.getFullYear().toString();
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
const dateTimeString = `${month}${day}${year}-${hours}${minutes}`;
// gcloud images are valid in the form of: (?:[a-z](?:[-a-z0-9]{0,61}[a-z0-9])?)
let newBlueprintName = imageName
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-')
.replace(/-{2,}/g, '-')
.replace(/^-+|-+$/g, '');
if (!/^[a-z]/.test(newBlueprintName)) {
newBlueprintName = 'i' + newBlueprintName;
}
const maxLength = 63;
const uniquePartLength = dateTimeString.length + 1;
const baseNameMaxLength = maxLength - uniquePartLength;
if (newBlueprintName.length > baseNameMaxLength) {
newBlueprintName = newBlueprintName.substring(0, baseNameMaxLength);
}
while (newBlueprintName.endsWith('-')) {
newBlueprintName = newBlueprintName.slice(0, -1);
}
return `${newBlueprintName}-${dateTimeString}`;
};