From 369b77530d20b2dd31e851a40a3aee4738a29f56 Mon Sep 17 00:00:00 2001 From: Justin Bennett Date: Wed, 2 Nov 2022 09:37:24 -0500 Subject: [PATCH 1/3] Re-apply boot disk hack removal to main --- app/forms/instance-create.tsx | 35 +++++++++++++---------------------- 1 file changed, 13 insertions(+), 22 deletions(-) diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index e61aaa979f..7d07fe6498 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -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 @@ -126,20 +124,6 @@ export function CreateInstanceForm() { 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: { @@ -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, ], @@ -161,8 +154,8 @@ export function CreateInstanceForm() { }, }) }} - submitDisabled={createDisk.isLoading || createInstance.isLoading} - submitError={createDisk.error || createInstance.error} + submitDisabled={createInstance.isLoading} + submitError={createInstance.error} > {({ control }) => ( <> @@ -267,9 +260,7 @@ export function CreateInstanceForm() { /> - - Create instance - + Create instance navigate(pb.instances(pageParams))} /> From 3c27a7397e16d356e50723be50fab39f84f8d4bd Mon Sep 17 00:00:00 2001 From: Justin Bennett Date: Wed, 2 Nov 2022 09:52:28 -0500 Subject: [PATCH 2/3] Update instance create msw behavior to match nexus --- libs/api-mocks/msw/handlers.ts | 90 +++++++++++++++++++++++++++++++++- libs/api-mocks/msw/util.ts | 32 ++++++++++++ 2 files changed, 121 insertions(+), 1 deletion(-) diff --git a/libs/api-mocks/msw/handlers.ts b/libs/api-mocks/msw/handlers.ts index 7039cf36bf..e4dbc1efbc 100644 --- a/libs/api-mocks/msw/handlers.ts +++ b/libs/api-mocks/msw/handlers.ts @@ -31,6 +31,7 @@ import { import { NotImplemented, errIfExists, + errIfInvalidDiskSize, getStartAndEndTime, getTimestamps, paginated, @@ -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 = { + 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 = { - id: uuid(), + id: instanceId, project_id: project.id, ...pick(body, 'name', 'description', 'hostname', 'memory', 'ncpus'), ...getTimestamps(), diff --git a/libs/api-mocks/msw/util.ts b/libs/api-mocks/msw/util.ts index a67dc41969..68424b7382 100644 --- a/libs/api-mocks/msw/util.ts +++ b/libs/api-mocks/msw/util.ts @@ -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' @@ -86,3 +91,30 @@ export const errIfExists = >( throw json({ error_code: 'ObjectAlreadyExists' }, { status: 400 }) } } + +export const errIfInvalidDiskSize = ( + params: DiskCreatePathParams, + disk: Json +) => { + 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' + } +} From 6012dfc285ea79cceab2ddb127ffe442c92c7da4 Mon Sep 17 00:00:00 2001 From: Justin Bennett Date: Wed, 2 Nov 2022 10:05:12 -0500 Subject: [PATCH 3/3] Remove full page form async --- app/components/form/FullPageForm.tsx | 2 +- app/forms/instance-create.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/components/form/FullPageForm.tsx b/app/components/form/FullPageForm.tsx index 34ad985034..b5562ae51b 100644 --- a/app/components/form/FullPageForm.tsx +++ b/app/components/form/FullPageForm.tsx @@ -17,7 +17,7 @@ interface FullPageFormProps { submitDisabled?: boolean error?: Error formOptions: UseFormProps - onSubmit: (values: TFieldValues) => Promise + onSubmit: (values: TFieldValues) => void /** Error from the API call */ submitError: ErrorResult | null /** diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index 7d07fe6498..c532ea200c 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -116,7 +116,7 @@ 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)