Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/components/form/FullPageForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ interface FullPageFormProps<TFieldValues extends FieldValues> {
submitDisabled?: boolean
error?: Error
formOptions: UseFormProps<TFieldValues>
onSubmit: (values: TFieldValues) => Promise<void>
onSubmit: (values: TFieldValues) => void
/** Error from the API call */
submitError: ErrorResult | null
/**
Expand Down
37 changes: 14 additions & 23 deletions app/forms/instance-create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,6 @@ export function CreateInstanceForm() {
const pageParams = useRequiredParams('orgName', 'projectName')
const navigate = useNavigate()

const createDisk = useApiMutation('diskCreate')

const createInstance = useApiMutation('instanceCreate', {
onSuccess(instance) {
// refetch list of instances
Expand Down Expand Up @@ -118,28 +116,14 @@ export function CreateInstanceForm() {
// // is how required radio fields work
// globalImage: Yup.string().required(),
// })}
onSubmit={async (values) => {
onSubmit={(values) => {
const instance = INSTANCE_SIZES.find((option) => option.id === values['type'])
invariant(instance, 'Expected instance type to be defined')
const image = images.find((i) => values.globalImage === i.id)
invariant(image, 'Expected image to be defined')

const bootDiskName = values.bootDiskName || genName(values.name, image.name)

await createDisk.mutateAsync({
path: pageParams,
body: {
// TODO: Determine the pattern of the default boot disk name
name: bootDiskName,
description: `Created as a boot disk for ${values.bootDiskName}`,
// TODO: Verify size is larger than the minimum image size
size: values.bootDiskSize * GiB,
diskSource: {
type: 'global_image',
imageId: values.globalImage,
},
},
})
createInstance.mutate({
path: pageParams,
body: {
Expand All @@ -150,8 +134,17 @@ export function CreateInstanceForm() {
ncpus: instance.ncpus,
disks: [
{
type: 'attach',
type: 'create',
// TODO: Determine the pattern of the default boot disk name
name: bootDiskName,
description: `Created as a boot disk for ${values.name}`,

// TODO: Verify size is larger than the minimum image size
size: values.bootDiskSize * GiB,
diskSource: {
type: 'global_image',
imageId: values.globalImage,
},
},
...values.disks,
],
Expand All @@ -161,8 +154,8 @@ export function CreateInstanceForm() {
},
})
}}
submitDisabled={createDisk.isLoading || createInstance.isLoading}
submitError={createDisk.error || createInstance.error}
submitDisabled={createInstance.isLoading}
submitError={createInstance.error}
>
{({ control }) => (
<>
Expand Down Expand Up @@ -267,9 +260,7 @@ export function CreateInstanceForm() {
/>

<Form.Actions>
<Form.Submit loading={createDisk.isLoading || createInstance.isLoading}>
Create instance
</Form.Submit>
<Form.Submit loading={createInstance.isLoading}>Create instance</Form.Submit>
<Form.Cancel onClick={() => navigate(pb.instances(pageParams))} />
</Form.Actions>
</>
Expand Down
90 changes: 89 additions & 1 deletion libs/api-mocks/msw/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
import {
NotImplemented,
errIfExists,
errIfInvalidDiskSize,
getStartAndEndTime,
getTimestamps,
paginated,
Expand Down Expand Up @@ -243,8 +244,95 @@ export const handlers = makeHandlers({

errIfExists(db.instances, { name: body.name, project_id: project.id })

const instanceId = uuid()

/**
* Eagerly check for disk errors. Execution will stop early and prevent orphaned disks from
* being created if there's a failure. In omicron this is done automatically via an undo on the saga.
*/
for (const diskParams of body.disks || []) {
if (diskParams.type === 'create') {
errIfExists(db.disks, { name: diskParams.name, project_id: project.id })
errIfInvalidDiskSize(params.path, diskParams)
} else {
lookupDisk({ ...params.path, diskName: diskParams.name })
}
}

/**
* Eagerly check for nic lookup failures. Execution will stop early and prevent orphaned nics from
* being created if there's a failure. In omicron this is done automatically via an undo on the saga.
*/
if (body.network_interfaces?.type === 'create') {
body.network_interfaces.params.forEach(({ vpc_name, subnet_name }) => {
lookupVpc({ ...params.path, vpcName: vpc_name })
lookupVpcSubnet({
...params.path,
vpcName: vpc_name,
subnetName: subnet_name,
})
})
}

for (const diskParams of body.disks || []) {
if (diskParams.type === 'create') {
const { size, name, description, disk_source } = diskParams
const newDisk: Json<Api.Disk> = {
id: uuid(),
name,
description,
size,
project_id: project.id,
state: { state: 'attached', instance: instanceId },
device_path: '/mnt/disk',
block_size: disk_source.type === 'blank' ? disk_source.block_size : 4096,
...getTimestamps(),
}
db.disks.push(newDisk)
} else {
const disk = lookupDisk({ ...params.path, diskName: diskParams.name })
disk.state = { state: 'attached', instance: instanceId }
}
}

if (body.network_interfaces?.type === 'default') {
db.networkInterfaces.push({
id: uuid(),
description: 'The default network interface',
instance_id: instanceId,
primary: true,
mac: '00:00:00:00:00:00',
ip: '127.0.0.1',
name: 'default',
vpc_id: uuid(),
subnet_id: uuid(),
...getTimestamps(),
})
} else if (body.network_interfaces?.type === 'create') {
body.network_interfaces.params.forEach(
({ name, description, ip, subnet_name, vpc_name }, i) => {
db.networkInterfaces.push({
id: uuid(),
name,
description,
instance_id: instanceId,
primary: i === 0 ? true : false,
mac: '00:00:00:00:00:00',
ip: ip || '127.0.0.1',
vpc_id: lookupVpc({ ...params.path, vpcName: vpc_name }).id,
subnet_id: lookupVpcSubnet({
...params.path,
vpcName: vpc_name,
subnetName: subnet_name,
}).id,
...getTimestamps(),
})
}
)
}

const newInstance: Json<Api.Instance> = {
id: uuid(),
id: instanceId,
project_id: project.id,
...pick(body, 'name', 'description', 'hostname', 'memory', 'ncpus'),
...getTimestamps(),
Expand Down
32 changes: 32 additions & 0 deletions libs/api-mocks/msw/util.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { subHours } from 'date-fns'

import type { DiskCreate, DiskCreatePathParams } from '@oxide/api'
import type { Json } from '@oxide/gen/msw-handlers'
import { json } from '@oxide/gen/msw-handlers'
import { GiB } from '@oxide/util'

import { db } from './db'

export { json } from '@oxide/gen/msw-handlers'

Expand Down Expand Up @@ -86,3 +91,30 @@ export const errIfExists = <T extends Record<string, unknown>>(
throw json({ error_code: 'ObjectAlreadyExists' }, { status: 400 })
}
}

export const errIfInvalidDiskSize = (
params: DiskCreatePathParams,
disk: Json<DiskCreate>
) => {
const source = disk.disk_source
if (source.type === 'snapshot') {
const snapshotSize = db.snapshots.find((s) => source.snapshot_id === s.id)?.size ?? 0
if (disk.size >= snapshotSize) return
throw 'Disk size must be greater than or equal to the snapshot size'
}
if (source.type === 'image') {
const imageSize = db.images.find((i) => source.image_id === i.id)?.size ?? 0
if (disk.size >= imageSize) return
throw 'Disk size must be greater than or equal to the image size'
}
if (source.type === 'global_image') {
const globalImageSize = db.globalImages.find((i) => source.image_id === i.id)?.size ?? 0
if (disk.size >= globalImageSize) return
throw 'Disk size must be greater than or equal to the global image size'
}
if (source.type === 'blank') {
if (disk.size >= 1 * GiB) return
// TODO: this is a bit arbitrary, should match whatever the API does
throw 'Minimum disk size is 1 GiB'
}
}