Skip to content

Commit f40001e

Browse files
committed
disable snapshot for local disks
1 parent a5fc417 commit f40001e

File tree

9 files changed

+94
-24
lines changed

9 files changed

+94
-24
lines changed

AGENTS.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,15 @@
1616

1717
# API utilities & constants
1818

19-
- Treat `app/api/util.ts` (and friends) as a thin translation layer: mirror backend rules only when the UI needs them, keep the client copy minimal, and always link to the authoritative Omicron source so reviewers can verify the behavior.
19+
- Treat `app/api/util.ts` (and friends) as a thin translation layer: mirror backend rules only when the UI needs them, keep the client copy minimal, and always link to the authoritative Omicron source so reviewers can verify the behavior. Only keep 7 chars of the commit hash in the URL.
2020
- API constants live in `app/api/util.ts` with links to Omicron source.
2121

2222
# Testing code
2323

2424
- Run local checks before sending PRs: `npm run lint`, `npm run tsc`, `npm test run`, and `npm run e2ec`; pass `-- --ui` for Playwright UI mode or project/name filters like `npm run e2ec -- instance -g 'boot disk'`.
2525
- Keep Playwright specs focused on user-visible behavior—use accessible locators (`getByRole`, `getByLabel`), the helpers in `test/e2e/utils.ts` (`expectToast`, `expectRowVisible`, `selectOption`, `clickRowAction`), and close toasts so follow-on assertions aren’t blocked.
2626
- Cover role-gated flows by logging in with `getPageAsUser`; exercise negative paths (e.g., forbidden actions) alongside happy paths as shown in `test/e2e/system-update.e2e.ts`.
27+
- Consider `expectVisible` and `expectNotVisible` deprecated: prefer `expect().toBeVisible()` and `toBeHidden()` in new code.
2728
- When UI needs new mock behavior, extend the MSW handlers/db minimally so E2E tests stay deterministic; prefer storing full API responses so subsequent calls see the updated state (`mock-api/msw/db.ts`, `mock-api/msw/handlers.ts`).
2829
- Co-locate Vitest specs next to the code they cover; use Testing Library utilities (`render`, `renderHook`, `fireEvent`, fake timers) to assert observable output rather than implementation details (`app/ui/lib/FileInput.spec.tsx`, `app/hooks/use-pagination.spec.ts`).
2930
- For sweeping styling changes, coordinate with the visual regression harness and follow `test/visual/README.md` for the workflow.
@@ -115,3 +116,4 @@
115116
- Check `app/util/*` for string formatting, date handling, IP parsing, etc. Check `types/util.d.ts` for type helpers.
116117
- Use `validateName` for resource names, `validateDescription` for descriptions, `validateIp`/`validateIpNet` for IPs.
117118
- Role helpers live in `app/api/roles.ts`.
119+
- Use ts-pattern exhaustive match when doing conditional logic on union types to make sure all arms are handled

