Skip to content

Commit 7de4b11

Browse files
authored
Fix size validation on disk create from 1023 GiB snapshot (#2641)
* fix size validation on disk create from 1023 GiB snapshot * test it, rework helpers to avoid mistakes * nearest10 -> diskSizeNearest10
1 parent 7548260 commit 7de4b11

File tree

9 files changed

+52
-23
lines changed

9 files changed

+52
-23
lines changed

app/components/form/fields/ImageSelectField.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import type { Image } from '@oxide/api'
1212
import type { InstanceCreateInput } from '~/forms/instance-create'
1313
import type { ComboboxItem } from '~/ui/lib/Combobox'
1414
import { Slash } from '~/ui/lib/Slash'
15-
import { nearest10 } from '~/util/math'
15+
import { diskSizeNearest10 } from '~/util/math'
1616
import { bytesToGiB, GiB } from '~/util/units'
1717

1818
import { ComboboxField } from './ComboboxField'
@@ -50,7 +50,7 @@ export function BootDiskImageSelectField({
5050
if (!image) return
5151
const imageSizeGiB = image.size / GiB
5252
if (diskSizeField.value < imageSizeGiB) {
53-
diskSizeField.onChange(nearest10(imageSizeGiB))
53+
diskSizeField.onChange(diskSizeNearest10(imageSizeGiB))
5454
}
5555
}}
5656
/>

app/forms/disk-create.tsx

+3-4
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import { Radio } from '~/ui/lib/Radio'
3737
import { RadioGroup } from '~/ui/lib/RadioGroup'
3838
import { Slash } from '~/ui/lib/Slash'
3939
import { toLocaleDateString } from '~/util/date'
40+
import { diskSizeNearest10 } from '~/util/math'
4041
import { bytesToGiB, GiB } from '~/util/units'
4142

4243
const blankDiskSource: DiskSource = {
@@ -227,8 +228,7 @@ const DiskSourceField = ({
227228
const image = images.find((i) => i.id === id)! // if it's selected, it must be present
228229
const imageSizeGiB = image.size / GiB
229230
if (diskSizeField.value < imageSizeGiB) {
230-
const nearest10 = Math.ceil(imageSizeGiB / 10) * 10
231-
diskSizeField.onChange(nearest10)
231+
diskSizeField.onChange(diskSizeNearest10(imageSizeGiB))
232232
}
233233
}}
234234
/>
@@ -288,8 +288,7 @@ const SnapshotSelectField = ({ control }: { control: Control<DiskCreate> }) => {
288288
const snapshot = snapshots.find((i) => i.id === id)! // if it's selected, it must be present
289289
const snapshotSizeGiB = snapshot.size / GiB
290290
if (diskSizeField.value < snapshotSizeGiB) {
291-
const nearest10 = Math.ceil(snapshotSizeGiB / 10) * 10
292-
diskSizeField.onChange(nearest10)
291+
diskSizeField.onChange(diskSizeNearest10(snapshotSizeGiB))
293292
}
294293
}}
295294
/>

app/forms/instance-create.tsx

+3-3
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ import { TipIcon } from '~/ui/lib/TipIcon'
7878
import { ALL_ISH } from '~/util/consts'
7979
import { readBlobAsBase64 } from '~/util/file'
8080
import { docLinks, links } from '~/util/links'
81-
import { nearest10 } from '~/util/math'
81+
import { diskSizeNearest10 } from '~/util/math'
8282
import { pb } from '~/util/path-builder'
8383
import { GiB } from '~/util/units'
8484

@@ -225,7 +225,7 @@ export function CreateInstanceForm() {
225225
...baseDefaultValues,
226226
bootDiskSourceType: defaultSource,
227227
sshPublicKeys: allKeys,
228-
bootDiskSize: nearest10(defaultImage?.size / GiB),
228+
bootDiskSize: diskSizeNearest10(defaultImage?.size / GiB),
229229
externalIps: [{ type: 'ephemeral', pool: defaultPool }],
230230
}
231231

@@ -474,7 +474,7 @@ export function CreateInstanceForm() {
474474
onValueChange={(val) => {
475475
setValue('bootDiskSourceType', val as BootDiskSourceType)
476476
if (imageSizeGiB && imageSizeGiB > bootDiskSize) {
477-
setValue('bootDiskSize', nearest10(imageSizeGiB))
477+
setValue('bootDiskSize', diskSizeNearest10(imageSizeGiB))
478478
}
479479
}}
480480
>

app/util/math.spec.ts

+6-3
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*/
88
import { describe, expect, it } from 'vitest'
99

10-
import { displayBigNum, nearest10, percentage, round, splitDecimal } from './math'
10+
import { diskSizeNearest10, displayBigNum, percentage, round, splitDecimal } from './math'
1111
import { GiB } from './units'
1212

1313
function roundTest() {
@@ -207,6 +207,9 @@ it.each([
207207
[109, 110],
208208
[110, 110],
209209
[111, 120],
210-
])('nearest10 %d → %d', (input, output) => {
211-
expect(nearest10(input)).toEqual(output)
210+
// clamped at max disk size
211+
[1021, 1023],
212+
[1023, 1023],
213+
])('diskSizeNearest10 %d → %d', (input, output) => {
214+
expect(diskSizeNearest10(input)).toEqual(output)
212215
})

app/util/math.ts

+8-3
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88

99
import * as R from 'remeda'
1010

11+
import { MAX_DISK_SIZE_GiB } from '~/api'
12+
1113
/**
1214
* Get the two parts of a number (before decimal and after-and-including
1315
* decimal) as strings. Round to 2 decimal points if necessary.
@@ -94,8 +96,11 @@ export function displayBigNum(
9496
}
9597

9698
/**
97-
* Gets the closest multiple of 10 larger than the passed-in number
99+
* Calculate disk size based on image or snapshot size. We round up to the
100+
* nearest 10, but also cap it at the max disk size so that, for example, a 1023
101+
* GiB image doesn't produce a 1030 GiB disk, which is not valid.
98102
*/
99-
export function nearest10(num: number): number {
100-
return Math.ceil(num / 10) * 10
103+
export function diskSizeNearest10(imageSizeGiB: number) {
104+
const nearest10 = Math.ceil(imageSizeGiB / 10) * 10
105+
return Math.min(nearest10, MAX_DISK_SIZE_GiB)
101106
}

mock-api/snapshot.ts

+15-2
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import { v4 as uuid } from 'uuid'
1010

1111
import type { Snapshot } from '@oxide/api'
1212

13+
import { GiB } from '~/util/units'
14+
1315
import { disks } from './disk'
1416
import type { Json } from './json-type'
1517
import { project } from './project'
@@ -81,7 +83,18 @@ export const snapshots: Json<Snapshot>[] = [
8183
project_id: project.id,
8284
time_created: new Date().toISOString(),
8385
time_modified: new Date().toISOString(),
84-
size: 1024 * 1024 * 1024 * 20,
86+
size: 20 * GiB,
87+
disk_id: disks[3].id,
88+
state: 'ready',
89+
},
90+
{
91+
id: '3b252b04-d896-49d3-bae3-0ac102eed9b4',
92+
name: 'snapshot-max-size',
93+
description: '',
94+
project_id: project.id,
95+
time_created: new Date().toISOString(),
96+
time_modified: new Date().toISOString(),
97+
size: 1023 * GiB, // the biggest size allowed
8598
disk_id: disks[3].id,
8699
state: 'ready',
87100
},
@@ -91,7 +104,7 @@ export const snapshots: Json<Snapshot>[] = [
91104
function generateSnapshot(index: number): Json<Snapshot> {
92105
return {
93106
id: uuid(),
94-
name: `disk-1-snapshot-${index + 7}`,
107+
name: `disk-1-snapshot-${index + 8}`,
95108
description: '',
96109
project_id: project.id,
97110
time_created: new Date().toISOString(),

test/e2e/disks.e2e.ts

+10-2
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import {
1111
expectNoToast,
1212
expectRowVisible,
1313
expectToast,
14-
expectVisible,
1514
test,
1615
} from './utils'
1716

@@ -60,14 +59,16 @@ test('Disk snapshot error', async ({ page }) => {
6059
test.describe('Disk create', () => {
6160
test.beforeEach(async ({ page }) => {
6261
await page.goto('/projects/mock-project/disks-new')
62+
await expect(page.getByRole('dialog', { name: 'Create disk' })).toBeVisible()
6363
await page.getByRole('textbox', { name: 'Name' }).fill('a-new-disk')
6464
})
6565

6666
test.afterEach(async ({ page }) => {
6767
await page.getByRole('button', { name: 'Create disk' }).click()
6868

69+
await expect(page.getByRole('dialog', { name: 'Create disk' })).toBeHidden()
6970
await expectToast(page, 'Disk a-new-disk created')
70-
await expectVisible(page, ['role=cell[name="a-new-disk"]'])
71+
await expect(page.getByRole('cell', { name: 'a-new-disk' })).toBeVisible()
7172
})
7273

7374
// expects are in the afterEach
@@ -83,6 +84,13 @@ test.describe('Disk create', () => {
8384
await page.getByRole('option', { name: 'delete-500' }).click()
8485
})
8586

87+
// max-size snapshot required a fix
88+
test('from max-size snapshot', async ({ page }) => {
89+
await page.getByRole('radio', { name: 'Snapshot' }).click()
90+
await page.getByRole('button', { name: 'Source snapshot' }).click()
91+
await page.getByRole('option', { name: 'snapshot-max' }).click()
92+
})
93+
8694
test('from image', async ({ page }) => {
8795
await page.getByRole('radio', { name: 'Image' }).click()
8896
await page.getByRole('button', { name: 'Source image' }).click()

test/e2e/pagination.e2e.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ test('pagination', async ({ page }) => {
5252
await nextButton.click()
5353
await expectCell(page, 'disk-1-snapshot-76')
5454
await expectCell(page, 'disk-1-snapshot-86')
55-
await expect(rows).toHaveCount(11)
55+
await expect(rows).toHaveCount(12)
5656
await expect(nextButton).toBeDisabled() // no more pages
5757

5858
await scrollTo(page, 250)

test/e2e/snapshots.e2e.ts

+4-3
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
*
66
* Copyright Oxide Computer Company
77
*/
8-
import { expect, expectNotVisible, expectRowVisible, expectVisible, test } from './utils'
8+
import { expect, expectRowVisible, expectVisible, test } from './utils'
99

1010
test('Click through snapshots', async ({ page }) => {
1111
await page.goto('/projects/mock-project')
@@ -28,7 +28,7 @@ test('Click through snapshots', async ({ page }) => {
2828
test('Confirm delete snapshot', async ({ page }) => {
2929
await page.goto('/projects/mock-project/snapshots')
3030

31-
const row = page.getByRole('row', { name: 'disk-1-snapshot-7' })
31+
const row = page.getByRole('row', { name: 'disk-1-snapshot-10' })
3232

3333
// scroll a little so the dropdown menu isn't behind the pagination bar
3434
await page.getByRole('table').click() // focus the content pane
@@ -53,7 +53,8 @@ test('Confirm delete snapshot', async ({ page }) => {
5353
await page.getByRole('button', { name: 'Confirm' }).click()
5454

5555
// modal closes, row is gone
56-
await expectNotVisible(page, [modal, row])
56+
await expect(modal).toBeHidden()
57+
await expect(row).toBeHidden()
5758
})
5859

5960
test('Error on delete snapshot', async ({ page }) => {

0 commit comments

Comments
 (0)