From 1e9517f1efd5c40d8ba3f25c9e88900ced6e5c46 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 12 Aug 2026 12:40:47 -0500 Subject: [PATCH 1/5] Gate default NIC option on a VPC named default --- app/api/util.ts | 13 +++++ .../form/fields/NetworkInterfaceField.tsx | 10 ++-- app/forms/instance-create.tsx | 50 +++++++++++-------- mock-api/msw/handlers.ts | 8 +++ test/e2e/instance-create.e2e.ts | 25 ++++++++++ 5 files changed, 79 insertions(+), 27 deletions(-) diff --git a/app/api/util.ts b/app/api/util.ts index f3091f865..674761a8f 100644 --- a/app/api/util.ts +++ b/app/api/util.ts @@ -22,6 +22,7 @@ import type { SiloIpPool, SiloUtilization, Sled, + Vpc, VpcFirewallRule, VpcFirewallRuleUpdate, } from './__generated__/Api' @@ -46,6 +47,18 @@ export const MIN_DISK_SIZE_GiB = 1 */ export const MAX_DISK_SIZE_GiB = 1023 +/** + * The `default_*` network interface attachment types resolve a VPC and VPC + * subnet both named literally 'default', so they fail with a 404 if that VPC + * doesn't exist, even when the project has other VPCs. + * + * https://github.com/oxidecomputer/omicron/blob/7a15082/nexus/src/app/sagas/instance_create.rs#L739-L773 + */ +export const DEFAULT_VPC_NAME = 'default' + +export const hasDefaultVpc = (vpcs: Vpc[]) => + vpcs.some((vpc) => vpc.name === DEFAULT_VPC_NAME) + type PortRange = [number, number] /** Parse '1234' into [1234, 1234] and '80-100' into [80, 100] */ diff --git a/app/components/form/fields/NetworkInterfaceField.tsx b/app/components/form/fields/NetworkInterfaceField.tsx index a9fd0f3ef..db4709a43 100644 --- a/app/components/form/fields/NetworkInterfaceField.tsx +++ b/app/components/form/fields/NetworkInterfaceField.tsx @@ -8,7 +8,7 @@ import { useState } from 'react' import { useController, type Control } from 'react-hook-form' -import type { InstanceNetworkInterfaceCreate } from '@oxide/api' +import { hasDefaultVpc, type InstanceNetworkInterfaceCreate, type Vpc } from '@oxide/api' import type { InstanceCreateInput } from '~/forms/instance-create' import { CreateNetworkInterfaceForm } from '~/forms/network-interface-create' @@ -31,11 +31,11 @@ const networkInterfaceTableColumns = [ export function NetworkInterfaceField({ control, disabled, - hasVpcs, + vpcs, }: { control: Control disabled: boolean - hasVpcs: boolean + vpcs: Vpc[] }) { const [showForm, setShowForm] = useState(false) @@ -82,7 +82,7 @@ export function NetworkInterfaceField({ handleModeChange(e.target.value)} > @@ -108,7 +108,7 @@ export function NetworkInterfaceField({ handleModeChange(e.target.value)} > diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index ce57bbea6..e30173826 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -14,8 +14,10 @@ import type { SetRequired } from 'type-fest' import { api, + DEFAULT_VPC_NAME, diskCan, genName, + hasDefaultVpc, INSTANCE_MAX_CPU, INSTANCE_MAX_RAM_GiB, isUnicastPool, @@ -34,6 +36,7 @@ import { type IpVersion, type NameOrId, type UnicastIpPool, + type Vpc, } from '@oxide/api' import { Images16Icon, @@ -410,19 +413,16 @@ export default function CreateInstanceForm() { [siloPools] ) - // Check if VPCs exist to determine default network interface type const { data: vpcs } = usePrefetchedQuery( q(api.vpcList, { query: { project, limit: ALL_ISH } }) ) - const hasVpcs = vpcs.items.length > 0 // Determine default network interface type: - // - If VPCs exist: default to dual-stack (API default, works with both IPv4 and IPv6 subnets) - // - If no VPCs exist: default to 'none' (user must create VPC first or use custom NICs) + // - If a default VPC exists: default to dual-stack (API default, works with both IPv4 and IPv6 subnets) + // - Otherwise: default to 'none' (user must create a VPC first or use custom NICs) // Note: Decoupled from external IP pool configuration, as NIC IP stack and external IPs are separate concerns - const defaultNetworkInterfaceType: InstanceNetworkInterfaceAttachment['type'] = hasVpcs - ? 'default_dual_stack' - : 'none' + const defaultNetworkInterfaceType: InstanceNetworkInterfaceAttachment['type'] = + hasDefaultVpc(vpcs.items) ? 'default_dual_stack' : 'none' const defaultSource = siloImages.length > 0 ? 'siloImage' : projectImages.length > 0 ? 'projectImage' : 'disk' @@ -841,7 +841,7 @@ export default function CreateInstanceForm() { control={control} isSubmitting={isSubmitting} unicastPools={unicastPools} - hasVpcs={hasVpcs} + vpcs={vpcs.items} /> Advanced @@ -878,12 +878,12 @@ const NetworkingSection = ({ control, isSubmitting, unicastPools, - hasVpcs, + vpcs, }: { control: Control isSubmitting: boolean unicastPools: UnicastIpPool[] - hasVpcs: boolean + vpcs: Vpc[] }) => { const networkInterfaces = useWatch({ control, name: 'networkInterfaces' }) const [floatingIpModalOpen, setFloatingIpModalOpen] = useState(false) @@ -953,21 +953,27 @@ const NetworkingSection = ({ ) + const vpcMessage = + vpcs.length === 0 ? ( + <> + A VPC is required to add network interfaces.{' '} + Create a VPC to enable networking. + + ) : !hasDefaultVpc(vpcs) ? ( + <> + This project has no VPC named {DEFAULT_VPC_NAME}. Choose Custom to select + an existing VPC and subnet, or{' '} + create a VPC named{' '} + {DEFAULT_VPC_NAME}. + + ) : null + return ( <> - {!hasVpcs && ( - - A VPC is required to add network interfaces.{' '} - Create a VPC to enable networking. - - } - /> + {vpcMessage && ( + )} - +

diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index c21817f67..1d782e7d3 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -13,6 +13,7 @@ import { match } from 'ts-pattern' import { validate as isUuid, v4 as uuid } from 'uuid' import { + DEFAULT_VPC_NAME, diskCan, fleetRoles, FLEET_ID, @@ -609,6 +610,13 @@ export const handlers = makeHandlers({ lookup.vpc({ ...query, vpc: vpc_name }) lookup.vpcSubnet({ ...query, vpc: vpc_name, subnet: subnet_name }) }) + } else if (body.network_interfaces?.type.startsWith('default_')) { + // The default attachment types resolve a VPC and subnet both named + // literally 'default', so they 404 when that VPC doesn't exist, even if + // the project has other VPCs. + // https://github.com/oxidecomputer/omicron/blob/7a15082/nexus/src/app/sagas/instance_create.rs#L739-L773 + lookup.vpc({ ...query, vpc: DEFAULT_VPC_NAME }) + lookup.vpcSubnet({ ...query, vpc: DEFAULT_VPC_NAME, subnet: DEFAULT_VPC_NAME }) } // validate floating IP attachments before we actually do anything diff --git a/test/e2e/instance-create.e2e.ts b/test/e2e/instance-create.e2e.ts index b6f81f9d9..de0d09c99 100644 --- a/test/e2e/instance-create.e2e.ts +++ b/test/e2e/instance-create.e2e.ts @@ -1171,6 +1171,31 @@ test('network interface options disabled when no VPCs exist', async ({ page }) = await expect(noneRadio).toBeChecked() }) +// The default_* attachment types resolve a VPC named 'default', so they 404 if +// that VPC has been deleted. other-project has a VPC, just not one named +// 'default', so only custom interfaces work there. +test('default network interface option disabled when there is no default VPC', async ({ + page, +}) => { + await page.goto('/projects/other-project/instances-new') + + const defaultRadio = page.getByRole('radio', { name: 'Default', exact: true }) + const customRadio = page.getByRole('radio', { name: 'Custom', exact: true }) + const noneRadio = page.getByRole('radio', { name: 'None', exact: true }) + + await expect( + page.getByText('Choose Custom to select an existing VPC and subnet, or') + ).toBeVisible() + + // default is out, but the project has a VPC, so custom interfaces still work + await expect(defaultRadio).toBeDisabled() + await expect(defaultRadio).not.toBeChecked() + await expect(customRadio).toBeEnabled() + + await expect(noneRadio).toBeEnabled() + await expect(noneRadio).toBeChecked() +}) + test('floating IPs are filtered by NIC IP version', async ({ page }) => { await page.goto('/projects/mock-project/instances-new') From 57d9762a67587032e2f15bdd46c6ca23ea51e10c Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 12 Aug 2026 15:12:44 -0500 Subject: [PATCH 2/5] use tooltip on disabled default instead of big message --- .../form/fields/NetworkInterfaceField.tsx | 35 +++++++++++++------ app/forms/instance-create.tsx | 8 ----- app/ui/lib/Radio.tsx | 8 +++-- test/e2e/instance-create.e2e.ts | 16 ++++++--- 4 files changed, 43 insertions(+), 24 deletions(-) diff --git a/app/components/form/fields/NetworkInterfaceField.tsx b/app/components/form/fields/NetworkInterfaceField.tsx index db4709a43..4865c269b 100644 --- a/app/components/form/fields/NetworkInterfaceField.tsx +++ b/app/components/form/fields/NetworkInterfaceField.tsx @@ -8,8 +8,14 @@ import { useState } from 'react' import { useController, type Control } from 'react-hook-form' -import { hasDefaultVpc, type InstanceNetworkInterfaceCreate, type Vpc } from '@oxide/api' +import { + DEFAULT_VPC_NAME, + hasDefaultVpc, + type InstanceNetworkInterfaceCreate, + type Vpc, +} from '@oxide/api' +import { HL } from '~/components/HL' import type { InstanceCreateInput } from '~/forms/instance-create' import { CreateNetworkInterfaceForm } from '~/forms/network-interface-create' import { Button } from '~/ui/lib/Button' @@ -17,6 +23,7 @@ import { FieldLabel } from '~/ui/lib/FieldLabel' import { Listbox } from '~/ui/lib/Listbox' import { MiniTable } from '~/ui/lib/MiniTable' import { Radio } from '~/ui/lib/Radio' +import { TipIcon } from '~/ui/lib/TipIcon' const networkInterfaceTableColumns = [ { header: 'Name', cell: (item: InstanceNetworkInterfaceCreate) => item.name }, @@ -38,6 +45,7 @@ export function NetworkInterfaceField({ vpcs: Vpc[] }) { const [showForm, setShowForm] = useState(false) + const defaultVpcExists = hasDefaultVpc(vpcs) /** * Used to preserve previous user choices in case they accidentally @@ -79,15 +87,22 @@ export function NetworkInterfaceField({ aria-labelledby="network-interface-type-label" >
- handleModeChange(e.target.value)} - > - Default - + + handleModeChange(e.target.value)} + > + Default + + {vpcs.length > 0 && !defaultVpcExists && ( + + Default networking requires a VPC named {DEFAULT_VPC_NAME} + + )} + {currentMode === 'default' && (
Create a VPC to enable networking. - ) : !hasDefaultVpc(vpcs) ? ( - <> - This project has no VPC named {DEFAULT_VPC_NAME}. Choose Custom to select - an existing VPC and subnet, or{' '} - create a VPC named{' '} - {DEFAULT_VPC_NAME}. - ) : null return ( diff --git a/app/ui/lib/Radio.tsx b/app/ui/lib/Radio.tsx index 2e74543bd..7bd0814b4 100644 --- a/app/ui/lib/Radio.tsx +++ b/app/ui/lib/Radio.tsx @@ -27,7 +27,7 @@ const fieldStyles = ` ` export const Radio = ({ children, className, ...inputProps }: RadioProps) => ( -