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
42 changes: 33 additions & 9 deletions app/api/util.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,22 +151,46 @@ test('diskCan', () => {
expect(diskCan.delete({ state: { state: 'attached', instance: 'xyz' } })).toBe(false)
expect(diskCan.delete({ state: { state: 'detached' } })).toBe(true)

// snapshot requires distributed disk type
expect(diskCan.snapshot({ state: { state: 'detached' }, diskType: 'distributed' })).toBe(
true
)
// snapshot requires distributed, non-read-only disk type
expect(
diskCan.snapshot({
state: { state: 'detached' },
diskType: 'distributed',
readOnly: false,
})
).toBe(true)
expect(
diskCan.snapshot({
state: { state: 'attached', instance: 'x' },
diskType: 'distributed',
readOnly: false,
})
).toBe(true)
expect(diskCan.snapshot({ state: { state: 'creating' }, diskType: 'distributed' })).toBe(
false
)
expect(diskCan.snapshot({ state: { state: 'detached' }, diskType: 'local' })).toBe(false)
expect(
diskCan.snapshot({ state: { state: 'attached', instance: 'x' }, diskType: 'local' })
diskCan.snapshot({
state: { state: 'creating' },
diskType: 'distributed',
readOnly: false,
})
).toBe(false)
expect(
diskCan.snapshot({ state: { state: 'detached' }, diskType: 'local', readOnly: false })
).toBe(false)
expect(
diskCan.snapshot({
state: { state: 'attached', instance: 'x' },
diskType: 'local',
readOnly: false,
})
).toBe(false)

// read-only disks cannot be snapshotted
expect(
diskCan.snapshot({
state: { state: 'detached' },
diskType: 'distributed',
readOnly: true,
})
).toBe(false)

// @ts-expect-error typechecker rejects actions that don't exist
Expand Down
10 changes: 6 additions & 4 deletions app/api/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,16 +209,18 @@ const diskStateActions = {

// snapshot has a type check in addition to state check
// https://github.com/oxidecomputer/omicron/blob/078f636/nexus/src/app/snapshot.rs#L100-L109
// NOTE: .states only captures the state requirement; local disks cannot be
// snapshotted regardless of state
// NOTE: .states only captures the state requirement; local and read-only disks
// cannot be snapshotted regardless of state
const snapshotStates: DiskState['state'][] = ['attached', 'detached']
// accept both camelCase (Disk) and snake_case (Json<Disk>) for use in MSW handlers
type SnapshotDisk =
| Pick<Disk, 'state' | 'diskType'>
| { state: DiskState; disk_type: DiskType }
| Pick<Disk, 'state' | 'diskType' | 'readOnly'>
| { state: DiskState; disk_type: DiskType; read_only: boolean }
const canSnapshot = (d: SnapshotDisk) => {
const diskType = 'diskType' in d ? d.diskType : d.disk_type
const readOnly = 'readOnly' in d ? d.readOnly : d.read_only
return (
!readOnly &&
snapshotStates.includes(d.state.state) &&
match(diskType)
.with('distributed', () => true)
Expand Down
2 changes: 1 addition & 1 deletion app/forms/snapshot-create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ export default function SnapshotCreate() {
<ComboboxField
label="Disk"
name="disk"
description="Only disks that can be snapshotted are listed"
description="Only disks that support snapshots are listed"
placeholder="Select a disk"
items={diskItemsForCombobox}
required
Expand Down
7 changes: 4 additions & 3 deletions app/pages/project/instances/common.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,14 @@ export const fancifyStates = (states: string[]) =>
intersperse(states.map(white), <>, </>, <> or </>)

/** Returns a disabled reason if the disk cannot be snapshotted, false otherwise */
export function snapshotDisabledReason(disk: Pick<Disk, 'state' | 'diskType'>): ReactNode {
export function snapshotDisabledReason(disk: Disk): ReactNode {
if (diskCan.snapshot(disk)) return false
if (disk.readOnly) return "Read-only disks don't support snapshots"
return match(disk.diskType)
.with('distributed', () => (
<>Only disks in state {fancifyStates(diskCan.snapshot.states)} can be snapshotted</>
<>Only disks in state {fancifyStates(diskCan.snapshot.states)} support snapshots</>
))
.with('local', () => 'Only distributed disks support snapshots')
.with('local', () => "Local disks don't support snapshots")
.exhaustive()
}

Expand Down
3 changes: 2 additions & 1 deletion mock-api/msw/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1349,9 +1349,10 @@ export const handlers = makeHandlers({

const disk = lookup.disk({ ...query, disk: body.disk })
if (!diskCan.snapshot(disk)) {
if (disk.read_only) throw "Read-only disks don't support snapshots"
throw match(disk.disk_type)
.with('distributed', () => 'Cannot snapshot disk in state ' + disk.state.state)
.with('local', () => 'Only distributed disks support snapshots')
.with('local', () => "Local disks don't support snapshots")
.exhaustive()
}

Expand Down
15 changes: 14 additions & 1 deletion test/e2e/disks.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,22 @@ test('Local disk snapshot disabled', async ({ page }) => {
await expect(snapshotItem).toBeDisabled()

// hover to see tooltip with disabled reason
await snapshotItem.hover()
await expect(page.getByRole('tooltip')).toHaveText("Local disks don't support snapshots")
})

test('Read-only disk snapshot disabled', async ({ page }) => {
await page.goto('/projects/mock-project/disks')

const row = page.getByRole('row', { name: 'read-only-disk', exact: false })
await row.getByRole('button', { name: 'Row actions' }).click()

const snapshotItem = page.getByRole('menuitem', { name: 'Snapshot' })
await expect(snapshotItem).toBeDisabled()

await snapshotItem.hover()
await expect(page.getByRole('tooltip')).toHaveText(
'Only distributed disks support snapshots'
"Read-only disks don't support snapshots"
)
})

Expand Down
Loading