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
34 changes: 33 additions & 1 deletion mock-api/disk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type { Disk, DiskState } from '@oxide/api'

import { GiB } from '~/util/units'

import { instance } from './instance'
import { instance, stoppedInstance } from './instance'
import type { Json } from './json-type'
import { Rando } from './msw/rando'
import { project, project2 } from './project'
Expand Down Expand Up @@ -83,9 +83,41 @@ export const disk2: Json<Disk> = {
read_only: false,
}

export const stoppedBootDisk: Json<Disk> = {
id: 'f5bc2085-d18e-4698-86ab-69c62a74e541',
name: 'disk-stopped-boot',
description: 'boot disk for db-stopped',
project_id: project.id,
time_created: new Date().toISOString(),
time_modified: new Date().toISOString(),
state: { state: 'attached', instance: stoppedInstance.id },
device_path: '/abc',
size: 2 * GiB,
block_size: 2048,
disk_type: 'distributed',
read_only: false,
}

export const stoppedDataDisk: Json<Disk> = {
id: '8f25d709-a76b-4399-a105-f2cfd8e52604',
name: 'disk-stopped-data',
description: 'data disk for db-stopped',
project_id: project.id,
time_created: new Date().toISOString(),
time_modified: new Date().toISOString(),
state: { state: 'attached', instance: stoppedInstance.id },
device_path: '/def',
size: 4 * GiB,
block_size: 2048,
disk_type: 'distributed',
read_only: false,
}

