Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create instance from existing boot disk #2076

Merged
merged 11 commits into from
Apr 3, 2024
2 changes: 2 additions & 0 deletions app/api/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,8 @@ export const diskCan = mapValues(
delete: ['detached', 'creating', 'faulted'],
// TODO: link to API source
snapshot: ['attached', 'detached'],
// https://github.com/oxidecomputer/omicron/blob/4970c71e/nexus/db-queries/src/db/datastore/disk.rs#L169-L172
attach: ['creating', 'detached'],
},
(states: DiskState['state'][]) => {
// only have to Pick because we want this to work for both Disk and
Expand Down
9 changes: 7 additions & 2 deletions app/components/form/fields/ImageSelectField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,18 @@ type ImageSelectFieldProps = {
disabled?: boolean
}

export function ImageSelectField({ images, control, disabled }: ImageSelectFieldProps) {
export function BootDiskImageSelectField({
images,
control,
disabled,
}: ImageSelectFieldProps) {
const diskSizeField = useController({ control, name: 'bootDiskSize' }).field
return (
<ListboxField
disabled={disabled}
control={control}
name="image"
name="bootDiskSource"
label="Image"
placeholder="Select an image"
items={images.map((i) => toListboxItem(i))}
required
Expand Down
174 changes: 122 additions & 52 deletions app/forms/instance-create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,22 @@ import type { SetRequired } from 'type-fest'

import {
apiQueryClient,
diskCan,
genName,
INSTANCE_MAX_CPU,
INSTANCE_MAX_RAM_GiB,
useApiMutation,
useApiQuery,
useApiQueryClient,
usePrefetchedApiQuery,
type InstanceCreate,
type PathParams as PP,
} from '@oxide/api'
import { Images16Icon, Instances24Icon } from '@oxide/design-system/icons/react'
import {
Images16Icon,
Instances24Icon,
Storage16Icon,
} from '@oxide/design-system/icons/react'

import { AccordionItem } from '~/components/AccordionItem'
import { CheckboxField } from '~/components/form/fields/CheckboxField'
Expand All @@ -32,7 +39,8 @@ import {
type DiskTableItem,
} from '~/components/form/fields/DisksTableField'
import { FileField } from '~/components/form/fields/FileField'
import { ImageSelectField } from '~/components/form/fields/ImageSelectField'
import { BootDiskImageSelectField as ImageSelectField } from '~/components/form/fields/ImageSelectField'
import { ListboxField } from '~/components/form/fields/ListboxField'
import { NameField } from '~/components/form/fields/NameField'
import { NetworkInterfaceField } from '~/components/form/fields/NetworkInterfaceField'
import { NumberField } from '~/components/form/fields/NumberField'
Expand Down Expand Up @@ -62,7 +70,8 @@ export type InstanceCreateInput = Assign<
disks: DiskTableItem[]
bootDiskName: string
bootDiskSize: number
image: string
bootDiskSourceType: 'disk' | 'image'
bootDiskSource: string
userData: File | null
// ssh keys are always specified. we do not need the undefined case
sshPublicKeys: NonNullable<InstanceCreate['sshPublicKeys']>
Expand All @@ -83,7 +92,9 @@ const baseDefaultValues: InstanceCreateInput = {

bootDiskName: '',
bootDiskSize: 10,
image: '',

bootDiskSource: '',
bootDiskSourceType: 'image',

disks: [],
networkInterfaces: { type: 'default' },
Expand All @@ -95,11 +106,24 @@ const baseDefaultValues: InstanceCreateInput = {
userData: null,
}

const useBootDiskItems = (projectSelector: PP.Project) => {
const { data: disks } = useApiQuery('diskList', {
query: { ...projectSelector, limit: 1000 },
Copy link
Collaborator

@david-crespo david-crespo Mar 25, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tested this with 500 disks in the response and it doesn't seem to get bogged down at all. Obvious candidate for a combobox.

})

return (
disks?.items
.filter(diskCan.attach)
benjaminleonard marked this conversation as resolved.
Show resolved Hide resolved
.map((disk) => ({ value: disk.name, label: disk.name })) || []
)
}

CreateInstanceForm.loader = async ({ params }: LoaderFunctionArgs) => {
await Promise.all([
// fetch both project and silo images
apiQueryClient.prefetchQuery('imageList', { query: getProjectSelector(params) }),
apiQueryClient.prefetchQuery('imageList', {}),
apiQueryClient.prefetchQuery('diskList', {}),
apiQueryClient.prefetchQuery('currentUserSshKeyList', {}),
])
return null
Expand Down Expand Up @@ -134,12 +158,14 @@ export function CreateInstanceForm() {

const defaultImage = allImages[0]

const disks = useBootDiskItems(projectSelector)

const { data: sshKeys } = usePrefetchedApiQuery('currentUserSshKeyList', {})
const allKeys = useMemo(() => sshKeys.items.map((key) => key.id), [sshKeys])

const defaultValues: InstanceCreateInput = {
...baseDefaultValues,
image: defaultImage?.id || '',
bootDiskSource: defaultImage?.id || '',
sshPublicKeys: allKeys,
// Use 2x the image size as the default boot disk size
bootDiskSize: Math.ceil(defaultImage?.size / GiB) * 2 || 10,
Expand All @@ -148,10 +174,12 @@ export function CreateInstanceForm() {
const form = useForm({ defaultValues })
const { control, setValue } = form

const imageInput = useWatch({ control: control, name: 'image' })
const imageInput = useWatch({ control: control, name: 'bootDiskSource' })
const image = allImages.find((i) => i.id === imageInput)
const imageSize = image?.size ? Math.ceil(image.size / GiB) : undefined

const sourceType = useWatch({ control: control, name: 'bootDiskSourceType' })

useEffect(() => {
if (createInstance.error) {
setIsSubmitting(false)
Expand All @@ -173,13 +201,34 @@ export function CreateInstanceForm() {
values.presetId === 'custom'
? { memory: values.memory, ncpus: values.ncpus }
: { memory: preset.memory, ncpus: preset.ncpus }
const image = allImages.find((i) => values.image === i.id)
// There should always be an image present, because …
// - The form is disabled unless there are images available.
// - The form defaults to including at least one image.
invariant(image, 'Expected image to be defined')

const bootDiskName = values.bootDiskName || genName(values.name, image.name)
const isDisk = values.bootDiskSourceType === 'disk'
const image = !isDisk && allImages.find((i) => values.bootDiskSource === i.id)

// There should always be an image or disk present, because …
// - The form is disabled unless there are images or disks available.
// - The form defaults to including at least one image.
invariant(
(image && values.bootDiskSize) || (isDisk && values.bootDiskSource),
'Expected boot disk to be defined'
)

const bootDisk = image
? {
type: 'create' as const,
// TODO: Determine the pattern of the default boot disk name
name: values.bootDiskName || genName(values.name, image.name),
description: `Created as a boot disk for ${values.name}`,

// Minimum size as greater than the image is validated
// directly on the boot disk size input
size: values.bootDiskSize * GiB,
diskSource: {
type: 'image' as const,
imageId: values.bootDiskSource,
},
}
: { type: 'attach' as const, name: values.bootDiskSource }

const userData = values.userData
? await readBlobAsBase64(values.userData)
Expand All @@ -193,23 +242,7 @@ export function CreateInstanceForm() {
description: values.description,
memory: instance.memory * GiB,
ncpus: instance.ncpus,
disks: [
{
type: 'create',
// TODO: Determine the pattern of the default boot disk name
name: bootDiskName,
description: `Created as a boot disk for ${values.name}`,

// Minimum size as greater than the image is validated
// directly on the boot disk size input
size: values.bootDiskSize * GiB,
diskSource: {
type: 'image',
imageId: values.image,
},
},
...values.disks,
],
disks: [bootDisk, ...values.disks],
externalIps: [{ type: 'ephemeral' }],
start: values.start,
networkInterfaces: values.networkInterfaces,
Expand Down Expand Up @@ -334,8 +367,14 @@ export function CreateInstanceForm() {
className="full-width"
// default to the project images tab if there are only project images
defaultValue={
projectImages.length > 0 && siloImages.length === 0 ? 'project' : 'silo'
siloImages.length > 0 ? 'silo' : projectImages.length > 0 ? 'project' : 'disk'
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice

}
onValueChange={(val) => {
setValue(
'bootDiskSourceType',
val === 'silo' || val === 'project' ? 'image' : 'disk'
)
}}
>
<Tabs.List aria-describedby="boot-disk">
<Tabs.Trigger value="silo" disabled={isSubmitting}>
Expand All @@ -344,12 +383,15 @@ export function CreateInstanceForm() {
<Tabs.Trigger value="project" disabled={isSubmitting}>
Project images
</Tabs.Trigger>
<Tabs.Trigger value="disk" disabled={isSubmitting}>
Existing disks
</Tabs.Trigger>
</Tabs.List>
{allImages.length === 0 && (
{allImages.length === 0 && disks.length === 0 && (
<Message
className="mb-8 ml-10 max-w-lg"
variant="notice"
content="Images are required to create a boot disk."
content="Images or disks are required to create or attach a boot disk."
/>
)}
<Tabs.Content value="silo" className="space-y-4">
Expand Down Expand Up @@ -388,29 +430,57 @@ export function CreateInstanceForm() {
/>
)}
</Tabs.Content>

<Tabs.Content value="disk" className="space-y-4">
{disks.length === 0 ? (
<div className="flex max-w-lg items-center justify-center rounded-lg border p-6 border-default">
<EmptyMessage
icon={<Storage16Icon />}
title="No detached disks found"
body="Only detached disks can be used as a boot disk"
/>
</div>
) : (
<ListboxField
label="Disk"
name="bootDiskSource"
description="Existing disks that are not attached to an instance"
items={disks}
required
control={control}
/>
)}
</Tabs.Content>
</Tabs.Root>

<div className="!my-16 content-['a']"></div>
{sourceType === 'image' && (
<>
<div key="divider" className="!my-12 content-['a']" />

<DiskSizeField
key="diskSizeField"
label="Disk size"
name="bootDiskSize"
control={control}
validate={(diskSizeGiB: number) => {
if (imageSize && diskSizeGiB < imageSize) {
return `Must be as large as selected image (min. ${imageSize} GiB)`
}
}}
disabled={isSubmitting}
/>
<NameField
key="bootDiskName"
name="bootDiskName"
label="Disk name"
tooltipText="Will be autogenerated if name not provided"
required={false}
control={control}
disabled={isSubmitting}
/>
</>
)}

<DiskSizeField
label="Disk size"
name="bootDiskSize"
control={control}
validate={(diskSizeGiB: number) => {
if (imageSize && diskSizeGiB < imageSize) {
return `Must be as large as selected image (min. ${imageSize} GiB)`
}
}}
disabled={isSubmitting}
/>
<NameField
name="bootDiskName"
label="Disk name"
tooltipText="Will be autogenerated if name not provided"
required={false}
control={control}
disabled={isSubmitting}
/>
<FormDivider />
<Form.Heading id="additional-disks">Additional disks</Form.Heading>

Expand Down
Loading