Skip to content

Commit b402ebb

Browse files
authored
Instance create: disable default networking when no default VPC (#3319)
The reason this half-worked is that we were disabling `Default` when there were no VPCs at all. Now we specifically check for a VPC called `default`. I also improved the styling on a disabled radio button — before the text was normal color and you couldn't tell it was disabled. <img width="233" height="181" alt="image" src="https://github.com/user-attachments/assets/c5416d3e-6a40-436e-81f4-924a5e56dac6" /> --- <img width="445" height="202" alt="image" src="https://github.com/user-attachments/assets/2f11a053-cc94-4ee1-97e7-aaa571609dca" />
1 parent b9cea7e commit b402ebb

7 files changed

Lines changed: 147 additions & 37 deletions

File tree

‎app/api/util.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import type {
2222
SiloIpPool,
2323
SiloUtilization,
2424
Sled,
25+
Vpc,
2526
VpcFirewallRule,
2627
VpcFirewallRuleUpdate,
2728
} from './__generated__/Api'
@@ -46,6 +47,18 @@ export const MIN_DISK_SIZE_GiB = 1
4647
*/
4748
export const MAX_DISK_SIZE_GiB = 1023
4849

50+
/**
51+
* The `default_*` network interface attachment types resolve a VPC and VPC
52+
* subnet both named literally 'default', so they fail with a 404 if that VPC
53+
* doesn't exist, even when the project has other VPCs.
54+
*
55+
* https://github.com/oxidecomputer/omicron/blob/7a15082/nexus/src/app/sagas/instance_create.rs#L739-L773
56+
*/
57+
export const DEFAULT_VPC_NAME = 'default'
58+
59+
export const hasDefaultVpc = (vpcs: Vpc[]) =>
60+
vpcs.some((vpc) => vpc.name === DEFAULT_VPC_NAME)
61+
4962
type PortRange = [number, number]
5063

5164
/** Parse '1234' into [1234, 1234] and '80-100' into [80, 100] */

‎app/components/form/fields/NetworkInterfaceField.tsx‎

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,22 @@
88
import { useState } from 'react'
99
import { useController, type Control } from 'react-hook-form'
1010

11-
import type { InstanceNetworkInterfaceCreate } from '@oxide/api'
11+
import {
12+
DEFAULT_VPC_NAME,
13+
hasDefaultVpc,
14+
type InstanceNetworkInterfaceCreate,
15+
type Vpc,
16+
} from '@oxide/api'
1217

18+
import { HL } from '~/components/HL'
1319
import type { InstanceCreateInput } from '~/forms/instance-create'
1420
import { CreateNetworkInterfaceForm } from '~/forms/network-interface-create'
1521
import { Button } from '~/ui/lib/Button'
1622
import { FieldLabel } from '~/ui/lib/FieldLabel'
1723
import { Listbox } from '~/ui/lib/Listbox'
1824
import { MiniTable } from '~/ui/lib/MiniTable'
1925
import { Radio } from '~/ui/lib/Radio'
26+
import { TipIcon } from '~/ui/lib/TipIcon'
2027

2128
const networkInterfaceTableColumns = [
2229
{ header: 'Name', text: (item: InstanceNetworkInterfaceCreate) => item.name },
@@ -31,13 +38,14 @@ const networkInterfaceTableColumns = [
3138
export function NetworkInterfaceField({
3239
control,
3340
disabled,
34-
hasVpcs,
41+
vpcs,
3542
}: {
3643
control: Control<InstanceCreateInput>
3744
disabled: boolean
38-
hasVpcs: boolean
45+
vpcs: Vpc[]
3946
}) {
4047
const [showForm, setShowForm] = useState(false)
48+
const defaultVpcExists = hasDefaultVpc(vpcs)
4149

4250
/**
4351
* Used to preserve previous user choices in case they accidentally
@@ -79,15 +87,26 @@ export function NetworkInterfaceField({
7987
aria-labelledby="network-interface-type-label"
8088
>
8189
<div className="space-y-2">
82-
<Radio
83-
name="networkInterfaceType"
84-
value="default"
85-
disabled={!hasVpcs || disabled}
86-
checked={currentMode === 'default'}
87-
onChange={(e) => handleModeChange(e.target.value)}
88-
>
89-
Default
90-
</Radio>
90+
<span className="inline-flex items-center gap-1.5">
91+
<Radio
92+
name="networkInterfaceType"
93+
value="default"
94+
disabled={!defaultVpcExists || disabled}
95+
checked={currentMode === 'default'}
96+
onChange={(e) => handleModeChange(e.target.value)}
97+
>
98+
Default
99+
</Radio>
100+
{
101+
// the no VPCs case is covered by a separate yellow banner message
102+
// saying you can't have any network interfaces
103+
vpcs.length > 0 && !defaultVpcExists && (
104+
<TipIcon>
105+
Default networking requires a VPC named <HL>{DEFAULT_VPC_NAME}</HL>
106+
</TipIcon>
107+
)
108+
}
109+
</span>
91110
{currentMode === 'default' && (
92111
<div className="mb-2 ml-6">
93112
<Listbox
@@ -108,7 +127,7 @@ export function NetworkInterfaceField({
108127
<Radio
109128
name="networkInterfaceType"
110129
value="create"
111-
disabled={!hasVpcs || disabled}
130+
disabled={vpcs.length === 0 || disabled}
112131
checked={currentMode === 'create'}
113132
onChange={(e) => handleModeChange(e.target.value)}
114133
>

‎app/forms/instance-create.tsx‎

Lines changed: 20 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
api,
1717
diskCan,
1818
genName,
19+
hasDefaultVpc,
1920
INSTANCE_MAX_CPU,
2021
INSTANCE_MAX_RAM_GiB,
2122
isUnicastPool,
@@ -34,6 +35,7 @@ import {
3435
type IpVersion,
3536
type NameOrId,
3637
type UnicastIpPool,
38+
type Vpc,
3739
} from '@oxide/api'
3840
import {
3941
Images16Icon,
@@ -410,19 +412,16 @@ export default function CreateInstanceForm() {
410412
[siloPools]
411413
)
412414

413-
// Check if VPCs exist to determine default network interface type
414415
const { data: vpcs } = usePrefetchedQuery(
415416
q(api.vpcList, { query: { project, limit: ALL_ISH } })
416417
)
417-
const hasVpcs = vpcs.items.length > 0
418418

419419
// Determine default network interface type:
420-
// - If VPCs exist: default to dual-stack (API default, works with both IPv4 and IPv6 subnets)
421-
// - If no VPCs exist: default to 'none' (user must create VPC first or use custom NICs)
420+
// - If a default VPC exists: default to dual-stack (API default, works with both IPv4 and IPv6 subnets)
421+
// - Otherwise: default to 'none' (user must create a VPC first or use custom NICs)
422422
// Note: Decoupled from external IP pool configuration, as NIC IP stack and external IPs are separate concerns
423-
const defaultNetworkInterfaceType: InstanceNetworkInterfaceAttachment['type'] = hasVpcs
424-
? 'default_dual_stack'
425-
: 'none'
423+
const defaultNetworkInterfaceType: InstanceNetworkInterfaceAttachment['type'] =
424+
hasDefaultVpc(vpcs.items) ? 'default_dual_stack' : 'none'
426425

427426
const defaultSource =
428427
siloImages.length > 0 ? 'siloImage' : projectImages.length > 0 ? 'projectImage' : 'disk'
@@ -841,7 +840,7 @@ export default function CreateInstanceForm() {
841840
control={control}
842841
isSubmitting={isSubmitting}
843842
unicastPools={unicastPools}
844-
hasVpcs={hasVpcs}
843+
vpcs={vpcs.items}
845844
/>
846845
<FormDivider />
847846
<Form.Heading id="advanced">Advanced</Form.Heading>
@@ -880,12 +879,12 @@ const NetworkingSection = ({
880879
control,
881880
isSubmitting,
882881
unicastPools,
883-
hasVpcs,
882+
vpcs,
884883
}: {
885884
control: Control<InstanceCreateInput>
886885
isSubmitting: boolean
887886
unicastPools: UnicastIpPool[]
888-
hasVpcs: boolean
887+
vpcs: Vpc[]
889888
}) => {
890889
const networkInterfaces = useWatch({ control, name: 'networkInterfaces' })
891890
const [floatingIpModalOpen, setFloatingIpModalOpen] = useState(false)
@@ -955,21 +954,20 @@ const NetworkingSection = ({
955954
</>
956955
)
957956

957+
const vpcMessage =
958+
vpcs.length === 0 ? (
959+
<>
960+
A VPC is required to add network interfaces.{' '}
961+
<Link to={pb.vpcsNew({ project })}>Create a VPC</Link> to enable networking.
962+
</>
963+
) : null
964+
958965
return (
959966
<>
960-
{!hasVpcs && (
961-
<Message
962-
className="mb-4"
963-
variant="notice"
964-
content={
965-
<>
966-
A VPC is required to add network interfaces.{' '}
967-
<Link to={pb.vpcsNew({ project })}>Create a VPC</Link> to enable networking.
968-
</>
969-
}
970-
/>
967+
{vpcMessage && (
968+
<Message className="mb-4 max-w-lg" variant="notice" content={vpcMessage} />
971969
)}
972-
<NetworkInterfaceField control={control} disabled={isSubmitting} hasVpcs={hasVpcs} />
970+
<NetworkInterfaceField control={control} disabled={isSubmitting} vpcs={vpcs} />
973971

974972
<div className="flex flex-1 flex-col gap-4">
975973
<h2 className="text-sans-md flex items-center">

‎app/ui/lib/Radio.tsx‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ const fieldStyles = `
2727
`
2828

2929
export const Radio = ({ children, className, ...inputProps }: RadioProps) => (
30-
<label className="text-sans-md inline-flex items-start">
30+
<label className="group text-sans-md inline-flex items-start">
3131
{/* Center the 1rem (h-4) radio button with the first line of text.
3232
1lh is the line height, so (1lh - 1rem) / 2 is the top offset
3333
that vertically centers the indicator within that line. */}
@@ -37,7 +37,11 @@ export const Radio = ({ children, className, ...inputProps }: RadioProps) => (
3737
<div className="bg-accent-inverse light:bg-(--theme-accent-600) pointer-events-none absolute top-1 left-1 hidden h-2 w-2 rounded-full peer-checked:block" />
3838
</span>
3939

40-
{children && <span className="text-default ml-2.5">{children}</span>}
40+
{children && (
41+
<span className="text-default group-has-disabled:text-disabled ml-2.5">
42+
{children}
43+
</span>
44+
)}
4145
</label>
4246
)
4347

‎mock-api/msw/handlers.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { match } from 'ts-pattern'
1313
import { validate as isUuid, v4 as uuid } from 'uuid'
1414

1515
import {
16+
DEFAULT_VPC_NAME,
1617
diskCan,
1718
fleetRoles,
1819
FLEET_ID,
@@ -612,6 +613,13 @@ export const handlers = makeHandlers({
612613
lookup.vpc({ ...query, vpc: vpc_name })
613614
lookup.vpcSubnet({ ...query, vpc: vpc_name, subnet: subnet_name })
614615
})
616+
} else if (body.network_interfaces?.type.startsWith('default_')) {
617+
// The default attachment types resolve a VPC and subnet both named
618+
// literally 'default', so they 404 when that VPC doesn't exist, even if
619+
// the project has other VPCs.
620+
// https://github.com/oxidecomputer/omicron/blob/7a15082/nexus/src/app/sagas/instance_create.rs#L739-L773
621+
lookup.vpc({ ...query, vpc: DEFAULT_VPC_NAME })
622+
lookup.vpcSubnet({ ...query, vpc: DEFAULT_VPC_NAME, subnet: DEFAULT_VPC_NAME })
615623
}
616624

617625
// validate floating IP attachments before we actually do anything

‎mock-api/vpc.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,17 @@ export const vpcSubnet2: Json<VpcSubnet> = {
211211
custom_router_id: customRouter.id,
212212
}
213213

214+
export const subnetOtherProject: Json<VpcSubnet> = {
215+
id: 'd4f387db-e012-4424-9226-d8a10070e0f3',
216+
name: 'other-subnet',
217+
description: 'a subnet in other-project',
218+
time_created,
219+
time_modified,
220+
vpc_id: vpc2.id,
221+
ipv4_block: '10.1.2.0/24',
222+
ipv6_block: 'fd9b:870a:4245:1::/64',
223+
}
224+
214225
// Subnets for test silos
215226
export const subnetKosman: Json<VpcSubnet> = {
216227
id: 'a1b2c3d4-e5f6-4890-9234-567890abcdef',
@@ -248,6 +259,7 @@ export const subnetAdorno: Json<VpcSubnet> = {
248259
export const vpcSubnets: Json<VpcSubnet[]> = [
249260
vpcSubnet,
250261
vpcSubnet2,
262+
subnetOtherProject,
251263
subnetKosman,
252264
subnetAnscombe,
253265
subnetAdorno,

‎test/e2e/instance-create.e2e.ts‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1191,6 +1191,62 @@ test('network interface options disabled when no VPCs exist', async ({ page }) =
11911191
await expect(noneRadio).toBeChecked()
11921192
})
11931193

1194+
// The default_* attachment types resolve a VPC named 'default', so they 404 if
1195+
// that VPC has been deleted. other-project has a VPC, just not one named
1196+
// 'default', so only custom interfaces work there.
1197+
test('custom network interface works without a default VPC', async ({ page }) => {
1198+
await page.goto('/projects/other-project/instances-new')
1199+
const instanceName = 'custom-nic-without-default-vpc'
1200+
1201+
const defaultRadio = page.getByRole('radio', { name: 'Default', exact: true })
1202+
const customRadio = page.getByRole('radio', { name: 'Custom', exact: true })
1203+
const noneRadio = page.getByRole('radio', { name: 'None', exact: true })
1204+
1205+
// default is out, but the project has a VPC, so custom interfaces still work
1206+
await expect(defaultRadio).toBeDisabled()
1207+
await expect(defaultRadio).not.toBeChecked()
1208+
await expect(customRadio).toBeEnabled()
1209+
1210+
const defaultRow = defaultRadio.locator('..').locator('..').locator('..')
1211+
const defaultTip = defaultRow.getByRole('button', { name: 'Tip' })
1212+
const tooltip = page.getByRole('tooltip')
1213+
1214+
await defaultTip.hover()
1215+
await expect(tooltip).toHaveText('Default networking requires a VPC named default')
1216+
1217+
await page.mouse.move(0, 0)
1218+
await expect(tooltip).toBeHidden()
1219+
await defaultTip.focus()
1220+
await expect(tooltip).toHaveText('Default networking requires a VPC named default')
1221+
1222+
await expect(noneRadio).toBeEnabled()
1223+
await expect(noneRadio).toBeChecked()
1224+
1225+
await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName)
1226+
await selectASiloImage(page, 'ubuntu-22-04')
1227+
1228+
await customRadio.click()
1229+
await page.getByRole('button', { name: 'Add network interface' }).click()
1230+
1231+
const modal = page.getByRole('dialog', { name: 'Add network interface' })
1232+
await modal.getByRole('textbox', { name: 'Name' }).fill('custom-primary')
1233+
await expect(modal.getByLabel('VPC', { exact: true })).toContainText('mock-vpc-2')
1234+
await modal.getByRole('button', { name: 'VPC subnet' }).click()
1235+
await page.getByRole('option', { name: 'other-subnet', exact: true }).click()
1236+
await modal.getByRole('button', { name: 'Add network interface' }).click()
1237+
1238+
await page.getByRole('button', { name: 'Create instance' }).click()
1239+
await closeToast(page)
1240+
await expect(page).toHaveURL(`/projects/other-project/instances/${instanceName}/storage`)
1241+
1242+
await page.getByRole('tab', { name: 'Networking' }).click()
1243+
await expectRowVisible(page.getByRole('table', { name: 'Network interfaces' }), {
1244+
name: 'custom-primaryprimary',
1245+
vpc: 'mock-vpc-2',
1246+
subnet: 'other-subnet',
1247+
})
1248+
})
1249+
11941250
test('floating IPs are filtered by NIC IP version', async ({ page }) => {
11951251
await page.goto('/projects/mock-project/instances-new')
11961252

0 commit comments

Comments
 (0)