app/api/util.spec.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,13 @@ test('diskCan', () => {
151151
expect(diskCan.delete({ state: { state: 'attached', instance: 'xyz' } })).toBe(false)
152152
expect(diskCan.delete({ state: { state: 'detached' } })).toBe(true)
153153

154+
// snapshot requires distributed disk type
155+
expect(diskCan.snapshot({ state: { state: 'detached' }, diskType: 'distributed' })).toBe(true)
156+
expect(diskCan.snapshot({ state: { state: 'attached', instance: 'x' }, diskType: 'distributed' })).toBe(true)
157+
expect(diskCan.snapshot({ state: { state: 'creating' }, diskType: 'distributed' })).toBe(false)
158+
expect(diskCan.snapshot({ state: { state: 'detached' }, diskType: 'local' })).toBe(false)
159+
expect(diskCan.snapshot({ state: { state: 'attached', instance: 'x' }, diskType: 'local' })).toBe(false)
160+
154161
// @ts-expect-error typechecker rejects actions that don't exist
155162
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
156163
diskCan.abc

app/api/util.ts

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,14 @@
77
*/
88

99
import * as R from 'remeda'
10+
import { match } from 'ts-pattern'
1011

1112
import { bytesToGiB } from '~/util/units'
1213

1314
import type {
1415
Disk,
1516
DiskState,
17+
DiskType,
1618
Instance,
1719
InstanceState,
1820
Measurement,
@@ -176,14 +178,12 @@ export function instanceAutoRestartingSoon(i: Instance) {
176178
)
177179
}
178180

179-
const diskActions = {
181+
const diskStateActions = {
180182
// this is a weird one because the list of states is dynamic and it includes
181183
// 'creating' in the unwind of the disk create saga, but does not include
182184
// 'creating' in the disk delete saga, which is what we care about
183185
// https://github.com/oxidecomputer/omicron/blob/6dd9802/nexus/src/app/sagas/disk_delete.rs?plain=1#L110
184186
delete: ['detached', 'faulted'],
185-
// TODO: link to API source. It's hard to determine from the saga code what the rule is here.
186-
snapshot: ['attached', 'detached'],
187187
// https://github.com/oxidecomputer/omicron/blob/6dd9802/nexus/db-queries/src/db/datastore/disk.rs#L173-L176
188188
attach: ['creating', 'detached'],
189189
// https://github.com/oxidecomputer/omicron/blob/6dd9802/nexus/db-queries/src/db/datastore/disk.rs#L313-L314
@@ -192,6 +192,27 @@ const diskActions = {
192192
setAsBootDisk: ['attached'],
193193
} satisfies Record<string, DiskState['state'][]>
194194

195+
// snapshot has a type check in addition to state check
196+
// https://github.com/oxidecomputer/omicron/blob/078f636/nexus/src/app/snapshot.rs#L100-L109
197+
// NOTE: .states only captures the state requirement; local disks cannot be
198+
// snapshotted regardless of state
199+
const snapshotStates: DiskState['state'][] = ['attached', 'detached']
200+
// accept both camelCase (Disk) and snake_case (Json<Disk>) for use in MSW handlers
201+
type SnapshotDisk =
202+
| Pick<Disk, 'state' | 'diskType'>
203+
| { state: DiskState; disk_type: DiskType }
204+
const canSnapshot = (d: SnapshotDisk) => {
205+
const diskType = 'diskType' in d ? d.diskType : d.disk_type
206+
return (
207+
snapshotStates.includes(d.state.state) &&
208+
match(diskType)
209+
.with('distributed', () => true)
210+
.with('local', () => false)
211+
.exhaustive()
212+
)
213+
}
214+
canSnapshot.states = snapshotStates
215+
195216
export function diskTransitioning(diskState: DiskState['state']) {
196217
return (
197218
diskState === 'attaching' ||
@@ -201,13 +222,16 @@ export function diskTransitioning(diskState: DiskState['state']) {
201222
)
202223
}
203224

204-
export const diskCan = R.mapValues(diskActions, (states: DiskState['state'][]) => {
205-
// only have to Pick because we want this to work for both Disk and
206-
// Json<Disk>, which we pass to it in the MSW handlers
207-
const test = (d: Pick<Disk, 'state'>) => states.includes(d.state.state)
208-
test.states = states
209-
return test
210-
})
225+
export const diskCan = {
226+
...R.mapValues(diskStateActions, (states: DiskState['state'][]) => {
227+
// only have to Pick because we want this to work for both Disk and
228+
// Json<Disk>, which we pass to it in the MSW handlers
229+
const test = (d: Pick<Disk, 'state'>) => states.includes(d.state.state)
230+
test.states = states
231+
return test
232+
}),
233+
snapshot: canSnapshot,
234+
}
211235

212236
/** Hard coded in the API, so we can hard code it here. */
213237
export const FLEET_ID = '001de000-1334-4000-8000-000000000000'

app/pages/project/disks/DisksPage.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ import { docLinks } from '~/util/links'
4141
import { pb } from '~/util/path-builder'
4242
import type * as PP from '~/util/path-params'
4343

44-
import { fancifyStates } from '../instances/common'
44+
import { fancifyStates, snapshotDisabledReason } from '../instances/common'
4545

4646
export const handle = makeCrumb('Disks', (p) => pb.disks(getProjectSelector(p)))
4747

@@ -123,11 +123,7 @@ export default function DisksPage() {
123123
},
124124
})
125125
},
126-
disabled: !diskCan.snapshot(disk) && (
127-
<>
128-
Only disks in state {fancifyStates(diskCan.snapshot.states)} can be snapshotted
129-
</>
130-
),
126+
disabled: snapshotDisabledReason(disk),
131127
},
132128
{
133129
label: 'Delete',

app/pages/project/instances/StorageTab.tsx

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import * as R from 'remeda'
1212

1313
import {
1414
api,
15-
diskCan,
1615
genName,
1716
instanceCan,
1817
q,
@@ -42,7 +41,7 @@ import { EMBody, EmptyMessage } from '~/ui/lib/EmptyMessage'
4241
import { TableEmptyBox } from '~/ui/lib/Table'
4342
import { links } from '~/util/links'
4443

45-
import { fancifyStates } from './common'
44+
import { snapshotDisabledReason } from './common'
4645

4746
export async function clientLoader({ params }: LoaderFunctionArgs) {
4847
const { project, instance } = getInstanceSelector(params)
@@ -147,9 +146,7 @@ export default function StorageTab() {
147146
const getSnapshotAction = useCallback(
148147
(disk: InstanceDisk) => ({
149148
label: 'Snapshot',
150-
disabled: !diskCan.snapshot(disk) && (
151-
<>Only disks in state {fancifyStates(diskCan.snapshot.states)} can be snapshotted</>
152-
),
149+
disabled: snapshotDisabledReason(disk),
153150
onActivate() {
154151
createSnapshot({
155152
query: { project },

app/pages/project/instances/common.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
* Copyright Oxide Computer Company
77
*/
88
import { createContext, useContext, type ReactNode } from 'react'
9+
import { match } from 'ts-pattern'
10+
11+
import { diskCan, type Disk } from '@oxide/api'
912

1013
import { intersperse } from '~/util/array'
1114
import { invariant } from '~/util/invariant'
@@ -19,6 +22,17 @@ const white = (s: string) => (
1922
export const fancifyStates = (states: string[]) =>
2023
intersperse(states.map(white), <>, </>, <> or </>)
2124

25+
/** Returns a disabled reason if the disk cannot be snapshotted, false otherwise */
26+
export function snapshotDisabledReason(disk: Pick<Disk, 'state' | 'diskType'>): ReactNode {
27+
if (diskCan.snapshot(disk)) return false
28+
return match(disk.diskType)
29+
.with('distributed', () => (
30+
<>Only disks in state {fancifyStates(diskCan.snapshot.states)} can be snapshotted</>
31+
))
32+
.with('local', () => 'Only distributed disks support snapshots')
33+
.exhaustive()
34+
}
35+
2236
type MetricsContextValue = {
2337
startTime: Date
2438
endTime: Date

mock-api/disk.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,19 @@ export const disks: Json<Disk>[] = [
203203
block_size: 2048,
204204
disk_type: 'distributed',
205205
},
206+
{
207+
id: 'b8e3de3a-3c97-4f23-a3f3-73e7d3d3b9c1',
208+
name: 'local-disk',
209+
description: 'A local disk that cannot be snapshotted',
210+
project_id: project.id,
211+
time_created: new Date().toISOString(),
212+
time_modified: new Date().toISOString(),
213+
state: { state: 'detached' },
214+
device_path: '/local',
215+
size: 12 * GiB,
216+
block_size: 2048,
217+
disk_type: 'local',
218+
},
206219
// put a ton of disks in project 2 so we can use it to test comboboxes
207220
...Array.from({ length: 1010 }).map((_, i) => {
208221
const numStr = (i + 1).toString().padStart(4, '0')

mock-api/msw/handlers.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1126,7 +1126,10 @@ export const handlers = makeHandlers({
11261126

11271127
const disk = lookup.disk({ ...query, disk: body.disk })
11281128
if (!diskCan.snapshot(disk)) {
1129-
throw 'Cannot snapshot disk in state ' + disk.state.state
1129+
throw match(disk.disk_type)
1130+
.with('distributed', () => 'Cannot snapshot disk in state ' + disk.state.state)
1131+
.with('local', () => 'Only distributed disks support snapshots')
1132+
.exhaustive()
11301133
}
11311134

11321135
const newSnapshot: Json<Api.Snapshot> = {

test/e2e/disks.e2e.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ test('List disks and snapshot', async ({ page }) => {
3030
await page.goto('/projects/mock-project/disks')
3131

3232
const table = page.getByRole('table')
33-
await expect(table.getByRole('row')).toHaveCount(12) // 11 + header
33+
await expect(table.getByRole('row')).toHaveCount(13) // 12 + header
3434

3535
// check one attached and one not attached
3636
await expectRowVisible(table, {
@@ -68,6 +68,20 @@ test('Disk snapshot error', async ({ page }) => {
6868
await expectToast(page, 'Failed to create snapshotCannot snapshot disk')
6969
})
7070

71+
test('Local disk snapshot disabled', async ({ page }) => {
72+
await page.goto('/projects/mock-project/disks')
73+
74+
const row = page.getByRole('row', { name: 'local-disk', exact: false })
75+
await row.getByRole('button', { name: 'Row actions' }).click()
76+
77+
const snapshotItem = page.getByRole('menuitem', { name: 'Snapshot' })
78+
await expect(snapshotItem).toBeDisabled()
79+
80+
// hover to see tooltip with disabled reason
81+
await snapshotItem.hover()
82+
await expect(page.getByRole('tooltip')).toHaveText('Only distributed disks support snapshots')
83+
})
84+
7185
test.describe('Disk create', () => {
7286
test.beforeEach(async ({ page }) => {
7387
await page.goto('/projects/mock-project/disks-new')

0 commit comments

Comments
 (0)