From ab670c130ec66cd41c59e4e2b16f5c5b3130a343 Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Tue, 14 May 2024 15:36:43 -0400 Subject: [PATCH 1/9] initial work --- packages/api-v4/src/images/images.ts | 21 +- packages/api-v4/src/images/types.ts | 3 +- .../src/components/TagsInput/TagsInput.tsx | 6 + .../Images/ImagesCreate/CreateImageTab.tsx | 570 ++++++++---------- .../Images/ImagesCreate/ImageCreate.tsx | 17 +- packages/manager/src/queries/images.ts | 4 +- packages/validation/src/images.schema.ts | 9 +- 7 files changed, 279 insertions(+), 351 deletions(-) diff --git a/packages/api-v4/src/images/images.ts b/packages/api-v4/src/images/images.ts index 0584e0ecc77..f6acfd3418a 100644 --- a/packages/api-v4/src/images/images.ts +++ b/packages/api-v4/src/images/images.ts @@ -12,7 +12,7 @@ import Request, { setXFilter, } from '../request'; import { Filter, Params, ResourcePage as Page } from '../types'; -import { Image, ImageUploadPayload, UploadImageResponse } from './types'; +import { CreateImagePayload, Image, ImageUploadPayload, UploadImageResponse } from './types'; /** * Get information about a single Image. @@ -39,25 +39,8 @@ export const getImages = (params: Params = {}, filters: Filter = {}) => /** * Create a private gold-master Image from a Linode Disk. - * - * @param diskId { number } The ID of the Linode Disk that this Image will be created from. - * @param label { string } A short description of the Image. Labels cannot contain special characters. - * @param description { string } A detailed description of this Image. - * @param cloud_init { boolean } An indicator of whether Image supports cloud-init. */ -export const createImage = ( - diskId: number, - label?: string, - description?: string, - cloud_init?: boolean -) => { - const data = { - disk_id: diskId, - ...(label && { label }), - ...(description && { description }), - ...(cloud_init && { cloud_init }), - }; - +export const createImage = (data: CreateImagePayload) => { return Request( setURL(`${API_ROOT}/images`), setMethod('POST'), diff --git a/packages/api-v4/src/images/types.ts b/packages/api-v4/src/images/types.ts index 79edbcc8ccf..4e19feb8039 100644 --- a/packages/api-v4/src/images/types.ts +++ b/packages/api-v4/src/images/types.ts @@ -33,10 +33,11 @@ export interface BaseImagePayload { label?: string; description?: string; cloud_init?: boolean; + tags?: string[]; } export interface CreateImagePayload extends BaseImagePayload { - diskID: number; + disk_id: number; } export interface ImageUploadPayload extends BaseImagePayload { diff --git a/packages/manager/src/components/TagsInput/TagsInput.tsx b/packages/manager/src/components/TagsInput/TagsInput.tsx index 5a5a5fe6d81..576a11d8e2d 100644 --- a/packages/manager/src/components/TagsInput/TagsInput.tsx +++ b/packages/manager/src/components/TagsInput/TagsInput.tsx @@ -39,6 +39,10 @@ export interface TagsInputProps { * The name of the input. */ name?: string; + /** + * Removes the default top margin + */ + noMarginTop?: boolean; /** * Callback fired when the value changes. */ @@ -60,6 +64,7 @@ export const TagsInput = (props: TagsInputProps) => { label, menuPlacement, name, + noMarginTop, onChange, tagError, value, @@ -132,6 +137,7 @@ export const TagsInput = (props: TagsInputProps) => { label={label || 'Add Tags'} menuPlacement={menuPlacement} name={name} + noMarginTop={noMarginTop} noOptionsMessage={getEmptyMessage} onChange={onChange} onCreateOption={createTag} diff --git a/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx index 3f85e979261..541600ea590 100644 --- a/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx +++ b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx @@ -1,355 +1,297 @@ -import { Disk, Linode, getLinodeDisks } from '@linode/api-v4/lib/linodes'; -import { APIError } from '@linode/api-v4/lib/types'; -import { Theme } from '@mui/material/styles'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { CreateImagePayload } from '@linode/api-v4'; +import { createImageSchema } from '@linode/validation'; import { useSnackbar } from 'notistack'; -import { equals } from 'ramda'; import * as React from 'react'; +import { Controller, useForm } from 'react-hook-form'; import { useHistory } from 'react-router-dom'; -import { makeStyles } from 'tss-react/mui'; +import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; import { Box } from 'src/components/Box'; import { Button } from 'src/components/Button/Button'; import { Checkbox } from 'src/components/Checkbox'; import { Link } from 'src/components/Link'; import { Notice } from 'src/components/Notice/Notice'; import { Paper } from 'src/components/Paper'; +import { Stack } from 'src/components/Stack'; +import { TagsInput } from 'src/components/TagsInput/TagsInput'; import { TextField } from 'src/components/TextField'; +import { TooltipIcon } from 'src/components/TooltipIcon'; import { Typography } from 'src/components/Typography'; -import { DiskSelect } from 'src/features/Linodes/DiskSelect/DiskSelect'; import { LinodeSelect } from 'src/features/Linodes/LinodeSelect/LinodeSelect'; import { useFlags } from 'src/hooks/useFlags'; +import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { useEventsPollingActions } from 'src/queries/events/events'; import { useCreateImageMutation } from 'src/queries/images'; -import { useGrants, useProfile } from 'src/queries/profile'; -import { useRegionsQuery } from 'src/queries/regions/regions'; -import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; -import { getAPIErrorFor } from 'src/utilities/getAPIErrorFor'; +import { useAllLinodeDisksQuery } from 'src/queries/linodes/disks'; +import { useGrants } from 'src/queries/profile'; -const useStyles = makeStyles()((theme: Theme) => ({ - buttonGroup: { - marginBottom: theme.spacing(2), - marginTop: theme.spacing(3), - [theme.breakpoints.down('sm')]: { - justifyContent: 'flex-end', - }, - }, - cloudInitCheckboxWrapper: { - marginLeft: 3, - marginTop: theme.spacing(2), - }, - container: { - '& .MuiFormHelperText-root': { - marginBottom: theme.spacing(2), - }, - padding: theme.spacing(3), - paddingBottom: theme.spacing(), - paddingTop: theme.spacing(2), - }, - diskAndPrice: { - '& > div': { - width: 415, - }, - }, - helperText: { - marginBottom: theme.spacing(), - marginTop: theme.spacing(2), - [theme.breakpoints.down('sm')]: { - width: '100%', - }, - width: '80%', - }, - rawDiskWarning: { - maxWidth: 600, - width: '100%', - }, -})); - -const cloudInitTooltipMessage = ( - - Many Linode supported distributions are compatible with cloud-init by - default, or you may have installed cloud-init.{' '} - - Learn more. - - -); - -export interface Props { - changeDescription: (e: React.ChangeEvent) => void; - changeIsCloudInit: () => void; - changeLabel: (e: React.ChangeEvent) => void; - description?: string; - isCloudInit?: boolean; - label?: string; -} +export const CreateImageTab = () => { + const [selectedLinodeId, setSelectedLinodeId] = React.useState( + null + ); -export const CreateImageTab: React.FC = (props) => { const { - changeDescription, - changeIsCloudInit, - changeLabel, - description, - isCloudInit, - label, - } = props; + control, + formState, + handleSubmit, + resetField, + setError, + watch, + } = useForm({ + mode: 'onBlur', + resolver: yupResolver(createImageSchema), + }); + + const flags = useFlags(); - const { classes } = useStyles(); const { enqueueSnackbar } = useSnackbar(); const { push } = useHistory(); - const { data: profile } = useProfile(); - const { data: grants } = useGrants(); - const flags = useFlags(); - const { data: regions } = useRegionsQuery(); - const { mutateAsync: createImage } = useCreateImageMutation(); const { checkForNewEvents } = useEventsPollingActions(); - const [selectedLinode, setSelectedLinode] = React.useState(); - const [selectedDisk, setSelectedDisk] = React.useState(''); - const [disks, setDisks] = React.useState([]); - const [notice, setNotice] = React.useState(); - const [errors, setErrors] = React.useState(); - const [submitting, setSubmitting] = React.useState(false); + const { data: grants } = useGrants(); - const canCreateImage = - Boolean(!profile?.restricted) || Boolean(grants?.global?.add_images); + const isImageCreateRestricted = useRestrictedGlobalGrantCheck({ + globalGrantType: 'add_images', + }); - const availableLinodesToImagize = profile?.restricted - ? grants?.linode - .filter((thisGrant) => thisGrant.permissions === 'read_write') - .map((thisGrant) => thisGrant.id) ?? [] - : null; + const onSubmit = handleSubmit(async (values) => { + try { + await createImage(values); - React.useEffect(() => { - if (!selectedLinode) { - return; - } - setSelectedDisk(''); + checkForNewEvents(); - getLinodeDisks(selectedLinode.id) - .then((response) => { - const filteredDisks = response.data.filter( - (disk) => disk.filesystem !== 'swap' - ); - - if (!equals(disks, filteredDisks)) { - setDisks(filteredDisks); - } - }) - .catch((_) => { - setErrors([ - { - field: 'disk_id', - reason: 'Could not retrieve disks for this Linode.', - }, - ]); + enqueueSnackbar('Image scheduled for creation.', { + variant: 'info', }); - }, [selectedLinode]); - - const handleLinodeChange = (linode: Linode | null) => { - if (linode !== null) { - // Clear any errors - setErrors(undefined); + push('/images'); + } catch (errors) { + for (const error of errors) { + if (error.field) { + setError(error.field, { message: error.reason }); + } else { + setError('root', { message: error.reason }); + } + } } - setSelectedLinode(linode ?? undefined); - }; - - const handleDiskChange = (diskID: null | string) => { - // Clear any errors - setErrors(undefined); - setSelectedDisk(diskID); - }; - - const onSubmit = () => { - setErrors(undefined); - setNotice(undefined); - setSubmitting(true); - - const safeDescription = description ?? ''; - createImage({ - cloud_init: isCloudInit ? isCloudInit : undefined, - description: safeDescription, - diskID: Number(selectedDisk), - label, - }) - .then((_) => { - checkForNewEvents(); - - setSubmitting(false); - - enqueueSnackbar('Image scheduled for creation.', { - variant: 'info', - }); + }); - push('/images'); - }) - .catch((errorResponse) => { - setSubmitting(false); - setErrors( - getAPIErrorOrDefault( - errorResponse, - 'There was an error creating the image.' - ) - ); - }); - }; - - const checkRequirements = () => { - // When creating an image, disable the submit button until a Linode and - // disk are selected. - const isDiskSelected = Boolean(selectedDisk); - - return !(isDiskSelected && selectedLinode); - }; - - const requirementsMet = checkRequirements(); - - const selectedDiskData: Disk | undefined = disks.find( - (d) => `${d.id}` === selectedDisk - ); - - const isRawDisk = selectedDiskData?.filesystem === 'raw'; - const rawDiskWarning = ( - - ); - - const hasErrorFor = getAPIErrorFor( - { - disk_id: 'Disk', - label: 'Label', - linode_id: 'Linode', - region: 'Region', - size: 'Size', - }, - errors - ); + const { + data: disks, + error: disksError, + isFetching: disksLoading, + } = useAllLinodeDisksQuery(selectedLinodeId ?? -1, selectedLinodeId !== null); - const labelError = hasErrorFor('label'); - const descriptionError = hasErrorFor('description'); - const generalError = hasErrorFor('none'); - const linodeError = hasErrorFor('linode_id'); - const diskError = hasErrorFor('disk_id'); + const selectedDiskId = watch('disk_id'); + const selectedDisk = + disks?.find((disk) => disk.id === selectedDiskId) ?? null; - const linodeIsNotInEdgeRegion = (linodeRegion: string) => - regions?.find((region) => region.id === linodeRegion)?.site_type !== 'edge'; + const isRawDisk = selectedDisk?.filesystem === 'raw'; return ( - - {!canCreateImage ? ( - - ) : null} - {generalError ? ( - - ) : null} - {notice ? : null} - - - (linodeIsNotInEdgeRegion(linode.region) && - availableLinodesToImagize?.includes(linode.id)) ?? - true - } - disabled={!canCreateImage} - errorText={linodeError} - onSelectionChange={(linode) => handleLinodeChange(linode)} - required - value={selectedLinode?.id || null} - /> - - - - - {isRawDisk ? rawDiskWarning : null} - {flags.metadata && ( - - - - )} - <> - - - - - Custom Images are billed at $0.10/GB per month.{' '} - - Learn more about requirements and considerations.{' '} - - For information about how to check and clean a Linux system’s disk - space,{' '} - - read this guide. - - - - - - + + + + ); }; - -export default CreateImageTab; - -const rawDiskWarningText = - 'Using a raw disk may fail, as Linode Images cannot be created from disks formatted with custom filesystems.'; diff --git a/packages/manager/src/features/Images/ImagesCreate/ImageCreate.tsx b/packages/manager/src/features/Images/ImagesCreate/ImageCreate.tsx index ebdcd452460..c954d558318 100644 --- a/packages/manager/src/features/Images/ImagesCreate/ImageCreate.tsx +++ b/packages/manager/src/features/Images/ImagesCreate/ImageCreate.tsx @@ -5,7 +5,11 @@ import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { NavTab, NavTabs } from 'src/components/NavTabs/NavTabs'; import { SuspenseLoader } from 'src/components/SuspenseLoader'; -const CreateImageTab = React.lazy(() => import('./CreateImageTab')); +const CreateImageTab = React.lazy(() => + import('./CreateImageTab').then((module) => ({ + default: module.CreateImageTab, + })) +); const ImageUpload = React.lazy(() => import('../ImageUpload')); export const ImageCreate = () => { @@ -32,16 +36,7 @@ export const ImageCreate = () => { const tabs: NavTab[] = [ { - render: ( - setIsCloudInit(!isCloudInit)} - changeLabel={handleSetLabel} - description={description} - isCloudInit={isCloudInit} - label={label} - /> - ), + render: , routeName: `${url}/disk`, title: 'Capture Image', }, diff --git a/packages/manager/src/queries/images.ts b/packages/manager/src/queries/images.ts index fcd52758f8a..4e678cf646b 100644 --- a/packages/manager/src/queries/images.ts +++ b/packages/manager/src/queries/images.ts @@ -62,9 +62,7 @@ export const useImageQuery = (imageId: string, enabled = true) => export const useCreateImageMutation = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ cloud_init, description, diskID, label }) => { - return createImage(diskID, label, description, cloud_init); - }, + mutationFn: createImage, onSuccess(image) { queryClient.invalidateQueries(imageQueries.paginated._def); queryClient.setQueryData( diff --git a/packages/validation/src/images.schema.ts b/packages/validation/src/images.schema.ts index 5ff64c8ec49..712bd7ceb2c 100644 --- a/packages/validation/src/images.schema.ts +++ b/packages/validation/src/images.schema.ts @@ -1,7 +1,8 @@ -import { boolean, number, object, string } from 'yup'; +import { array, boolean, number, object, string } from 'yup'; const labelSchema = string() - .max(50, 'Length must be 50 characters or less.') + .min(1, 'Label must be between 1 and 50 characters.') + .max(50, 'Label must be between 1 and 50 characters.') .matches( /^[a-zA-Z0-9,.?\-_\s']+$/, 'Image labels cannot contain special characters.' @@ -11,6 +12,7 @@ export const baseImageSchema = object().shape({ label: labelSchema.notRequired(), description: string().notRequired().min(1).max(65000), cloud_init: boolean().notRequired(), + tags: array(string()).notRequired(), }); export const createImageSchema = baseImageSchema.shape({ @@ -27,7 +29,8 @@ export const uploadImageSchema = baseImageSchema.shape({ export const updateImageSchema = object().shape({ label: string() .notRequired() - .max(50, 'Length must be 50 characters or less.') + .min(1, 'Label must be between 1 and 50 characters.') + .max(50, 'Label must be between 1 and 50 characters.') .matches( /^[a-zA-Z0-9,.?\-_\s']+$/, 'Image labels cannot contain special characters.' From 94f779ec54f12f079c4f4d6093aaf951fa80c4bb Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Tue, 14 May 2024 17:49:03 -0400 Subject: [PATCH 2/9] add helper text --- .../src/features/Images/ImagesCreate/CreateImageTab.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx index 541600ea590..cce4d99decf 100644 --- a/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx +++ b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx @@ -134,10 +134,13 @@ export const CreateImageTab = () => { ( { inputRef: field.ref, }} clearOnBlur - disabled={isImageCreateRestricted} label="Disk" loading={disksLoading} noMarginTop From ab7b573bccd22d618a4f6aadc73887c008f4f592 Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Tue, 14 May 2024 19:02:02 -0400 Subject: [PATCH 3/9] make the restricted user experience better --- .../Images/ImagesCreate/CreateImageTab.tsx | 23 ++++++++++++------- .../Linodes/LinodeSelect/LinodeSelect.tsx | 4 ++++ 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx index cce4d99decf..f72a03a8075 100644 --- a/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx +++ b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx @@ -110,22 +110,29 @@ export const CreateImageTab = () => { )} { - setSelectedLinodeId(linode?.id ?? null); - if (linode === null) { - resetField('disk_id'); - } - }} - optionsFilter={ + getOptionDisabled={ grants ? (linode) => grants.linode.some( (grant) => grant.id === linode.id && - grant.permissions === 'read_write' + grant.permissions === 'read_only' ) : undefined } + helperText={ + grants?.linode.some( + (grant) => grant.permissions === 'read_only' + ) + ? 'You can only create Images from Linodes you have read/write access to.' + : undefined + } + onSelectionChange={(linode) => { + setSelectedLinodeId(linode?.id ?? null); + if (linode === null) { + resetField('disk_id'); + } + }} disabled={isImageCreateRestricted} noMarginTop required diff --git a/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.tsx b/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.tsx index fcaac5bab0f..c51f9aa9f70 100644 --- a/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.tsx +++ b/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.tsx @@ -23,6 +23,8 @@ interface LinodeSelectProps { errorText?: string; /** Filter sent to the API when retrieving account Linodes. */ filter?: Filter; + /** Determines if the Linode option should be disabled. */ + getOptionDisabled?: (linode: Linode) => boolean; /** Hint displayed in normal styling. */ helperText?: string; /** The ID of the input. */ @@ -83,6 +85,7 @@ export const LinodeSelect = ( disabled, errorText, filter, + getOptionDisabled, helperText, id, label, @@ -169,6 +172,7 @@ export const LinodeSelect = ( disablePortal={true} disabled={disabled} errorText={error?.[0].reason ?? errorText} + getOptionDisabled={getOptionDisabled} helperText={helperText} id={id} inputValue={inputValue} From c712e0232ef53735e34738c9bd165334715895bb Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Tue, 14 May 2024 20:13:04 -0400 Subject: [PATCH 4/9] fixes and clean up --- .../Images/ImagesCreate/CreateImageTab.tsx | 25 ++++++++++++++++++- packages/validation/src/images.schema.ts | 13 +++------- 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx index f72a03a8075..a8f8addda1a 100644 --- a/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx +++ b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx @@ -14,6 +14,7 @@ import { Link } from 'src/components/Link'; import { Notice } from 'src/components/Notice/Notice'; import { Paper } from 'src/components/Paper'; import { Stack } from 'src/components/Stack'; +import { SupportLink } from 'src/components/SupportLink'; import { TagsInput } from 'src/components/TagsInput/TagsInput'; import { TextField } from 'src/components/TextField'; import { TooltipIcon } from 'src/components/TooltipIcon'; @@ -109,6 +110,23 @@ export const CreateImageTab = () => { /> )} + Select Linode & Disk + + By default, Linode images are limited to 6144 MB of data per disk. + Ensure your content doesn't exceed this limit, or{' '} + {' '} + to request a higher limit. Additionally, images can't be created + from a raw disk or a disk that's formatted using a custom file + system. + { + Image Details ( { ( + field.onChange( + e.target.value === '' ? undefined : e.target.value + ) + } disabled={isImageCreateRestricted} errorText={fieldState.error?.message} inputRef={field.ref} @@ -261,7 +285,6 @@ export const CreateImageTab = () => { multiline noMarginTop onBlur={field.onBlur} - onChange={field.onChange} rows={1} value={field.value ?? ''} /> diff --git a/packages/validation/src/images.schema.ts b/packages/validation/src/images.schema.ts index 712bd7ceb2c..bd5e64ab4da 100644 --- a/packages/validation/src/images.schema.ts +++ b/packages/validation/src/images.schema.ts @@ -8,7 +8,7 @@ const labelSchema = string() 'Image labels cannot contain special characters.' ); -export const baseImageSchema = object().shape({ +export const baseImageSchema = object({ label: labelSchema.notRequired(), description: string().notRequired().min(1).max(65000), cloud_init: boolean().notRequired(), @@ -26,15 +26,8 @@ export const uploadImageSchema = baseImageSchema.shape({ region: string().required('Region is required.'), }); -export const updateImageSchema = object().shape({ - label: string() - .notRequired() - .min(1, 'Label must be between 1 and 50 characters.') - .max(50, 'Label must be between 1 and 50 characters.') - .matches( - /^[a-zA-Z0-9,.?\-_\s']+$/, - 'Image labels cannot contain special characters.' - ), +export const updateImageSchema = object({ + label: labelSchema.notRequired(), description: string() .notRequired() .max(65000, 'Length must be 65000 characters or less.'), From 54b7be7ca1a40d77300a043bd090b9217186b4c5 Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Tue, 14 May 2024 20:18:13 -0400 Subject: [PATCH 5/9] fix type errors --- .../cypress/e2e/core/stackscripts/create-stackscripts.spec.ts | 4 +++- packages/manager/src/features/Images/ImagesDrawer.tsx | 2 +- .../LinodesDetail/LinodeStorage/CreateImageFromDiskDialog.tsx | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts b/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts index 052588d6282..0051b6b0c75 100644 --- a/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts +++ b/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts @@ -132,7 +132,9 @@ const createLinodeAndImage = async () => { await resizeLinodeDisk(linode.id, diskId, resizedDiskSize); await pollLinodeDiskSize(linode.id, diskId, resizedDiskSize); - const image = await createImage(diskId, randomLabel(), randomPhrase()); + const image = await createImage({ + disk_id: diskId, + }); await pollImageStatus( image.id, diff --git a/packages/manager/src/features/Images/ImagesDrawer.tsx b/packages/manager/src/features/Images/ImagesDrawer.tsx index bbe5b17e6b3..ac08178c47b 100644 --- a/packages/manager/src/features/Images/ImagesDrawer.tsx +++ b/packages/manager/src/features/Images/ImagesDrawer.tsx @@ -222,7 +222,7 @@ export const ImagesDrawer = (props: CombinedProps) => { case 'imagize': createImage({ description: safeDescription, - diskID: Number(selectedDisk), + disk_id: Number(selectedDisk), label, }) .then(() => { diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/CreateImageFromDiskDialog.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/CreateImageFromDiskDialog.tsx index 5ae93881e6e..f14d1a48988 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/CreateImageFromDiskDialog.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/CreateImageFromDiskDialog.tsx @@ -34,7 +34,7 @@ export const CreateImageFromDiskDialog = (props: Props) => { const onCreate = async () => { await createImage({ - diskID: disk?.id ?? -1, + disk_id: disk?.id ?? -1, }); enqueueSnackbar('Image scheduled for creation.', { variant: 'info', From 62211180aef1eaf3fd2044eaf58656c205e750fd Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Tue, 14 May 2024 21:02:26 -0400 Subject: [PATCH 6/9] add missing placeholder text --- .../manager/src/features/Images/ImagesCreate/CreateImageTab.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx index a8f8addda1a..86e89d65d67 100644 --- a/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx +++ b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx @@ -180,6 +180,7 @@ export const CreateImageTab = () => { onBlur={field.onBlur} onChange={(e, disk) => field.onChange(disk?.id ?? null)} options={disks?.filter((d) => d.filesystem !== 'swap') ?? []} + placeholder="Select a Disk" value={selectedDisk} /> )} From 4f4d6250e1819af12fc60a9655f950ed14cf2e3a Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Tue, 14 May 2024 23:20:16 -0400 Subject: [PATCH 7/9] improve e2e testing for image creation --- .../e2e/core/images/create-image.spec.ts | 87 +++++++++ .../core/images/smoke-create-image.spec.ts | 171 ++++++++++-------- 2 files changed, 181 insertions(+), 77 deletions(-) create mode 100644 packages/manager/cypress/e2e/core/images/create-image.spec.ts diff --git a/packages/manager/cypress/e2e/core/images/create-image.spec.ts b/packages/manager/cypress/e2e/core/images/create-image.spec.ts new file mode 100644 index 00000000000..b4e73064099 --- /dev/null +++ b/packages/manager/cypress/e2e/core/images/create-image.spec.ts @@ -0,0 +1,87 @@ +import type { Linode } from '@linode/api-v4'; +import { authenticate } from 'support/api/authentication'; +import { ui } from 'support/ui'; +import { cleanUp } from 'support/util/cleanup'; +import { createTestLinode } from 'support/util/linodes'; +import { randomLabel, randomPhrase } from 'support/util/random'; + +authenticate(); +describe('create image (e2e)', () => { + before(() => { + cleanUp(['linodes', 'images']); + }); + + it('create image from a linode', () => { + const label = randomLabel(); + const description = randomPhrase(); + + // When Alpine 3.19 becomes deprecated, we will have to update these values for the test to pass. + const image = 'linode/alpine3.19'; + const disk = 'Alpine 3.19 Disk'; + + cy.defer( + createTestLinode({ image }, { waitForDisks: true }), + 'create linode' + ).then((linode: Linode) => { + cy.visitWithLogin('/images/create'); + + // Find the Linode select and open it + cy.findByLabelText('Linode') + .should('be.visible') + .should('be.enabled') + .should('have.attr', 'placeholder', 'Select a Linode') + .click() + .type(linode.label); + + // Select the Linode + ui.autocompletePopper + .findByTitle(linode.label) + .should('be.visible') + .should('be.enabled') + .click(); + + // Find the Disk select and open it + cy.findByLabelText('Disk') + .should('be.visible') + .should('be.enabled') + .click(); + + // Select the Linode disk + ui.autocompletePopper.findByTitle(disk).should('be.visible').click(); + + // Give the Image a label + cy.findByLabelText('Label') + .should('be.enabled') + .should('be.visible') + .type(label); + + // Give the Image a description + cy.findByLabelText('Description') + .should('be.enabled') + .should('be.visible') + .type(description); + + // Submit the image create form + ui.button + .findByTitle('Create Image') + .should('be.enabled') + .should('have.attr', 'type', 'submit') + .click(); + + ui.toast.assertMessage('Image scheduled for creation.'); + + // Verify we redirect to the images landing page upon successful creation + cy.url().should('endWith', 'images'); + + // Verify the newly created image shows on the Images landing page + cy.findByText(label) + .closest('tr') + .within(() => { + // Verify Image label shows + cy.findByText(label).should('be.visible'); + // Verify Image has status of "Creating" + cy.findByText('Creating', { exact: false }).should('be.visible'); + }); + }); + }); +}); diff --git a/packages/manager/cypress/e2e/core/images/smoke-create-image.spec.ts b/packages/manager/cypress/e2e/core/images/smoke-create-image.spec.ts index 70068ae9dc4..4f578d3bd7e 100644 --- a/packages/manager/cypress/e2e/core/images/smoke-create-image.spec.ts +++ b/packages/manager/cypress/e2e/core/images/smoke-create-image.spec.ts @@ -1,52 +1,28 @@ -import type { Linode, Disk } from '@linode/api-v4'; +import { eventFactory, linodeFactory } from 'src/factories'; +import { linodeDiskFactory } from 'src/factories/disk'; import { imageFactory } from 'src/factories/images'; -import { authenticate } from 'support/api/authentication'; -import { createLinode, deleteLinodeById } from 'support/api/linodes'; -import { - mockCreateImage, - mockGetCustomImages, -} from 'support/intercepts/images'; -import { mockGetLinodeDisks } from 'support/intercepts/linodes'; -import { cleanUp } from 'support/util/cleanup'; +import { mockGetEvents } from 'support/intercepts/events'; +import { mockCreateImage } from 'support/intercepts/images'; +import { mockGetLinodeDisks, mockGetLinodes } from 'support/intercepts/linodes'; +import { ui } from 'support/ui'; import { randomLabel, randomNumber, randomPhrase } from 'support/util/random'; -const diskLabel = 'Debian 10 Disk'; - -const mockDisks: Disk[] = [ - { - id: 44311273, - status: 'ready', - label: diskLabel, - created: '2020-08-21T17:26:14', - updated: '2020-08-21T17:26:30', - filesystem: 'ext4', - size: 81408, - }, - { - id: 44311274, - status: 'ready', - label: '512 MB Swap Image', - created: '2020-08-21T17:26:14', - updated: '2020-08-21T17:26:31', - filesystem: 'swap', - size: 512, - }, -]; - -authenticate(); -describe('create image', () => { - before(() => { - cleanUp('linodes'); - }); +describe('create image (using mocks)', () => { + it('create image from a linode', () => { + const mockDisks = [ + linodeDiskFactory.build({ label: 'Debian 10 Disk', filesystem: 'ext4' }), + linodeDiskFactory.build({ + label: '512 MB Swap Image', + filesystem: 'swap', + }), + ]; + + const mockLinode = linodeFactory.build(); - it('captures image from Linode and mocks create image', () => { - const imageLabel = randomLabel(); - const imageDescription = randomPhrase(); - const diskLabel = 'Debian 10 Disk'; const mockNewImage = imageFactory.build({ id: `private/${randomNumber(1000, 99999)}`, - label: imageLabel, - description: imageDescription, + label: randomLabel(), + description: randomPhrase(), type: 'manual', is_public: false, vendor: null, @@ -55,40 +31,81 @@ describe('create image', () => { status: 'creating', }); - // stub incoming response - const mockImages = imageFactory.buildList(2); - mockGetCustomImages(mockImages).as('getImages'); + mockGetLinodes([mockLinode]).as('getLinodes'); + mockGetLinodeDisks(mockLinode.id, mockDisks).as('getDisks'); + + cy.visitWithLogin('/images/create'); + + // Wait for Linodes to load + cy.wait('@getLinodes'); + + // Find the Linode select and open it + cy.findByLabelText('Linode') + .should('be.visible') + .should('be.enabled') + .should('have.attr', 'placeholder', 'Select a Linode') + .click(); + + // Select the Linode + ui.autocompletePopper + .findByTitle(mockLinode.label) + .should('be.visible') + .should('be.enabled') + .click(); + + // Verify disks load when a Linode is selected + cy.wait('@getDisks'); + + // Find the Disk select and open it + cy.findByLabelText('Disk') + .should('be.visible') + .should('be.enabled') + .click(); + + // Select the Linode disk + ui.autocompletePopper + .findByTitle(mockDisks[0].label) + .should('be.visible') + .click(); + + // Give the Image a label + cy.findByLabelText('Label') + .should('be.enabled') + .should('be.visible') + .type(mockNewImage.label); + + // Give the Image a description + cy.findByLabelText('Description') + .should('be.enabled') + .should('be.visible') + .type(mockNewImage.description!); + + // Mock the Image creation POST response mockCreateImage(mockNewImage).as('createImage'); - createLinode().then((linode: Linode) => { - // stub incoming disks response - mockGetLinodeDisks(linode.id, mockDisks).as('getDisks'); - cy.visitWithLogin('/images'); - cy.get('[data-qa-header]') - .should('be.visible') - .within(() => { - cy.findByText('Images').should('be.visible'); - }); - - cy.get('[data-qa-header]') - .should('be.visible') - .within(() => { - cy.findByText('Images').should('be.visible'); - }); - - cy.wait('@getImages'); - cy.findByText('Create Image').click(); - cy.findByLabelText('Linode').click(); - cy.findByText(linode.label).click(); - cy.wait('@getDisks'); - cy.contains('Select a Disk').click().type(`${diskLabel}{enter}`); - cy.findAllByLabelText('Label', { exact: false }).type( - `${imageLabel}{enter}` - ); - cy.findAllByLabelText('Description').type(imageDescription); - cy.get('[data-qa-submit]').click(); - cy.wait('@createImage'); - cy.url().should('endWith', 'images'); - deleteLinodeById(linode.id); - }); + + // Submit the image create form + ui.button + .findByTitle('Create Image') + .should('be.enabled') + .should('have.attr', 'type', 'submit') + .click(); + + // Verify the POST /v4/images request happens + cy.wait('@createImage'); + + ui.toast.assertMessage('Image scheduled for creation.'); + + // Verify we redirect to the images landing page upon successful creation + cy.url().should('endWith', 'images'); + + mockGetEvents([ + eventFactory.build({ action: 'disk_imagize', status: 'finished' }), + ]).as('getEvents'); + + // Wait for the next events polling request + cy.wait('@getEvents'); + + // Verify a success toast shows + ui.toast.assertMessage('Image My Config successfully created.'); }); }); From 6d62c33a4a0305ceba9508710c4645feab20b8db Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Wed, 15 May 2024 09:39:13 -0400 Subject: [PATCH 8/9] add changesets --- packages/manager/.changeset/pr-10471-added-1715780084724.md | 5 +++++ packages/manager/.changeset/pr-10471-tests-1715780339835.md | 5 +++++ .../validation/.changeset/pr-10471-added-1715780189054.md | 5 +++++ .../validation/.changeset/pr-10471-changed-1715780120037.md | 5 +++++ 4 files changed, 20 insertions(+) create mode 100644 packages/manager/.changeset/pr-10471-added-1715780084724.md create mode 100644 packages/manager/.changeset/pr-10471-tests-1715780339835.md create mode 100644 packages/validation/.changeset/pr-10471-added-1715780189054.md create mode 100644 packages/validation/.changeset/pr-10471-changed-1715780120037.md diff --git a/packages/manager/.changeset/pr-10471-added-1715780084724.md b/packages/manager/.changeset/pr-10471-added-1715780084724.md new file mode 100644 index 00000000000..d6583cf25b2 --- /dev/null +++ b/packages/manager/.changeset/pr-10471-added-1715780084724.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +Tags to Image create capture tab ([#10471](https://github.com/linode/manager/pull/10471)) diff --git a/packages/manager/.changeset/pr-10471-tests-1715780339835.md b/packages/manager/.changeset/pr-10471-tests-1715780339835.md new file mode 100644 index 00000000000..3a47837eb67 --- /dev/null +++ b/packages/manager/.changeset/pr-10471-tests-1715780339835.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Clean up and improves image creation Cypress tests ([#10471](https://github.com/linode/manager/pull/10471)) diff --git a/packages/validation/.changeset/pr-10471-added-1715780189054.md b/packages/validation/.changeset/pr-10471-added-1715780189054.md new file mode 100644 index 00000000000..baab53173bd --- /dev/null +++ b/packages/validation/.changeset/pr-10471-added-1715780189054.md @@ -0,0 +1,5 @@ +--- +"@linode/validation": Added +--- + +`tags` to `createImageSchema` ([#10471](https://github.com/linode/manager/pull/10471)) diff --git a/packages/validation/.changeset/pr-10471-changed-1715780120037.md b/packages/validation/.changeset/pr-10471-changed-1715780120037.md new file mode 100644 index 00000000000..d107a1db4be --- /dev/null +++ b/packages/validation/.changeset/pr-10471-changed-1715780120037.md @@ -0,0 +1,5 @@ +--- +"@linode/validation": Changed +--- + +Improved Image `label` validation ([#10471](https://github.com/linode/manager/pull/10471)) From a60cf2ffbb9b76cef170a2702ea490dd69928209 Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Wed, 15 May 2024 09:49:39 -0400 Subject: [PATCH 9/9] improve api-v4 docs --- packages/api-v4/src/images/types.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/api-v4/src/images/types.ts b/packages/api-v4/src/images/types.ts index 4e19feb8039..ed7e97c0080 100644 --- a/packages/api-v4/src/images/types.ts +++ b/packages/api-v4/src/images/types.ts @@ -30,13 +30,31 @@ export interface UploadImageResponse { } export interface BaseImagePayload { + /** + * A short title of this Image. + * + * Defaults to the label of the Disk it is being created from if not provided. + */ label?: string; + /** + * A detailed description of this Image. + */ description?: string; + /** + * Whether this Image supports cloud-init. + * @default false + */ cloud_init?: boolean; + /** + * An array of Tags applied to this Image. Tags are for organizational purposes only. + */ tags?: string[]; } export interface CreateImagePayload extends BaseImagePayload { + /** + * The ID of the Linode Disk that this Image will be created from. + */ disk_id: number; }