export const disks: Json<Disk>[] = [
disk1,
disk2,
stoppedBootDisk,
stoppedDataDisk,
{
id: '3b768903-1d0b-4d78-9308-c12d3889bdfb',
name: 'disk-3',
Expand Down
14 changes: 14 additions & 0 deletions mock-api/instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,19 @@ export const instanceDb2: Json<Instance> = {
boot_disk_id: '48f94570-60d8-401c-857f-5bf912d2d3fc', // disk-2: needs to be written out here to reduce circular dependencies
}

// Pre-stopped instance used by tests that only stop an instance to bypass a
// "must be stopped" precondition. Lets those tests skip the stop dance.
export const stoppedInstance: Json<Instance> = {
...base,
id: '43ad3fc4-cf13-49ae-8171-35dbf0dd30f0',
name: 'db-stopped',
description: 'a stopped instance',
hostname: 'oxide.com',
project_id: project.id,
run_state: 'stopped',
boot_disk_id: 'f5bc2085-d18e-4698-86ab-69c62a74e541', // disk-stopped-boot
}

export const instances: Json<Instance>[] = [
instance,
failedInstance,
Expand All @@ -139,4 +152,5 @@ export const instances: Json<Instance>[] = [
failedCooledRestartNever,
instanceUpdateError,
instanceDb2,
stoppedInstance,
]
2 changes: 1 addition & 1 deletion mock-api/msw/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -626,7 +626,7 @@ const initDb = {
ipPools: [...mock.ipPools],
ipPoolSilos: [...mock.ipPoolSilos],
ipPoolRanges: [...mock.ipPoolRanges],
networkInterfaces: [mock.networkInterface],
networkInterfaces: [mock.networkInterface, mock.stoppedInstanceNic],
physicalDisks: [...mock.physicalDisks],
projects: [...projects],
racks: [...mock.racks],
Expand Down
22 changes: 21 additions & 1 deletion mock-api/network-interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
*/
import type { InstanceNetworkInterface } from '@oxide/api'

import { instance } from './instance'
import { instance, stoppedInstance } from './instance'
import type { Json } from './json-type'
import { vpc, vpcSubnet } from './vpc'

Expand Down Expand Up @@ -36,3 +36,23 @@ export const networkInterface: Json<InstanceNetworkInterface> = {
time_modified: new Date().toISOString(),
vpc_id: vpc.id,
}

export const stoppedInstanceNic: Json<InstanceNetworkInterface> = {
id: '0864924b-17b0-4467-9dd1-f2461bb84b9a',
name: 'my-nic',
description: 'a network interface',
primary: true,
instance_id: stoppedInstance.id,
ip_stack: {
type: 'dual_stack',
value: {
v4: { ip: '172.30.0.11', transit_ips: ['172.30.0.0/22'] },
v6: { ip: '::2', transit_ips: ['::/64'] },
},
},
mac: '',
subnet_id: vpcSubnet.id,
time_created: new Date().toISOString(),
time_modified: new Date().toISOString(),
vpc_id: vpc.id,
}
2 changes: 1 addition & 1 deletion test/e2e/disks.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ test('List disks and snapshot', async ({ page }) => {
await page.goto('/projects/mock-project/disks')

const table = page.getByRole('table')
await expect(table.getByRole('row')).toHaveCount(14) // 13 + header
await expect(table.getByRole('row')).toHaveCount(16) // 15 + header

// check one attached and one not attached
await expectRowVisible(table, {
Expand Down
17 changes: 15 additions & 2 deletions test/e2e/firewall-rules.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -639,10 +639,17 @@ test('arbitrary values combobox', async ({ page }) => {
'not-there-yet',
'instance-update-error',
'db2',
'db-stopped',
])

await input.fill('d')
await expectOptions(page, ['db1', 'instance-update-error', 'db2', 'Custom: d'])
await expectOptions(page, [
'db1',
'instance-update-error',
'db2',
'db-stopped',
'Custom: d',
])

await input.blur()
await expect(page.getByRole('option')).toBeHidden()
Expand All @@ -651,7 +658,13 @@ test('arbitrary values combobox', async ({ page }) => {
await input.focus()

// same options show up after blur (there was a bug around this)
await expectOptions(page, ['db1', 'instance-update-error', 'db2', 'Custom: d'])
await expectOptions(page, [
'db1',
'instance-update-error',
'db2',
'db-stopped',
'Custom: d',
])

// make sure typing in ICMP filter input actually updates the underlying value,
// triggering a validation error for bad input. without onInputChange binding
Expand Down
72 changes: 29 additions & 43 deletions test/e2e/instance-disks.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,10 +86,7 @@ test('Disabled actions', async ({ page }) => {
})

test('Attach disk', async ({ page }) => {
await page.goto('/projects/mock-project/instances/db1')

// Have to stop instance to edit disks
await stopInstance(page)
await page.goto('/projects/mock-project/instances/db-stopped')

// Attach existing disk form
await page.click('role=button[name="Attach existing disk"]')
Expand All @@ -100,8 +97,8 @@ test('Attach disk', async ({ page }) => {
await expectVisible(page, ['role=dialog >> text="Disk name is required"'])

await page.getByRole('combobox', { name: 'Disk name' }).click()
// disk-1 is already attached, so should not be visible in the list
await expectNotVisible(page, ['role=option[name="disk-1"]'])
// disk-stopped-boot is already attached, so should not be visible in the list
await expectNotVisible(page, ['role=option[name="disk-stopped-boot"]'])
await expectVisible(page, ['role=option[name="disk-3"]', 'role=option[name="disk-4"]'])
await page.click('role=option[name="disk-3"]')

Expand All @@ -110,14 +107,11 @@ test('Attach disk', async ({ page }) => {
})

test('Create disk', async ({ page }) => {
await page.goto('/projects/mock-project/instances/db1')
await page.goto('/projects/mock-project/instances/db-stopped')

const row = page.getByRole('cell', { name: 'created-disk' })
await expect(row).toBeHidden()

// Have to stop instance to edit disks
await stopInstance(page)

// New disk form
const createForm = page.getByRole('dialog', { name: 'Create disk' })
await expect(createForm).toBeHidden()
Expand All @@ -138,17 +132,14 @@ test('Create disk', async ({ page }) => {
})

test('Detach disk', async ({ page }) => {
await page.goto('/projects/mock-project/instances/db1')
await page.goto('/projects/mock-project/instances/db-stopped')

// Have to stop instance to edit disks
await stopInstance(page)

const successMsg = page.getByText('Disk disk-2 detached').first()
const row = page.getByRole('row', { name: 'disk-2' })
const successMsg = page.getByText('Disk disk-stopped-data detached').first()
const row = page.getByRole('row', { name: 'disk-stopped-data' })
await expect(row).toBeVisible()
await expect(successMsg).toBeHidden()

await clickRowAction(page, 'disk-2', 'Detach')
await clickRowAction(page, 'disk-stopped-data', 'Detach')
await page.getByRole('button', { name: 'Confirm' }).click()
await expect(successMsg).toBeVisible()
await expect(row).toBeHidden() // disk row goes away
Expand Down Expand Up @@ -176,9 +167,7 @@ test('Snapshot disk', async ({ page }) => {
})

test('Attach disk error clears when modal closes', async ({ page }) => {
await page.goto('/projects/mock-project/instances/db1')

await stopInstance(page)
await page.goto('/projects/mock-project/instances/db-stopped')

// Attach disks until we hit the limit
const disksToAttach = [
Expand Down Expand Up @@ -244,62 +233,59 @@ test('Attach disk error clears when modal closes', async ({ page }) => {
})

test('Change boot disk', async ({ page }) => {
await page.goto('/projects/mock-project/instances/db1')
await page.goto('/projects/mock-project/instances/db-stopped')

// assert disk-1 is boot disk, disk-2 also there
const bootDiskTable = page.getByRole('table', { name: 'Boot disk' })
const otherDisksTable = page.getByRole('table', { name: 'Additional disks' })
const confirm = page.getByRole('button', { name: 'Confirm' })
const noBootDisk = page.getByText('No boot disk set')
const noOtherDisks = page.getByText('No other disks')

const disk1 = { Disk: 'disk-1', size: '2 GiB' }
const disk2 = { Disk: 'disk-2', size: '4 GiB' }

await expectRowVisible(bootDiskTable, disk1)
await expectRowVisible(otherDisksTable, disk2)
const bootDisk = { Disk: 'disk-stopped-boot', size: '2 GiB' }
const dataDisk = { Disk: 'disk-stopped-data', size: '4 GiB' }

await stopInstance(page)
await expectRowVisible(bootDiskTable, bootDisk)
await expectRowVisible(otherDisksTable, dataDisk)

// Set disk-2 as boot disk
await clickRowAction(page, 'disk-2', 'Set as boot disk')
// Set disk-stopped-data as boot disk
await clickRowAction(page, 'disk-stopped-data', 'Set as boot disk')
await confirm.click()

await expectRowVisible(bootDiskTable, disk2)
await expectRowVisible(otherDisksTable, disk1)
await expectRowVisible(bootDiskTable, dataDisk)
await expectRowVisible(otherDisksTable, bootDisk)

// Unset boot disk
await expect(noBootDisk).toBeHidden()

await clickRowAction(page, 'disk-2', 'Unset as boot disk')
await clickRowAction(page, 'disk-stopped-data', 'Unset as boot disk')
await confirm.click()

await expect(noBootDisk).toBeVisible()
await expectRowVisible(otherDisksTable, disk1)
await expectRowVisible(otherDisksTable, disk2)
await expectRowVisible(otherDisksTable, bootDisk)
await expectRowVisible(otherDisksTable, dataDisk)

await expect(page.getByText('Setting a boot disk is recommended')).toBeVisible()

// detach disk so there's only one
await clickRowAction(page, 'disk-2', 'Detach')
await clickRowAction(page, 'disk-stopped-data', 'Detach')
await page.getByRole('button', { name: 'Confirm' }).click()

await expect(page.getByText('Instance will boot from disk-1')).toBeVisible()
await expect(page.getByText('Instance will boot from disk-stopped-boot')).toBeVisible()

// set disk-1 back as boot disk
await clickRowAction(page, 'disk-1', 'Set as boot disk')
// set disk-stopped-boot back as boot disk
await clickRowAction(page, 'disk-stopped-boot', 'Set as boot disk')
await confirm.click()

await expect(noBootDisk).toBeHidden()
await expect(noOtherDisks).toBeVisible()

// Remove disk-1 altogether, no disks left
await clickRowAction(page, 'disk-1', 'Unset as boot disk')
// Remove disk-stopped-boot altogether, no disks left
await clickRowAction(page, 'disk-stopped-boot', 'Unset as boot disk')
await confirm.click()

await expectRowVisible(otherDisksTable, disk1)
await expectRowVisible(otherDisksTable, bootDisk)

await clickRowAction(page, 'disk-1', 'Detach')
await clickRowAction(page, 'disk-stopped-boot', 'Detach')
await page.getByRole('button', { name: 'Confirm' }).click()

await expect(noBootDisk).toBeVisible()
Expand Down
6 changes: 2 additions & 4 deletions test/e2e/instance-networking.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -316,10 +316,8 @@ test('Instance networking tab — SNAT IPs', async ({ page }) => {
})

test('Edit network interface - Transit IPs', async ({ page }) => {
await page.goto('/projects/mock-project/instances/db1/networking')

// Stop the instance to enable editing
await stopInstance(page)
// use a stopped instance so editing is enabled
await page.goto('/projects/mock-project/instances/db-stopped/networking')

await clickRowAction(page, 'my-nic', 'Edit')

Expand Down
4 changes: 3 additions & 1 deletion test/e2e/instance.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,9 @@ test("polling doesn't close row actions: instances", async ({ page }) => {
await closeToast(page)

const menu = page.getByRole('menu')
const stopped = page.getByText('stopped')
// scope to db1's row — db-stopped is also in the table with state 'stopped'
const db1Row = page.getByRole('row', { name: 'db1', exact: false })
const stopped = db1Row.getByText('stopped')

await expect(menu).toBeHidden()
await expect(stopped).toBeHidden()
Expand Down
21 changes: 6 additions & 15 deletions test/e2e/network-interface-create.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,11 @@
*/
import { test } from '@playwright/test'

import { expect, expectRowVisible, stopInstance } from './utils'
import { expect, expectRowVisible } from './utils'

test('can create a NIC with a specified IP address', async ({ page }) => {
// go to an instance's Network Interfaces page
await page.goto('/projects/mock-project/instances/db1/networking')

await stopInstance(page)
// use a stopped instance so we can edit NICs
await page.goto('/projects/mock-project/instances/db-stopped/networking')

// open the add network interface side modal
await page.getByRole('button', { name: 'Add network interface' }).click()
Expand All @@ -40,10 +38,7 @@ test('can create a NIC with a specified IP address', async ({ page }) => {
})

test('can create a NIC with a blank IP address', async ({ page }) => {
// go to an instance's Network Interfaces page
await page.goto('/projects/mock-project/instances/db1/networking')

await stopInstance(page)
await page.goto('/projects/mock-project/instances/db-stopped/networking')

// open the add network interface side modal
await page.getByRole('button', { name: 'Add network interface' }).click()
Expand Down Expand Up @@ -83,9 +78,7 @@ test('can create a NIC with a blank IP address', async ({ page }) => {
})

test('can create a NIC with IPv6 only', async ({ page }) => {
await page.goto('/projects/mock-project/instances/db1/networking')

await stopInstance(page)
await page.goto('/projects/mock-project/instances/db-stopped/networking')

await page.getByRole('button', { name: 'Add network interface' }).click()

Expand All @@ -108,9 +101,7 @@ test('can create a NIC with IPv6 only', async ({ page }) => {
})

test('can create a NIC with dual-stack and explicit IPs', async ({ page }) => {
await page.goto('/projects/mock-project/instances/db1/networking')

await stopInstance(page)
await page.goto('/projects/mock-project/instances/db-stopped/networking')

await page.getByRole('button', { name: 'Add network interface' }).click()

Expand Down
Loading
Loading