From e685eb49bc77ce656cb97701d8b16e8f13e9875a Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 8 Jan 2026 16:00:47 -0800 Subject: [PATCH 01/45] Update to new networking API shape, with IPv6 --- OMICRON_VERSION | 2 +- app/api/__generated__/Api.ts | 253 +++++++++++++++--- app/api/__generated__/OMICRON_VERSION | 2 +- app/api/__generated__/validate.ts | 202 ++++++++++++-- app/components/AttachEphemeralIpModal.tsx | 7 +- app/forms/floating-ip-create.tsx | 22 +- app/forms/instance-create.tsx | 6 +- app/forms/network-interface-create.tsx | 26 +- app/forms/network-interface-edit.tsx | 18 +- app/pages/project/instances/NetworkingTab.tsx | 34 ++- app/util/ip-stack.ts | 31 +++ mock-api/msw/db.ts | 7 + mock-api/msw/handlers.ts | 118 ++++++-- mock-api/network-interface.ts | 9 +- test/e2e/instance-networking.e2e.ts | 2 +- 15 files changed, 636 insertions(+), 103 deletions(-) create mode 100644 app/util/ip-stack.ts diff --git a/OMICRON_VERSION b/OMICRON_VERSION index cd93756d9..2a5ae4903 100644 --- a/OMICRON_VERSION +++ b/OMICRON_VERSION @@ -1 +1 @@ -dd74446cbe12d52540d92b62f2de7eaf6520d591 +135be59591f1ba4bc5941f63bf3a08a0b187b1a9 diff --git a/app/api/__generated__/Api.ts b/app/api/__generated__/Api.ts index d3abd0aec..db95aa333 100644 --- a/app/api/__generated__/Api.ts +++ b/app/api/__generated__/Api.ts @@ -170,6 +170,49 @@ export type AddressLotViewResponse = { lot: AddressLot } +/** + * The IP address version. + */ +export type IpVersion = 'v4' | 'v6' + +/** + * Specify which IP pool to allocate from. + */ +export type PoolSelector = + /** Use the specified pool by name or ID. */ + | { + /** The pool to allocate from. */ + pool: NameOrId + type: 'explicit' + } + /** Use the default pool for the silo. */ + | { + /** IP version to use when multiple default pools exist. Required if both IPv4 and IPv6 default pools are configured. */ + ipVersion?: IpVersion | null + type: 'auto' + } + +/** + * Specify how to allocate a floating IP address. + */ +export type AddressSelector = + /** Reserve a specific IP address. */ + | { + /** The IP address to reserve. Must be available in the pool. */ + ip: string + /** The pool containing this address. If not specified, the default pool for the address's IP version is used. */ + pool?: NameOrId | null + type: 'explicit' + } + /** Automatically allocate an IP address from a specified pool. */ + | { + /** Pool selection. + +If omitted, this field uses the silo's default pool. If the silo has default pools for both IPv4 and IPv6, the request will fail unless `ip_version` is specified in the pool selector. */ + poolSelector?: PoolSelector + type: 'auto' + } + /** * Describes the scope of affinity for the purposes of co-location. */ @@ -1934,8 +1977,8 @@ export type Distributionint64 = { * Parameters for creating an ephemeral IP address for an instance. */ export type EphemeralIpCreate = { - /** Name or ID of the IP pool used to allocate an address. If unspecified, the default IP pool will be used. */ - pool?: NameOrId | null + /** Pool to allocate from. */ + poolSelector?: PoolSelector } export type ExternalIp = @@ -1981,8 +2024,12 @@ SNAT addresses are ephemeral addresses used only for outbound connectivity. */ * Parameters for creating an external IP address for instances. */ export type ExternalIpCreate = - /** An IP address providing both inbound and outbound access. The address is automatically assigned from the provided IP pool or the default IP pool if not specified. */ - | { pool?: NameOrId | null; type: 'ephemeral' } + /** An IP address providing both inbound and outbound access. The address is automatically assigned from a pool. */ + | { + /** Pool to allocate from. */ + poolSelector?: PoolSelector + type: 'ephemeral' + } /** An IP address providing both inbound and outbound access. The address is an existing floating IP object assigned to the current project. The floating IP must not be in use by another instance or service. */ @@ -2126,12 +2173,10 @@ export type FloatingIpAttach = { * Parameters for creating a new floating IP address for instances. */ export type FloatingIpCreate = { + /** IP address allocation method. */ + addressSelector?: AddressSelector description: string - /** An IP address to reserve for use as a floating IP. This field is optional: when not set, an address will be automatically chosen from `pool`. If set, then the IP must be available in the resolved `pool`. */ - ip?: string | null name: Name - /** The parent IP pool that a floating IP is pulled from. If unset, the default pool is selected. */ - pool?: NameOrId | null } /** @@ -2382,18 +2427,70 @@ export type InstanceDiskAttachment = type: 'attach' } +/** + * How a VPC-private IP address is assigned to a network interface. + */ +export type Ipv4Assignment = + /** Automatically assign an IP address from the VPC Subnet. */ + | { type: 'auto' } + /** Explicitly assign a specific address, if available. */ + | { type: 'explicit'; value: string } + +/** + * Configuration for a network interface's IPv4 addressing. + */ +export type PrivateIpv4StackCreate = { + /** The VPC-private address to assign to the interface. */ + ip: Ipv4Assignment + /** Additional IP networks the interface can send / receive on. */ + transitIps?: Ipv4Net[] +} + +/** + * How a VPC-private IP address is assigned to a network interface. + */ +export type Ipv6Assignment = + /** Automatically assign an IP address from the VPC Subnet. */ + | { type: 'auto' } + /** Explicitly assign a specific address, if available. */ + | { type: 'explicit'; value: string } + +/** + * Configuration for a network interface's IPv6 addressing. + */ +export type PrivateIpv6StackCreate = { + /** The VPC-private address to assign to the interface. */ + ip: Ipv6Assignment + /** Additional IP networks the interface can send / receive on. */ + transitIps?: Ipv6Net[] +} + +/** + * Create parameters for a network interface's IP stack. + */ +export type PrivateIpStackCreate = + /** The interface has only an IPv4 stack. */ + | { type: 'v4'; value: PrivateIpv4StackCreate } + /** The interface has only an IPv6 stack. */ + | { type: 'v6'; value: PrivateIpv6StackCreate } + /** The interface has both an IPv4 and IPv6 stack. */ + | { + type: 'dual_stack' + value: { v4: PrivateIpv4StackCreate; v6: PrivateIpv6StackCreate } + } + /** * Create-time parameters for an `InstanceNetworkInterface` */ export type InstanceNetworkInterfaceCreate = { description: string - /** The IP address for the interface. One will be auto-assigned if not provided. */ - ip?: string | null + /** The IP stack configuration for this interface. + +If not provided, a default configuration will be used, which creates a dual-stack IPv4 / IPv6 interface. */ + ipConfig?: PrivateIpStackCreate name: Name /** The VPC Subnet in which to create the interface. */ subnetName: Name - /** A set of additional networks that this interface may send and receive traffic on. */ - transitIps?: IpNet[] /** The VPC in which to create the interface. */ vpcName: Name } @@ -2406,8 +2503,18 @@ export type InstanceNetworkInterfaceAttachment = If more than one interface is provided, then the first will be designated the primary interface for the instance. */ | { params: InstanceNetworkInterfaceCreate[]; type: 'create' } - /** The default networking configuration for an instance is to create a single primary interface with an automatically-assigned IP address. The IP will be pulled from the Project's default VPC / VPC Subnet. */ - | { type: 'default' } + /** Create a single primary interface with an automatically-assigned IPv4 address. + +The IP will be pulled from the Project's default VPC / VPC Subnet. */ + | { type: 'default_ipv4' } + /** Create a single primary interface with an automatically-assigned IPv6 address. + +The IP will be pulled from the Project's default VPC / VPC Subnet. */ + | { type: 'default_ipv6' } + /** Create a single primary interface with automatically-assigned IPv4 and IPv6 addresses. + +The IPs will be pulled from the Project's default VPC / VPC Subnet. */ + | { type: 'default_dual_stack' } /** No network interfaces at all will be created for the instance. */ | { type: 'none' } @@ -2467,6 +2574,37 @@ If not provided, all SSH public keys from the user's profile will be sent. If an userData?: string } +/** + * The VPC-private IPv4 stack for a network interface + */ +export type PrivateIpv4Stack = { + /** The VPC-private IPv4 address for the interface. */ + ip: string + /** A set of additional IPv4 networks that this interface may send and receive traffic on. */ + transitIps: Ipv4Net[] +} + +/** + * The VPC-private IPv6 stack for a network interface + */ +export type PrivateIpv6Stack = { + /** The VPC-private IPv6 address for the interface. */ + ip: string + /** A set of additional IPv6 networks that this interface may send and receive traffic on. */ + transitIps: Ipv6Net[] +} + +/** + * The VPC-private IP stack for a network interface. + */ +export type PrivateIpStack = + /** The interface has only an IPv4 stack. */ + | { type: 'v4'; value: PrivateIpv4Stack } + /** The interface has only an IPv6 stack. */ + | { type: 'v6'; value: PrivateIpv6Stack } + /** The interface is dual-stack IPv4 and IPv6. */ + | { type: 'dual_stack'; value: { v4: PrivateIpv4Stack; v6: PrivateIpv6Stack } } + /** * A MAC address * @@ -2484,8 +2622,8 @@ export type InstanceNetworkInterface = { id: string /** The Instance to which the interface belongs. */ instanceId: string - /** The IP address assigned to this interface. */ - ip: string + /** The VPC-private IP stack for this interface. */ + ipStack: PrivateIpStack /** The MAC address assigned to this interface. */ mac: MacAddr /** unique, mutable, user-controlled identifier for each resource */ @@ -2498,8 +2636,6 @@ export type InstanceNetworkInterface = { timeCreated: Date /** timestamp when this resource was last modified */ timeModified: Date - /** A set of additional networks that this interface may send and receive traffic on. */ - transitIps?: IpNet[] /** The VPC to which the interface belongs. */ vpcId: string } @@ -2528,7 +2664,7 @@ If applied to a secondary interface, that interface will become the primary on t Note that this can only be used to select a new primary interface for an instance. Requests to change the primary interface into a secondary will return an error. */ primary?: boolean - /** A set of additional networks that this interface may send and receive traffic on. */ + /** A set of additional networks that this interface may send and receive traffic on */ transitIps?: IpNet[] } @@ -2698,11 +2834,6 @@ export type InternetGatewayResultsPage = { nextPage?: string | null } -/** - * The IP address version. - */ -export type IpVersion = 'v4' | 'v6' - /** * Type of IP pool. */ @@ -2727,7 +2858,7 @@ export type IpPool = { ipVersion: IpVersion /** unique, mutable, user-controlled identifier for each resource */ name: Name - /** Type of IP pool (unicast or multicast) */ + /** Type of IP pool (unicast or multicast). */ poolType: IpPoolType /** timestamp when this resource was created */ timeCreated: Date @@ -2754,7 +2885,9 @@ The default is IPv4. */ } export type IpPoolLinkSilo = { - /** When a pool is the default for a silo, floating IPs and instance ephemeral IPs will come from that pool when no other pool is specified. There can be at most one default for a given silo. */ + /** When a pool is the default for a silo, floating IPs and instance ephemeral IPs will come from that pool when no other pool is specified. + +A silo can have at most one default pool per combination of pool type (unicast or multicast) and IP version (IPv4 or IPv6), allowing up to 4 default pools total. */ isDefault: boolean silo: NameOrId } @@ -2807,7 +2940,9 @@ export type IpPoolResultsPage = { */ export type IpPoolSiloLink = { ipPoolId: string - /** When a pool is the default for a silo, floating IPs and instance ephemeral IPs will come from that pool when no other pool is specified. There can be at most one default for a given silo. */ + /** When a pool is the default for a silo, floating IPs and instance ephemeral IPs will come from that pool when no other pool is specified. + +A silo can have at most one default pool per combination of pool type (unicast or multicast) and IP version (IPv4 or IPv6), allowing up to 4 default pools total. */ isDefault: boolean siloId: string } @@ -2823,7 +2958,9 @@ export type IpPoolSiloLinkResultsPage = { } export type IpPoolSiloUpdate = { - /** When a pool is the default for a silo, floating IPs and instance ephemeral IPs will come from that pool when no other pool is specified. There can be at most one default for a given silo, so when a pool is made default, an existing default will remain linked but will no longer be the default. */ + /** When a pool is the default for a silo, floating IPs and instance ephemeral IPs will come from that pool when no other pool is specified. + +A silo can have at most one default pool per combination of pool type (unicast or multicast) and IP version (IPv4 or IPv6), allowing up to 4 default pools total. When a pool is made default, an existing default of the same type and version will remain linked but will no longer be the default. */ isDefault: boolean } @@ -3194,6 +3331,49 @@ export type MulticastGroupUpdate = { sourceIps?: string[] | null } +/** + * VPC-private IPv4 configuration for a network interface. + */ +export type PrivateIpv4Config = { + /** VPC-private IP address. */ + ip: string + /** The IP subnet. */ + subnet: Ipv4Net + /** Additional networks on which the interface can send / receive traffic. */ + transitIps?: Ipv4Net[] +} + +/** + * VPC-private IPv6 configuration for a network interface. + */ +export type PrivateIpv6Config = { + /** VPC-private IP address. */ + ip: string + /** The IP subnet. */ + subnet: Ipv6Net + /** Additional networks on which the interface can send / receive traffic. */ + transitIps: Ipv6Net[] +} + +/** + * VPC-private IP address configuration for a network interface. + */ +export type PrivateIpConfig = + /** The interface has only an IPv4 configuration. */ + | { type: 'v4'; value: PrivateIpv4Config } + /** The interface has only an IPv6 configuration. */ + | { type: 'v6'; value: PrivateIpv6Config } + /** The interface is dual-stack. */ + | { + type: 'dual_stack' + value: { + /** The interface's IPv4 configuration. */ + v4: PrivateIpv4Config + /** The interface's IPv6 configuration. */ + v6: PrivateIpv6Config + } + } + /** * The type of network interface */ @@ -3215,14 +3395,12 @@ export type Vni = number */ export type NetworkInterface = { id: string - ip: string + ipConfig: PrivateIpConfig kind: NetworkInterfaceKind mac: MacAddr name: Name primary: boolean slot: number - subnet: IpNet - transitIps?: IpNet[] vni: Vni } @@ -3381,8 +3559,9 @@ export type Probe = { */ export type ProbeCreate = { description: string - ipPool?: NameOrId | null name: Name + /** Pool to allocate from. */ + poolSelector?: PoolSelector sled: string } @@ -3820,10 +3999,16 @@ export type SiloIpPool = { description: string /** unique, immutable, system-controlled identifier for each resource */ id: string - /** When a pool is the default for a silo, floating IPs and instance ephemeral IPs will come from that pool when no other pool is specified. There can be at most one default for a given silo. */ + /** The IP version for the pool. */ + ipVersion: IpVersion + /** When a pool is the default for a silo, floating IPs and instance ephemeral IPs will come from that pool when no other pool is specified. + +A silo can have at most one default pool per combination of pool type (unicast or multicast) and IP version (IPv4 or IPv6), allowing up to 4 default pools total. */ isDefault: boolean /** unique, mutable, user-controlled identifier for each resource */ name: Name + /** Type of IP pool (unicast or multicast). */ + poolType: IpPoolType /** timestamp when this resource was created */ timeCreated: Date /** timestamp when this resource was last modified */ @@ -6865,7 +7050,7 @@ export class Api { * Pulled from info.version in the OpenAPI schema. Sent in the * `api-version` header on all requests. */ - apiVersion = '2025121200.0.0' + apiVersion = '2026010500.0.0' constructor({ host = '', baseParams = {}, token }: ApiConfig = {}) { this.host = host diff --git a/app/api/__generated__/OMICRON_VERSION b/app/api/__generated__/OMICRON_VERSION index ee1f8feeb..cc761a204 100644 --- a/app/api/__generated__/OMICRON_VERSION +++ b/app/api/__generated__/OMICRON_VERSION @@ -1,2 +1,2 @@ # generated file. do not update manually. see docs/update-pinned-api.md -dd74446cbe12d52540d92b62f2de7eaf6520d591 +135be59591f1ba4bc5941f63bf3a08a0b187b1a9 diff --git a/app/api/__generated__/validate.ts b/app/api/__generated__/validate.ts index 6b776098e..579588256 100644 --- a/app/api/__generated__/validate.ts +++ b/app/api/__generated__/validate.ts @@ -174,6 +174,43 @@ export const AddressLotViewResponse = z.preprocess( z.object({ blocks: AddressLotBlock.array(), lot: AddressLot }) ) +/** + * The IP address version. + */ +export const IpVersion = z.preprocess(processResponseBody, z.enum(['v4', 'v6'])) + +/** + * Specify which IP pool to allocate from. + */ +export const PoolSelector = z.preprocess( + processResponseBody, + z.union([ + z.object({ pool: NameOrId, type: z.enum(['explicit']) }), + z.object({ + ipVersion: IpVersion.nullable().default(null).optional(), + type: z.enum(['auto']), + }), + ]) +) + +/** + * Specify how to allocate a floating IP address. + */ +export const AddressSelector = z.preprocess( + processResponseBody, + z.union([ + z.object({ + ip: z.ipv4(), + pool: NameOrId.nullable().optional(), + type: z.enum(['explicit']), + }), + z.object({ + poolSelector: PoolSelector.default({ ipVersion: null, type: 'auto' }).optional(), + type: z.enum(['auto']), + }), + ]) +) + /** * Describes the scope of affinity for the purposes of co-location. */ @@ -1778,7 +1815,9 @@ export const Distributionint64 = z.preprocess( */ export const EphemeralIpCreate = z.preprocess( processResponseBody, - z.object({ pool: NameOrId.nullable().optional() }) + z.object({ + poolSelector: PoolSelector.default({ ipVersion: null, type: 'auto' }).optional(), + }) ) /** @@ -1821,7 +1860,10 @@ export const ExternalIp = z.preprocess( export const ExternalIpCreate = z.preprocess( processResponseBody, z.union([ - z.object({ pool: NameOrId.nullable().optional(), type: z.enum(['ephemeral']) }), + z.object({ + poolSelector: PoolSelector.default({ ipVersion: null, type: 'auto' }).optional(), + type: z.enum(['ephemeral']), + }), z.object({ floatingIp: NameOrId, type: z.enum(['floating']) }), ]) ) @@ -1972,10 +2014,12 @@ export const FloatingIpAttach = z.preprocess( export const FloatingIpCreate = z.preprocess( processResponseBody, z.object({ + addressSelector: AddressSelector.default({ + poolSelector: { ipVersion: null, type: 'auto' }, + type: 'auto', + }).optional(), description: z.string(), - ip: z.ipv4().nullable().optional(), name: Name, - pool: NameOrId.nullable().optional(), }) ) @@ -2212,6 +2256,59 @@ export const InstanceDiskAttachment = z.preprocess( ]) ) +/** + * How a VPC-private IP address is assigned to a network interface. + */ +export const Ipv4Assignment = z.preprocess( + processResponseBody, + z.union([ + z.object({ type: z.enum(['auto']) }), + z.object({ type: z.enum(['explicit']), value: z.ipv4() }), + ]) +) + +/** + * Configuration for a network interface's IPv4 addressing. + */ +export const PrivateIpv4StackCreate = z.preprocess( + processResponseBody, + z.object({ ip: Ipv4Assignment, transitIps: Ipv4Net.array().default([]).optional() }) +) + +/** + * How a VPC-private IP address is assigned to a network interface. + */ +export const Ipv6Assignment = z.preprocess( + processResponseBody, + z.union([ + z.object({ type: z.enum(['auto']) }), + z.object({ type: z.enum(['explicit']), value: z.ipv6() }), + ]) +) + +/** + * Configuration for a network interface's IPv6 addressing. + */ +export const PrivateIpv6StackCreate = z.preprocess( + processResponseBody, + z.object({ ip: Ipv6Assignment, transitIps: Ipv6Net.array().default([]).optional() }) +) + +/** + * Create parameters for a network interface's IP stack. + */ +export const PrivateIpStackCreate = z.preprocess( + processResponseBody, + z.union([ + z.object({ type: z.enum(['v4']), value: PrivateIpv4StackCreate }), + z.object({ type: z.enum(['v6']), value: PrivateIpv6StackCreate }), + z.object({ + type: z.enum(['dual_stack']), + value: z.object({ v4: PrivateIpv4StackCreate, v6: PrivateIpv6StackCreate }), + }), + ]) +) + /** * Create-time parameters for an `InstanceNetworkInterface` */ @@ -2219,10 +2316,15 @@ export const InstanceNetworkInterfaceCreate = z.preprocess( processResponseBody, z.object({ description: z.string(), - ip: z.ipv4().nullable().optional(), + ipConfig: PrivateIpStackCreate.default({ + type: 'dual_stack', + value: { + v4: { ip: { type: 'auto' }, transitIps: [] }, + v6: { ip: { type: 'auto' }, transitIps: [] }, + }, + }).optional(), name: Name, subnetName: Name, - transitIps: IpNet.array().default([]).optional(), vpcName: Name, }) ) @@ -2234,7 +2336,9 @@ export const InstanceNetworkInterfaceAttachment = z.preprocess( processResponseBody, z.union([ z.object({ params: InstanceNetworkInterfaceCreate.array(), type: z.enum(['create']) }), - z.object({ type: z.enum(['default']) }), + z.object({ type: z.enum(['default_ipv4']) }), + z.object({ type: z.enum(['default_ipv6']) }), + z.object({ type: z.enum(['default_dual_stack']) }), z.object({ type: z.enum(['none']) }), ]) ) @@ -2258,7 +2362,7 @@ export const InstanceCreate = z.preprocess( name: Name, ncpus: InstanceCpuCount, networkInterfaces: InstanceNetworkInterfaceAttachment.default({ - type: 'default', + type: 'default_dual_stack', }).optional(), sshPublicKeys: NameOrId.array().nullable().optional(), start: SafeBoolean.default(true).optional(), @@ -2266,6 +2370,37 @@ export const InstanceCreate = z.preprocess( }) ) +/** + * The VPC-private IPv4 stack for a network interface + */ +export const PrivateIpv4Stack = z.preprocess( + processResponseBody, + z.object({ ip: z.ipv4(), transitIps: Ipv4Net.array() }) +) + +/** + * The VPC-private IPv6 stack for a network interface + */ +export const PrivateIpv6Stack = z.preprocess( + processResponseBody, + z.object({ ip: z.ipv6(), transitIps: Ipv6Net.array() }) +) + +/** + * The VPC-private IP stack for a network interface. + */ +export const PrivateIpStack = z.preprocess( + processResponseBody, + z.union([ + z.object({ type: z.enum(['v4']), value: PrivateIpv4Stack }), + z.object({ type: z.enum(['v6']), value: PrivateIpv6Stack }), + z.object({ + type: z.enum(['dual_stack']), + value: z.object({ v4: PrivateIpv4Stack, v6: PrivateIpv6Stack }), + }), + ]) +) + /** * A MAC address * @@ -2289,14 +2424,13 @@ export const InstanceNetworkInterface = z.preprocess( description: z.string(), id: z.uuid(), instanceId: z.uuid(), - ip: z.ipv4(), + ipStack: PrivateIpStack, mac: MacAddr, name: Name, primary: SafeBoolean, subnetId: z.uuid(), timeCreated: z.coerce.date(), timeModified: z.coerce.date(), - transitIps: IpNet.array().default([]).optional(), vpcId: z.uuid(), }) ) @@ -2468,11 +2602,6 @@ export const InternetGatewayResultsPage = z.preprocess( z.object({ items: InternetGateway.array(), nextPage: z.string().nullable().optional() }) ) -/** - * The IP address version. - */ -export const IpVersion = z.preprocess(processResponseBody, z.enum(['v4', 'v6'])) - /** * Type of IP pool. */ @@ -2904,6 +3033,41 @@ export const MulticastGroupUpdate = z.preprocess( }) ) +/** + * VPC-private IPv4 configuration for a network interface. + */ +export const PrivateIpv4Config = z.preprocess( + processResponseBody, + z.object({ + ip: z.ipv4(), + subnet: Ipv4Net, + transitIps: Ipv4Net.array().default([]).optional(), + }) +) + +/** + * VPC-private IPv6 configuration for a network interface. + */ +export const PrivateIpv6Config = z.preprocess( + processResponseBody, + z.object({ ip: z.ipv6(), subnet: Ipv6Net, transitIps: Ipv6Net.array() }) +) + +/** + * VPC-private IP address configuration for a network interface. + */ +export const PrivateIpConfig = z.preprocess( + processResponseBody, + z.union([ + z.object({ type: z.enum(['v4']), value: PrivateIpv4Config }), + z.object({ type: z.enum(['v6']), value: PrivateIpv6Config }), + z.object({ + type: z.enum(['dual_stack']), + value: z.object({ v4: PrivateIpv4Config, v6: PrivateIpv6Config }), + }), + ]) +) + /** * The type of network interface */ @@ -2928,14 +3092,12 @@ export const NetworkInterface = z.preprocess( processResponseBody, z.object({ id: z.uuid(), - ip: z.ipv4(), + ipConfig: PrivateIpConfig, kind: NetworkInterfaceKind, mac: MacAddr, name: Name, primary: SafeBoolean, slot: z.number().min(0).max(255), - subnet: IpNet, - transitIps: IpNet.array().default([]).optional(), vni: Vni, }) ) @@ -3097,8 +3259,8 @@ export const ProbeCreate = z.preprocess( processResponseBody, z.object({ description: z.string(), - ipPool: NameOrId.nullable().optional(), name: Name, + poolSelector: PoolSelector.default({ ipVersion: null, type: 'auto' }).optional(), sled: z.uuid(), }) ) @@ -3498,8 +3660,10 @@ export const SiloIpPool = z.preprocess( z.object({ description: z.string(), id: z.uuid(), + ipVersion: IpVersion, isDefault: SafeBoolean, name: Name, + poolType: IpPoolType, timeCreated: z.coerce.date(), timeModified: z.coerce.date(), }) diff --git a/app/components/AttachEphemeralIpModal.tsx b/app/components/AttachEphemeralIpModal.tsx index af8a384c1..24f8aec03 100644 --- a/app/components/AttachEphemeralIpModal.tsx +++ b/app/components/AttachEphemeralIpModal.tsx @@ -65,13 +65,14 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) + onAction={() => { + if (!pool) return instanceEphemeralIpAttach.mutate({ path: { instance }, query: { project }, - body: { pool }, + body: { poolSelector: { type: 'explicit', pool } }, }) - } + }} onDismiss={onDismiss} > diff --git a/app/forms/floating-ip-create.tsx b/app/forms/floating-ip-create.tsx index 5ff9086f7..4ff110568 100644 --- a/app/forms/floating-ip-create.tsx +++ b/app/forms/floating-ip-create.tsx @@ -27,10 +27,10 @@ import { Message } from '~/ui/lib/Message' import { ALL_ISH } from '~/util/consts' import { pb } from '~/util/path-builder' -const defaultValues: Omit = { +const defaultValues: FloatingIpCreate = { name: '', description: '', - pool: undefined, + addressSelector: undefined, } export const handle = titleCrumb('New Floating IP') @@ -65,7 +65,21 @@ export default function CreateFloatingIpSideModalForm() { formType="create" resourceName="floating IP" onDismiss={() => navigate(pb.floatingIps(projectSelector))} - onSubmit={(body) => createFloatingIp.mutate({ query: projectSelector, body })} + onSubmit={(values) => { + // Transform the form values to properly construct addressSelector + const pool = form.getValues('addressSelector.poolSelector.pool' as any) + const body = { + name: values.name, + description: values.description, + addressSelector: pool + ? { + type: 'auto' as const, + poolSelector: { type: 'explicit' as const, pool }, + } + : undefined, + } + createFloatingIp.mutate({ query: projectSelector, body }) + }} loading={createFloatingIp.isPending} submitError={createFloatingIp.error} > @@ -89,7 +103,7 @@ export default function CreateFloatingIpSideModalForm() { /> , 'ip'> = { +const defaultValues = { name: '', description: '', - ip: '', subnetName: '', vpcName: '', + ip: '', } type CreateNetworkInterfaceFormProps = { @@ -60,7 +59,19 @@ export function CreateNetworkInterfaceForm({ resourceName="network interface" title="Add network interface" onDismiss={onDismiss} - onSubmit={({ ip, ...rest }) => onSubmit({ ip: ip.trim() || undefined, ...rest })} + onSubmit={({ ip, ...rest }) => { + // Transform to IPv4 ipConfig structure + const ipConfig = ip.trim() + ? { + type: 'v4' as const, + value: { + ip: { type: 'explicit' as const, value: ip.trim() }, + transitIps: [], + }, + } + : undefined + onSubmit({ ...rest, ipConfig }) + }} loading={loading} submitError={submitError} > @@ -83,7 +94,12 @@ export function CreateNetworkInterfaceForm({ required control={form.control} /> - + ) } diff --git a/app/forms/network-interface-edit.tsx b/app/forms/network-interface-edit.tsx index cc7d2a482..431815a70 100644 --- a/app/forms/network-interface-edit.tsx +++ b/app/forms/network-interface-edit.tsx @@ -7,7 +7,7 @@ */ import { useEffect } from 'react' import { useForm } from 'react-hook-form' -import * as R from 'remeda' + import { api, @@ -52,11 +52,17 @@ export function EditNetworkInterfaceForm({ }, }) - const defaultValues = R.pick(editing, [ - 'name', - 'description', - 'transitIps', - ]) satisfies InstanceNetworkInterfaceUpdate + // Extract transitIps from ipStack for the form + const extractedTransitIps = + editing.ipStack.type === 'dual_stack' + ? editing.ipStack.value.v4.transitIps + : editing.ipStack.value.transitIps + + const defaultValues = { + name: editing.name, + description: editing.description, + transitIps: extractedTransitIps, + } satisfies InstanceNetworkInterfaceUpdate const form = useForm({ defaultValues }) const transitIps = form.watch('transitIps') || [] diff --git a/app/pages/project/instances/NetworkingTab.tsx b/app/pages/project/instances/NetworkingTab.tsx index e968ea31f..6f0395121 100644 --- a/app/pages/project/instances/NetworkingTab.tsx +++ b/app/pages/project/instances/NetworkingTab.tsx @@ -152,9 +152,15 @@ const staticCols = [ ), }), colHelper.accessor('description', Columns.description), - colHelper.accessor('ip', { + colHelper.display({ + id: 'ip', header: 'Private IP', - cell: (info) => , + cell: (info) => { + const nic = info.row.original + const ip = + nic.ipStack.type === 'dual_stack' ? nic.ipStack.value.v4.ip : nic.ipStack.value.ip + return + }, }), colHelper.accessor('vpcId', { header: 'vpc', @@ -164,15 +170,23 @@ const staticCols = [ header: 'subnet', cell: (info) => , }), - colHelper.accessor('transitIps', { + colHelper.display({ + id: 'transitIps', header: 'Transit IPs', - cell: (info) => ( - - {info.getValue()?.map((ip) => ( -
{ip}
- ))} -
- ), + cell: (info) => { + const nic = info.row.original + const transitIps = + nic.ipStack.type === 'dual_stack' + ? nic.ipStack.value.v4.transitIps + : nic.ipStack.value.transitIps + return ( + + {transitIps?.map((ip) => ( +
{ip}
+ ))} +
+ ) + }, }), ] diff --git a/app/util/ip-stack.ts b/app/util/ip-stack.ts new file mode 100644 index 000000000..d26461112 --- /dev/null +++ b/app/util/ip-stack.ts @@ -0,0 +1,31 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import type { InstanceNetworkInterface } from '@oxide/api' + +/** + * Extract the primary IP address from an instance network interface's IP stack. + * For dual-stack, returns the IPv4 address. + */ +export function getIpFromStack(nic: InstanceNetworkInterface): string { + if (nic.ipStack.type === 'dual_stack') { + return nic.ipStack.value.v4.ip + } + return nic.ipStack.value.ip +} + +/** + * Extract transit IPs from an instance network interface's IP stack. + * For dual-stack, returns the IPv4 transit IPs (since transit IPs are generally version-agnostic in the UI). + */ +export function getTransitIpsFromStack(nic: InstanceNetworkInterface): string[] { + if (nic.ipStack.type === 'dual_stack') { + return nic.ipStack.value.v4.transitIps + } + return nic.ipStack.value.transitIps +} diff --git a/mock-api/msw/db.ts b/mock-api/msw/db.ts index 95ce6718d..7262cbb36 100644 --- a/mock-api/msw/db.ts +++ b/mock-api/msw/db.ts @@ -62,6 +62,13 @@ function ensureNoParentSelectors( export const resolveIpPool = (pool: string | undefined | null) => pool ? lookup.ipPool({ pool }) : lookup.siloDefaultIpPool({ silo: defaultSilo.id }) +export const resolvePoolSelector = ( + poolSelector: { pool: string; type: 'explicit' } | { type: 'auto' } | undefined +) => + poolSelector?.type === 'explicit' + ? lookup.ipPool({ pool: poolSelector.pool }) + : lookup.siloDefaultIpPool({ silo: defaultSilo.id }) + export const getIpFromPool = (pool: Json) => { const ipPoolRange = db.ipPoolRanges.find((range) => range.ip_pool_id === pool.id) if (!ipPoolRange) throw notFoundErr(`IP range for pool '${pool.name}'`) diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 3f678f5c2..42f2f9af0 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -42,7 +42,8 @@ import { lookup, lookupById, notFoundErr, - resolveIpPool, + + resolvePoolSelector, utilizationForSilo, } from './db' import { @@ -72,6 +73,56 @@ import { // client camel-cases the keys and parses date fields. Inside the mock API everything // is *JSON type. +// Helper to resolve IP assignment to actual IP string +const resolveIp = ( + assignment: { type: 'auto' } | { type: 'explicit'; value: string }, + defaultIp = '127.0.0.1' +) => (assignment.type === 'explicit' ? assignment.value : defaultIp) + +// Convert PrivateIpStackCreate to PrivateIpStack +const resolveIpStack = ( + config: + | { type: 'v4'; value: Api.PrivateIpv4StackCreate } + | { type: 'v6'; value: Api.PrivateIpv6StackCreate } + | { + type: 'dual_stack' + value: { v4: Api.PrivateIpv4StackCreate; v6: Api.PrivateIpv6StackCreate } + }, + defaultIp = '127.0.0.1' +): + | { type: 'v4'; value: { ip: string; transit_ips: string[] } } + | { type: 'v6'; value: { ip: string; transit_ips: string[] } } + | { + type: 'dual_stack' + value: { + v4: { ip: string; transit_ips: string[] } + v6: { ip: string; transit_ips: string[] } + } + } => { + if (config.type === 'dual_stack') { + return { + type: 'dual_stack', + value: { + v4: { + ip: resolveIp(config.value.v4.ip, defaultIp), + transit_ips: config.value.v4.transitIps || [], + }, + v6: { + ip: resolveIp(config.value.v6.ip, defaultIp), + transit_ips: config.value.v6.transitIps || [], + }, + }, + } + } + return { + type: config.type, + value: { + ip: resolveIp(config.value.ip, defaultIp), + transit_ips: config.value.transitIps || [], + }, + } +} + export const handlers = makeHandlers({ logout: () => 204, ping: () => ({ status: 'ok' }), @@ -254,16 +305,23 @@ export const handlers = makeHandlers({ errIfExists(db.floatingIps, { name: body.name, project_id: project.id }) // TODO: when IP is specified, use ipInAnyRange to check that it is in the pool - const pool = body.pool - ? lookup.siloIpPool({ pool: body.pool, silo: defaultSilo.id }) - : lookup.siloDefaultIpPool({ silo: defaultSilo.id }) + const addressSelector = body.address_selector || { type: 'auto' } + const pool = + addressSelector.type === 'explicit' && addressSelector.pool + ? lookup.siloIpPool({ pool: addressSelector.pool, silo: defaultSilo.id }) + : addressSelector.type === 'auto' && addressSelector.pool_selector?.type === 'explicit' + ? lookup.siloIpPool({ + pool: addressSelector.pool_selector.pool, + silo: defaultSilo.id, + }) + : lookup.siloDefaultIpPool({ silo: defaultSilo.id }) const newFloatingIp: Json = { id: uuid(), project_id: project.id, // TODO: use ip-num to actually get the next available IP in the pool ip: - body.ip || + (addressSelector.type === 'explicit' && addressSelector.ip) || Array.from({ length: 4 }) .map(() => Math.floor(Math.random() * 256)) .join('.'), @@ -473,7 +531,7 @@ export const handlers = makeHandlers({ // if there are no ranges in the pool or if the pool doesn't exist, // which aren't quite as good as checking that there are actually IPs // available, but they are good things to check - const pool = resolveIpPool(ip.pool) + const pool = resolvePoolSelector(ip.pool_selector) getIpFromPool(pool) } }) @@ -517,14 +575,30 @@ export const handlers = makeHandlers({ // a hack but not very important const anyVpc = db.vpcs.find((v) => v.project_id === project.id) const anySubnet = db.vpcSubnets.find((s) => s.vpc_id === anyVpc?.id) - if (body.network_interfaces?.type === 'default' && anyVpc && anySubnet) { + const niType = body.network_interfaces?.type + if ( + (niType === 'default_ipv4' || niType === 'default_ipv6' || niType === 'default_dual_stack') && + anyVpc && + anySubnet + ) { db.networkInterfaces.push({ id: uuid(), description: 'The default network interface', instance_id: instanceId, primary: true, mac: '00:00:00:00:00:00', - ip: '127.0.0.1', + ip_stack: + niType === 'default_dual_stack' + ? { + type: 'dual_stack', + value: { + v4: { ip: '127.0.0.1', transit_ips: [] }, + v6: { ip: '::1', transit_ips: [] }, + }, + } + : niType === 'default_ipv6' + ? { type: 'v6', value: { ip: '::1', transit_ips: [] } } + : { type: 'v4', value: { ip: '127.0.0.1', transit_ips: [] } }, name: 'default', vpc_id: anyVpc.id, subnet_id: anySubnet.id, @@ -532,7 +606,7 @@ export const handlers = makeHandlers({ }) } else if (body.network_interfaces?.type === 'create') { body.network_interfaces.params.forEach( - ({ name, description, ip, subnet_name, vpc_name }, i) => { + ({ name, description, ip_config, subnet_name, vpc_name }, i) => { db.networkInterfaces.push({ id: uuid(), name, @@ -540,7 +614,12 @@ export const handlers = makeHandlers({ instance_id: instanceId, primary: i === 0 ? true : false, mac: '00:00:00:00:00:00', - ip: ip || '127.0.0.1', + ip_stack: ip_config + ? resolveIpStack(ip_config) + : { + type: 'v4', + value: { ip: '127.0.0.1', transit_ips: [] }, + }, vpc_id: lookup.vpc({ ...query, vpc: vpc_name }).id, subnet_id: lookup.vpcSubnet({ ...query, vpc: vpc_name, subnet: subnet_name }) .id, @@ -561,7 +640,7 @@ export const handlers = makeHandlers({ // we've already validated that the IP isn't attached floatingIp.instance_id = instanceId } else if (ip.type === 'ephemeral') { - const pool = resolveIpPool(ip.pool) + const pool = resolvePoolSelector(ip.pool_selector) const firstAvailableAddress = getIpFromPool(pool) db.ephemeralIps.push({ @@ -743,7 +822,7 @@ export const handlers = makeHandlers({ }, instanceEphemeralIpAttach({ path, query: projectParams, body }) { const instance = lookup.instance({ ...path, ...projectParams }) - const pool = resolveIpPool(body.pool) + const pool = resolvePoolSelector(body.pool_selector) const ip = getIpFromPool(pool) const externalIp = { ip, ip_pool_id: pool.id, kind: 'ephemeral' as const } @@ -795,7 +874,7 @@ export const handlers = makeHandlers({ ) errIfExists(nicsForInstance, { name: body.name }) - const { name, description, subnet_name, vpc_name, ip } = body + const { name, description, subnet_name, vpc_name, ip_config } = body const vpc = lookup.vpc({ ...query, vpc: vpc_name }) const subnet = lookup.vpcSubnet({ ...query, vpc: vpc_name, subnet: subnet_name }) @@ -807,7 +886,9 @@ export const handlers = makeHandlers({ instance_id: instance.id, name, description, - ip: ip || '123.45.68.8', + ip_stack: ip_config + ? resolveIpStack(ip_config, '123.45.68.8') + : { type: 'v4', value: { ip: '123.45.68.8', transit_ips: [] } }, vpc_id: vpc.id, subnet_id: subnet.id, mac: '', @@ -842,7 +923,14 @@ export const handlers = makeHandlers({ } if (body.transit_ips) { - nic.transit_ips = body.transit_ips + // TODO: Real API would parse IpNet[] and route IPv4/IPv6 to appropriate stacks. + // For mock, we just put all transit IPs into both stacks. + if (nic.ip_stack.type === 'dual_stack') { + nic.ip_stack.value.v4.transit_ips = body.transit_ips + nic.ip_stack.value.v6.transit_ips = body.transit_ips + } else { + nic.ip_stack.value.transit_ips = body.transit_ips + } } return nic diff --git a/mock-api/network-interface.ts b/mock-api/network-interface.ts index 5c28fafba..bb0f3bc7c 100644 --- a/mock-api/network-interface.ts +++ b/mock-api/network-interface.ts @@ -17,11 +17,16 @@ export const networkInterface: Json = { description: 'a network interface', primary: true, instance_id: instance.id, - ip: '172.30.0.10', + ip_stack: { + type: 'v4', + value: { + ip: '172.30.0.10', + transit_ips: ['172.30.0.0/22'], + }, + }, mac: '', subnet_id: vpcSubnet.id, time_created: new Date().toISOString(), time_modified: new Date().toISOString(), - transit_ips: ['172.30.0.0/22'], vpc_id: vpc.id, } diff --git a/test/e2e/instance-networking.e2e.ts b/test/e2e/instance-networking.e2e.ts index dfa036e82..11cdf6b5c 100644 --- a/test/e2e/instance-networking.e2e.ts +++ b/test/e2e/instance-networking.e2e.ts @@ -51,7 +51,7 @@ test('Instance networking tab — NIC table', async ({ page }) => { await expectVisible(page, [ 'role=heading[name="Add network interface"]', 'role=textbox[name="Description"]', - 'role=textbox[name="IP Address"]', + 'role=textbox[name="IP Address (IPv4)"]', ]) await page.getByRole('textbox', { name: 'Name' }).fill('nic-2') From 24045ff3474fb4b7c2a9d90e4c70cea4d9ff7d79 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 9 Jan 2026 15:09:15 -0800 Subject: [PATCH 02/45] Update api generator to 0.13.1 and run --- app/api/__generated__/validate.ts | 85 ++++++++++++---------------- app/forms/network-interface-edit.tsx | 1 - mock-api/msw/handlers.ts | 8 ++- package-lock.json | 31 ++++++++-- package.json | 2 +- 5 files changed, 69 insertions(+), 58 deletions(-) diff --git a/app/api/__generated__/validate.ts b/app/api/__generated__/validate.ts index 579588256..596067ef0 100644 --- a/app/api/__generated__/validate.ts +++ b/app/api/__generated__/validate.ts @@ -186,10 +186,7 @@ export const PoolSelector = z.preprocess( processResponseBody, z.union([ z.object({ pool: NameOrId, type: z.enum(['explicit']) }), - z.object({ - ipVersion: IpVersion.nullable().default(null).optional(), - type: z.enum(['auto']), - }), + z.object({ ipVersion: IpVersion.nullable().default(null), type: z.enum(['auto']) }), ]) ) @@ -205,7 +202,7 @@ export const AddressSelector = z.preprocess( type: z.enum(['explicit']), }), z.object({ - poolSelector: PoolSelector.default({ ipVersion: null, type: 'auto' }).optional(), + poolSelector: PoolSelector.default({ ipVersion: null, type: 'auto' }), type: z.enum(['auto']), }), ]) @@ -1815,9 +1812,7 @@ export const Distributionint64 = z.preprocess( */ export const EphemeralIpCreate = z.preprocess( processResponseBody, - z.object({ - poolSelector: PoolSelector.default({ ipVersion: null, type: 'auto' }).optional(), - }) + z.object({ poolSelector: PoolSelector.default({ ipVersion: null, type: 'auto' }) }) ) /** @@ -1861,7 +1856,7 @@ export const ExternalIpCreate = z.preprocess( processResponseBody, z.union([ z.object({ - poolSelector: PoolSelector.default({ ipVersion: null, type: 'auto' }).optional(), + poolSelector: PoolSelector.default({ ipVersion: null, type: 'auto' }), type: z.enum(['ephemeral']), }), z.object({ floatingIp: NameOrId, type: z.enum(['floating']) }), @@ -2017,7 +2012,7 @@ export const FloatingIpCreate = z.preprocess( addressSelector: AddressSelector.default({ poolSelector: { ipVersion: null, type: 'auto' }, type: 'auto', - }).optional(), + }), description: z.string(), name: Name, }) @@ -2272,7 +2267,7 @@ export const Ipv4Assignment = z.preprocess( */ export const PrivateIpv4StackCreate = z.preprocess( processResponseBody, - z.object({ ip: Ipv4Assignment, transitIps: Ipv4Net.array().default([]).optional() }) + z.object({ ip: Ipv4Assignment, transitIps: Ipv4Net.array().default([]) }) ) /** @@ -2291,7 +2286,7 @@ export const Ipv6Assignment = z.preprocess( */ export const PrivateIpv6StackCreate = z.preprocess( processResponseBody, - z.object({ ip: Ipv6Assignment, transitIps: Ipv6Net.array().default([]).optional() }) + z.object({ ip: Ipv6Assignment, transitIps: Ipv6Net.array().default([]) }) ) /** @@ -2322,7 +2317,7 @@ export const InstanceNetworkInterfaceCreate = z.preprocess( v4: { ip: { type: 'auto' }, transitIps: [] }, v6: { ip: { type: 'auto' }, transitIps: [] }, }, - }).optional(), + }), name: Name, subnetName: Name, vpcName: Name, @@ -2349,24 +2344,24 @@ export const InstanceNetworkInterfaceAttachment = z.preprocess( export const InstanceCreate = z.preprocess( processResponseBody, z.object({ - antiAffinityGroups: NameOrId.array().default([]).optional(), - autoRestartPolicy: InstanceAutoRestartPolicy.nullable().default(null).optional(), - bootDisk: InstanceDiskAttachment.nullable().default(null).optional(), - cpuPlatform: InstanceCpuPlatform.nullable().default(null).optional(), + antiAffinityGroups: NameOrId.array().default([]), + autoRestartPolicy: InstanceAutoRestartPolicy.nullable().default(null), + bootDisk: InstanceDiskAttachment.nullable().default(null), + cpuPlatform: InstanceCpuPlatform.nullable().default(null), description: z.string(), - disks: InstanceDiskAttachment.array().default([]).optional(), - externalIps: ExternalIpCreate.array().default([]).optional(), + disks: InstanceDiskAttachment.array().default([]), + externalIps: ExternalIpCreate.array().default([]), hostname: Hostname, memory: ByteCount, - multicastGroups: NameOrId.array().default([]).optional(), + multicastGroups: NameOrId.array().default([]), name: Name, ncpus: InstanceCpuCount, networkInterfaces: InstanceNetworkInterfaceAttachment.default({ type: 'default_dual_stack', - }).optional(), + }), sshPublicKeys: NameOrId.array().nullable().optional(), - start: SafeBoolean.default(true).optional(), - userData: z.string().default('').optional(), + start: SafeBoolean.default(true), + userData: z.string().default(''), }) ) @@ -2456,8 +2451,8 @@ export const InstanceNetworkInterfaceUpdate = z.preprocess( z.object({ description: z.string().nullable().optional(), name: Name.nullable().optional(), - primary: SafeBoolean.default(false).optional(), - transitIps: IpNet.array().default([]).optional(), + primary: SafeBoolean.default(false), + transitIps: IpNet.array().default([]), }) ) @@ -2487,7 +2482,7 @@ export const InstanceUpdate = z.preprocess( bootDisk: NameOrId.nullable(), cpuPlatform: InstanceCpuPlatform.nullable(), memory: ByteCount, - multicastGroups: NameOrId.array().nullable().default(null).optional(), + multicastGroups: NameOrId.array().nullable().default(null), ncpus: InstanceCpuCount, }) ) @@ -2637,9 +2632,9 @@ export const IpPoolCreate = z.preprocess( processResponseBody, z.object({ description: z.string(), - ipVersion: IpVersion.default('v4').optional(), + ipVersion: IpVersion.default('v4'), name: Name, - poolType: IpPoolType.default('unicast').optional(), + poolType: IpPoolType.default('unicast'), }) ) @@ -2968,11 +2963,11 @@ export const MulticastGroupCreate = z.preprocess( processResponseBody, z.object({ description: z.string(), - multicastIp: z.ipv4().nullable().default(null).optional(), - mvlan: z.number().min(0).max(65535).nullable().optional(), + multicastIp: z.ipv4().nullable().default(null), + mvlan: z.number().min(0).max(65535).nullable().default(null), name: Name, - pool: NameOrId.nullable().default(null).optional(), - sourceIps: z.ipv4().array().nullable().default(null).optional(), + pool: NameOrId.nullable().default(null), + sourceIps: z.ipv4().array().nullable().default(null), }) ) @@ -3038,11 +3033,7 @@ export const MulticastGroupUpdate = z.preprocess( */ export const PrivateIpv4Config = z.preprocess( processResponseBody, - z.object({ - ip: z.ipv4(), - subnet: Ipv4Net, - transitIps: Ipv4Net.array().default([]).optional(), - }) + z.object({ ip: z.ipv4(), subnet: Ipv4Net, transitIps: Ipv4Net.array().default([]) }) ) /** @@ -3260,7 +3251,7 @@ export const ProbeCreate = z.preprocess( z.object({ description: z.string(), name: Name, - poolSelector: PoolSelector.default({ ipVersion: null, type: 'auto' }).optional(), + poolSelector: PoolSelector.default({ ipVersion: null, type: 'auto' }), sled: z.uuid(), }) ) @@ -3527,7 +3518,7 @@ export const SamlIdentityProviderCreate = z.preprocess( idpEntityId: z.string(), idpMetadataSource: IdpMetadataSource, name: Name, - signingKeypair: DerEncodedKeyPair.nullable().default(null).optional(), + signingKeypair: DerEncodedKeyPair.nullable().default(null), sloUrl: z.string(), spClientId: z.string(), technicalContactEmail: z.string(), @@ -3643,9 +3634,7 @@ export const SiloCreate = z.preprocess( description: z.string(), discoverable: SafeBoolean, identityMode: SiloIdentityMode, - mappedFleetRoles: z - .record(z.string(), FleetRole.array().refine(...uniqueItems)) - .optional(), + mappedFleetRoles: z.record(z.string(), FleetRole.array().refine(...uniqueItems)), name: Name, quotas: SiloQuotasCreate, tlsCertificates: CertificateCreate.array(), @@ -4207,14 +4196,14 @@ export const SwitchPortSettingsCreate = z.preprocess( processResponseBody, z.object({ addresses: AddressConfig.array(), - bgpPeers: BgpPeerConfig.array().default([]).optional(), + bgpPeers: BgpPeerConfig.array().default([]), description: z.string(), - groups: NameOrId.array().default([]).optional(), - interfaces: SwitchInterfaceConfigCreate.array().default([]).optional(), + groups: NameOrId.array().default([]), + interfaces: SwitchInterfaceConfigCreate.array().default([]), links: LinkConfigCreate.array(), name: Name, portConfig: SwitchPortConfigCreate, - routes: RouteConfig.array().default([]).optional(), + routes: RouteConfig.array().default([]), }) ) @@ -4674,7 +4663,7 @@ export const VpcFirewallRuleUpdate = z.preprocess( */ export const VpcFirewallRuleUpdateParams = z.preprocess( processResponseBody, - z.object({ rules: VpcFirewallRuleUpdate.array().default([]).optional() }) + z.object({ rules: VpcFirewallRuleUpdate.array().default([]) }) ) /** @@ -4812,7 +4801,7 @@ export const WebhookCreate = z.preprocess( endpoint: z.string(), name: Name, secrets: z.string().array(), - subscriptions: AlertSubscription.array().default([]).optional(), + subscriptions: AlertSubscription.array().default([]), }) ) diff --git a/app/forms/network-interface-edit.tsx b/app/forms/network-interface-edit.tsx index 431815a70..1a23c24d1 100644 --- a/app/forms/network-interface-edit.tsx +++ b/app/forms/network-interface-edit.tsx @@ -8,7 +8,6 @@ import { useEffect } from 'react' import { useForm } from 'react-hook-form' - import { api, queryClient, diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 42f2f9af0..4037a80a2 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -42,7 +42,6 @@ import { lookup, lookupById, notFoundErr, - resolvePoolSelector, utilizationForSilo, } from './db' @@ -309,7 +308,8 @@ export const handlers = makeHandlers({ const pool = addressSelector.type === 'explicit' && addressSelector.pool ? lookup.siloIpPool({ pool: addressSelector.pool, silo: defaultSilo.id }) - : addressSelector.type === 'auto' && addressSelector.pool_selector?.type === 'explicit' + : addressSelector.type === 'auto' && + addressSelector.pool_selector?.type === 'explicit' ? lookup.siloIpPool({ pool: addressSelector.pool_selector.pool, silo: defaultSilo.id, @@ -577,7 +577,9 @@ export const handlers = makeHandlers({ const anySubnet = db.vpcSubnets.find((s) => s.vpc_id === anyVpc?.id) const niType = body.network_interfaces?.type if ( - (niType === 'default_ipv4' || niType === 'default_ipv6' || niType === 'default_dual_stack') && + (niType === 'default_ipv4' || + niType === 'default_ipv6' || + niType === 'default_dual_stack') && anyVpc && anySubnet ) { diff --git a/package-lock.json b/package-lock.json index 921fafc1e..5c6802547 100644 --- a/package-lock.json +++ b/package-lock.json @@ -58,7 +58,7 @@ "devDependencies": { "@ianvs/prettier-plugin-sort-imports": "^4.7.0", "@mswjs/http-middleware": "^0.10.3", - "@oxide/openapi-gen-ts": "~0.12.0", + "@oxide/openapi-gen-ts": "~0.13.1", "@playwright/test": "^1.56.1", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", @@ -361,6 +361,16 @@ "tough-cookie": "^4.1.4" } }, + "node_modules/@commander-js/extra-typings": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@commander-js/extra-typings/-/extra-typings-14.0.0.tgz", + "integrity": "sha512-hIn0ncNaJRLkZrxBIp5AsW/eXEHNKYQBh0aPdoUqNgD+Io3NIykQqpKFyKcuasZhicGaEZJX/JBSIkZ4e5x8Dg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "commander": "~14.0.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.2.tgz", @@ -1659,13 +1669,13 @@ } }, "node_modules/@oxide/openapi-gen-ts": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@oxide/openapi-gen-ts/-/openapi-gen-ts-0.12.0.tgz", - "integrity": "sha512-lebNC+PMbtXc7Ao4fPbJskEGkz6w7yfpaOh/tqboXgo6T3pznSRPF+GJFerGVnU0TRb1SgqItIBccPogsqZiJw==", + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/@oxide/openapi-gen-ts/-/openapi-gen-ts-0.13.1.tgz", + "integrity": "sha512-ZVT4vfHrEniWKwwhWEjkaZfbxzjZGPaTjwb4u+I8P5qaT2YkT/D6Y7W/SH4nAOKOSl1PZUIDgfjVQ9rDh8CYKA==", "dev": true, "license": "MPL-2.0", "dependencies": { - "minimist": "^1.2.8", + "@commander-js/extra-typings": "^14.0.0", "prettier": "2.7.1", "swagger-parser": "^10.0.3", "ts-pattern": "^5.1.1" @@ -7045,6 +7055,17 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/commander": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz", + "integrity": "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", diff --git a/package.json b/package.json index 85af5cf5e..0ebbfddda 100644 --- a/package.json +++ b/package.json @@ -83,7 +83,7 @@ "devDependencies": { "@ianvs/prettier-plugin-sort-imports": "^4.7.0", "@mswjs/http-middleware": "^0.10.3", - "@oxide/openapi-gen-ts": "~0.12.0", + "@oxide/openapi-gen-ts": "~0.13.1", "@playwright/test": "^1.56.1", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", From 7674539d2ca5a85ba68d0e28f6d2edcd5c6b3ccc Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Mon, 12 Jan 2026 12:14:45 -0800 Subject: [PATCH 03/45] Remove unused helper --- app/util/ip-stack.ts | 31 ------------------------------- 1 file changed, 31 deletions(-) delete mode 100644 app/util/ip-stack.ts diff --git a/app/util/ip-stack.ts b/app/util/ip-stack.ts deleted file mode 100644 index d26461112..000000000 --- a/app/util/ip-stack.ts +++ /dev/null @@ -1,31 +0,0 @@ -/* - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright Oxide Computer Company - */ - -import type { InstanceNetworkInterface } from '@oxide/api' - -/** - * Extract the primary IP address from an instance network interface's IP stack. - * For dual-stack, returns the IPv4 address. - */ -export function getIpFromStack(nic: InstanceNetworkInterface): string { - if (nic.ipStack.type === 'dual_stack') { - return nic.ipStack.value.v4.ip - } - return nic.ipStack.value.ip -} - -/** - * Extract transit IPs from an instance network interface's IP stack. - * For dual-stack, returns the IPv4 transit IPs (since transit IPs are generally version-agnostic in the UI). - */ -export function getTransitIpsFromStack(nic: InstanceNetworkInterface): string[] { - if (nic.ipStack.type === 'dual_stack') { - return nic.ipStack.value.v4.transitIps - } - return nic.ipStack.value.transitIps -} From 1468da64f0a297103e80572f55d65c2bb9470b8a Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Mon, 12 Jan 2026 13:13:01 -0800 Subject: [PATCH 04/45] simpler handling, as action is impossible without a pool --- app/components/AttachEphemeralIpModal.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/app/components/AttachEphemeralIpModal.tsx b/app/components/AttachEphemeralIpModal.tsx index 24f8aec03..fd4bb9768 100644 --- a/app/components/AttachEphemeralIpModal.tsx +++ b/app/components/AttachEphemeralIpModal.tsx @@ -65,14 +65,13 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) { - if (!pool) return + onAction={() => instanceEphemeralIpAttach.mutate({ path: { instance }, query: { project }, - body: { poolSelector: { type: 'explicit', pool } }, + body: { poolSelector: { type: 'explicit', pool: pool! } }, }) - }} + } onDismiss={onDismiss} > From 2b49c3fd262188dc218a062549505eb4a7a3d025 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 14 Jan 2026 15:12:06 -0800 Subject: [PATCH 05/45] Updates to Networking Interfaces table --- app/forms/network-interface-edit.tsx | 2 +- app/pages/project/instances/NetworkingTab.tsx | 28 ++++++++++++++----- mock-api/network-interface.ts | 12 ++++++-- 3 files changed, 31 insertions(+), 11 deletions(-) diff --git a/app/forms/network-interface-edit.tsx b/app/forms/network-interface-edit.tsx index 1a23c24d1..619e082b8 100644 --- a/app/forms/network-interface-edit.tsx +++ b/app/forms/network-interface-edit.tsx @@ -54,7 +54,7 @@ export function EditNetworkInterfaceForm({ // Extract transitIps from ipStack for the form const extractedTransitIps = editing.ipStack.type === 'dual_stack' - ? editing.ipStack.value.v4.transitIps + ? [...editing.ipStack.value.v4.transitIps, ...editing.ipStack.value.v6.transitIps] : editing.ipStack.value.transitIps const defaultValues = { diff --git a/app/pages/project/instances/NetworkingTab.tsx b/app/pages/project/instances/NetworkingTab.tsx index 6f0395121..fed676c51 100644 --- a/app/pages/project/instances/NetworkingTab.tsx +++ b/app/pages/project/instances/NetworkingTab.tsx @@ -157,9 +157,18 @@ const staticCols = [ header: 'Private IP', cell: (info) => { const nic = info.row.original - const ip = - nic.ipStack.type === 'dual_stack' ? nic.ipStack.value.v4.ip : nic.ipStack.value.ip - return + const { ipStack } = nic + + if (ipStack.type === 'dual_stack') { + return ( +
+ + +
+ ) + } + + return }, }), colHelper.accessor('vpcId', { @@ -175,10 +184,15 @@ const staticCols = [ header: 'Transit IPs', cell: (info) => { const nic = info.row.original - const transitIps = - nic.ipStack.type === 'dual_stack' - ? nic.ipStack.value.v4.transitIps - : nic.ipStack.value.transitIps + const { ipStack } = nic + + let transitIps: string[] = [] + if (ipStack.type === 'v4' || ipStack.type === 'v6') { + transitIps = ipStack.value.transitIps + } else if (ipStack.type === 'dual_stack') { + // Combine both v4 and v6 transit IPs for dual-stack + transitIps = [...ipStack.value.v4.transitIps, ...ipStack.value.v6.transitIps] + } return ( {transitIps?.map((ip) => ( diff --git a/mock-api/network-interface.ts b/mock-api/network-interface.ts index bb0f3bc7c..3734b51ae 100644 --- a/mock-api/network-interface.ts +++ b/mock-api/network-interface.ts @@ -18,10 +18,16 @@ export const networkInterface: Json = { primary: true, instance_id: instance.id, ip_stack: { - type: 'v4', + type: 'dual_stack', value: { - ip: '172.30.0.10', - transit_ips: ['172.30.0.0/22'], + v4: { + ip: '172.30.0.10', + transit_ips: ['172.30.0.0/22'], + }, + v6: { + ip: '::1', + transit_ips: ['::/64'], + }, }, }, mac: '', From df1bdbf37b44e182f098c9d87fd57d30a196a9b0 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 16 Jan 2026 09:50:54 -0800 Subject: [PATCH 06/45] Update tests --- app/pages/project/instances/NetworkingTab.tsx | 2 +- mock-api/msw/handlers.ts | 21 +++++++++++-------- test/e2e/instance-networking.e2e.ts | 7 ++++--- test/e2e/network-interface-create.e2e.ts | 4 ++-- 4 files changed, 19 insertions(+), 15 deletions(-) diff --git a/app/pages/project/instances/NetworkingTab.tsx b/app/pages/project/instances/NetworkingTab.tsx index fed676c51..f3a558d83 100644 --- a/app/pages/project/instances/NetworkingTab.tsx +++ b/app/pages/project/instances/NetworkingTab.tsx @@ -161,7 +161,7 @@ const staticCols = [ if (ipStack.type === 'dual_stack') { return ( -
+
diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 4037a80a2..efc531fef 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -87,7 +87,8 @@ const resolveIpStack = ( type: 'dual_stack' value: { v4: Api.PrivateIpv4StackCreate; v6: Api.PrivateIpv6StackCreate } }, - defaultIp = '127.0.0.1' + defaultV4Ip = '127.0.0.1', + defaultV6Ip = '::1' ): | { type: 'v4'; value: { ip: string; transit_ips: string[] } } | { type: 'v6'; value: { ip: string; transit_ips: string[] } } @@ -103,11 +104,11 @@ const resolveIpStack = ( type: 'dual_stack', value: { v4: { - ip: resolveIp(config.value.v4.ip, defaultIp), + ip: resolveIp(config.value.v4.ip, defaultV4Ip), transit_ips: config.value.v4.transitIps || [], }, v6: { - ip: resolveIp(config.value.v6.ip, defaultIp), + ip: resolveIp(config.value.v6.ip, defaultV6Ip), transit_ips: config.value.v6.transitIps || [], }, }, @@ -116,7 +117,7 @@ const resolveIpStack = ( return { type: config.type, value: { - ip: resolveIp(config.value.ip, defaultIp), + ip: resolveIp(config.value.ip, config.type === 'v6' ? defaultV6Ip : defaultV4Ip), transit_ips: config.value.transitIps || [], }, } @@ -889,7 +890,7 @@ export const handlers = makeHandlers({ name, description, ip_stack: ip_config - ? resolveIpStack(ip_config, '123.45.68.8') + ? resolveIpStack(ip_config, '123.45.68.8', 'fd12:3456::') : { type: 'v4', value: { ip: '123.45.68.8', transit_ips: [] } }, vpc_id: vpc.id, subnet_id: subnet.id, @@ -925,11 +926,13 @@ export const handlers = makeHandlers({ } if (body.transit_ips) { - // TODO: Real API would parse IpNet[] and route IPv4/IPv6 to appropriate stacks. - // For mock, we just put all transit IPs into both stacks. if (nic.ip_stack.type === 'dual_stack') { - nic.ip_stack.value.v4.transit_ips = body.transit_ips - nic.ip_stack.value.v6.transit_ips = body.transit_ips + // Separate IPv4 and IPv6 transit IPs + const [v6TransitIps, v4TransitIps] = R.partition(body.transit_ips, (ip) => + ip.includes(':') + ) + nic.ip_stack.value.v4.transit_ips = v4TransitIps + nic.ip_stack.value.v6.transit_ips = v6TransitIps } else { nic.ip_stack.value.transit_ips = body.transit_ips } diff --git a/test/e2e/instance-networking.e2e.ts b/test/e2e/instance-networking.e2e.ts index 11cdf6b5c..53d3df621 100644 --- a/test/e2e/instance-networking.e2e.ts +++ b/test/e2e/instance-networking.e2e.ts @@ -266,11 +266,12 @@ test('Edit network interface - Transit IPs', async ({ page }) => { await modal.getByRole('button', { name: 'Update network interface' }).click() // Assert the transit IP is in the NICs table + // The NIC now has 3 transit IPs: 172.30.0.0/22 (v4), 192.168.0.0/16 (v4), and ::/64 (v6) const nicTable = page.getByRole('table', { name: 'Network interfaces' }) - await expectRowVisible(nicTable, { 'Transit IPs': '172.30.0.0/22+1' }) + await expectRowVisible(nicTable, { 'Transit IPs': '172.30.0.0/22+2' }) - await page.getByText('+1').hover() + await page.getByText('+2').hover() await expect( - page.getByRole('tooltip', { name: 'Other transit IPs 192.168.0.0/16' }) + page.getByRole('tooltip', { name: 'Other transit IPs 192.168.0.0/16 ::/64' }) ).toBeVisible() }) diff --git a/test/e2e/network-interface-create.e2e.ts b/test/e2e/network-interface-create.e2e.ts index c735553fe..8565716ce 100644 --- a/test/e2e/network-interface-create.e2e.ts +++ b/test/e2e/network-interface-create.e2e.ts @@ -69,7 +69,7 @@ test('can create a NIC with a blank IP address', async ({ page }) => { await sidebar.getByRole('button', { name: 'Add network interface' }).click() await expect(sidebar).toBeHidden() - // ip address is auto-assigned + // ip address is auto-assigned (dual-stack by default) const table = page.getByRole('table', { name: 'Network interfaces' }) - await expectRowVisible(table, { name: 'nic-2', 'Private IP': '123.45.68.8' }) + await expectRowVisible(table, { name: 'nic-2', 'Private IP': '123.45.68.8fd12:3456::' }) }) From f2519242275cd380a28dc6e6e1276d41e13ed11a Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 16 Jan 2026 14:22:10 -0800 Subject: [PATCH 07/45] Update form with IPv4, IPv6, dual stack --- app/forms/network-interface-create.tsx | 97 ++++++++++++++++++++---- mock-api/msw/handlers.ts | 9 ++- test/e2e/instance-networking.e2e.ts | 3 +- test/e2e/network-interface-create.e2e.ts | 69 +++++++++++++++-- 4 files changed, 155 insertions(+), 23 deletions(-) diff --git a/app/forms/network-interface-create.tsx b/app/forms/network-interface-create.tsx index 2fc75a634..edb4f99c9 100644 --- a/app/forms/network-interface-create.tsx +++ b/app/forms/network-interface-create.tsx @@ -14,18 +14,23 @@ import { api, q, type ApiError, type InstanceNetworkInterfaceCreate } from '@oxi import { DescriptionField } from '~/components/form/fields/DescriptionField' import { ListboxField } from '~/components/form/fields/ListboxField' import { NameField } from '~/components/form/fields/NameField' +import { RadioField } from '~/components/form/fields/RadioField' import { SubnetListbox } from '~/components/form/fields/SubnetListbox' import { TextField } from '~/components/form/fields/TextField' import { SideModalForm } from '~/components/form/SideModalForm' import { useProjectSelector } from '~/hooks/use-params' import { FormDivider } from '~/ui/lib/Divider' +type IpStackType = 'v4' | 'v6' | 'dual_stack' + const defaultValues = { name: '', description: '', subnetName: '', vpcName: '', - ip: '', + ipStackType: 'dual_stack' as IpStackType, + ipv4: '', + ipv6: '', } type CreateNetworkInterfaceFormProps = { @@ -51,6 +56,7 @@ export function CreateNetworkInterfaceForm({ const vpcs = useMemo(() => vpcsData?.items || [], [vpcsData]) const form = useForm({ defaultValues }) + const ipStackType = form.watch('ipStackType') return ( { - // Transform to IPv4 ipConfig structure - const ipConfig = ip.trim() - ? { - type: 'v4' as const, - value: { - ip: { type: 'explicit' as const, value: ip.trim() }, + onSubmit={({ ipStackType, ipv4, ipv6, ...rest }) => { + // Build ipConfig based on the selected IP stack type + let ipConfig: InstanceNetworkInterfaceCreate['ipConfig'] + + if (ipStackType === 'v4') { + ipConfig = { + type: 'v4', + value: { + ip: ipv4.trim() ? { type: 'explicit', value: ipv4.trim() } : { type: 'auto' }, + transitIps: [], + }, + } + } else if (ipStackType === 'v6') { + ipConfig = { + type: 'v6', + value: { + ip: ipv6.trim() ? { type: 'explicit', value: ipv6.trim() } : { type: 'auto' }, + transitIps: [], + }, + } + } else { + // dual_stack + ipConfig = { + type: 'dual_stack', + value: { + v4: { + ip: ipv4.trim() + ? { type: 'explicit', value: ipv4.trim() } + : { type: 'auto' }, + transitIps: [], + }, + v6: { + ip: ipv6.trim() + ? { type: 'explicit', value: ipv6.trim() } + : { type: 'auto' }, transitIps: [], }, - } - : undefined + }, + } + } + onSubmit({ ...rest, ipConfig }) }} loading={loading} @@ -94,12 +130,45 @@ export function CreateNetworkInterfaceForm({ required control={form.control} /> - + + {(ipStackType === 'v4' || ipStackType === 'dual_stack') && ( + + )} + + {(ipStackType === 'v6' || ipStackType === 'dual_stack') && ( + + )} ) } diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 22bd59a08..82dde2650 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -891,7 +891,14 @@ export const handlers = makeHandlers({ description, ip_stack: ip_config ? resolveIpStack(ip_config, '123.45.68.8', 'fd12:3456::') - : { type: 'v4', value: { ip: '123.45.68.8', transit_ips: [] } }, + : // Default is dual-stack with auto-assigned IPs + { + type: 'dual_stack', + value: { + v4: { ip: '123.45.68.8', transit_ips: [] }, + v6: { ip: 'fd12:3456::', transit_ips: [] }, + }, + }, vpc_id: vpc.id, subnet_id: subnet.id, mac: '', diff --git a/test/e2e/instance-networking.e2e.ts b/test/e2e/instance-networking.e2e.ts index 53d3df621..3079160e9 100644 --- a/test/e2e/instance-networking.e2e.ts +++ b/test/e2e/instance-networking.e2e.ts @@ -51,7 +51,8 @@ test('Instance networking tab — NIC table', async ({ page }) => { await expectVisible(page, [ 'role=heading[name="Add network interface"]', 'role=textbox[name="Description"]', - 'role=textbox[name="IP Address (IPv4)"]', + 'role=textbox[name="IPv4 Address"]', + 'role=textbox[name="IPv6 Address"]', ]) await page.getByRole('textbox', { name: 'Name' }).fill('nic-2') diff --git a/test/e2e/network-interface-create.e2e.ts b/test/e2e/network-interface-create.e2e.ts index 8565716ce..b68f427b4 100644 --- a/test/e2e/network-interface-create.e2e.ts +++ b/test/e2e/network-interface-create.e2e.ts @@ -10,7 +10,7 @@ import { test } from '@playwright/test' import { expect, expectRowVisible, stopInstance } from './utils' test('can create a NIC with a specified IP address', async ({ page }) => { - // go to an instance’s Network Interfaces page + // go to an instance's Network Interfaces page await page.goto('/projects/mock-project/instances/db1/networking') await stopInstance(page) @@ -24,7 +24,10 @@ test('can create a NIC with a specified IP address', async ({ page }) => { await page.getByRole('option', { name: 'mock-vpc' }).click() await page.getByRole('button', { name: 'Subnet' }).click() await page.getByRole('option', { name: 'mock-subnet' }).click() - await page.getByLabel('IP Address').fill('1.2.3.4') + + // Select IPv4 only + await page.getByRole('radio', { name: 'IPv4', exact: true }).click() + await page.getByLabel('IPv4 Address').fill('1.2.3.4') const sidebar = page.getByRole('dialog', { name: 'Add network interface' }) @@ -37,7 +40,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 + // go to an instance's Network Interfaces page await page.goto('/projects/mock-project/instances/db1/networking') await stopInstance(page) @@ -52,8 +55,9 @@ test('can create a NIC with a blank IP address', async ({ page }) => { await page.getByRole('button', { name: 'Subnet' }).click() await page.getByRole('option', { name: 'mock-subnet' }).click() - // make sure the IP address field has a non-conforming bit of text in it - await page.getByLabel('IP Address').fill('x') + // Dual-stack is selected by default, so both fields should be visible + // make sure the IPv4 address field has a non-conforming bit of text in it + await page.getByLabel('IPv4 Address').fill('x') // try to submit it const sidebar = page.getByRole('dialog', { name: 'Add network interface' }) @@ -62,8 +66,9 @@ test('can create a NIC with a blank IP address', async ({ page }) => { // it should error out await expect(sidebar.getByText('Zod error for body')).toBeVisible() - // make sure the IP address field has spaces in it - await page.getByLabel('IP Address').fill(' ') + // make sure both IP address fields have spaces in them + await page.getByLabel('IPv4 Address').fill(' ') + await page.getByLabel('IPv6 Address').fill(' ') // test that the form can be submitted and a new network interface is created await sidebar.getByRole('button', { name: 'Add network interface' }).click() @@ -73,3 +78,53 @@ test('can create a NIC with a blank IP address', async ({ page }) => { const table = page.getByRole('table', { name: 'Network interfaces' }) await expectRowVisible(table, { name: 'nic-2', 'Private IP': '123.45.68.8fd12:3456::' }) }) + +test('can create a NIC with IPv6 only', async ({ page }) => { + await page.goto('/projects/mock-project/instances/db1/networking') + + await stopInstance(page) + + await page.getByRole('button', { name: 'Add network interface' }).click() + + await page.getByLabel('Name').fill('nic-3') + await page.getByLabel('VPC', { exact: true }).click() + await page.getByRole('option', { name: 'mock-vpc' }).click() + await page.getByRole('button', { name: 'Subnet' }).click() + await page.getByRole('option', { name: 'mock-subnet' }).click() + + // Select IPv6 only + await page.getByRole('radio', { name: 'IPv6', exact: true }).click() + await page.getByLabel('IPv6 Address').fill('::1') + + const sidebar = page.getByRole('dialog', { name: 'Add network interface' }) + await sidebar.getByRole('button', { name: 'Add network interface' }).click() + await expect(sidebar).toBeHidden() + + const table = page.getByRole('table', { name: 'Network interfaces' }) + await expectRowVisible(table, { name: 'nic-3', 'Private IP': '::1' }) +}) + +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.getByRole('button', { name: 'Add network interface' }).click() + + await page.getByLabel('Name').fill('nic-4') + await page.getByLabel('VPC', { exact: true }).click() + await page.getByRole('option', { name: 'mock-vpc' }).click() + await page.getByRole('button', { name: 'Subnet' }).click() + await page.getByRole('option', { name: 'mock-subnet' }).click() + + // Dual-stack is selected by default + await page.getByLabel('IPv4 Address').fill('10.0.0.5') + await page.getByLabel('IPv6 Address').fill('fd00::5') + + const sidebar = page.getByRole('dialog', { name: 'Add network interface' }) + await sidebar.getByRole('button', { name: 'Add network interface' }).click() + await expect(sidebar).toBeHidden() + + const table = page.getByRole('table', { name: 'Network interfaces' }) + await expectRowVisible(table, { name: 'nic-4', 'Private IP': '10.0.0.5fd00::5' }) +}) From 49c1dcf96d219dd774616c74f29638d0e7939e13 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 16 Jan 2026 15:17:05 -0800 Subject: [PATCH 08/45] proper v4 vs v6 filtering --- app/components/AttachEphemeralIpModal.tsx | 7 ++++--- app/forms/floating-ip-create.tsx | 15 ++++++--------- mock-api/msw/handlers.ts | 11 +++++++---- 3 files changed, 17 insertions(+), 16 deletions(-) diff --git a/app/components/AttachEphemeralIpModal.tsx b/app/components/AttachEphemeralIpModal.tsx index fd4bb9768..24f8aec03 100644 --- a/app/components/AttachEphemeralIpModal.tsx +++ b/app/components/AttachEphemeralIpModal.tsx @@ -65,13 +65,14 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) + onAction={() => { + if (!pool) return instanceEphemeralIpAttach.mutate({ path: { instance }, query: { project }, - body: { poolSelector: { type: 'explicit', pool: pool! } }, + body: { poolSelector: { type: 'explicit', pool } }, }) - } + }} onDismiss={onDismiss} > diff --git a/app/forms/floating-ip-create.tsx b/app/forms/floating-ip-create.tsx index 4ff110568..5c30c3f7c 100644 --- a/app/forms/floating-ip-create.tsx +++ b/app/forms/floating-ip-create.tsx @@ -27,10 +27,10 @@ import { Message } from '~/ui/lib/Message' import { ALL_ISH } from '~/util/consts' import { pb } from '~/util/path-builder' -const defaultValues: FloatingIpCreate = { +const defaultValues = { name: '', description: '', - addressSelector: undefined, + pool: '', } export const handle = titleCrumb('New Floating IP') @@ -65,12 +65,9 @@ export default function CreateFloatingIpSideModalForm() { formType="create" resourceName="floating IP" onDismiss={() => navigate(pb.floatingIps(projectSelector))} - onSubmit={(values) => { - // Transform the form values to properly construct addressSelector - const pool = form.getValues('addressSelector.poolSelector.pool' as any) - const body = { - name: values.name, - description: values.description, + onSubmit={({ pool, ...values }) => { + const body: FloatingIpCreate = { + ...values, addressSelector: pool ? { type: 'auto' as const, @@ -103,7 +100,7 @@ export default function CreateFloatingIpSideModalForm() { /> - ip.includes(':') - ) + // Parse and separate IPv4 and IPv6 transit IPs using proper IP parsing + // This matches how the real API routes IpNet[] to the appropriate stacks + const [v6TransitIps, v4TransitIps] = R.partition(body.transit_ips, (ipNet) => { + const parsed = parseIpNet(ipNet) + return parsed.type === 'v6' + }) nic.ip_stack.value.v4.transit_ips = v4TransitIps nic.ip_stack.value.v6.transit_ips = v6TransitIps } else { From ed375cf85aaf9fc313394c6638551f406242ee90 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 16 Jan 2026 16:26:48 -0800 Subject: [PATCH 09/45] update types in form --- app/forms/floating-ip-create.tsx | 9 +++- app/forms/network-interface-create.tsx | 69 ++++++++++++-------------- 2 files changed, 39 insertions(+), 39 deletions(-) diff --git a/app/forms/floating-ip-create.tsx b/app/forms/floating-ip-create.tsx index 5c30c3f7c..9ff4dae77 100644 --- a/app/forms/floating-ip-create.tsx +++ b/app/forms/floating-ip-create.tsx @@ -27,10 +27,15 @@ import { Message } from '~/ui/lib/Message' import { ALL_ISH } from '~/util/consts' import { pb } from '~/util/path-builder' -const defaultValues = { +type FloatingIpCreateFormData = { + name: string + description: string + pool?: string +} + +const defaultValues: FloatingIpCreateFormData = { name: '', description: '', - pool: '', } export const handle = titleCrumb('New Floating IP') diff --git a/app/forms/network-interface-create.tsx b/app/forms/network-interface-create.tsx index edb4f99c9..b675beb02 100644 --- a/app/forms/network-interface-create.tsx +++ b/app/forms/network-interface-create.tsx @@ -8,6 +8,7 @@ import { useQuery } from '@tanstack/react-query' import { useMemo } from 'react' import { useForm } from 'react-hook-form' +import { match } from 'ts-pattern' import { api, q, type ApiError, type InstanceNetworkInterfaceCreate } from '@oxide/api' @@ -33,6 +34,22 @@ const defaultValues = { ipv6: '', } +// Helper to build IP assignment from string +function buildIpAssignment( + ipString: string +): { type: 'auto' } | { type: 'explicit'; value: string } { + const trimmed = ipString.trim() + return trimmed ? { type: 'explicit', value: trimmed } : { type: 'auto' } +} + +// Helper to build a single IP stack (v4 or v6) +function buildIpStack(ipString: string) { + return { + ip: buildIpAssignment(ipString), + transitIps: [], + } +} + type CreateNetworkInterfaceFormProps = { onDismiss: () => void onSubmit: (values: InstanceNetworkInterfaceCreate) => void @@ -66,45 +83,23 @@ export function CreateNetworkInterfaceForm({ title="Add network interface" onDismiss={onDismiss} onSubmit={({ ipStackType, ipv4, ipv6, ...rest }) => { - // Build ipConfig based on the selected IP stack type - let ipConfig: InstanceNetworkInterfaceCreate['ipConfig'] - - if (ipStackType === 'v4') { - ipConfig = { - type: 'v4', - value: { - ip: ipv4.trim() ? { type: 'explicit', value: ipv4.trim() } : { type: 'auto' }, - transitIps: [], - }, - } - } else if (ipStackType === 'v6') { - ipConfig = { - type: 'v6', - value: { - ip: ipv6.trim() ? { type: 'explicit', value: ipv6.trim() } : { type: 'auto' }, - transitIps: [], - }, - } - } else { - // dual_stack - ipConfig = { - type: 'dual_stack', + const ipConfig = match(ipStackType) + .with('v4', () => ({ + type: 'v4' as const, + value: buildIpStack(ipv4), + })) + .with('v6', () => ({ + type: 'v6' as const, + value: buildIpStack(ipv6), + })) + .with('dual_stack', () => ({ + type: 'dual_stack' as const, value: { - v4: { - ip: ipv4.trim() - ? { type: 'explicit', value: ipv4.trim() } - : { type: 'auto' }, - transitIps: [], - }, - v6: { - ip: ipv6.trim() - ? { type: 'explicit', value: ipv6.trim() } - : { type: 'auto' }, - transitIps: [], - }, + v4: buildIpStack(ipv4), + v6: buildIpStack(ipv6), }, - } - } + })) + .exhaustive() onSubmit({ ...rest, ipConfig }) }} From 8546afe59573360a7244e0891184a590fd1125bd Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Sat, 17 Jan 2026 00:04:45 -0800 Subject: [PATCH 10/45] more defaults --- .../form/fields/NetworkInterfaceField.tsx | 54 +++++++++++++++---- app/forms/instance-create.tsx | 2 +- 2 files changed, 45 insertions(+), 11 deletions(-) diff --git a/app/components/form/fields/NetworkInterfaceField.tsx b/app/components/form/fields/NetworkInterfaceField.tsx index 3de33ddbe..b12fb17bc 100644 --- a/app/components/form/fields/NetworkInterfaceField.tsx +++ b/app/components/form/fields/NetworkInterfaceField.tsx @@ -8,10 +8,7 @@ import { useState } from 'react' import { useController, type Control } from 'react-hook-form' -import type { - InstanceNetworkInterfaceAttachment, - InstanceNetworkInterfaceCreate, -} from '@oxide/api' +import type { InstanceNetworkInterfaceCreate } from '@oxide/api' import type { InstanceCreateInput } from '~/forms/instance-create' import { CreateNetworkInterfaceForm } from '~/forms/network-interface-create' @@ -44,6 +41,17 @@ export function NetworkInterfaceField({ field: { value, onChange }, } = useController({ control, name: 'networkInterfaces' }) + // Map API types to radio values + // 'default_ipv4' | 'default_ipv6' | 'default_dual_stack' all map to 'default' + const radioValue = + value.type === 'default_ipv4' || + value.type === 'default_ipv6' || + value.type === 'default_dual_stack' + ? 'default' + : value.type + + const isDefaultSelected = radioValue === 'default' + return (
Network interface @@ -53,18 +61,21 @@ export function NetworkInterfaceField({ name="networkInterfaceType" column className="pt-1" - defaultChecked={value.type} + defaultChecked={radioValue} onChange={(event) => { - const newType = event.target.value as InstanceNetworkInterfaceAttachment['type'] + const radioSelection = event.target.value if (value.type === 'create') { setOldParams(value.params) } - if (newType === 'create') { - onChange({ type: newType, params: oldParams }) - } else { - onChange({ type: newType }) + if (radioSelection === 'create') { + onChange({ type: 'create', params: oldParams }) + } else if (radioSelection === 'default') { + // When user selects 'default', use dual_stack as the default + onChange({ type: 'default_dual_stack' }) + } else if (radioSelection === 'none') { + onChange({ type: 'none' }) } }} disabled={disabled} @@ -73,6 +84,29 @@ export function NetworkInterfaceField({ Default Custom + {isDefaultSelected && ( +
+ { + const ipVersionType = event.target.value as + | 'default_ipv4' + | 'default_ipv6' + | 'default_dual_stack' + onChange({ type: ipVersionType }) + }} + disabled={disabled} + > + IPv4 & IPv6 + IPv4 + IPv6 + +
+ )} {value.type === 'create' && ( <> Date: Sat, 17 Jan 2026 06:15:12 -0800 Subject: [PATCH 11/45] flatten default options --- .../form/fields/NetworkInterfaceField.tsx | 51 +++---------------- 1 file changed, 8 insertions(+), 43 deletions(-) diff --git a/app/components/form/fields/NetworkInterfaceField.tsx b/app/components/form/fields/NetworkInterfaceField.tsx index b12fb17bc..6893c6215 100644 --- a/app/components/form/fields/NetworkInterfaceField.tsx +++ b/app/components/form/fields/NetworkInterfaceField.tsx @@ -41,17 +41,6 @@ export function NetworkInterfaceField({ field: { value, onChange }, } = useController({ control, name: 'networkInterfaces' }) - // Map API types to radio values - // 'default_ipv4' | 'default_ipv6' | 'default_dual_stack' all map to 'default' - const radioValue = - value.type === 'default_ipv4' || - value.type === 'default_ipv6' || - value.type === 'default_dual_stack' - ? 'default' - : value.type - - const isDefaultSelected = radioValue === 'default' - return (
Network interface @@ -61,52 +50,28 @@ export function NetworkInterfaceField({ name="networkInterfaceType" column className="pt-1" - defaultChecked={radioValue} + defaultChecked={value.type} onChange={(event) => { - const radioSelection = event.target.value + const newType = event.target.value if (value.type === 'create') { setOldParams(value.params) } - if (radioSelection === 'create') { + if (newType === 'create') { onChange({ type: 'create', params: oldParams }) - } else if (radioSelection === 'default') { - // When user selects 'default', use dual_stack as the default - onChange({ type: 'default_dual_stack' }) - } else if (radioSelection === 'none') { - onChange({ type: 'none' }) + } else { + onChange({ type: newType as typeof value.type }) } }} disabled={disabled} > + Default IPv4 & IPv6 + Default IPv4 + Default IPv6 None - Default Custom - {isDefaultSelected && ( -
- { - const ipVersionType = event.target.value as - | 'default_ipv4' - | 'default_ipv6' - | 'default_dual_stack' - onChange({ type: ipVersionType }) - }} - disabled={disabled} - > - IPv4 & IPv6 - IPv4 - IPv6 - -
- )} {value.type === 'create' && ( <> Date: Sat, 17 Jan 2026 07:25:34 -0800 Subject: [PATCH 12/45] Add instance create tests --- test/e2e/instance-create.e2e.ts | 109 ++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/test/e2e/instance-create.e2e.ts b/test/e2e/instance-create.e2e.ts index ee3c74890..cc653ff48 100644 --- a/test/e2e/instance-create.e2e.ts +++ b/test/e2e/instance-create.e2e.ts @@ -676,3 +676,112 @@ test('Validate CPU and RAM', async ({ page }) => { await expect(cpuMsg).toBeVisible() await expect(memMsg).toBeVisible() }) + +test('create instance with IPv6-only networking', async ({ page }) => { + await page.goto('/projects/mock-project/instances-new') + + const instanceName = 'ipv6-only-instance' + await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) + await selectASiloImage(page, 'ubuntu-22-04') + + // Open networking accordion + await page.getByRole('button', { name: 'Networking' }).click() + + // Select "Default IPv6" network interface + await page.getByRole('radio', { name: 'Default IPv6', exact: true }).click() + + // Create instance + await page.getByRole('button', { name: 'Create instance' }).click() + + await expect(page).toHaveURL(/\/instances\/ipv6-only-instance/) + + // Navigate to the Networking tab + await page.getByRole('tab', { name: 'Networking' }).click() + + // Check that the network interfaces table shows up + const nicTable = page.getByRole('table', { name: 'Network interfaces' }) + await expect(nicTable).toBeVisible() + + // Verify the Private IP column exists and contains an IPv6 address + const privateIpCell = nicTable.getByRole('cell').filter({ hasText: /::/ }) + await expect(privateIpCell.first()).toBeVisible() + + // Verify no IPv4 address is shown (no periods in a dotted-decimal format within the Private IP) + // We check that the cell with IPv6 doesn't also contain IPv4 + const cellText = await privateIpCell.first().textContent() + expect(cellText).toMatch(/::/) + expect(cellText).not.toMatch(/\d+\.\d+\.\d+\.\d+/) +}) + +test('create instance with IPv4-only networking', async ({ page }) => { + await page.goto('/projects/mock-project/instances-new') + + const instanceName = 'ipv4-only-instance' + await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) + await selectASiloImage(page, 'ubuntu-22-04') + + // Open networking accordion + await page.getByRole('button', { name: 'Networking' }).click() + + // Select "Default IPv4" network interface + await page.getByRole('radio', { name: 'Default IPv4', exact: true }).click() + + // Create instance + await page.getByRole('button', { name: 'Create instance' }).click() + + await expect(page).toHaveURL(/\/instances\/ipv4-only-instance/) + + // Navigate to the Networking tab + await page.getByRole('tab', { name: 'Networking' }).click() + + // Check that the network interfaces table shows up + const nicTable = page.getByRole('table', { name: 'Network interfaces' }) + await expect(nicTable).toBeVisible() + + // Verify the Private IP column exists and contains an IPv4 address + const privateIpCell = nicTable.getByRole('cell').filter({ hasText: /127\.0\.0\.1/ }) + await expect(privateIpCell.first()).toBeVisible() + + // Verify no IPv6 address is shown (no colons in IPv6 format within the Private IP) + const cellText = await privateIpCell.first().textContent() + expect(cellText).toMatch(/\d+\.\d+\.\d+\.\d+/) + expect(cellText).not.toMatch(/::/) +}) + +test('create instance with dual-stack networking shows both IPs', async ({ page }) => { + await page.goto('/projects/mock-project/instances-new') + + const instanceName = 'dual-stack-instance' + await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) + await selectASiloImage(page, 'ubuntu-22-04') + + // Open networking accordion + await page.getByRole('button', { name: 'Networking' }).click() + + // Default is already "Default IPv4 & IPv6", so no need to select it + + // Create instance + await page.getByRole('button', { name: 'Create instance' }).click() + + await expect(page).toHaveURL(/\/instances\/dual-stack-instance/) + + // Navigate to the Networking tab + await page.getByRole('tab', { name: 'Networking' }).click() + + // Check that the network interfaces table shows up + const nicTable = page.getByRole('table', { name: 'Network interfaces' }) + await expect(nicTable).toBeVisible() + + // Verify both IPv4 and IPv6 addresses are shown + const privateIpCells = nicTable + .locator('tbody tr') + .first() + .locator('td') + .filter({ hasText: /127\.0\.0\.1/ }) + await expect(privateIpCells.first()).toBeVisible() + + // Check that the same cell contains IPv6 + const cellText = await privateIpCells.first().textContent() + expect(cellText).toMatch(/127\.0\.0\.1/) // IPv4 + expect(cellText).toMatch(/::1/) // IPv6 +}) From f38bf20bd925b7e38d2ae04c713da2e72ef3fc5e Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 20 Jan 2026 09:31:29 -0800 Subject: [PATCH 13/45] Update to latest Omicron; npm run gen-api --- OMICRON_VERSION | 2 +- app/api/__generated__/Api.ts | 377 ++++++++++---------------- app/api/__generated__/OMICRON_VERSION | 2 +- app/api/__generated__/msw-handlers.ts | 83 +----- app/api/__generated__/validate.ts | 231 ++++++---------- app/forms/floating-ip-create.tsx | 2 +- mock-api/msw/handlers.ts | 20 +- 7 files changed, 240 insertions(+), 477 deletions(-) diff --git a/OMICRON_VERSION b/OMICRON_VERSION index 2a5ae4903..be6a09b19 100644 --- a/OMICRON_VERSION +++ b/OMICRON_VERSION @@ -1 +1 @@ -135be59591f1ba4bc5941f63bf3a08a0b187b1a9 +26b33a11962d82b29fde9a2e3233d038d5495c44 diff --git a/app/api/__generated__/Api.ts b/app/api/__generated__/Api.ts index db95aa333..445f5de6d 100644 --- a/app/api/__generated__/Api.ts +++ b/app/api/__generated__/Api.ts @@ -58,6 +58,49 @@ export type Address = { vlanId?: number | null } +/** + * The IP address version. + */ +export type IpVersion = 'v4' | 'v6' + +/** + * Specify which IP pool to allocate from. + */ +export type PoolSelector = + /** Use the specified pool by name or ID. */ + | { + /** The pool to allocate from. */ + pool: NameOrId + type: 'explicit' + } + /** Use the default pool for the silo. */ + | { + /** IP version to use when multiple default pools exist. Required if both IPv4 and IPv6 default pools are configured. */ + ipVersion?: IpVersion | null + type: 'auto' + } + +/** + * Specify how to allocate a floating IP address. + */ +export type AddressAllocator = + /** Reserve a specific IP address. */ + | { + /** The IP address to reserve. Must be available in the pool. */ + ip: string + /** The pool containing this address. If not specified, the default pool for the address's IP version is used. */ + pool?: NameOrId | null + type: 'explicit' + } + /** Automatically allocate an IP address from a specified pool. */ + | { + /** Pool selection. + +If omitted, this field uses the silo's default pool. If the silo has default pools for both IPv4 and IPv6, the request will fail unless `ip_version` is specified in the pool selector. */ + poolSelector?: PoolSelector + type: 'auto' + } + /** * A set of addresses associated with a port configuration. */ @@ -170,49 +213,6 @@ export type AddressLotViewResponse = { lot: AddressLot } -/** - * The IP address version. - */ -export type IpVersion = 'v4' | 'v6' - -/** - * Specify which IP pool to allocate from. - */ -export type PoolSelector = - /** Use the specified pool by name or ID. */ - | { - /** The pool to allocate from. */ - pool: NameOrId - type: 'explicit' - } - /** Use the default pool for the silo. */ - | { - /** IP version to use when multiple default pools exist. Required if both IPv4 and IPv6 default pools are configured. */ - ipVersion?: IpVersion | null - type: 'auto' - } - -/** - * Specify how to allocate a floating IP address. - */ -export type AddressSelector = - /** Reserve a specific IP address. */ - | { - /** The IP address to reserve. Must be available in the pool. */ - ip: string - /** The pool containing this address. If not specified, the default pool for the address's IP version is used. */ - pool?: NameOrId | null - type: 'explicit' - } - /** Automatically allocate an IP address from a specified pool. */ - | { - /** Pool selection. - -If omitted, this field uses the silo's default pool. If the silo has default pools for both IPv4 and IPv6, the request will fail unless `ip_version` is specified in the pool selector. */ - poolSelector?: PoolSelector - type: 'auto' - } - /** * Describes the scope of affinity for the purposes of co-location. */ @@ -675,6 +675,19 @@ export type AuditLogEntryActor = | { kind: 'scim'; siloId: string } | { kind: 'unauthenticated' } +/** + * Authentication method used for a request + */ +export type AuthMethod = + /** Console session cookie */ + | 'session_cookie' + + /** Device access token (OAuth 2.0 device authorization flow) */ + | 'access_token' + + /** SCIM client bearer token */ + | 'scim_token' + /** * Result of an audit log entry */ @@ -701,8 +714,10 @@ export type AuditLogEntryResult = */ export type AuditLogEntry = { actor: AuditLogEntryActor - /** How the user authenticated the request. Possible values are "session_cookie" and "access_token". Optional because it will not be defined on unauthenticated requests like login attempts. */ - authMethod?: string | null + /** How the user authenticated the request (access token, session, or SCIM token). Null for unauthenticated requests like login attempts. */ + authMethod?: AuthMethod | null + /** ID of the credential used for authentication. Null for unauthenticated requests. The value of `auth_method` indicates what kind of credential it is (access token, session, or SCIM token). */ + credentialId?: string | null /** Unique identifier for the audit log entry */ id: string /** API endpoint ID, e.g., `project_create` */ @@ -2174,7 +2189,7 @@ export type FloatingIpAttach = { */ export type FloatingIpCreate = { /** IP address allocation method. */ - addressSelector?: AddressSelector + addressAllocator?: AddressAllocator description: string name: Name } @@ -2427,6 +2442,27 @@ export type InstanceDiskAttachment = type: 'attach' } +/** + * A multicast group identifier + * + * Can be a UUID, a name, or an IP address + */ +export type MulticastGroupIdentifier = string + +/** + * Specification for joining a multicast group with optional source filtering. + * + * Used in `InstanceCreate` and `InstanceUpdate` to specify multicast group membership along with per-member source IP configuration. + */ +export type MulticastGroupJoinSpec = { + /** The multicast group to join, specified by name, UUID, or IP address. */ + group: MulticastGroupIdentifier + /** IP version for pool selection when creating a group by name. Required if both IPv4 and IPv6 default multicast pools are linked. */ + ipVersion?: IpVersion | null + /** Source IPs for source-filtered multicast (SSM). Optional for ASM groups, required for SSM groups (232.0.0.0/8, ff3x::/32). */ + sourceIps?: string[] | null +} + /** * How a VPC-private IP address is assigned to a network interface. */ @@ -2555,10 +2591,10 @@ By default, all instances have outbound connectivity, but no inbound connectivit hostname: Hostname /** The amount of RAM (in bytes) to be allocated to the instance */ memory: ByteCount - /** The multicast groups this instance should join. + /** Multicast groups this instance should join at creation. -The instance will be automatically added as a member of the specified multicast groups during creation, enabling it to send and receive multicast traffic for those groups. */ - multicastGroups?: NameOrId[] +Groups can be specified by name, UUID, or IP address. Non-existent groups are created automatically. */ + multicastGroups?: MulticastGroupJoinSpec[] name: Name /** The number of vCPUs to be allocated to the instance */ ncpus: InstanceCpuCount @@ -2574,6 +2610,18 @@ If not provided, all SSH public keys from the user's profile will be sent. If an userData?: string } +/** + * Parameters for joining an instance to a multicast group. + * + * When joining by IP address, the pool containing the multicast IP is auto-discovered from all linked multicast pools. + */ +export type InstanceMulticastGroupJoin = { + /** IP version for pool selection when creating a group by name. Required if both IPv4 and IPv6 default multicast pools are linked. */ + ipVersion?: IpVersion | null + /** Source IPs for source-filtered multicast (SSM). Optional for ASM groups, required for SSM groups (232.0.0.0/8, ff3x::/32). */ + sourceIps?: string[] | null +} + /** * The VPC-private IPv4 stack for a network interface */ @@ -2712,8 +2760,10 @@ An instance that does not have a boot disk set will use the boot options specifi When specified, this replaces the instance's current multicast group membership with the new set of groups. The instance will leave any groups not listed here and join any new groups that are specified. -If not provided (None), the instance's multicast group membership will not be changed. */ - multicastGroups?: NameOrId[] | null +Each entry can specify the group by name, UUID, or IP address, along with optional source IP filtering for SSM (Source-Specific Multicast). When a group doesn't exist, it will be implicitly created using the default multicast pool (or you can specify `ip_version` to disambiguate if needed). + +If not provided, the instance's multicast group membership will not be changed. */ + multicastGroups?: MulticastGroupJoinSpec[] | null /** The number of vCPUs to be allocated to the instance */ ncpus: InstanceCpuCount } @@ -3240,7 +3290,9 @@ export type MulticastGroup = { mvlan?: number | null /** unique, mutable, user-controlled identifier for each resource */ name: Name - /** Source IP addresses for Source-Specific Multicast (SSM). Empty array means any source is allowed. */ + /** Union of all member source IP addresses (computed, read-only). + +This field shows the combined source IPs across all group members. Individual members may subscribe to different sources; this union reflects all sources that any member is subscribed to. Empty array means no members have source filtering enabled. */ sourceIps: string[] /** Current state of the multicast group. */ state: string @@ -3250,26 +3302,6 @@ export type MulticastGroup = { timeModified: Date } -/** - * Create-time parameters for a multicast group. - */ -export type MulticastGroupCreate = { - description: string - /** The multicast IP address to allocate. If None, one will be allocated from the default pool. */ - multicastIp?: string | null - /** Multicast VLAN (MVLAN) for egress multicast traffic to upstream networks. Tags packets leaving the rack to traverse VLAN-segmented upstream networks. - -Valid range: 2-4094 (VLAN IDs 0-1 are reserved by IEEE 802.1Q standard). */ - mvlan?: number | null - name: Name - /** Name or ID of the IP pool to allocate from. If None, uses the default multicast pool. */ - pool?: NameOrId | null - /** Source IP addresses for Source-Specific Multicast (SSM). - -None uses default behavior (Any-Source Multicast). Empty list explicitly allows any source (Any-Source Multicast). Non-empty list restricts to specific sources (SSM). */ - sourceIps?: string[] | null -} - /** * View of a Multicast Group Member (instance belonging to a multicast group) */ @@ -3282,8 +3314,14 @@ export type MulticastGroupMember = { instanceId: string /** The ID of the multicast group this member belongs to. */ multicastGroupId: string + /** The multicast IP address of the group this member belongs to. */ + multicastIp: string /** unique, mutable, user-controlled identifier for each resource */ name: Name + /** Source IP addresses for this member's multicast subscription. + +- **ASM**: Sources are optional. Empty array means any source is allowed. Non-empty array enables source filtering (IGMPv3/MLDv2). - **SSM**: Sources are required for SSM addresses (232/8, ff3x::/32). */ + sourceIps: string[] /** Current state of the multicast group membership. */ state: string /** timestamp when this resource was created */ @@ -3292,14 +3330,6 @@ export type MulticastGroupMember = { timeModified: Date } -/** - * Parameters for adding an instance to a multicast group. - */ -export type MulticastGroupMemberAdd = { - /** Name or ID of the instance to add to the multicast group */ - instance: NameOrId -} - /** * A single page of results */ @@ -3320,17 +3350,6 @@ export type MulticastGroupResultsPage = { nextPage?: string | null } -/** - * Update-time parameters for a multicast group. - */ -export type MulticastGroupUpdate = { - description?: string | null - /** Multicast VLAN (MVLAN) for egress multicast traffic to upstream networks. Set to null to clear the MVLAN. Valid range: 2-4094 when provided. Omit the field to leave mvlan unchanged. */ - mvlan?: number | null - name?: Name | null - sourceIps?: string[] | null -} - /** * VPC-private IPv4 configuration for a network interface. */ @@ -5952,12 +5971,15 @@ export interface InstanceMulticastGroupListPathParams { } export interface InstanceMulticastGroupListQueryParams { + limit?: number | null + pageToken?: string | null project?: NameOrId + sortBy?: IdSortMode } export interface InstanceMulticastGroupJoinPathParams { instance: NameOrId - multicastGroup: NameOrId + multicastGroup: MulticastGroupIdentifier } export interface InstanceMulticastGroupJoinQueryParams { @@ -5966,7 +5988,7 @@ export interface InstanceMulticastGroupJoinQueryParams { export interface InstanceMulticastGroupLeavePathParams { instance: NameOrId - multicastGroup: NameOrId + multicastGroup: MulticastGroupIdentifier } export interface InstanceMulticastGroupLeaveQueryParams { @@ -6176,19 +6198,11 @@ export interface MulticastGroupListQueryParams { } export interface MulticastGroupViewPathParams { - multicastGroup: NameOrId -} - -export interface MulticastGroupUpdatePathParams { - multicastGroup: NameOrId -} - -export interface MulticastGroupDeletePathParams { - multicastGroup: NameOrId + multicastGroup: MulticastGroupIdentifier } export interface MulticastGroupMemberListPathParams { - multicastGroup: NameOrId + multicastGroup: MulticastGroupIdentifier } export interface MulticastGroupMemberListQueryParams { @@ -6197,23 +6211,6 @@ export interface MulticastGroupMemberListQueryParams { sortBy?: IdSortMode } -export interface MulticastGroupMemberAddPathParams { - multicastGroup: NameOrId -} - -export interface MulticastGroupMemberAddQueryParams { - project?: NameOrId -} - -export interface MulticastGroupMemberRemovePathParams { - instance: NameOrId - multicastGroup: NameOrId -} - -export interface MulticastGroupMemberRemoveQueryParams { - project?: NameOrId -} - export interface InstanceNetworkInterfaceListQueryParams { instance?: NameOrId limit?: number | null @@ -6568,10 +6565,6 @@ export interface SystemMetricQueryParams { silo?: NameOrId } -export interface LookupMulticastGroupByIpPathParams { - address: string -} - export interface NetworkingAddressLotListQueryParams { limit?: number | null pageToken?: string | null @@ -7050,7 +7043,7 @@ export class Api { * Pulled from info.version in the OpenAPI schema. Sent in the * `api-version` header on all requests. */ - apiVersion = '2026010500.0.0' + apiVersion = '2026011600.0.0' constructor({ host = '', baseParams = {}, token }: ApiConfig = {}) { this.host = host @@ -7199,7 +7192,7 @@ export class Api { }) }, /** - * View a support bundle + * View support bundle */ supportBundleView: ( { path }: { path: SupportBundleViewPathParams }, @@ -7882,7 +7875,7 @@ export class Api { }) }, /** - * Create a disk + * Create disk */ diskCreate: ( { query, body }: { query: DiskCreateQueryParams; body: DiskCreate }, @@ -8501,7 +8494,7 @@ export class Api { }) }, /** - * List multicast groups for instance + * List multicast groups for an instance */ instanceMulticastGroupList: ( { @@ -8521,27 +8514,30 @@ export class Api { }) }, /** - * Join multicast group. + * Join multicast group by name, IP address, or UUID */ instanceMulticastGroupJoin: ( { path, query = {}, + body, }: { path: InstanceMulticastGroupJoinPathParams query?: InstanceMulticastGroupJoinQueryParams + body: InstanceMulticastGroupJoin }, params: FetchParams = {} ) => { return this.request({ path: `/v1/instances/${path.instance}/multicast-groups/${path.multicastGroup}`, method: 'PUT', + body, query, ...params, }) }, /** - * Leave multicast group. + * Leave multicast group by name, IP address, or UUID */ instanceMulticastGroupLeave: ( { @@ -8561,7 +8557,7 @@ export class Api { }) }, /** - * Reboot an instance + * Reboot instance */ instanceReboot: ( { @@ -9001,7 +8997,7 @@ export class Api { }) }, /** - * List all multicast groups. + * List multicast groups */ multicastGroupList: ( { query = {} }: { query?: MulticastGroupListQueryParams }, @@ -9015,21 +9011,7 @@ export class Api { }) }, /** - * Create a multicast group. - */ - multicastGroupCreate: ( - { body }: { body: MulticastGroupCreate }, - params: FetchParams = {} - ) => { - return this.request({ - path: `/v1/multicast-groups`, - method: 'POST', - body, - ...params, - }) - }, - /** - * Fetch a multicast group. + * Fetch multicast group */ multicastGroupView: ( { path }: { path: MulticastGroupViewPathParams }, @@ -9042,34 +9024,7 @@ export class Api { }) }, /** - * Update a multicast group. - */ - multicastGroupUpdate: ( - { path, body }: { path: MulticastGroupUpdatePathParams; body: MulticastGroupUpdate }, - params: FetchParams = {} - ) => { - return this.request({ - path: `/v1/multicast-groups/${path.multicastGroup}`, - method: 'PUT', - body, - ...params, - }) - }, - /** - * Delete a multicast group. - */ - multicastGroupDelete: ( - { path }: { path: MulticastGroupDeletePathParams }, - params: FetchParams = {} - ) => { - return this.request({ - path: `/v1/multicast-groups/${path.multicastGroup}`, - method: 'DELETE', - ...params, - }) - }, - /** - * List members of a multicast group. + * List members of multicast group */ multicastGroupMemberList: ( { @@ -9088,49 +9043,6 @@ export class Api { ...params, }) }, - /** - * Add instance to a multicast group. - */ - multicastGroupMemberAdd: ( - { - path, - query = {}, - body, - }: { - path: MulticastGroupMemberAddPathParams - query?: MulticastGroupMemberAddQueryParams - body: MulticastGroupMemberAdd - }, - params: FetchParams = {} - ) => { - return this.request({ - path: `/v1/multicast-groups/${path.multicastGroup}/members`, - method: 'POST', - body, - query, - ...params, - }) - }, - /** - * Remove instance from a multicast group. - */ - multicastGroupMemberRemove: ( - { - path, - query = {}, - }: { - path: MulticastGroupMemberRemovePathParams - query?: MulticastGroupMemberRemoveQueryParams - }, - params: FetchParams = {} - ) => { - return this.request({ - path: `/v1/multicast-groups/${path.multicastGroup}/members/${path.instance}`, - method: 'DELETE', - query, - ...params, - }) - }, /** * List network interfaces */ @@ -9296,7 +9208,7 @@ export class Api { }) }, /** - * Update a project + * Update project */ projectUpdate: ( { path, body }: { path: ProjectUpdatePathParams; body: ProjectUpdate }, @@ -9441,7 +9353,7 @@ export class Api { }) }, /** - * Get a physical disk + * Get physical disk */ physicalDiskView: ( { path }: { path: PhysicalDiskViewPathParams }, @@ -9928,7 +9840,7 @@ export class Api { }) }, /** - * Add range to IP pool. + * Add range to an IP pool */ ipPoolRangeAdd: ( { path, body }: { path: IpPoolRangeAddPathParams; body: IpRange }, @@ -10089,19 +10001,6 @@ export class Api { ...params, }) }, - /** - * Look up multicast group by IP address. - */ - lookupMulticastGroupByIp: ( - { path }: { path: LookupMulticastGroupByIpPathParams }, - params: FetchParams = {} - ) => { - return this.request({ - path: `/v1/system/multicast-groups/by-ip/${path.address}`, - method: 'GET', - ...params, - }) - }, /** * List address lots */ @@ -10201,7 +10100,7 @@ export class Api { }) }, /** - * Disable a BFD session + * Disable BFD session */ networkingBfdDisable: ( { body }: { body: BfdSessionDisable }, @@ -10215,7 +10114,7 @@ export class Api { }) }, /** - * Enable a BFD session + * Enable BFD session */ networkingBfdEnable: ( { body }: { body: BfdSessionEnable }, @@ -10611,7 +10510,7 @@ export class Api { }) }, /** - * Create a silo + * Create silo */ siloCreate: ({ body }: { body: SiloCreate }, params: FetchParams = {}) => { return this.request({ @@ -10632,7 +10531,7 @@ export class Api { }) }, /** - * Delete a silo + * Delete silo */ siloDelete: ({ path }: { path: SiloDeletePathParams }, params: FetchParams = {}) => { return this.request({ @@ -11381,7 +11280,7 @@ export class Api { }) }, /** - * Update a VPC + * Update VPC */ vpcUpdate: ( { diff --git a/app/api/__generated__/OMICRON_VERSION b/app/api/__generated__/OMICRON_VERSION index cc761a204..26a3df73c 100644 --- a/app/api/__generated__/OMICRON_VERSION +++ b/app/api/__generated__/OMICRON_VERSION @@ -1,2 +1,2 @@ # generated file. do not update manually. see docs/update-pinned-api.md -135be59591f1ba4bc5941f63bf3a08a0b187b1a9 +26b33a11962d82b29fde9a2e3233d038d5495c44 diff --git a/app/api/__generated__/msw-handlers.ts b/app/api/__generated__/msw-handlers.ts index 3d0aa5ace..a29025911 100644 --- a/app/api/__generated__/msw-handlers.ts +++ b/app/api/__generated__/msw-handlers.ts @@ -639,6 +639,7 @@ export interface MSWHandlers { instanceMulticastGroupJoin: (params: { path: Api.InstanceMulticastGroupJoinPathParams query: Api.InstanceMulticastGroupJoinQueryParams + body: Json req: Request cookies: Record }) => Promisable> @@ -842,31 +843,12 @@ export interface MSWHandlers { req: Request cookies: Record }) => Promisable> - /** `POST /v1/multicast-groups` */ - multicastGroupCreate: (params: { - body: Json - req: Request - cookies: Record - }) => Promisable> /** `GET /v1/multicast-groups/:multicastGroup` */ multicastGroupView: (params: { path: Api.MulticastGroupViewPathParams req: Request cookies: Record }) => Promisable> - /** `PUT /v1/multicast-groups/:multicastGroup` */ - multicastGroupUpdate: (params: { - path: Api.MulticastGroupUpdatePathParams - body: Json - req: Request - cookies: Record - }) => Promisable> - /** `DELETE /v1/multicast-groups/:multicastGroup` */ - multicastGroupDelete: (params: { - path: Api.MulticastGroupDeletePathParams - req: Request - cookies: Record - }) => Promisable /** `GET /v1/multicast-groups/:multicastGroup/members` */ multicastGroupMemberList: (params: { path: Api.MulticastGroupMemberListPathParams @@ -874,21 +856,6 @@ export interface MSWHandlers { req: Request cookies: Record }) => Promisable> - /** `POST /v1/multicast-groups/:multicastGroup/members` */ - multicastGroupMemberAdd: (params: { - path: Api.MulticastGroupMemberAddPathParams - query: Api.MulticastGroupMemberAddQueryParams - body: Json - req: Request - cookies: Record - }) => Promisable> - /** `DELETE /v1/multicast-groups/:multicastGroup/members/:instance` */ - multicastGroupMemberRemove: (params: { - path: Api.MulticastGroupMemberRemovePathParams - query: Api.MulticastGroupMemberRemoveQueryParams - req: Request - cookies: Record - }) => Promisable /** `GET /v1/network-interfaces` */ instanceNetworkInterfaceList: (params: { query: Api.InstanceNetworkInterfaceListQueryParams @@ -1305,12 +1272,6 @@ export interface MSWHandlers { req: Request cookies: Record }) => Promisable> - /** `GET /v1/system/multicast-groups/by-ip/:address` */ - lookupMulticastGroupByIp: (params: { - path: Api.LookupMulticastGroupByIpPathParams - req: Request - cookies: Record - }) => Promisable> /** `GET /v1/system/networking/address-lot` */ networkingAddressLotList: (params: { query: Api.NetworkingAddressLotListQueryParams @@ -2498,7 +2459,7 @@ export function makeHandlers(handlers: MSWHandlers): HttpHandler[] { handler( handlers['instanceMulticastGroupJoin'], schema.InstanceMulticastGroupJoinParams, - null + schema.InstanceMulticastGroupJoin ) ), http.delete( @@ -2675,26 +2636,10 @@ export function makeHandlers(handlers: MSWHandlers): HttpHandler[] { '/v1/multicast-groups', handler(handlers['multicastGroupList'], schema.MulticastGroupListParams, null) ), - http.post( - '/v1/multicast-groups', - handler(handlers['multicastGroupCreate'], null, schema.MulticastGroupCreate) - ), http.get( '/v1/multicast-groups/:multicastGroup', handler(handlers['multicastGroupView'], schema.MulticastGroupViewParams, null) ), - http.put( - '/v1/multicast-groups/:multicastGroup', - handler( - handlers['multicastGroupUpdate'], - schema.MulticastGroupUpdateParams, - schema.MulticastGroupUpdate - ) - ), - http.delete( - '/v1/multicast-groups/:multicastGroup', - handler(handlers['multicastGroupDelete'], schema.MulticastGroupDeleteParams, null) - ), http.get( '/v1/multicast-groups/:multicastGroup/members', handler( @@ -2703,22 +2648,6 @@ export function makeHandlers(handlers: MSWHandlers): HttpHandler[] { null ) ), - http.post( - '/v1/multicast-groups/:multicastGroup/members', - handler( - handlers['multicastGroupMemberAdd'], - schema.MulticastGroupMemberAddParams, - schema.MulticastGroupMemberAdd - ) - ), - http.delete( - '/v1/multicast-groups/:multicastGroup/members/:instance', - handler( - handlers['multicastGroupMemberRemove'], - schema.MulticastGroupMemberRemoveParams, - null - ) - ), http.get( '/v1/network-interfaces', handler( @@ -3054,14 +2983,6 @@ export function makeHandlers(handlers: MSWHandlers): HttpHandler[] { '/v1/system/metrics/:metricName', handler(handlers['systemMetric'], schema.SystemMetricParams, null) ), - http.get( - '/v1/system/multicast-groups/by-ip/:address', - handler( - handlers['lookupMulticastGroupByIp'], - schema.LookupMulticastGroupByIpParams, - null - ) - ), http.get( '/v1/system/networking/address-lot', handler( diff --git a/app/api/__generated__/validate.ts b/app/api/__generated__/validate.ts index 596067ef0..672c41e85 100644 --- a/app/api/__generated__/validate.ts +++ b/app/api/__generated__/validate.ts @@ -85,6 +85,40 @@ export const Address = z.preprocess( }) ) +/** + * The IP address version. + */ +export const IpVersion = z.preprocess(processResponseBody, z.enum(['v4', 'v6'])) + +/** + * Specify which IP pool to allocate from. + */ +export const PoolSelector = z.preprocess( + processResponseBody, + z.union([ + z.object({ pool: NameOrId, type: z.enum(['explicit']) }), + z.object({ ipVersion: IpVersion.nullable().default(null), type: z.enum(['auto']) }), + ]) +) + +/** + * Specify how to allocate a floating IP address. + */ +export const AddressAllocator = z.preprocess( + processResponseBody, + z.union([ + z.object({ + ip: z.ipv4(), + pool: NameOrId.nullable().optional(), + type: z.enum(['explicit']), + }), + z.object({ + poolSelector: PoolSelector.default({ ipVersion: null, type: 'auto' }), + type: z.enum(['auto']), + }), + ]) +) + /** * A set of addresses associated with a port configuration. */ @@ -174,40 +208,6 @@ export const AddressLotViewResponse = z.preprocess( z.object({ blocks: AddressLotBlock.array(), lot: AddressLot }) ) -/** - * The IP address version. - */ -export const IpVersion = z.preprocess(processResponseBody, z.enum(['v4', 'v6'])) - -/** - * Specify which IP pool to allocate from. - */ -export const PoolSelector = z.preprocess( - processResponseBody, - z.union([ - z.object({ pool: NameOrId, type: z.enum(['explicit']) }), - z.object({ ipVersion: IpVersion.nullable().default(null), type: z.enum(['auto']) }), - ]) -) - -/** - * Specify how to allocate a floating IP address. - */ -export const AddressSelector = z.preprocess( - processResponseBody, - z.union([ - z.object({ - ip: z.ipv4(), - pool: NameOrId.nullable().optional(), - type: z.enum(['explicit']), - }), - z.object({ - poolSelector: PoolSelector.default({ ipVersion: null, type: 'auto' }), - type: z.enum(['auto']), - }), - ]) -) - /** * Describes the scope of affinity for the purposes of co-location. */ @@ -638,6 +638,14 @@ export const AuditLogEntryActor = z.preprocess( ]) ) +/** + * Authentication method used for a request + */ +export const AuthMethod = z.preprocess( + processResponseBody, + z.enum(['session_cookie', 'access_token', 'scim_token']) +) + /** * Result of an audit log entry */ @@ -662,7 +670,8 @@ export const AuditLogEntry = z.preprocess( processResponseBody, z.object({ actor: AuditLogEntryActor, - authMethod: z.string().nullable().optional(), + authMethod: AuthMethod.nullable().optional(), + credentialId: z.uuid().nullable().optional(), id: z.uuid(), operationId: z.string(), requestId: z.string(), @@ -2009,7 +2018,7 @@ export const FloatingIpAttach = z.preprocess( export const FloatingIpCreate = z.preprocess( processResponseBody, z.object({ - addressSelector: AddressSelector.default({ + addressAllocator: AddressAllocator.default({ poolSelector: { ipVersion: null, type: 'auto' }, type: 'auto', }), @@ -2251,6 +2260,27 @@ export const InstanceDiskAttachment = z.preprocess( ]) ) +/** + * A multicast group identifier + * + * Can be a UUID, a name, or an IP address + */ +export const MulticastGroupIdentifier = z.preprocess(processResponseBody, z.string()) + +/** + * Specification for joining a multicast group with optional source filtering. + * + * Used in `InstanceCreate` and `InstanceUpdate` to specify multicast group membership along with per-member source IP configuration. + */ +export const MulticastGroupJoinSpec = z.preprocess( + processResponseBody, + z.object({ + group: MulticastGroupIdentifier, + ipVersion: IpVersion.nullable().default(null), + sourceIps: z.ipv4().array().nullable().default(null), + }) +) + /** * How a VPC-private IP address is assigned to a network interface. */ @@ -2353,7 +2383,7 @@ export const InstanceCreate = z.preprocess( externalIps: ExternalIpCreate.array().default([]), hostname: Hostname, memory: ByteCount, - multicastGroups: NameOrId.array().default([]), + multicastGroups: MulticastGroupJoinSpec.array().default([]), name: Name, ncpus: InstanceCpuCount, networkInterfaces: InstanceNetworkInterfaceAttachment.default({ @@ -2365,6 +2395,19 @@ export const InstanceCreate = z.preprocess( }) ) +/** + * Parameters for joining an instance to a multicast group. + * + * When joining by IP address, the pool containing the multicast IP is auto-discovered from all linked multicast pools. + */ +export const InstanceMulticastGroupJoin = z.preprocess( + processResponseBody, + z.object({ + ipVersion: IpVersion.nullable().default(null), + sourceIps: z.ipv4().array().nullable().default(null), + }) +) + /** * The VPC-private IPv4 stack for a network interface */ @@ -2482,7 +2525,7 @@ export const InstanceUpdate = z.preprocess( bootDisk: NameOrId.nullable(), cpuPlatform: InstanceCpuPlatform.nullable(), memory: ByteCount, - multicastGroups: NameOrId.array().nullable().default(null), + multicastGroups: MulticastGroupJoinSpec.array().nullable().default(null), ncpus: InstanceCpuCount, }) ) @@ -2956,21 +2999,6 @@ export const MulticastGroup = z.preprocess( }) ) -/** - * Create-time parameters for a multicast group. - */ -export const MulticastGroupCreate = z.preprocess( - processResponseBody, - z.object({ - description: z.string(), - multicastIp: z.ipv4().nullable().default(null), - mvlan: z.number().min(0).max(65535).nullable().default(null), - name: Name, - pool: NameOrId.nullable().default(null), - sourceIps: z.ipv4().array().nullable().default(null), - }) -) - /** * View of a Multicast Group Member (instance belonging to a multicast group) */ @@ -2981,21 +3009,15 @@ export const MulticastGroupMember = z.preprocess( id: z.uuid(), instanceId: z.uuid(), multicastGroupId: z.uuid(), + multicastIp: z.ipv4(), name: Name, + sourceIps: z.ipv4().array(), state: z.string(), timeCreated: z.coerce.date(), timeModified: z.coerce.date(), }) ) -/** - * Parameters for adding an instance to a multicast group. - */ -export const MulticastGroupMemberAdd = z.preprocess( - processResponseBody, - z.object({ instance: NameOrId }) -) - /** * A single page of results */ @@ -3015,19 +3037,6 @@ export const MulticastGroupResultsPage = z.preprocess( z.object({ items: MulticastGroup.array(), nextPage: z.string().nullable().optional() }) ) -/** - * Update-time parameters for a multicast group. - */ -export const MulticastGroupUpdate = z.preprocess( - processResponseBody, - z.object({ - description: z.string().nullable().optional(), - mvlan: z.number().min(0).max(65535).nullable().optional(), - name: Name.nullable().optional(), - sourceIps: z.ipv4().array().nullable().optional(), - }) -) - /** * VPC-private IPv4 configuration for a network interface. */ @@ -5912,7 +5921,10 @@ export const InstanceMulticastGroupListParams = z.preprocess( instance: NameOrId, }), query: z.object({ + limit: z.number().min(1).max(4294967295).nullable().optional(), + pageToken: z.string().nullable().optional(), project: NameOrId.optional(), + sortBy: IdSortMode.optional(), }), }) ) @@ -5922,7 +5934,7 @@ export const InstanceMulticastGroupJoinParams = z.preprocess( z.object({ path: z.object({ instance: NameOrId, - multicastGroup: NameOrId, + multicastGroup: MulticastGroupIdentifier, }), query: z.object({ project: NameOrId.optional(), @@ -5935,7 +5947,7 @@ export const InstanceMulticastGroupLeaveParams = z.preprocess( z.object({ path: z.object({ instance: NameOrId, - multicastGroup: NameOrId, + multicastGroup: MulticastGroupIdentifier, }), query: z.object({ project: NameOrId.optional(), @@ -6309,39 +6321,11 @@ export const MulticastGroupListParams = z.preprocess( }) ) -export const MulticastGroupCreateParams = z.preprocess( - processResponseBody, - z.object({ - path: z.object({}), - query: z.object({}), - }) -) - export const MulticastGroupViewParams = z.preprocess( processResponseBody, z.object({ path: z.object({ - multicastGroup: NameOrId, - }), - query: z.object({}), - }) -) - -export const MulticastGroupUpdateParams = z.preprocess( - processResponseBody, - z.object({ - path: z.object({ - multicastGroup: NameOrId, - }), - query: z.object({}), - }) -) - -export const MulticastGroupDeleteParams = z.preprocess( - processResponseBody, - z.object({ - path: z.object({ - multicastGroup: NameOrId, + multicastGroup: MulticastGroupIdentifier, }), query: z.object({}), }) @@ -6351,7 +6335,7 @@ export const MulticastGroupMemberListParams = z.preprocess( processResponseBody, z.object({ path: z.object({ - multicastGroup: NameOrId, + multicastGroup: MulticastGroupIdentifier, }), query: z.object({ limit: z.number().min(1).max(4294967295).nullable().optional(), @@ -6361,31 +6345,6 @@ export const MulticastGroupMemberListParams = z.preprocess( }) ) -export const MulticastGroupMemberAddParams = z.preprocess( - processResponseBody, - z.object({ - path: z.object({ - multicastGroup: NameOrId, - }), - query: z.object({ - project: NameOrId.optional(), - }), - }) -) - -export const MulticastGroupMemberRemoveParams = z.preprocess( - processResponseBody, - z.object({ - path: z.object({ - instance: NameOrId, - multicastGroup: NameOrId, - }), - query: z.object({ - project: NameOrId.optional(), - }), - }) -) - export const InstanceNetworkInterfaceListParams = z.preprocess( processResponseBody, z.object({ @@ -7104,16 +7063,6 @@ export const SystemMetricParams = z.preprocess( }) ) -export const LookupMulticastGroupByIpParams = z.preprocess( - processResponseBody, - z.object({ - path: z.object({ - address: z.ipv4(), - }), - query: z.object({}), - }) -) - export const NetworkingAddressLotListParams = z.preprocess( processResponseBody, z.object({ diff --git a/app/forms/floating-ip-create.tsx b/app/forms/floating-ip-create.tsx index 9ff4dae77..7e5366334 100644 --- a/app/forms/floating-ip-create.tsx +++ b/app/forms/floating-ip-create.tsx @@ -73,7 +73,7 @@ export default function CreateFloatingIpSideModalForm() { onSubmit={({ pool, ...values }) => { const body: FloatingIpCreate = { ...values, - addressSelector: pool + addressAllocator: pool ? { type: 'auto' as const, poolSelector: { type: 'explicit' as const, pool }, diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 71def2c2b..5b870b3be 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -306,14 +306,14 @@ export const handlers = makeHandlers({ errIfExists(db.floatingIps, { name: body.name, project_id: project.id }) // TODO: when IP is specified, use ipInAnyRange to check that it is in the pool - const addressSelector = body.address_selector || { type: 'auto' } + const addressAllocator = body.address_allocator || { type: 'auto' } const pool = - addressSelector.type === 'explicit' && addressSelector.pool - ? lookup.siloIpPool({ pool: addressSelector.pool, silo: defaultSilo.id }) - : addressSelector.type === 'auto' && - addressSelector.pool_selector?.type === 'explicit' + addressAllocator.type === 'explicit' && addressAllocator.pool + ? lookup.siloIpPool({ pool: addressAllocator.pool, silo: defaultSilo.id }) + : addressAllocator.type === 'auto' && + addressAllocator.pool_selector?.type === 'explicit' ? lookup.siloIpPool({ - pool: addressSelector.pool_selector.pool, + pool: addressAllocator.pool_selector.pool, silo: defaultSilo.id, }) : lookup.siloDefaultIpPool({ silo: defaultSilo.id }) @@ -323,7 +323,7 @@ export const handlers = makeHandlers({ project_id: project.id, // TODO: use ip-num to actually get the next available IP in the pool ip: - (addressSelector.type === 'explicit' && addressSelector.ip) || + (addressAllocator.type === 'explicit' && addressAllocator.ip) || Array.from({ length: 4 }) .map(() => Math.floor(Math.random() * 256)) .join('.'), @@ -2102,14 +2102,8 @@ export const handlers = makeHandlers({ localIdpUserDelete: NotImplemented, localIdpUserSetPassword: NotImplemented, loginSaml: NotImplemented, - lookupMulticastGroupByIp: NotImplemented, - multicastGroupCreate: NotImplemented, - multicastGroupDelete: NotImplemented, multicastGroupList: NotImplemented, - multicastGroupMemberAdd: NotImplemented, multicastGroupMemberList: NotImplemented, - multicastGroupMemberRemove: NotImplemented, - multicastGroupUpdate: NotImplemented, multicastGroupView: NotImplemented, networkingAddressLotBlockList: NotImplemented, networkingAddressLotCreate: NotImplemented, From e9e82f976d31498620c10cad239ae0d71def1ded Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 20 Jan 2026 13:05:10 -0800 Subject: [PATCH 14/45] Bump @oxide/openapi-gen-ts --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5c6802547..8c68ce424 100644 --- a/package-lock.json +++ b/package-lock.json @@ -58,7 +58,7 @@ "devDependencies": { "@ianvs/prettier-plugin-sort-imports": "^4.7.0", "@mswjs/http-middleware": "^0.10.3", - "@oxide/openapi-gen-ts": "~0.13.1", + "@oxide/openapi-gen-ts": "~0.14.0", "@playwright/test": "^1.56.1", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", @@ -1669,9 +1669,9 @@ } }, "node_modules/@oxide/openapi-gen-ts": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/@oxide/openapi-gen-ts/-/openapi-gen-ts-0.13.1.tgz", - "integrity": "sha512-ZVT4vfHrEniWKwwhWEjkaZfbxzjZGPaTjwb4u+I8P5qaT2YkT/D6Y7W/SH4nAOKOSl1PZUIDgfjVQ9rDh8CYKA==", + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@oxide/openapi-gen-ts/-/openapi-gen-ts-0.14.0.tgz", + "integrity": "sha512-L7n/3Ox8UTgDwdDvqCr+PekXcTboq5HQhdEawZWD8ct9QkycCciZoClLWApz/B5T9eiQjZq/5nqyE5JJqqw6nw==", "dev": true, "license": "MPL-2.0", "dependencies": { diff --git a/package.json b/package.json index 0ebbfddda..114c8cd6b 100644 --- a/package.json +++ b/package.json @@ -83,7 +83,7 @@ "devDependencies": { "@ianvs/prettier-plugin-sort-imports": "^4.7.0", "@mswjs/http-middleware": "^0.10.3", - "@oxide/openapi-gen-ts": "~0.13.1", + "@oxide/openapi-gen-ts": "~0.14.0", "@playwright/test": "^1.56.1", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", From 114baee2f10c37bace78d399c1f5561978aaf6a0 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 20 Jan 2026 13:06:14 -0800 Subject: [PATCH 15/45] npm run gen-api --- app/api/__generated__/validate.ts | 83 +++++++++++++++++-------------- 1 file changed, 47 insertions(+), 36 deletions(-) diff --git a/app/api/__generated__/validate.ts b/app/api/__generated__/validate.ts index 672c41e85..08eb03a9c 100644 --- a/app/api/__generated__/validate.ts +++ b/app/api/__generated__/validate.ts @@ -108,7 +108,7 @@ export const AddressAllocator = z.preprocess( processResponseBody, z.union([ z.object({ - ip: z.ipv4(), + ip: z.union([z.ipv4(), z.ipv6()]), pool: NameOrId.nullable().optional(), type: z.enum(['explicit']), }), @@ -152,7 +152,11 @@ export const AddressLot = z.preprocess( */ export const AddressLotBlock = z.preprocess( processResponseBody, - z.object({ firstAddress: z.ipv4(), id: z.uuid(), lastAddress: z.ipv4() }) + z.object({ + firstAddress: z.union([z.ipv4(), z.ipv6()]), + id: z.uuid(), + lastAddress: z.union([z.ipv4(), z.ipv6()]), + }) ) /** @@ -160,7 +164,10 @@ export const AddressLotBlock = z.preprocess( */ export const AddressLotBlockCreate = z.preprocess( processResponseBody, - z.object({ firstAddress: z.ipv4(), lastAddress: z.ipv4() }) + z.object({ + firstAddress: z.union([z.ipv4(), z.ipv6()]), + lastAddress: z.union([z.ipv4(), z.ipv6()]), + }) ) /** @@ -677,7 +684,7 @@ export const AuditLogEntry = z.preprocess( requestId: z.string(), requestUri: z.string(), result: AuditLogEntryResult, - sourceIp: z.ipv4(), + sourceIp: z.union([z.ipv4(), z.ipv6()]), timeCompleted: z.coerce.date(), timeStarted: z.coerce.date(), userAgent: z.string().nullable().optional(), @@ -727,7 +734,7 @@ export const BfdMode = z.preprocess( */ export const BfdSessionDisable = z.preprocess( processResponseBody, - z.object({ remote: z.ipv4(), switch: Name }) + z.object({ remote: z.union([z.ipv4(), z.ipv6()]), switch: Name }) ) /** @@ -737,9 +744,9 @@ export const BfdSessionEnable = z.preprocess( processResponseBody, z.object({ detectionThreshold: z.number().min(0).max(255), - local: z.ipv4().nullable().optional(), + local: z.union([z.ipv4(), z.ipv6()]).nullable().optional(), mode: BfdMode, - remote: z.ipv4(), + remote: z.union([z.ipv4(), z.ipv6()]), requiredRx: z.number().min(0), switch: Name, }) @@ -754,9 +761,9 @@ export const BfdStatus = z.preprocess( processResponseBody, z.object({ detectionThreshold: z.number().min(0).max(255), - local: z.ipv4().nullable().optional(), + local: z.union([z.ipv4(), z.ipv6()]).nullable().optional(), mode: BfdMode, - peer: z.ipv4(), + peer: z.union([z.ipv4(), z.ipv6()]), requiredRx: z.number().min(0), state: BfdState, switch: Name, @@ -881,7 +888,7 @@ export const ImportExportPolicy = z.preprocess( export const BgpPeer = z.preprocess( processResponseBody, z.object({ - addr: z.ipv4(), + addr: z.union([z.ipv4(), z.ipv6()]), allowedExport: ImportExportPolicy, allowedImport: ImportExportPolicy, bgpConfig: NameOrId, @@ -930,7 +937,7 @@ export const BgpPeerState = z.preprocess( export const BgpPeerStatus = z.preprocess( processResponseBody, z.object({ - addr: z.ipv4(), + addr: z.union([z.ipv4(), z.ipv6()]), localAsn: z.number().min(0).max(4294967295), remoteAsn: z.number().min(0).max(4294967295), state: BgpPeerState, @@ -1837,17 +1844,21 @@ export const ExternalIp = z.preprocess( z.union([ z.object({ firstPort: z.number().min(0).max(65535), - ip: z.ipv4(), + ip: z.union([z.ipv4(), z.ipv6()]), ipPoolId: z.uuid(), kind: z.enum(['snat']), lastPort: z.number().min(0).max(65535), }), - z.object({ ip: z.ipv4(), ipPoolId: z.uuid(), kind: z.enum(['ephemeral']) }), + z.object({ + ip: z.union([z.ipv4(), z.ipv6()]), + ipPoolId: z.uuid(), + kind: z.enum(['ephemeral']), + }), z.object({ description: z.string(), id: z.uuid(), instanceId: z.uuid().nullable().optional(), - ip: z.ipv4(), + ip: z.union([z.ipv4(), z.ipv6()]), ipPoolId: z.uuid(), kind: z.enum(['floating']), name: Name, @@ -1934,7 +1945,7 @@ export const FieldValue = z.preprocess( z.object({ type: z.enum(['u32']), value: z.number().min(0).max(4294967295) }), z.object({ type: z.enum(['i64']), value: z.number() }), z.object({ type: z.enum(['u64']), value: z.number().min(0) }), - z.object({ type: z.enum(['ip_addr']), value: z.ipv4() }), + z.object({ type: z.enum(['ip_addr']), value: z.union([z.ipv4(), z.ipv6()]) }), z.object({ type: z.enum(['uuid']), value: z.uuid() }), z.object({ type: z.enum(['bool']), value: SafeBoolean }), ]) @@ -1990,7 +2001,7 @@ export const FloatingIp = z.preprocess( description: z.string(), id: z.uuid(), instanceId: z.uuid().nullable().optional(), - ip: z.ipv4(), + ip: z.union([z.ipv4(), z.ipv6()]), ipPoolId: z.uuid(), name: Name, projectId: z.uuid(), @@ -2277,7 +2288,7 @@ export const MulticastGroupJoinSpec = z.preprocess( z.object({ group: MulticastGroupIdentifier, ipVersion: IpVersion.nullable().default(null), - sourceIps: z.ipv4().array().nullable().default(null), + sourceIps: z.union([z.ipv4(), z.ipv6()]).array().nullable().default(null), }) ) @@ -2404,7 +2415,7 @@ export const InstanceMulticastGroupJoin = z.preprocess( processResponseBody, z.object({ ipVersion: IpVersion.nullable().default(null), - sourceIps: z.ipv4().array().nullable().default(null), + sourceIps: z.union([z.ipv4(), z.ipv6()]).array().nullable().default(null), }) ) @@ -2568,7 +2579,7 @@ export const InternetGatewayCreate = z.preprocess( export const InternetGatewayIpAddress = z.preprocess( processResponseBody, z.object({ - address: z.ipv4(), + address: z.union([z.ipv4(), z.ipv6()]), description: z.string(), id: z.uuid(), internetGatewayId: z.uuid(), @@ -2583,7 +2594,7 @@ export const InternetGatewayIpAddress = z.preprocess( */ export const InternetGatewayIpAddressCreate = z.preprocess( processResponseBody, - z.object({ address: z.ipv4(), description: z.string(), name: Name }) + z.object({ address: z.union([z.ipv4(), z.ipv6()]), description: z.string(), name: Name }) ) /** @@ -2805,7 +2816,7 @@ export const LldpLinkConfigCreate = z.preprocess( enabled: SafeBoolean, linkDescription: z.string().nullable().optional(), linkName: z.string().nullable().optional(), - managementIp: z.ipv4().nullable().optional(), + managementIp: z.union([z.ipv4(), z.ipv6()]).nullable().optional(), systemDescription: z.string().nullable().optional(), systemName: z.string().nullable().optional(), }) @@ -2870,7 +2881,7 @@ export const LldpLinkConfig = z.preprocess( id: z.uuid(), linkDescription: z.string().nullable().optional(), linkName: z.string().nullable().optional(), - managementIp: z.ipv4().nullable().optional(), + managementIp: z.union([z.ipv4(), z.ipv6()]).nullable().optional(), systemDescription: z.string().nullable().optional(), systemName: z.string().nullable().optional(), }) @@ -2879,7 +2890,7 @@ export const LldpLinkConfig = z.preprocess( export const NetworkAddress = z.preprocess( processResponseBody, z.union([ - z.object({ ipAddr: z.ipv4() }), + z.object({ ipAddr: z.union([z.ipv4(), z.ipv6()]) }), z.object({ iEEE802: z.number().min(0).max(255).array() }), ]) ) @@ -2939,7 +2950,7 @@ export const LoopbackAddress = z.preprocess( export const LoopbackAddressCreate = z.preprocess( processResponseBody, z.object({ - address: z.ipv4(), + address: z.union([z.ipv4(), z.ipv6()]), addressLot: NameOrId, anycast: SafeBoolean, mask: z.number().min(0).max(255), @@ -2989,10 +3000,10 @@ export const MulticastGroup = z.preprocess( description: z.string(), id: z.uuid(), ipPoolId: z.uuid(), - multicastIp: z.ipv4(), + multicastIp: z.union([z.ipv4(), z.ipv6()]), mvlan: z.number().min(0).max(65535).nullable().optional(), name: Name, - sourceIps: z.ipv4().array(), + sourceIps: z.union([z.ipv4(), z.ipv6()]).array(), state: z.string(), timeCreated: z.coerce.date(), timeModified: z.coerce.date(), @@ -3009,9 +3020,9 @@ export const MulticastGroupMember = z.preprocess( id: z.uuid(), instanceId: z.uuid(), multicastGroupId: z.uuid(), - multicastIp: z.ipv4(), + multicastIp: z.union([z.ipv4(), z.ipv6()]), name: Name, - sourceIps: z.ipv4().array(), + sourceIps: z.union([z.ipv4(), z.ipv6()]).array(), state: z.string(), timeCreated: z.coerce.date(), timeModified: z.coerce.date(), @@ -3274,7 +3285,7 @@ export const ProbeExternalIp = z.preprocess( processResponseBody, z.object({ firstPort: z.number().min(0).max(65535), - ip: z.ipv4(), + ip: z.union([z.ipv4(), z.ipv6()]), kind: ProbeExternalIpKind, lastPort: z.number().min(0).max(65535), }) @@ -3388,7 +3399,7 @@ export const Route = z.preprocess( processResponseBody, z.object({ dst: IpNet, - gw: z.ipv4(), + gw: z.union([z.ipv4(), z.ipv6()]), ribPriority: z.number().min(0).max(255).nullable().optional(), vid: z.number().min(0).max(65535).nullable().optional(), }) @@ -3410,7 +3421,7 @@ export const RouteConfig = z.preprocess( export const RouteDestination = z.preprocess( processResponseBody, z.union([ - z.object({ type: z.enum(['ip']), value: z.ipv4() }), + z.object({ type: z.enum(['ip']), value: z.union([z.ipv4(), z.ipv6()]) }), z.object({ type: z.enum(['ip_net']), value: IpNet }), z.object({ type: z.enum(['vpc']), value: Name }), z.object({ type: z.enum(['subnet']), value: Name }), @@ -3423,7 +3434,7 @@ export const RouteDestination = z.preprocess( export const RouteTarget = z.preprocess( processResponseBody, z.union([ - z.object({ type: z.enum(['ip']), value: z.ipv4() }), + z.object({ type: z.enum(['ip']), value: z.union([z.ipv4(), z.ipv6()]) }), z.object({ type: z.enum(['vpc']), value: Name }), z.object({ type: z.enum(['subnet']), value: Name }), z.object({ type: z.enum(['instance']), value: Name }), @@ -4152,7 +4163,7 @@ export const SwitchPortRouteConfig = z.preprocess( processResponseBody, z.object({ dst: IpNet, - gw: z.ipv4(), + gw: z.union([z.ipv4(), z.ipv6()]), interfaceName: Name, portSettingsId: z.uuid(), ribPriority: z.number().min(0).max(255).nullable().optional(), @@ -4581,7 +4592,7 @@ export const VpcFirewallRuleHostFilter = z.preprocess( z.object({ type: z.enum(['vpc']), value: Name }), z.object({ type: z.enum(['subnet']), value: Name }), z.object({ type: z.enum(['instance']), value: Name }), - z.object({ type: z.enum(['ip']), value: z.ipv4() }), + z.object({ type: z.enum(['ip']), value: z.union([z.ipv4(), z.ipv6()]) }), z.object({ type: z.enum(['ip_net']), value: IpNet }), ]) ) @@ -4624,7 +4635,7 @@ export const VpcFirewallRuleTarget = z.preprocess( z.object({ type: z.enum(['vpc']), value: Name }), z.object({ type: z.enum(['subnet']), value: Name }), z.object({ type: z.enum(['instance']), value: Name }), - z.object({ type: z.enum(['ip']), value: z.ipv4() }), + z.object({ type: z.enum(['ip']), value: z.union([z.ipv4(), z.ipv6()]) }), z.object({ type: z.enum(['ip_net']), value: IpNet }), ]) ) @@ -7303,7 +7314,7 @@ export const NetworkingLoopbackAddressDeleteParams = z.preprocess( processResponseBody, z.object({ path: z.object({ - address: z.ipv4(), + address: z.union([z.ipv4(), z.ipv6()]), rackId: z.uuid(), subnetMask: z.number().min(0).max(255), switchLocation: Name, From 0e2ea44955048a46726fe7e4e2302ff8fc38f953 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 21 Jan 2026 14:40:06 -0800 Subject: [PATCH 16/45] Update UX for ephemeral IP attach modal --- app/components/AttachEphemeralIpModal.tsx | 20 +++++--------------- test/e2e/instance-networking.e2e.ts | 20 ++++++++++++++++++-- 2 files changed, 23 insertions(+), 17 deletions(-) diff --git a/app/components/AttachEphemeralIpModal.tsx b/app/components/AttachEphemeralIpModal.tsx index 24f8aec03..e4c3c69d5 100644 --- a/app/components/AttachEphemeralIpModal.tsx +++ b/app/components/AttachEphemeralIpModal.tsx @@ -6,7 +6,6 @@ * Copyright Oxide Computer Company */ -import { useMemo } from 'react' import { useForm } from 'react-hook-form' import { api, q, queryClient, useApiMutation, usePrefetchedQuery } from '~/api' @@ -24,10 +23,6 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) const { data: siloPools } = usePrefetchedQuery( q(api.projectIpPoolList, { query: { limit: ALL_ISH } }) ) - const defaultPool = useMemo( - () => siloPools?.items.find((pool) => pool.isDefault), - [siloPools] - ) const instanceEphemeralIpAttach = useApiMutation(api.instanceEphemeralIpAttach, { onSuccess(ephemeralIp) { queryClient.invalidateEndpoint('instanceExternalIpList') @@ -39,7 +34,7 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) addToast({ title: 'Error', content: err.message, variant: 'error' }) }, }) - const form = useForm({ defaultValues: { pool: defaultPool?.name } }) + const form = useForm({ defaultValues: { pool: '' } }) const pool = form.watch('pool') return ( @@ -51,26 +46,21 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) control={form.control} name="pool" label="IP pool" - placeholder={ - siloPools?.items && siloPools.items.length > 0 - ? 'Select a pool' - : 'No pools available' - } + placeholder="Default pool" items={siloPools.items.map(toIpPoolItem)} - required /> { - if (!pool) return instanceEphemeralIpAttach.mutate({ path: { instance }, query: { project }, - body: { poolSelector: { type: 'explicit', pool } }, + body: pool + ? { poolSelector: { type: 'explicit', pool } } + : { poolSelector: { type: 'auto' } }, }) }} onDismiss={onDismiss} diff --git a/test/e2e/instance-networking.e2e.ts b/test/e2e/instance-networking.e2e.ts index 3079160e9..7f41656e9 100644 --- a/test/e2e/instance-networking.e2e.ts +++ b/test/e2e/instance-networking.e2e.ts @@ -122,9 +122,25 @@ test('Instance networking tab — Detach / Attach Ephemeral IPs', async ({ page // The 'Attach ephemeral IP' button should be visible and enabled now that the existing ephemeral IP has been detached await expect(attachEphemeralIpButton).toBeEnabled() - // Attach a new ephemeral IP + // Attach a new ephemeral IP using the default pool (don't select a pool) await attachEphemeralIpButton.click() - const modal = page.getByRole('dialog', { name: 'Attach ephemeral IP' }) + let modal = page.getByRole('dialog', { name: 'Attach ephemeral IP' }) + await expect(modal).toBeVisible() + // Click Attach without selecting a pool - should use default pool + await page.getByRole('button', { name: 'Attach', exact: true }).click() + await expect(modal).toBeHidden() + await expect(ephemeralCell).toBeVisible() + + // The 'Attach ephemeral IP' button should be hidden after attaching an ephemeral IP + await expect(attachEphemeralIpButton).toBeHidden() + + // Detach and test with explicit pool selection + await clickRowAction(page, 'ephemeral', 'Detach') + await page.getByRole('button', { name: 'Confirm' }).click() + await expect(ephemeralCell).toBeHidden() + + await attachEphemeralIpButton.click() + modal = page.getByRole('dialog', { name: 'Attach ephemeral IP' }) await expect(modal).toBeVisible() await page.getByRole('button', { name: 'IP pool' }).click() await page.getByRole('option', { name: 'ip-pool-2' }).click() From 807c927b259f5ff352681ca5f1107196d925bd4a Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 21 Jan 2026 15:22:13 -0800 Subject: [PATCH 17/45] e2e text flexibility --- test/e2e/network-interface-create.e2e.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/test/e2e/network-interface-create.e2e.ts b/test/e2e/network-interface-create.e2e.ts index b68f427b4..87308afee 100644 --- a/test/e2e/network-interface-create.e2e.ts +++ b/test/e2e/network-interface-create.e2e.ts @@ -76,7 +76,10 @@ test('can create a NIC with a blank IP address', async ({ page }) => { // ip address is auto-assigned (dual-stack by default) const table = page.getByRole('table', { name: 'Network interfaces' }) - await expectRowVisible(table, { name: 'nic-2', 'Private IP': '123.45.68.8fd12:3456::' }) + await expectRowVisible(table, { + name: 'nic-2', + 'Private IP': expect.stringMatching(/123\.45\.68\.8\s*fd12:3456::/), + }) }) test('can create a NIC with IPv6 only', async ({ page }) => { @@ -126,5 +129,8 @@ test('can create a NIC with dual-stack and explicit IPs', async ({ page }) => { await expect(sidebar).toBeHidden() const table = page.getByRole('table', { name: 'Network interfaces' }) - await expectRowVisible(table, { name: 'nic-4', 'Private IP': '10.0.0.5fd00::5' }) + await expectRowVisible(table, { + name: 'nic-4', + 'Private IP': expect.stringMatching(/10\.0\.0\.5\s*fd00::5/), + }) }) From abc336f4396bf72fa749f10d203d5be167521f66 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 21 Jan 2026 15:50:17 -0800 Subject: [PATCH 18/45] fix bug when defaultPool was falsy --- app/forms/instance-create.tsx | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index e594a8dc7..8b6891523 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -234,7 +234,7 @@ export default function CreateInstanceForm() { bootDiskSize: diskSizeNearest10(defaultImage?.size / GiB), externalIps: defaultPool ? [{ type: 'ephemeral', poolSelector: { type: 'explicit', pool: defaultPool } }] - : [], + : [{ type: 'ephemeral' }], } const form = useForm({ defaultValues }) @@ -648,7 +648,10 @@ const AdvancedAccordion = ({ const externalIps = useController({ control, name: 'externalIps' }) const ephemeralIp = externalIps.field.value?.find((ip) => ip.type === 'ephemeral') const assignEphemeralIp = !!ephemeralIp - const selectedPool = ephemeralIp && 'pool' in ephemeralIp ? ephemeralIp.pool : undefined + const selectedPool = + ephemeralIp?.poolSelector?.type === 'explicit' + ? ephemeralIp.poolSelector.pool + : undefined const defaultPool = siloPools.find((pool) => pool.isDefault)?.name const attachedFloatingIps = (externalIps.field.value || []).filter(isFloating) @@ -743,7 +746,15 @@ const AdvancedAccordion = ({ ? externalIps.field.value?.filter((ip) => ip.type !== 'ephemeral') : [ ...(externalIps.field.value || []), - { type: 'ephemeral', pool: selectedPool || defaultPool }, + selectedPool || defaultPool + ? { + type: 'ephemeral', + poolSelector: { + type: 'explicit', + pool: selectedPool || defaultPool, + }, + } + : { type: 'ephemeral' }, ] externalIps.field.onChange(newExternalIps) }} @@ -761,7 +772,9 @@ const AdvancedAccordion = ({ required onChange={(value) => { const newExternalIps = externalIps.field.value?.map((ip) => - ip.type === 'ephemeral' ? { ...ip, pool: value } : ip + ip.type === 'ephemeral' + ? { type: 'ephemeral', poolSelector: { type: 'explicit', pool: value } } + : ip ) externalIps.field.onChange(newExternalIps) }} From e887c747993aba88bf64d3e50125c23ca470c616 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 21 Jan 2026 15:52:32 -0800 Subject: [PATCH 19/45] fix runtime issue if siloPools haven't loaded --- app/components/AttachEphemeralIpModal.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/components/AttachEphemeralIpModal.tsx b/app/components/AttachEphemeralIpModal.tsx index e4c3c69d5..c568f9c0e 100644 --- a/app/components/AttachEphemeralIpModal.tsx +++ b/app/components/AttachEphemeralIpModal.tsx @@ -47,13 +47,14 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) name="pool" label="IP pool" placeholder="Default pool" - items={siloPools.items.map(toIpPoolItem)} + items={(siloPools?.items ?? []).map(toIpPoolItem)} /> { instanceEphemeralIpAttach.mutate({ path: { instance }, From 9d08a826f008a258355d412b9bc2a8e1147c4be5 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 21 Jan 2026 16:50:36 -0800 Subject: [PATCH 20/45] Fix bug where when both IPv4 and IPv6 default pools exist, { poolSelector: { type: 'auto' } } fails unless ipVersion is specified --- app/components/AttachEphemeralIpModal.tsx | 45 +++++++++++++++++++++-- app/forms/instance-create.tsx | 25 ++++++++++++- 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/app/components/AttachEphemeralIpModal.tsx b/app/components/AttachEphemeralIpModal.tsx index c568f9c0e..2a30ce5b7 100644 --- a/app/components/AttachEphemeralIpModal.tsx +++ b/app/components/AttachEphemeralIpModal.tsx @@ -6,9 +6,17 @@ * Copyright Oxide Computer Company */ +import { useMemo } from 'react' import { useForm } from 'react-hook-form' -import { api, q, queryClient, useApiMutation, usePrefetchedQuery } from '~/api' +import { + api, + q, + queryClient, + useApiMutation, + usePrefetchedQuery, + type IpVersion, +} from '~/api' import { ListboxField } from '~/components/form/fields/ListboxField' import { HL } from '~/components/HL' import { useInstanceSelector } from '~/hooks/use-params' @@ -23,6 +31,18 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) const { data: siloPools } = usePrefetchedQuery( q(api.projectIpPoolList, { query: { limit: ALL_ISH } }) ) + + // Detect if both IPv4 and IPv6 default unicast pools exist + const hasDualDefaults = useMemo(() => { + if (!siloPools) return false + const defaultUnicastPools = siloPools.items.filter( + (pool) => pool.isDefault && pool.poolType === 'unicast' + ) + const hasV4Default = defaultUnicastPools.some((p) => p.ipVersion === 'v4') + const hasV6Default = defaultUnicastPools.some((p) => p.ipVersion === 'v6') + return hasV4Default && hasV6Default + }, [siloPools]) + const instanceEphemeralIpAttach = useApiMutation(api.instanceEphemeralIpAttach, { onSuccess(ephemeralIp) { queryClient.invalidateEndpoint('instanceExternalIpList') @@ -34,8 +54,12 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) addToast({ title: 'Error', content: err.message, variant: 'error' }) }, }) - const form = useForm({ defaultValues: { pool: '' } }) + + const form = useForm<{ pool: string; ipVersion: IpVersion }>({ + defaultValues: { pool: '', ipVersion: 'v4' }, + }) const pool = form.watch('pool') + const ipVersion = form.watch('ipVersion') return ( @@ -49,6 +73,19 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) placeholder="Default pool" items={(siloPools?.items ?? []).map(toIpPoolItem)} /> + {!pool && hasDualDefaults && ( + + )} @@ -61,7 +98,9 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) query: { project }, body: pool ? { poolSelector: { type: 'explicit', pool } } - : { poolSelector: { type: 'auto' } }, + : hasDualDefaults + ? { poolSelector: { type: 'auto', ipVersion } } + : { poolSelector: { type: 'auto' } }, }) }} onDismiss={onDismiss} diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index 8b6891523..702991c74 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -227,6 +227,17 @@ export default function CreateInstanceForm() { const defaultSource = siloImages.length > 0 ? 'siloImage' : projectImages.length > 0 ? 'projectImage' : 'disk' + // Detect if both IPv4 and IPv6 default unicast pools exist + const hasDualDefaults = useMemo(() => { + if (!siloPools) return false + const defaultUnicastPools = siloPools.items.filter( + (pool) => pool.isDefault && pool.poolType === 'unicast' + ) + const hasV4Default = defaultUnicastPools.some((p) => p.ipVersion === 'v4') + const hasV6Default = defaultUnicastPools.some((p) => p.ipVersion === 'v6') + return hasV4Default && hasV6Default + }, [siloPools]) + const defaultValues: InstanceCreateInput = { ...baseDefaultValues, bootDiskSourceType: defaultSource, @@ -234,7 +245,9 @@ export default function CreateInstanceForm() { bootDiskSize: diskSizeNearest10(defaultImage?.size / GiB), externalIps: defaultPool ? [{ type: 'ephemeral', poolSelector: { type: 'explicit', pool: defaultPool } }] - : [{ type: 'ephemeral' }], + : hasDualDefaults + ? [{ type: 'ephemeral', poolSelector: { type: 'auto', ipVersion: 'v4' } }] + : [{ type: 'ephemeral' }], } const form = useForm({ defaultValues }) @@ -598,6 +611,7 @@ export default function CreateInstanceForm() { control={control} isSubmitting={isSubmitting} siloPools={siloPools.items} + hasDualDefaults={hasDualDefaults} /> Create instance @@ -634,10 +648,12 @@ const AdvancedAccordion = ({ control, isSubmitting, siloPools, + hasDualDefaults, }: { control: Control isSubmitting: boolean siloPools: Array + hasDualDefaults: boolean }) => { // we track this state manually for the sole reason that we need to be able to // tell, inside AccordionItem, when an accordion is opened so we can scroll its @@ -754,7 +770,12 @@ const AdvancedAccordion = ({ pool: selectedPool || defaultPool, }, } - : { type: 'ephemeral' }, + : hasDualDefaults + ? { + type: 'ephemeral', + poolSelector: { type: 'auto', ipVersion: 'v4' }, + } + : { type: 'ephemeral' }, ] externalIps.field.onChange(newExternalIps) }} From da700ef4cc39a0629f5d8b72cc281121333398b4 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 21 Jan 2026 17:35:01 -0800 Subject: [PATCH 21/45] Better handling of dual default pools --- app/pages/system/networking/IpPoolPage.tsx | 46 ++++---- app/pages/system/silos/SiloIpPoolsTab.tsx | 119 ++++++++++++++++----- app/table/cells/DefaultPoolCell.tsx | 10 +- mock-api/ip-pool.ts | 2 +- mock-api/msw/handlers.ts | 17 ++- test/e2e/instance-create.e2e.ts | 2 +- test/e2e/silos.e2e.ts | 28 ++--- 7 files changed, 152 insertions(+), 72 deletions(-) diff --git a/app/pages/system/networking/IpPoolPage.tsx b/app/pages/system/networking/IpPoolPage.tsx index 3e5286d66..93c3f270c 100644 --- a/app/pages/system/networking/IpPoolPage.tsx +++ b/app/pages/system/networking/IpPoolPage.tsx @@ -255,26 +255,6 @@ function SiloNameFromId({ value: siloId }: { value: string }) { } const silosColHelper = createColumnHelper() -const silosStaticCols = [ - silosColHelper.accessor('siloId', { - header: 'Silo', - cell: (info) => , - }), - silosColHelper.accessor('isDefault', { - header: () => { - return ( - - Pool is silo default - - IPs are allocated from the default pool when users ask for an IP without - specifying a pool - - - ) - }, - cell: (info) => , - }), -] function LinkedSilosTable() { const poolSelector = useIpPoolSelector() @@ -328,7 +308,31 @@ function LinkedSilosTable() { /> ) - const columns = useColsWithActions(silosStaticCols, makeActions) + const silosCols = useMemo( + () => [ + silosColHelper.accessor('siloId', { + header: 'Silo', + cell: (info) => , + }), + silosColHelper.accessor('isDefault', { + header: () => { + return ( + + Pool is silo default + + IPs are allocated from the default pool when users ask for an IP without + specifying a pool + + + ) + }, + cell: (info) => , + }), + ], + [] + ) + + const columns = useColsWithActions(silosCols, makeActions) const { table } = useQueryTable({ query: ipPoolSiloList(poolSelector), columns, diff --git a/app/pages/system/silos/SiloIpPoolsTab.tsx b/app/pages/system/silos/SiloIpPoolsTab.tsx index 2226f3aee..3bec42f85 100644 --- a/app/pages/system/silos/SiloIpPoolsTab.tsx +++ b/app/pages/system/silos/SiloIpPoolsTab.tsx @@ -12,8 +12,16 @@ import { useCallback, useMemo, useState } from 'react' import { useForm } from 'react-hook-form' import { type LoaderFunctionArgs } from 'react-router' -import { api, getListQFn, queryClient, useApiMutation, type SiloIpPool } from '@oxide/api' +import { + api, + getListQFn, + queryClient, + useApiMutation, + type IpVersion, + type SiloIpPool, +} from '@oxide/api' import { Networking24Icon } from '@oxide/design-system/icons/react' +import { Badge } from '@oxide/design-system/ui' import { ComboboxField } from '~/components/form/fields/ComboboxField' import { HL } from '~/components/HL' @@ -46,15 +54,6 @@ const EmptyState = () => ( const colHelper = createColumnHelper() -const staticCols = [ - colHelper.accessor('name', { cell: makeLinkCell((pool) => pb.ipPool({ pool })) }), - colHelper.accessor('description', Columns.description), - colHelper.accessor('isDefault', { - header: 'Default', - cell: (info) => , - }), -] - const allPoolsQuery = getListQFn(api.ipPoolList, { query: { limit: ALL_ISH } }) const allSiloPoolsQuery = (silo: string) => @@ -70,19 +69,85 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { return null } +// Helper component that computes dual defaults from table data +function DefaultPoolCellWithContext({ + isDefault, + ipVersion, + allRows, +}: { + isDefault: boolean + ipVersion: IpVersion + allRows: SiloIpPool[] +}) { + // Compute dual defaults from current table data + const hasDualDefaults = useMemo(() => { + const defaultUnicastPools = allRows.filter( + (pool) => pool.isDefault && pool.poolType === 'unicast' + ) + const hasV4Default = defaultUnicastPools.some((p) => p.ipVersion === 'v4') + const hasV6Default = defaultUnicastPools.some((p) => p.ipVersion === 'v6') + return hasV4Default && hasV6Default + }, [allRows]) + + return ( + + ) +} + export default function SiloIpPoolsTab() { const { silo } = useSiloSelector() const [showLinkModal, setShowLinkModal] = useState(false) - // Fetch all_ish, but there should only be a few anyway. Not prefetched - // because the prefetched one only gets 25 to match the query table. This req - // is better to do async because they can't click make default that fast - // anyway. - const { data: allPools } = useQuery(allSiloPoolsQuery(silo).optionsFn()) + // Fetch all pools for the table and for computing dual defaults + const { data: allPoolsData } = useQuery(allSiloPoolsQuery(silo).optionsFn()) + const allPools = allPoolsData?.items - // used in change default confirm modal - const defaultPool = useMemo( - () => (allPools ? allPools.items.find((p) => p.isDefault)?.name : undefined), + // Define columns + const staticCols = useMemo( + () => [ + colHelper.accessor('name', { cell: makeLinkCell((pool) => pb.ipPool({ pool })) }), + colHelper.accessor('description', Columns.description), + colHelper.accessor('ipVersion', { + header: 'IP Version', + cell: (info) => + info.getValue() === 'v4' ? ( + v4 + ) : ( + v6 + ), + }), + colHelper.accessor('poolType', { + header: 'Type', + cell: (info) => + info.getValue() === 'unicast' ? ( + Unicast + ) : ( + Multicast + ), + }), + colHelper.accessor('isDefault', { + header: 'Default', + cell: (info) => ( + r.original)} + /> + ), + }), + ], + [] + ) + + // used in change default confirm modal - find existing default for same version/type + const findDefaultForVersionType = useCallback( + (ipVersion: string, poolType: string) => + allPools?.find( + (p) => p.isDefault && p.ipVersion === ipVersion && p.poolType === poolType + )?.name, [allPools] ) @@ -125,18 +190,22 @@ export default function SiloIpPoolsTab() { actionType: 'danger', }) } else { - const modalContent = defaultPool ? ( + const existingDefault = findDefaultForVersionType(pool.ipVersion, pool.poolType) + const versionLabel = pool.ipVersion === 'v4' ? 'IPv4' : 'IPv6' + const typeLabel = pool.poolType === 'unicast' ? 'unicast' : 'multicast' + + const modalContent = existingDefault ? (

- Are you sure you want to change the default pool from {defaultPool}{' '} - to {pool.name}? + Are you sure you want to change the default {versionLabel} {typeLabel} pool + from {existingDefault} to {pool.name}?

) : (

- Are you sure you want to make {pool.name} the default pool for this - silo? + Are you sure you want to make {pool.name} the default{' '} + {versionLabel} {typeLabel} pool for this silo?

) - const verb = defaultPool ? 'change' : 'make' + const verb = existingDefault ? 'change' : 'make' confirmAction({ doAction: () => updatePoolLink({ @@ -171,7 +240,7 @@ export default function SiloIpPoolsTab() { }, }, ], - [defaultPool, silo, unlinkPool, updatePoolLink] + [findDefaultForVersionType, silo, unlinkPool, updatePoolLink] ) const columns = useColsWithActions(staticCols, makeActions) diff --git a/app/table/cells/DefaultPoolCell.tsx b/app/table/cells/DefaultPoolCell.tsx index 066db6402..192d4c44a 100644 --- a/app/table/cells/DefaultPoolCell.tsx +++ b/app/table/cells/DefaultPoolCell.tsx @@ -8,10 +8,16 @@ import { Success12Icon } from '@oxide/design-system/icons/react' import { Badge } from '@oxide/design-system/ui' -export const DefaultPoolCell = ({ isDefault }: { isDefault: boolean }) => +export const DefaultPoolCell = ({ + isDefault, + ipVersion, +}: { + isDefault: boolean + ipVersion?: string +}) => isDefault ? ( <> - default + default{ipVersion} ) : null diff --git a/mock-api/ip-pool.ts b/mock-api/ip-pool.ts index ee7d26a63..564f59f7a 100644 --- a/mock-api/ip-pool.ts +++ b/mock-api/ip-pool.ts @@ -62,7 +62,7 @@ export const ipPoolSilos: Json[] = [ { ip_pool_id: ipPool2.id, silo_id: defaultSilo.id, - is_default: false, + is_default: true, // Both v4 and v6 pools are default - valid dual-default scenario }, ] diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 5b870b3be..71ab43ad1 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -1079,11 +1079,22 @@ export const handlers = makeHandlers({ const ipPoolSilo = lookup.ipPoolSiloLink(path) // if we're setting default, we need to set is_default false on the existing default + // for the same IP version and pool type (a silo can have separate defaults for v4/v6) if (body.is_default) { const silo = lookup.silo(path) - const existingDefault = db.ipPoolSilos.find( - (ips) => ips.silo_id === silo.id && ips.is_default - ) + const currentPool = lookup.ipPool({ pool: ipPoolSilo.ip_pool_id }) + + // Find existing default with same version and type + const existingDefault = db.ipPoolSilos.find((ips) => { + if (ips.silo_id !== silo.id || !ips.is_default) return false + const pool = db.ipPools.find((p) => p.id === ips.ip_pool_id) + return ( + pool && + pool.ip_version === currentPool.ip_version && + pool.pool_type === currentPool.pool_type + ) + }) + if (existingDefault) { existingDefault.is_default = false } diff --git a/test/e2e/instance-create.e2e.ts b/test/e2e/instance-create.e2e.ts index cc653ff48..305a678da 100644 --- a/test/e2e/instance-create.e2e.ts +++ b/test/e2e/instance-create.e2e.ts @@ -88,7 +88,7 @@ test('can create an instance', async ({ page }) => { // re-checking the box should re-enable the selector, and other options should be selectable await checkbox.check() - await selectOption(page, 'IP pool for ephemeral IP', 'ip-pool-2 VPN IPs') + await selectOption(page, 'IP pool for ephemeral IP', 'ip-pool-2 default VPN IPs') // should be visible in accordion await expect(page.getByRole('radiogroup', { name: 'Network interface' })).toBeVisible() diff --git a/test/e2e/silos.e2e.ts b/test/e2e/silos.e2e.ts index 1c76229a3..41439e84a 100644 --- a/test/e2e/silos.e2e.ts +++ b/test/e2e/silos.e2e.ts @@ -264,8 +264,9 @@ test('Silo IP pools', async ({ page }) => { await page.goto('/system/silos/maze-war/ip-pools') const table = page.getByRole('table') - await expectRowVisible(table, { name: 'ip-pool-1', Default: 'default' }) - await expectRowVisible(table, { name: 'ip-pool-2', Default: '' }) + // Both pools start as default (one IPv4, one IPv6) - valid dual-default scenario + await expectRowVisible(table, { name: 'ip-pool-1', Default: 'default v4' }) + await expectRowVisible(table, { name: 'ip-pool-2', Default: 'default v6' }) await expect(table.getByRole('row')).toHaveCount(3) // header + 2 // clicking on pool goes to pool detail @@ -273,20 +274,7 @@ test('Silo IP pools', async ({ page }) => { await expect(page).toHaveURL('/system/networking/ip-pools/ip-pool-1') await page.goBack() - // make default - await clickRowAction(page, 'ip-pool-2', 'Make default') - await expect( - page - .getByRole('dialog', { name: 'Confirm change default' }) - .getByText( - 'Are you sure you want to change the default pool from ip-pool-1 to ip-pool-2?' - ) - ).toBeVisible() - await page.getByRole('button', { name: 'Confirm' }).click() - await expectRowVisible(table, { name: 'ip-pool-1', Default: '' }) - await expectRowVisible(table, { name: 'ip-pool-2', Default: 'default' }) - - // unlink + // unlink IPv4 pool await clickRowAction(page, 'ip-pool-1', 'Unlink') await expect( page @@ -295,9 +283,10 @@ test('Silo IP pools', async ({ page }) => { ).toBeVisible() await page.getByRole('button', { name: 'Confirm' }).click() await expect(page.getByRole('cell', { name: 'ip-pool-1' })).toBeHidden() + // ip-pool-2 should still be default, but now it's the only default so no version shown await expectRowVisible(table, { name: 'ip-pool-2', Default: 'default' }) - // clear default + // clear default for IPv6 pool await clickRowAction(page, 'ip-pool-2', 'Clear default') await expect( page @@ -312,8 +301,9 @@ test('Silo IP pools link pool', async ({ page }) => { await page.goto('/system/silos/maze-war/ip-pools') const table = page.getByRole('table') - await expectRowVisible(table, { name: 'ip-pool-1', Default: 'default' }) - await expectRowVisible(table, { name: 'ip-pool-2', Default: '' }) + // Both pools start as default (one IPv4, one IPv6) + await expectRowVisible(table, { name: 'ip-pool-1', Default: 'default v4' }) + await expectRowVisible(table, { name: 'ip-pool-2', Default: 'default v6' }) await expect(table.getByRole('row')).toHaveCount(3) // header + 2 const modal = page.getByRole('dialog', { name: 'Link pool' }) From a1e52583c3b306365be1faa507456b99fdf3070e Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 22 Jan 2026 08:58:44 -0800 Subject: [PATCH 22/45] Simplify default badging --- app/pages/system/silos/SiloIpPoolsTab.tsx | 73 ++++------------------- test/e2e/silos.e2e.ts | 16 ++--- 2 files changed, 20 insertions(+), 69 deletions(-) diff --git a/app/pages/system/silos/SiloIpPoolsTab.tsx b/app/pages/system/silos/SiloIpPoolsTab.tsx index 3bec42f85..4043ea4c0 100644 --- a/app/pages/system/silos/SiloIpPoolsTab.tsx +++ b/app/pages/system/silos/SiloIpPoolsTab.tsx @@ -12,14 +12,7 @@ import { useCallback, useMemo, useState } from 'react' import { useForm } from 'react-hook-form' import { type LoaderFunctionArgs } from 'react-router' -import { - api, - getListQFn, - queryClient, - useApiMutation, - type IpVersion, - type SiloIpPool, -} from '@oxide/api' +import { api, getListQFn, queryClient, useApiMutation, type SiloIpPool } from '@oxide/api' import { Networking24Icon } from '@oxide/design-system/icons/react' import { Badge } from '@oxide/design-system/ui' @@ -29,7 +22,6 @@ import { makeCrumb } from '~/hooks/use-crumbs' import { getSiloSelector, useSiloSelector } from '~/hooks/use-params' import { confirmAction } from '~/stores/confirm-action' import { addToast } from '~/stores/toast' -import { DefaultPoolCell } from '~/table/cells/DefaultPoolCell' import { makeLinkCell } from '~/table/cells/LinkCell' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' import { Columns } from '~/table/columns/common' @@ -69,74 +61,33 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { return null } -// Helper component that computes dual defaults from table data -function DefaultPoolCellWithContext({ - isDefault, - ipVersion, - allRows, -}: { - isDefault: boolean - ipVersion: IpVersion - allRows: SiloIpPool[] -}) { - // Compute dual defaults from current table data - const hasDualDefaults = useMemo(() => { - const defaultUnicastPools = allRows.filter( - (pool) => pool.isDefault && pool.poolType === 'unicast' - ) - const hasV4Default = defaultUnicastPools.some((p) => p.ipVersion === 'v4') - const hasV6Default = defaultUnicastPools.some((p) => p.ipVersion === 'v6') - return hasV4Default && hasV6Default - }, [allRows]) - - return ( - - ) -} - export default function SiloIpPoolsTab() { const { silo } = useSiloSelector() const [showLinkModal, setShowLinkModal] = useState(false) - // Fetch all pools for the table and for computing dual defaults + // Fetch all_ish, but there should only be a few anyway. Not prefetched + // because the prefetched one only gets 25 to match the query table. This req + // is better to do async because they can't click make default that fast + // anyway. const { data: allPoolsData } = useQuery(allSiloPoolsQuery(silo).optionsFn()) const allPools = allPoolsData?.items - // Define columns const staticCols = useMemo( () => [ colHelper.accessor('name', { cell: makeLinkCell((pool) => pb.ipPool({ pool })) }), colHelper.accessor('description', Columns.description), colHelper.accessor('ipVersion', { header: 'IP Version', - cell: (info) => - info.getValue() === 'v4' ? ( - v4 - ) : ( - v6 - ), + cell: (info) => ( + <> + {info.getValue()} + {info.row.original.isDefault && default} + + ), }), colHelper.accessor('poolType', { header: 'Type', - cell: (info) => - info.getValue() === 'unicast' ? ( - Unicast - ) : ( - Multicast - ), - }), - colHelper.accessor('isDefault', { - header: 'Default', - cell: (info) => ( - r.original)} - /> - ), + cell: (info) => {info.getValue()}, }), ], [] diff --git a/test/e2e/silos.e2e.ts b/test/e2e/silos.e2e.ts index 41439e84a..ef0d7aa15 100644 --- a/test/e2e/silos.e2e.ts +++ b/test/e2e/silos.e2e.ts @@ -265,8 +265,8 @@ test('Silo IP pools', async ({ page }) => { const table = page.getByRole('table') // Both pools start as default (one IPv4, one IPv6) - valid dual-default scenario - await expectRowVisible(table, { name: 'ip-pool-1', Default: 'default v4' }) - await expectRowVisible(table, { name: 'ip-pool-2', Default: 'default v6' }) + await expectRowVisible(table, { name: 'ip-pool-1', 'IP Version': 'v4default' }) + await expectRowVisible(table, { name: 'ip-pool-2', 'IP Version': 'v6default' }) await expect(table.getByRole('row')).toHaveCount(3) // header + 2 // clicking on pool goes to pool detail @@ -283,8 +283,8 @@ test('Silo IP pools', async ({ page }) => { ).toBeVisible() await page.getByRole('button', { name: 'Confirm' }).click() await expect(page.getByRole('cell', { name: 'ip-pool-1' })).toBeHidden() - // ip-pool-2 should still be default, but now it's the only default so no version shown - await expectRowVisible(table, { name: 'ip-pool-2', Default: 'default' }) + // ip-pool-2 should still be default + await expectRowVisible(table, { name: 'ip-pool-2', 'IP Version': 'v6default' }) // clear default for IPv6 pool await clickRowAction(page, 'ip-pool-2', 'Clear default') @@ -294,7 +294,7 @@ test('Silo IP pools', async ({ page }) => { .getByText('Are you sure you want ip-pool-2 to stop being the default') ).toBeVisible() await page.getByRole('button', { name: 'Confirm' }).click() - await expectRowVisible(table, { name: 'ip-pool-2', Default: '' }) + await expectRowVisible(table, { name: 'ip-pool-2', 'IP Version': 'v6' }) }) test('Silo IP pools link pool', async ({ page }) => { @@ -302,8 +302,8 @@ test('Silo IP pools link pool', async ({ page }) => { const table = page.getByRole('table') // Both pools start as default (one IPv4, one IPv6) - await expectRowVisible(table, { name: 'ip-pool-1', Default: 'default v4' }) - await expectRowVisible(table, { name: 'ip-pool-2', Default: 'default v6' }) + await expectRowVisible(table, { name: 'ip-pool-1', 'IP Version': 'v4default' }) + await expectRowVisible(table, { name: 'ip-pool-2', 'IP Version': 'v6default' }) await expect(table.getByRole('row')).toHaveCount(3) // header + 2 const modal = page.getByRole('dialog', { name: 'Link pool' }) @@ -332,7 +332,7 @@ test('Silo IP pools link pool', async ({ page }) => { // modal closes and we see the thing in the table await expect(modal).toBeHidden() - await expectRowVisible(table, { name: 'ip-pool-3', Default: '' }) + await expectRowVisible(table, { name: 'ip-pool-3', 'IP Version': 'v4' }) }) // just a convenient form to test this with because it's tall From 5ca1528b6bfb19811830b7c9a4c5e06a0c3e301a Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 22 Jan 2026 09:57:52 -0800 Subject: [PATCH 23/45] Fix v6 automatic pool assignment issue --- app/components/AttachEphemeralIpModal.tsx | 2 +- app/forms/network-interface-create.tsx | 10 +++++-- app/util/ip.ts | 6 ++-- mock-api/msw/db.ts | 36 +++++++++++++++++++---- 4 files changed, 43 insertions(+), 11 deletions(-) diff --git a/app/components/AttachEphemeralIpModal.tsx b/app/components/AttachEphemeralIpModal.tsx index 2a30ce5b7..492ba26f5 100644 --- a/app/components/AttachEphemeralIpModal.tsx +++ b/app/components/AttachEphemeralIpModal.tsx @@ -65,7 +65,7 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) -
+ pool ? lookup.ipPool({ pool }) : lookup.siloDefaultIpPool({ silo: defaultSilo.id }) export const resolvePoolSelector = ( - poolSelector: { pool: string; type: 'explicit' } | { type: 'auto' } | undefined -) => - poolSelector?.type === 'explicit' - ? lookup.ipPool({ pool: poolSelector.pool }) - : lookup.siloDefaultIpPool({ silo: defaultSilo.id }) + poolSelector: + | { pool: string; type: 'explicit' } + | { type: 'auto'; ip_version?: IpVersion | null } + | undefined +) => { + if (poolSelector?.type === 'explicit') { + return lookup.ipPool({ pool: poolSelector.pool }) + } + + // For 'auto' type, find the default pool for the specified IP version (or any default if not specified) + const silo = lookup.silo({ silo: defaultSilo.id }) + const links = db.ipPoolSilos.filter((ips) => ips.silo_id === silo.id && ips.is_default) + + if (poolSelector?.ip_version) { + // Find default pool matching the specified IP version + const link = links.find((ips) => { + const pool = db.ipPools.find((p) => p.id === ips.ip_pool_id) + return pool?.ip_version === poolSelector.ip_version + }) + if (link) { + return lookupById(db.ipPools, link.ip_pool_id) + } + } + + // Fall back to any default pool (for backwards compatibility) + const link = links[0] + if (!link) throw notFoundErr(`default pool for silo '${defaultSilo.id}'`) + return lookupById(db.ipPools, link.ip_pool_id) +} export const getIpFromPool = (pool: Json) => { const ipPoolRange = db.ipPoolRanges.find((range) => range.ip_pool_id === pool.id) From f224a463460da818130fa4f70a1302784a3c2869 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 22 Jan 2026 11:56:13 -0800 Subject: [PATCH 24/45] Remove DefaultPoolCell --- app/pages/system/networking/IpPoolPage.tsx | 16 ++++++++++++--- app/table/cells/DefaultPoolCell.tsx | 23 ---------------------- 2 files changed, 13 insertions(+), 26 deletions(-) delete mode 100644 app/table/cells/DefaultPoolCell.tsx diff --git a/app/pages/system/networking/IpPoolPage.tsx b/app/pages/system/networking/IpPoolPage.tsx index 93c3f270c..f8ef4ee60 100644 --- a/app/pages/system/networking/IpPoolPage.tsx +++ b/app/pages/system/networking/IpPoolPage.tsx @@ -22,7 +22,12 @@ import { type IpPoolRange, type IpPoolSiloLink, } from '@oxide/api' -import { IpGlobal16Icon, IpGlobal24Icon } from '@oxide/design-system/icons/react' +import { + IpGlobal16Icon, + IpGlobal24Icon, + Success12Icon, +} from '@oxide/design-system/icons/react' +import { Badge } from '@oxide/design-system/ui' import { CapacityBar } from '~/components/CapacityBar' import { DocsPopover } from '~/components/DocsPopover' @@ -35,7 +40,6 @@ import { getIpPoolSelector, useIpPoolSelector } from '~/hooks/use-params' import { confirmAction } from '~/stores/confirm-action' import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' -import { DefaultPoolCell } from '~/table/cells/DefaultPoolCell' import { SkeletonCell } from '~/table/cells/EmptyCell' import { LinkCell } from '~/table/cells/LinkCell' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' @@ -326,7 +330,13 @@ function LinkedSilosTable() { ) }, - cell: (info) => , + cell: (info) => + info.getValue() ? ( + <> + + default + + ) : null, }), ], [] diff --git a/app/table/cells/DefaultPoolCell.tsx b/app/table/cells/DefaultPoolCell.tsx deleted file mode 100644 index 192d4c44a..000000000 --- a/app/table/cells/DefaultPoolCell.tsx +++ /dev/null @@ -1,23 +0,0 @@ -/* - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright Oxide Computer Company - */ -import { Success12Icon } from '@oxide/design-system/icons/react' -import { Badge } from '@oxide/design-system/ui' - -export const DefaultPoolCell = ({ - isDefault, - ipVersion, -}: { - isDefault: boolean - ipVersion?: string -}) => - isDefault ? ( - <> - - default{ipVersion} - - ) : null From a9971f2b45569f9d637e2ac875c386a25cdf7c67 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 22 Jan 2026 12:38:50 -0800 Subject: [PATCH 25/45] Ensure unicast pools are used for ephemeral IP form --- app/components/AttachEphemeralIpModal.tsx | 39 +++++++++++++++++++++-- app/ui/lib/Modal.tsx | 3 ++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/app/components/AttachEphemeralIpModal.tsx b/app/components/AttachEphemeralIpModal.tsx index 492ba26f5..4ce3f3d3d 100644 --- a/app/components/AttachEphemeralIpModal.tsx +++ b/app/components/AttachEphemeralIpModal.tsx @@ -32,6 +32,16 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) q(api.projectIpPoolList, { query: { limit: ALL_ISH } }) ) + // Only unicast pools can be used for ephemeral IPs + const unicastPools = useMemo(() => { + if (!siloPools) return [] + return siloPools.items.filter((p) => p.poolType === 'unicast') + }, [siloPools]) + + const hasDefaultUnicastPool = useMemo(() => { + return unicastPools.some((p) => p.isDefault) + }, [unicastPools]) + // Detect if both IPv4 and IPv6 default unicast pools exist const hasDualDefaults = useMemo(() => { if (!siloPools) return false @@ -61,6 +71,15 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) const pool = form.watch('pool') const ipVersion = form.watch('ipVersion') + const getDisabledReason = () => { + if (!siloPools) return 'Loading pools...' + if (unicastPools.length === 0) return 'No unicast pools available' + if (!pool && !hasDefaultUnicastPool) { + return 'No default pool available; select a pool to continue' + } + return undefined + } + return ( @@ -70,8 +89,19 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) control={form.control} name="pool" label="IP pool" - placeholder="Default pool" - items={(siloPools?.items ?? []).map(toIpPoolItem)} + placeholder={ + unicastPools.length === 0 + ? 'No unicast pools available' + : hasDefaultUnicastPool + ? 'Default pool' + : 'Select a pool (no default available)' + } + description={ + unicastPools.length === 0 + ? 'Contact your administrator to create a unicast IP pool' + : undefined + } + items={unicastPools.map(toIpPoolItem)} /> {!pool && hasDualDefaults && ( void }) { instanceEphemeralIpAttach.mutate({ path: { instance }, diff --git a/app/ui/lib/Modal.tsx b/app/ui/lib/Modal.tsx index 0f90eb8b9..60e116849 100644 --- a/app/ui/lib/Modal.tsx +++ b/app/ui/lib/Modal.tsx @@ -113,6 +113,7 @@ type FooterProps = { actionLoading?: boolean cancelText?: string disabled?: boolean + disabledReason?: React.ReactNode showCancel?: boolean } & MergeExclusive<{ formId: string }, { onAction: () => void }> @@ -125,6 +126,7 @@ Modal.Footer = ({ actionLoading, cancelText, disabled, + disabledReason, formId, showCancel = true, }: FooterProps) => ( @@ -143,6 +145,7 @@ Modal.Footer = ({ variant={actionType} onClick={onAction} disabled={!!disabled} + disabledReason={disabledReason} loading={actionLoading} > {actionText} From 9037197a82a511393447051afcccc1f0efebe9a8 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 22 Jan 2026 15:52:05 -0800 Subject: [PATCH 26/45] Fix incorrect pool issue with Floating IP create flow --- app/forms/floating-ip-create.tsx | 53 ++++++++++++++-- app/forms/instance-create.tsx | 104 +++++++++++++++++++++++++------ mock-api/msw/handlers.ts | 32 +++++----- 3 files changed, 148 insertions(+), 41 deletions(-) diff --git a/app/forms/floating-ip-create.tsx b/app/forms/floating-ip-create.tsx index 7e5366334..a6a33ddee 100644 --- a/app/forms/floating-ip-create.tsx +++ b/app/forms/floating-ip-create.tsx @@ -7,11 +7,18 @@ */ import * as Accordion from '@radix-ui/react-accordion' import { useQuery } from '@tanstack/react-query' -import { useState } from 'react' +import { useMemo, useState } from 'react' import { useForm } from 'react-hook-form' import { useNavigate } from 'react-router' -import { api, q, queryClient, useApiMutation, type FloatingIpCreate } from '@oxide/api' +import { + api, + q, + queryClient, + useApiMutation, + type FloatingIpCreate, + type IpVersion, +} from '@oxide/api' import { AccordionItem } from '~/components/AccordionItem' import { DescriptionField } from '~/components/form/fields/DescriptionField' @@ -31,11 +38,13 @@ type FloatingIpCreateFormData = { name: string description: string pool?: string + ipVersion: IpVersion } const defaultValues: FloatingIpCreateFormData = { name: '', description: '', + ipVersion: 'v4', } export const handle = titleCrumb('New Floating IP') @@ -47,6 +56,20 @@ export default function CreateFloatingIpSideModalForm() { q(api.projectIpPoolList, { query: { limit: ALL_ISH } }) ) + // Only unicast pools can be used for floating IPs + const unicastPools = useMemo(() => { + if (!allPools) return [] + return allPools.items.filter((p) => p.poolType === 'unicast') + }, [allPools]) + + // Detect if both IPv4 and IPv6 default unicast pools exist + const hasDualDefaults = useMemo(() => { + const defaultUnicastPools = unicastPools.filter((pool) => pool.isDefault) + const hasV4Default = defaultUnicastPools.some((p) => p.ipVersion === 'v4') + const hasV6Default = defaultUnicastPools.some((p) => p.ipVersion === 'v6') + return hasV4Default && hasV6Default + }, [unicastPools]) + const projectSelector = useProjectSelector() const navigate = useNavigate() @@ -61,6 +84,7 @@ export default function CreateFloatingIpSideModalForm() { }) const form = useForm({ defaultValues }) + const pool = form.watch('pool') const [openItems, setOpenItems] = useState([]) @@ -70,7 +94,7 @@ export default function CreateFloatingIpSideModalForm() { formType="create" resourceName="floating IP" onDismiss={() => navigate(pb.floatingIps(projectSelector))} - onSubmit={({ pool, ...values }) => { + onSubmit={({ pool, ipVersion, ...values }) => { const body: FloatingIpCreate = { ...values, addressAllocator: pool @@ -78,7 +102,12 @@ export default function CreateFloatingIpSideModalForm() { type: 'auto' as const, poolSelector: { type: 'explicit' as const, pool }, } - : undefined, + : hasDualDefaults + ? { + type: 'auto' as const, + poolSelector: { type: 'auto' as const, ipVersion }, + } + : undefined, } createFloatingIp.mutate({ query: projectSelector, body }) }} @@ -106,11 +135,25 @@ export default function CreateFloatingIpSideModalForm() { + + {!pool && hasDualDefaults && ( + + )} diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index 702991c74..8b8c3ffae 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -26,6 +26,7 @@ import { type Image, type InstanceCreate, type InstanceDiskAttachment, + type IpVersion, type NameOrId, type SiloIpPool, } from '@oxide/api' @@ -50,6 +51,7 @@ import { import { FileField } from '~/components/form/fields/FileField' import { BootDiskImageSelectField as ImageSelectField } from '~/components/form/fields/ImageSelectField' import { toIpPoolItem } from '~/components/form/fields/ip-pool-item' +import { ListboxField } from '~/components/form/fields/ListboxField' import { NameField } from '~/components/form/fields/NameField' import { NetworkInterfaceField } from '~/components/form/fields/NetworkInterfaceField' import { NumberField } from '~/components/form/fields/NumberField' @@ -127,6 +129,8 @@ export type InstanceCreateInput = Assign< userData: File | null // ssh keys are always specified. we do not need the undefined case sshPublicKeys: NonNullable + // IP version for ephemeral IP when dual defaults exist + ephemeralIpVersion: IpVersion } > @@ -159,6 +163,7 @@ const baseDefaultValues: InstanceCreateInput = { userData: null, externalIps: [{ type: 'ephemeral' }], + ephemeralIpVersion: 'v4', } export async function clientLoader({ params }: LoaderFunctionArgs) { @@ -243,6 +248,8 @@ export default function CreateInstanceForm() { bootDiskSourceType: defaultSource, sshPublicKeys: allKeys, bootDiskSize: diskSizeNearest10(defaultImage?.size / GiB), + // When dual defaults exist and no explicit pool, default to v4 for dual_stack + ephemeralIpVersion: 'v4', externalIps: defaultPool ? [{ type: 'ephemeral', poolSelector: { type: 'explicit', pool: defaultPool } }] : hasDualDefaults @@ -273,6 +280,20 @@ export default function CreateInstanceForm() { } }, [createInstance.error]) + // Watch networkInterfaces and update ephemeralIpVersion when dual defaults exist + const networkInterfaces = useWatch({ control, name: 'networkInterfaces' }) + useEffect(() => { + if (!hasDualDefaults) return + + // Couple ephemeral IP version to network interface type when dual defaults exist + if (networkInterfaces.type === 'default_ipv6') { + setValue('ephemeralIpVersion', 'v6') + } else if (networkInterfaces.type === 'default_ipv4') { + setValue('ephemeralIpVersion', 'v4') + } + // For default_dual_stack, leave as-is (user can choose in UI) + }, [networkInterfaces, hasDualDefaults, setValue]) + const otherDisks = useWatch({ control, name: 'otherDisks' }) const unavailableDiskNames = [ ...allDisks, // existing disks from the API @@ -612,6 +633,7 @@ export default function CreateInstanceForm() { isSubmitting={isSubmitting} siloPools={siloPools.items} hasDualDefaults={hasDualDefaults} + defaultPool={defaultPool} /> Create instance @@ -649,11 +671,13 @@ const AdvancedAccordion = ({ isSubmitting, siloPools, hasDualDefaults, + defaultPool, }: { control: Control isSubmitting: boolean siloPools: Array hasDualDefaults: boolean + defaultPool?: string }) => { // we track this state manually for the sole reason that we need to be able to // tell, inside AccordionItem, when an accordion is opened so we can scroll its @@ -668,9 +692,31 @@ const AdvancedAccordion = ({ ephemeralIp?.poolSelector?.type === 'explicit' ? ephemeralIp.poolSelector.pool : undefined - const defaultPool = siloPools.find((pool) => pool.isDefault)?.name const attachedFloatingIps = (externalIps.field.value || []).filter(isFloating) + const ephemeralIpVersionField = useController({ control, name: 'ephemeralIpVersion' }) + + // Update externalIps when ephemeralIpVersion changes and no explicit pool is selected + useEffect(() => { + if (!hasDualDefaults || !assignEphemeralIp || selectedPool) return + + const ipVersion = ephemeralIpVersionField.field.value || 'v4' + const newExternalIps = externalIps.field.value?.map((ip) => + ip.type === 'ephemeral' + ? { type: 'ephemeral', poolSelector: { type: 'auto', ipVersion } } + : ip + ) + if (newExternalIps) { + externalIps.field.onChange(newExternalIps) + } + }, [ + ephemeralIpVersionField.field.value, + hasDualDefaults, + assignEphemeralIp, + selectedPool, + externalIps, + ]) + const instanceName = useWatch({ control, name: 'name' }) const { project } = useProjectSelector() @@ -773,7 +819,10 @@ const AdvancedAccordion = ({ : hasDualDefaults ? { type: 'ephemeral', - poolSelector: { type: 'auto', ipVersion: 'v4' }, + poolSelector: { + type: 'auto', + ipVersion: ephemeralIpVersionField.field.value || 'v4', + }, } : { type: 'ephemeral' }, ] @@ -783,23 +832,40 @@ const AdvancedAccordion = ({ Allocate and attach an ephemeral IP address {assignEphemeralIp && ( - pool.name === selectedPool)?.name}`} - items={siloPools.map(toIpPoolItem)} - disabled={!assignEphemeralIp || isSubmitting} - required - onChange={(value) => { - const newExternalIps = externalIps.field.value?.map((ip) => - ip.type === 'ephemeral' - ? { type: 'ephemeral', poolSelector: { type: 'explicit', pool: value } } - : ip - ) - externalIps.field.onChange(newExternalIps) - }} - /> + <> + pool.name === selectedPool)?.name}`} + items={siloPools.map(toIpPoolItem)} + disabled={!assignEphemeralIp || isSubmitting} + required + onChange={(value) => { + const newExternalIps = externalIps.field.value?.map((ip) => + ip.type === 'ephemeral' + ? { type: 'ephemeral', poolSelector: { type: 'explicit', pool: value } } + : ip + ) + externalIps.field.onChange(newExternalIps) + }} + /> + + {!selectedPool && hasDualDefaults && ( + + )} + )}
diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 71ab43ad1..47d29b491 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -305,28 +305,26 @@ export const handlers = makeHandlers({ const project = lookup.project(query) errIfExists(db.floatingIps, { name: body.name, project_id: project.id }) - // TODO: when IP is specified, use ipInAnyRange to check that it is in the pool const addressAllocator = body.address_allocator || { type: 'auto' } - const pool = - addressAllocator.type === 'explicit' && addressAllocator.pool - ? lookup.siloIpPool({ pool: addressAllocator.pool, silo: defaultSilo.id }) - : addressAllocator.type === 'auto' && - addressAllocator.pool_selector?.type === 'explicit' - ? lookup.siloIpPool({ - pool: addressAllocator.pool_selector.pool, - silo: defaultSilo.id, - }) - : lookup.siloDefaultIpPool({ silo: defaultSilo.id }) + + // Determine the pool, respecting ipVersion when specified + let pool: Json + if (addressAllocator.type === 'explicit' && addressAllocator.pool) { + pool = lookup.siloIpPool({ pool: addressAllocator.pool, silo: defaultSilo.id }) + } else if (addressAllocator.type === 'auto') { + pool = resolvePoolSelector(addressAllocator.pool_selector) + } else { + pool = lookup.siloDefaultIpPool({ silo: defaultSilo.id }) + } + + // Generate IP from the pool (respects pool's IP version) + const ip = + (addressAllocator.type === 'explicit' && addressAllocator.ip) || getIpFromPool(pool) const newFloatingIp: Json = { id: uuid(), project_id: project.id, - // TODO: use ip-num to actually get the next available IP in the pool - ip: - (addressAllocator.type === 'explicit' && addressAllocator.ip) || - Array.from({ length: 4 }) - .map(() => Math.floor(Math.random() * 256)) - .join('.'), + ip, ip_pool_id: pool.id, description: body.description, name: body.name, From 54768625b792eda2154baa8830fd11e6a404caba Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 22 Jan 2026 16:36:26 -0800 Subject: [PATCH 27/45] Proper handling of unicast pools in instance create --- app/forms/instance-create.tsx | 44 +++++++++++++++++++++-------------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index 8b8c3ffae..dacd6da4c 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -224,24 +224,30 @@ export default function CreateInstanceForm() { const { data: siloPools } = usePrefetchedQuery( q(api.projectIpPoolList, { query: { limit: ALL_ISH } }) ) - const defaultPool = useMemo( - () => (siloPools ? siloPools.items.find((p) => p.isDefault)?.name : undefined), + + // Only unicast pools can be used for ephemeral IPs + const unicastPools = useMemo( + () => siloPools?.items.filter((p) => p.poolType === 'unicast') || [], [siloPools] ) - const defaultSource = - siloImages.length > 0 ? 'siloImage' : projectImages.length > 0 ? 'projectImage' : 'disk' - // Detect if both IPv4 and IPv6 default unicast pools exist const hasDualDefaults = useMemo(() => { - if (!siloPools) return false - const defaultUnicastPools = siloPools.items.filter( - (pool) => pool.isDefault && pool.poolType === 'unicast' - ) + const defaultUnicastPools = unicastPools.filter((pool) => pool.isDefault) const hasV4Default = defaultUnicastPools.some((p) => p.ipVersion === 'v4') const hasV6Default = defaultUnicastPools.some((p) => p.ipVersion === 'v6') return hasV4Default && hasV6Default - }, [siloPools]) + }, [unicastPools]) + + // Only use a default pool if exactly one unicast default exists + // When dual defaults exist, we'll use { type: 'auto', ipVersion } instead + const defaultPool = useMemo(() => { + if (hasDualDefaults) return undefined + return unicastPools.find((p) => p.isDefault)?.name + }, [unicastPools, hasDualDefaults]) + + const defaultSource = + siloImages.length > 0 ? 'siloImage' : projectImages.length > 0 ? 'projectImage' : 'disk' const defaultValues: InstanceCreateInput = { ...baseDefaultValues, @@ -631,7 +637,7 @@ export default function CreateInstanceForm() { @@ -669,13 +675,13 @@ const FloatingIpLabel = ({ ip }: { ip: FloatingIp }) => ( const AdvancedAccordion = ({ control, isSubmitting, - siloPools, + unicastPools, hasDualDefaults, defaultPool, }: { control: Control isSubmitting: boolean - siloPools: Array + unicastPools: Array hasDualDefaults: boolean defaultPool?: string }) => { @@ -709,12 +715,13 @@ const AdvancedAccordion = ({ if (newExternalIps) { externalIps.field.onChange(newExternalIps) } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [ ephemeralIpVersionField.field.value, hasDualDefaults, assignEphemeralIp, selectedPool, - externalIps, + // NOTE: Do not include externalIps in deps - it would cause infinite loop ]) const instanceName = useWatch({ control, name: 'name' }) @@ -837,14 +844,17 @@ const AdvancedAccordion = ({ name="pools" label="IP pool for ephemeral IP" placeholder={defaultPool ? `${defaultPool} (default)` : 'Select a pool'} - selected={`${siloPools.find((pool) => pool.name === selectedPool)?.name}`} - items={siloPools.map(toIpPoolItem)} + selected={`${unicastPools.find((pool) => pool.name === selectedPool)?.name}`} + items={unicastPools.map(toIpPoolItem)} disabled={!assignEphemeralIp || isSubmitting} required onChange={(value) => { const newExternalIps = externalIps.field.value?.map((ip) => ip.type === 'ephemeral' - ? { type: 'ephemeral', poolSelector: { type: 'explicit', pool: value } } + ? { + type: 'ephemeral', + poolSelector: { type: 'explicit', pool: value }, + } : ip ) externalIps.field.onChange(newExternalIps) From c194ff35d273f67fc8eb81fbfc1d45c5b48a9e72 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 22 Jan 2026 19:34:06 -0800 Subject: [PATCH 28/45] make sure external IP version matches NIC type --- app/forms/instance-create.tsx | 47 ++++++++++++++++++-------- mock-api/ip-pool.ts | 62 +++++++++++++++++++++++++++++++++-- mock-api/msw/db.ts | 39 +++++++++++++--------- mock-api/msw/handlers.ts | 42 +++++++++++++++++++++--- 4 files changed, 155 insertions(+), 35 deletions(-) diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index dacd6da4c..97b7c7af4 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -640,6 +640,7 @@ export default function CreateInstanceForm() { unicastPools={unicastPools} hasDualDefaults={hasDualDefaults} defaultPool={defaultPool} + networkInterfaces={networkInterfaces} /> Create instance @@ -678,12 +679,14 @@ const AdvancedAccordion = ({ unicastPools, hasDualDefaults, defaultPool, + networkInterfaces, }: { control: Control isSubmitting: boolean unicastPools: Array hasDualDefaults: boolean defaultPool?: string + networkInterfaces: InstanceCreate['networkInterfaces'] }) => { // we track this state manually for the sole reason that we need to be able to // tell, inside AccordionItem, when an accordion is opened so we can scroll its @@ -861,20 +864,36 @@ const AdvancedAccordion = ({ }} /> - {!selectedPool && hasDualDefaults && ( - - )} + {!selectedPool && + hasDualDefaults && + (() => { + // Determine which IP versions are compatible with the NIC + // Based on Omicron validation: external IP version must match NIC's private IP stack + const nicType = networkInterfaces?.type + const compatibleVersions: Array<{ label: string; value: 'v4' | 'v6' }> = + [] + + if (nicType === 'default_ipv4' || nicType === 'default_dual_stack') { + compatibleVersions.push({ label: 'IPv4', value: 'v4' }) + } + if (nicType === 'default_ipv6' || nicType === 'default_dual_stack') { + compatibleVersions.push({ label: 'IPv6', value: 'v6' }) + } + + // Only show selector if there's a choice to make + if (compatibleVersions.length <= 1) return null + + return ( + + ) + })()} )}
diff --git a/mock-api/ip-pool.ts b/mock-api/ip-pool.ts index 564f59f7a..c58813344 100644 --- a/mock-api/ip-pool.ts +++ b/mock-api/ip-pool.ts @@ -51,7 +51,35 @@ export const ipPool4: Json = { pool_type: 'unicast', } -export const ipPools: Json[] = [ipPool1, ipPool2, ipPool3, ipPool4] +// Multicast pools for testing that they are NOT selected for ephemeral/floating IPs +export const ipPool5Multicast: Json = { + id: 'b6c4a6b9-761e-4d28-94c0-fd3d7738ef1d', + name: 'ip-pool-5-multicast-v4', + description: 'Multicast v4 pool', + time_created: new Date().toISOString(), + time_modified: new Date().toISOString(), + ip_version: 'v4', + pool_type: 'multicast', +} + +export const ipPool6Multicast: Json = { + id: 'c7d5b7ca-872f-4e39-95d1-fe4e8849fg2e', + name: 'ip-pool-6-multicast-v6', + description: 'Multicast v6 pool', + time_created: new Date().toISOString(), + time_modified: new Date().toISOString(), + ip_version: 'v6', + pool_type: 'multicast', +} + +export const ipPools: Json[] = [ + ipPool1, + ipPool2, + ipPool3, + ipPool4, + ipPool5Multicast, + ipPool6Multicast, +] export const ipPoolSilos: Json[] = [ { @@ -62,7 +90,18 @@ export const ipPoolSilos: Json[] = [ { ip_pool_id: ipPool2.id, silo_id: defaultSilo.id, - is_default: true, // Both v4 and v6 pools are default - valid dual-default scenario + is_default: true, // Both v4 and v6 unicast pools are default - valid dual-default scenario + }, + // Make multicast pools also default to test that they are NOT selected + { + ip_pool_id: ipPool5Multicast.id, + silo_id: defaultSilo.id, + is_default: true, + }, + { + ip_pool_id: ipPool6Multicast.id, + silo_id: defaultSilo.id, + is_default: true, }, ] @@ -105,4 +144,23 @@ export const ipPoolRanges: Json = [ }, time_created: new Date().toISOString(), }, + // Multicast pool ranges (should NOT be used for ephemeral/floating IPs) + { + id: 'e8f6c8db-983g-4f4a-a6e2-gf5f9960gh3f', + ip_pool_id: ipPool5Multicast.id, + range: { + first: '224.0.0.1', + last: '224.0.0.20', + }, + time_created: new Date().toISOString(), + }, + { + id: 'f9g7d9ec-a94h-5g5b-b7f3-hg6ga071hi4g', + ip_pool_id: ipPool6Multicast.id, + range: { + first: 'ff00::1', + last: 'ff00::20', + }, + time_created: new Date().toISOString(), + }, ] diff --git a/mock-api/msw/db.ts b/mock-api/msw/db.ts index 0cb481a4f..648ddac52 100644 --- a/mock-api/msw/db.ts +++ b/mock-api/msw/db.ts @@ -10,7 +10,7 @@ import * as R from 'remeda' import { validate as isUuid } from 'uuid' -import type { ApiTypes as Api, IpVersion } from '@oxide/api' +import type { ApiTypes as Api, IpPoolType, IpVersion } from '@oxide/api' import * as mock from '@oxide/api-mocks' import { json } from '~/api/__generated__/msw-handlers' @@ -66,30 +66,39 @@ export const resolvePoolSelector = ( poolSelector: | { pool: string; type: 'explicit' } | { type: 'auto'; ip_version?: IpVersion | null } - | undefined + | undefined, + poolType?: IpPoolType ) => { if (poolSelector?.type === 'explicit') { return lookup.ipPool({ pool: poolSelector.pool }) } - // For 'auto' type, find the default pool for the specified IP version (or any default if not specified) + // For 'auto' type, find the default pool for the specified IP version and pool type const silo = lookup.silo({ silo: defaultSilo.id }) const links = db.ipPoolSilos.filter((ips) => ips.silo_id === silo.id && ips.is_default) - if (poolSelector?.ip_version) { - // Find default pool matching the specified IP version - const link = links.find((ips) => { - const pool = db.ipPools.find((p) => p.id === ips.ip_pool_id) - return pool?.ip_version === poolSelector.ip_version - }) - if (link) { - return lookupById(db.ipPools, link.ip_pool_id) + // Filter candidate pools by both IP version and pool type + const candidateLinks = links.filter((ips) => { + const pool = db.ipPools.find((p) => p.id === ips.ip_pool_id) + if (!pool) return false + + // If poolType specified, filter by it + if (poolType && pool.pool_type !== poolType) return false + + // If IP version specified, filter by it + if (poolSelector?.ip_version && pool.ip_version !== poolSelector.ip_version) { + return false } - } - // Fall back to any default pool (for backwards compatibility) - const link = links[0] - if (!link) throw notFoundErr(`default pool for silo '${defaultSilo.id}'`) + return true + }) + + const link = candidateLinks[0] + if (!link) { + const typeStr = poolType ? ` ${poolType}` : '' + const versionStr = poolSelector?.ip_version ? ` ${poolSelector.ip_version}` : '' + throw notFoundErr(`default${typeStr}${versionStr} pool for silo '${defaultSilo.id}'`) + } return lookupById(db.ipPools, link.ip_pool_id) } diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 47d29b491..f43154375 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -308,11 +308,12 @@ export const handlers = makeHandlers({ const addressAllocator = body.address_allocator || { type: 'auto' } // Determine the pool, respecting ipVersion when specified + // Floating IPs must use unicast pools let pool: Json if (addressAllocator.type === 'explicit' && addressAllocator.pool) { pool = lookup.siloIpPool({ pool: addressAllocator.pool, silo: defaultSilo.id }) } else if (addressAllocator.type === 'auto') { - pool = resolvePoolSelector(addressAllocator.pool_selector) + pool = resolvePoolSelector(addressAllocator.pool_selector, 'unicast') } else { pool = lookup.siloDefaultIpPool({ silo: defaultSilo.id }) } @@ -516,6 +517,14 @@ export const handlers = makeHandlers({ } // validate floating IP attachments before we actually do anything + // Determine what IP stacks the instance will have based on network interfaces + const hasIpv4Nic = + body.network_interfaces?.type === 'default_ipv4' || + body.network_interfaces?.type === 'default_dual_stack' + const hasIpv6Nic = + body.network_interfaces?.type === 'default_ipv6' || + body.network_interfaces?.type === 'default_dual_stack' + body.external_ips?.forEach((ip) => { if (ip.type === 'floating') { // throw if floating IP doesn't exist @@ -531,8 +540,31 @@ export const handlers = makeHandlers({ // if there are no ranges in the pool or if the pool doesn't exist, // which aren't quite as good as checking that there are actually IPs // available, but they are good things to check - const pool = resolvePoolSelector(ip.pool_selector) + // Ephemeral IPs must use unicast pools + const pool = resolvePoolSelector(ip.pool_selector, 'unicast') getIpFromPool(pool) + + // Validate that external IP version matches NIC's IP stack + // Based on Omicron validation in nexus/db-queries/src/db/datastore/external_ip.rs:544-661 + const ipVersion = pool.ip_version + if (ipVersion === 'v4' && !hasIpv4Nic) { + throw json( + { + error_code: 'InvalidRequest', + message: `The ephemeral external IP is an IPv4 address, but the instance with ID ${body.name} does not have a primary network interface with a VPC-private IPv4 address. Add a VPC-private IPv4 address to the interface, or attach a different IP address`, + }, + { status: 400 } + ) + } + if (ipVersion === 'v6' && !hasIpv6Nic) { + throw json( + { + error_code: 'InvalidRequest', + message: `The ephemeral external IP is an IPv6 address, but the instance with ID ${body.name} does not have a primary network interface with a VPC-private IPv6 address. Add a VPC-private IPv6 address to the interface, or attach a different IP address`, + }, + { status: 400 } + ) + } } }) @@ -642,7 +674,8 @@ export const handlers = makeHandlers({ // we've already validated that the IP isn't attached floatingIp.instance_id = instanceId } else if (ip.type === 'ephemeral') { - const pool = resolvePoolSelector(ip.pool_selector) + // Ephemeral IPs must use unicast pools + const pool = resolvePoolSelector(ip.pool_selector, 'unicast') const firstAvailableAddress = getIpFromPool(pool) db.ephemeralIps.push({ @@ -824,7 +857,8 @@ export const handlers = makeHandlers({ }, instanceEphemeralIpAttach({ path, query: projectParams, body }) { const instance = lookup.instance({ ...path, ...projectParams }) - const pool = resolvePoolSelector(body.pool_selector) + // Ephemeral IPs must use unicast pools + const pool = resolvePoolSelector(body.pool_selector, 'unicast') const ip = getIpFromPool(pool) const externalIp = { ip, ip_pool_id: pool.id, kind: 'ephemeral' as const } From d45997f644e99433c38c4644d3bae8d688446008 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 22 Jan 2026 21:15:07 -0800 Subject: [PATCH 29/45] Better flow for IP Pool selector; add component --- app/components/AttachEphemeralIpModal.tsx | 91 ++++---- app/components/form/fields/IpPoolSelector.tsx | 133 +++++++++++ app/forms/floating-ip-create.tsx | 54 ++--- app/forms/instance-create.tsx | 221 +++++++++--------- 4 files changed, 296 insertions(+), 203 deletions(-) create mode 100644 app/components/form/fields/IpPoolSelector.tsx diff --git a/app/components/AttachEphemeralIpModal.tsx b/app/components/AttachEphemeralIpModal.tsx index 4ce3f3d3d..76f166020 100644 --- a/app/components/AttachEphemeralIpModal.tsx +++ b/app/components/AttachEphemeralIpModal.tsx @@ -6,7 +6,7 @@ * Copyright Oxide Computer Company */ -import { useMemo } from 'react' +import { useEffect, useMemo } from 'react' import { useForm } from 'react-hook-form' import { @@ -17,20 +17,21 @@ import { usePrefetchedQuery, type IpVersion, } from '~/api' -import { ListboxField } from '~/components/form/fields/ListboxField' +import { IpPoolSelector } from '~/components/form/fields/IpPoolSelector' import { HL } from '~/components/HL' import { useInstanceSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { Modal } from '~/ui/lib/Modal' import { ALL_ISH } from '~/util/consts' -import { toIpPoolItem } from './form/fields/ip-pool-item' - export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) => { const { project, instance } = useInstanceSelector() const { data: siloPools } = usePrefetchedQuery( q(api.projectIpPoolList, { query: { limit: ALL_ISH } }) ) + const { data: nics } = usePrefetchedQuery( + q(api.instanceNetworkInterfaceList, { query: { project, instance } }) + ) // Only unicast pools can be used for ephemeral IPs const unicastPools = useMemo(() => { @@ -42,16 +43,24 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) return unicastPools.some((p) => p.isDefault) }, [unicastPools]) - // Detect if both IPv4 and IPv6 default unicast pools exist - const hasDualDefaults = useMemo(() => { - if (!siloPools) return false - const defaultUnicastPools = siloPools.items.filter( - (pool) => pool.isDefault && pool.poolType === 'unicast' + // Determine compatible IP versions based on instance's network interfaces + // External IP version must match the NIC's private IP stack + const compatibleVersions: IpVersion[] = useMemo(() => { + if (!nics) return [] + + const nicItems = nics.items + const hasV4Nic = nicItems.some( + (nic) => nic.ipStack.type === 'v4' || nic.ipStack.type === 'dual_stack' + ) + const hasV6Nic = nicItems.some( + (nic) => nic.ipStack.type === 'v6' || nic.ipStack.type === 'dual_stack' ) - const hasV4Default = defaultUnicastPools.some((p) => p.ipVersion === 'v4') - const hasV6Default = defaultUnicastPools.some((p) => p.ipVersion === 'v6') - return hasV4Default && hasV6Default - }, [siloPools]) + + const versions: IpVersion[] = [] + if (hasV4Nic) versions.push('v4') + if (hasV6Nic) versions.push('v6') + return versions + }, [nics]) const instanceEphemeralIpAttach = useApiMutation(api.instanceEphemeralIpAttach, { onSuccess(ephemeralIp) { @@ -66,8 +75,18 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) }) const form = useForm<{ pool: string; ipVersion: IpVersion }>({ - defaultValues: { pool: '', ipVersion: 'v4' }, + defaultValues: { + pool: '', + ipVersion: 'v4', + }, }) + + // Update ipVersion if only one version is compatible + useEffect(() => { + if (compatibleVersions.length === 1) { + form.setValue('ipVersion', compatibleVersions[0]) + } + }, [compatibleVersions, form]) const pool = form.watch('pool') const ipVersion = form.watch('ipVersion') @@ -84,38 +103,18 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) - - + - {!pool && hasDualDefaults && ( - - )} @@ -131,9 +130,7 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) query: { project }, body: pool ? { poolSelector: { type: 'explicit', pool } } - : hasDualDefaults - ? { poolSelector: { type: 'auto', ipVersion } } - : { poolSelector: { type: 'auto' } }, + : { poolSelector: { type: 'auto', ipVersion } }, }) }} onDismiss={onDismiss} diff --git a/app/components/form/fields/IpPoolSelector.tsx b/app/components/form/fields/IpPoolSelector.tsx new file mode 100644 index 000000000..8377e7222 --- /dev/null +++ b/app/components/form/fields/IpPoolSelector.tsx @@ -0,0 +1,133 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import type { Control, UseFormSetValue } from 'react-hook-form' + +import type { IpVersion, SiloIpPool } from '@oxide/api' + +import { Radio } from '~/ui/lib/Radio' + +import { toIpPoolItem } from './ip-pool-item' +import { ListboxField } from './ListboxField' + +type IpPoolSelectorProps = { + control: Control + poolFieldName: string + ipVersionFieldName: string + pools: SiloIpPool[] + /** Current value of the pool field - used to determine radio selection */ + currentPool: string | undefined + /** Current value of the IP version field - used to determine radio selection */ + currentIpVersion: IpVersion + /** Function to update form values */ + setValue: UseFormSetValue + disabled?: boolean + /** + * Compatible IP versions based on network interface type + * If not provided, both v4 and v6 are allowed + */ + compatibleVersions?: IpVersion[] +} + +/** + * IP Pool selector with radio button pattern: + * - "IPv4 default" (if v4 default exists and is compatible) + * - "IPv6 default" (if v6 default exists and is compatible) + * - "Use custom pool" (with pool dropdown) + */ +export function IpPoolSelector({ + control, + poolFieldName, + ipVersionFieldName, + pools, + currentPool, + currentIpVersion, + setValue, + disabled = false, + compatibleVersions, +}: IpPoolSelectorProps) { + // Determine which default pool versions exist + const hasV4Default = pools.some((p) => p.isDefault && p.ipVersion === 'v4') + const hasV6Default = pools.some((p) => p.isDefault && p.ipVersion === 'v6') + + // Filter default options by compatible versions + const showV4Default = + hasV4Default && (!compatibleVersions || compatibleVersions.includes('v4')) + const showV6Default = + hasV6Default && (!compatibleVersions || compatibleVersions.includes('v6')) + + // Derive current selection from pool and ipVersion + type SelectionType = 'v4-default' | 'v6-default' | 'custom' + const currentSelection: SelectionType = currentPool + ? 'custom' + : currentIpVersion === 'v6' + ? 'v6-default' + : 'v4-default' + + return ( +
+
+ Select IP pool +
+ {showV4Default && ( + { + setValue(poolFieldName, '') + setValue(ipVersionFieldName, 'v4') + }} + disabled={disabled} + > + IPv4 default + + )} + {showV6Default && ( + { + setValue(poolFieldName, '') + setValue(ipVersionFieldName, 'v6') + }} + disabled={disabled} + > + IPv6 default + + )} + { + // Set to first pool in list so the dropdown shows with a valid selection + if (pools.length > 0) { + setValue(poolFieldName, pools[0].name) + } + }} + disabled={disabled} + > + custom pool + +
+
+ + {currentSelection === 'custom' && ( + + )} +
+ ) +} diff --git a/app/forms/floating-ip-create.tsx b/app/forms/floating-ip-create.tsx index a6a33ddee..d545023bf 100644 --- a/app/forms/floating-ip-create.tsx +++ b/app/forms/floating-ip-create.tsx @@ -22,15 +22,13 @@ import { import { AccordionItem } from '~/components/AccordionItem' import { DescriptionField } from '~/components/form/fields/DescriptionField' -import { toIpPoolItem } from '~/components/form/fields/ip-pool-item' -import { ListboxField } from '~/components/form/fields/ListboxField' +import { IpPoolSelector } from '~/components/form/fields/IpPoolSelector' import { NameField } from '~/components/form/fields/NameField' import { SideModalForm } from '~/components/form/SideModalForm' import { HL } from '~/components/HL' import { titleCrumb } from '~/hooks/use-crumbs' import { useProjectSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' -import { Message } from '~/ui/lib/Message' import { ALL_ISH } from '~/util/consts' import { pb } from '~/util/path-builder' @@ -62,14 +60,6 @@ export default function CreateFloatingIpSideModalForm() { return allPools.items.filter((p) => p.poolType === 'unicast') }, [allPools]) - // Detect if both IPv4 and IPv6 default unicast pools exist - const hasDualDefaults = useMemo(() => { - const defaultUnicastPools = unicastPools.filter((pool) => pool.isDefault) - const hasV4Default = defaultUnicastPools.some((p) => p.ipVersion === 'v4') - const hasV6Default = defaultUnicastPools.some((p) => p.ipVersion === 'v6') - return hasV4Default && hasV6Default - }, [unicastPools]) - const projectSelector = useProjectSelector() const navigate = useNavigate() @@ -85,6 +75,7 @@ export default function CreateFloatingIpSideModalForm() { const form = useForm({ defaultValues }) const pool = form.watch('pool') + const ipVersion = form.watch('ipVersion') const [openItems, setOpenItems] = useState([]) @@ -102,12 +93,10 @@ export default function CreateFloatingIpSideModalForm() { type: 'auto' as const, poolSelector: { type: 'explicit' as const, pool }, } - : hasDualDefaults - ? { - type: 'auto' as const, - poolSelector: { type: 'auto' as const, ipVersion }, - } - : undefined, + : { + type: 'auto' as const, + poolSelector: { type: 'auto' as const, ipVersion }, + }, } createFloatingIp.mutate({ query: projectSelector, body }) }} @@ -128,32 +117,15 @@ export default function CreateFloatingIpSideModalForm() { label="Advanced" value="advanced" > - - - - - {!pool && hasDualDefaults && ( - - )} diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index 97b7c7af4..645a914d1 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -7,7 +7,13 @@ */ import * as Accordion from '@radix-ui/react-accordion' import { useEffect, useMemo, useState } from 'react' -import { useController, useForm, useWatch, type Control } from 'react-hook-form' +import { + useController, + useForm, + useWatch, + type Control, + type UseFormSetValue, +} from 'react-hook-form' import { useNavigate, type LoaderFunctionArgs } from 'react-router' import type { SetRequired } from 'type-fest' @@ -50,8 +56,7 @@ import { } from '~/components/form/fields/DisksTableField' import { FileField } from '~/components/form/fields/FileField' import { BootDiskImageSelectField as ImageSelectField } from '~/components/form/fields/ImageSelectField' -import { toIpPoolItem } from '~/components/form/fields/ip-pool-item' -import { ListboxField } from '~/components/form/fields/ListboxField' +import { IpPoolSelector } from '~/components/form/fields/IpPoolSelector' import { NameField } from '~/components/form/fields/NameField' import { NetworkInterfaceField } from '~/components/form/fields/NetworkInterfaceField' import { NumberField } from '~/components/form/fields/NumberField' @@ -131,6 +136,8 @@ export type InstanceCreateInput = Assign< sshPublicKeys: NonNullable // IP version for ephemeral IP when dual defaults exist ephemeralIpVersion: IpVersion + // Pool for ephemeral IP - used to sync with IpPoolSelector component + ephemeralIpPool: string } > @@ -164,6 +171,7 @@ const baseDefaultValues: InstanceCreateInput = { userData: null, externalIps: [{ type: 'ephemeral' }], ephemeralIpVersion: 'v4', + ephemeralIpPool: '', } export async function clientLoader({ params }: LoaderFunctionArgs) { @@ -239,16 +247,15 @@ export default function CreateInstanceForm() { return hasV4Default && hasV6Default }, [unicastPools]) - // Only use a default pool if exactly one unicast default exists - // When dual defaults exist, we'll use { type: 'auto', ipVersion } instead - const defaultPool = useMemo(() => { - if (hasDualDefaults) return undefined - return unicastPools.find((p) => p.isDefault)?.name - }, [unicastPools, hasDualDefaults]) const defaultSource = siloImages.length > 0 ? 'siloImage' : projectImages.length > 0 ? 'projectImage' : 'disk' + // Calculate if there's a single default pool (not dual defaults) + const singleDefaultPool = !hasDualDefaults + ? unicastPools.find((p) => p.isDefault)?.name + : undefined + const defaultValues: InstanceCreateInput = { ...baseDefaultValues, bootDiskSourceType: defaultSource, @@ -256,8 +263,10 @@ export default function CreateInstanceForm() { bootDiskSize: diskSizeNearest10(defaultImage?.size / GiB), // When dual defaults exist and no explicit pool, default to v4 for dual_stack ephemeralIpVersion: 'v4', - externalIps: defaultPool - ? [{ type: 'ephemeral', poolSelector: { type: 'explicit', pool: defaultPool } }] + // Set ephemeralIpPool if there's a single default, otherwise leave empty (for radio "use default") + ephemeralIpPool: '', + externalIps: singleDefaultPool + ? [{ type: 'ephemeral', poolSelector: { type: 'explicit', pool: singleDefaultPool } }] : hasDualDefaults ? [{ type: 'ephemeral', poolSelector: { type: 'auto', ipVersion: 'v4' } }] : [{ type: 'ephemeral' }], @@ -638,9 +647,8 @@ export default function CreateInstanceForm() { control={control} isSubmitting={isSubmitting} unicastPools={unicastPools} - hasDualDefaults={hasDualDefaults} - defaultPool={defaultPool} networkInterfaces={networkInterfaces} + setValue={setValue} /> Create instance @@ -677,16 +685,14 @@ const AdvancedAccordion = ({ control, isSubmitting, unicastPools, - hasDualDefaults, - defaultPool, networkInterfaces, + setValue, }: { control: Control isSubmitting: boolean unicastPools: Array - hasDualDefaults: boolean - defaultPool?: string networkInterfaces: InstanceCreate['networkInterfaces'] + setValue: UseFormSetValue }) => { // we track this state manually for the sole reason that we need to be able to // tell, inside AccordionItem, when an accordion is opened so we can scroll its @@ -697,33 +703,60 @@ const AdvancedAccordion = ({ const externalIps = useController({ control, name: 'externalIps' }) const ephemeralIp = externalIps.field.value?.find((ip) => ip.type === 'ephemeral') const assignEphemeralIp = !!ephemeralIp - const selectedPool = - ephemeralIp?.poolSelector?.type === 'explicit' - ? ephemeralIp.poolSelector.pool - : undefined const attachedFloatingIps = (externalIps.field.value || []).filter(isFloating) const ephemeralIpVersionField = useController({ control, name: 'ephemeralIpVersion' }) + const ephemeralIpPoolField = useController({ control, name: 'ephemeralIpPool' }) + + const ephemeralIpPool = ephemeralIpPoolField.field.value - // Update externalIps when ephemeralIpVersion changes and no explicit pool is selected + // Initialize ephemeralIpPool once on mount if externalIps already has an explicit pool useEffect(() => { - if (!hasDualDefaults || !assignEphemeralIp || selectedPool) return + const initialPool = + ephemeralIp?.poolSelector?.type === 'explicit' + ? ephemeralIp.poolSelector.pool + : undefined + if (initialPool && !ephemeralIpPool) { + ephemeralIpPoolField.field.onChange(initialPool) + } + // Only run on mount + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + // Update externalIps when ephemeralIpPool or ephemeralIpVersion changes + useEffect(() => { + if (!assignEphemeralIp) return + + const pool = ephemeralIpPoolField.field.value const ipVersion = ephemeralIpVersionField.field.value || 'v4' - const newExternalIps = externalIps.field.value?.map((ip) => - ip.type === 'ephemeral' - ? { type: 'ephemeral', poolSelector: { type: 'auto', ipVersion } } - : ip - ) + + const newExternalIps = externalIps.field.value?.map((ip) => { + if (ip.type !== 'ephemeral') return ip + + // Explicit pool selected + if (pool) { + return { + type: 'ephemeral', + poolSelector: { type: 'explicit', pool }, + } + } + + // No pool selected - use default with explicit IP version + // User selected v4 or v6 via radio button + return { + type: 'ephemeral', + poolSelector: { type: 'auto', ipVersion }, + } + }) + if (newExternalIps) { externalIps.field.onChange(newExternalIps) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [ + ephemeralIpPoolField.field.value, ephemeralIpVersionField.field.value, - hasDualDefaults, assignEphemeralIp, - selectedPool, // NOTE: Do not include externalIps in deps - it would cause infinite loop ]) @@ -810,92 +843,50 @@ const AdvancedAccordion = ({ it is deleted - { - const newExternalIps = assignEphemeralIp - ? externalIps.field.value?.filter((ip) => ip.type !== 'ephemeral') - : [ - ...(externalIps.field.value || []), - selectedPool || defaultPool - ? { - type: 'ephemeral', - poolSelector: { - type: 'explicit', - pool: selectedPool || defaultPool, - }, - } - : hasDualDefaults - ? { - type: 'ephemeral', - poolSelector: { - type: 'auto', - ipVersion: ephemeralIpVersionField.field.value || 'v4', - }, - } - : { type: 'ephemeral' }, - ] - externalIps.field.onChange(newExternalIps) - }} - > - Allocate and attach an ephemeral IP address - - {assignEphemeralIp && ( - <> - pool.name === selectedPool)?.name}`} - items={unicastPools.map(toIpPoolItem)} - disabled={!assignEphemeralIp || isSubmitting} - required - onChange={(value) => { - const newExternalIps = externalIps.field.value?.map((ip) => - ip.type === 'ephemeral' - ? { - type: 'ephemeral', - poolSelector: { type: 'explicit', pool: value }, - } - : ip - ) - externalIps.field.onChange(newExternalIps) - }} - /> - {!selectedPool && - hasDualDefaults && - (() => { - // Determine which IP versions are compatible with the NIC - // Based on Omicron validation: external IP version must match NIC's private IP stack - const nicType = networkInterfaces?.type - const compatibleVersions: Array<{ label: string; value: 'v4' | 'v6' }> = - [] - - if (nicType === 'default_ipv4' || nicType === 'default_dual_stack') { - compatibleVersions.push({ label: 'IPv4', value: 'v4' }) - } - if (nicType === 'default_ipv6' || nicType === 'default_dual_stack') { - compatibleVersions.push({ label: 'IPv6', value: 'v6' }) - } - - // Only show selector if there's a choice to make - if (compatibleVersions.length <= 1) return null - - return ( - - ) - })()} - - )} + {/* Calculate compatible IP versions based on NIC type */} + {(() => { + const nicType = networkInterfaces?.type + const compatibleVersions: IpVersion[] = [] + + if (nicType === 'default_ipv4' || nicType === 'default_dual_stack') { + compatibleVersions.push('v4') + } + if (nicType === 'default_ipv6' || nicType === 'default_dual_stack') { + compatibleVersions.push('v6') + } + + return ( + <> + { + const newExternalIps = assignEphemeralIp + ? externalIps.field.value?.filter((ip) => ip.type !== 'ephemeral') + : [...(externalIps.field.value || []), { type: 'ephemeral' }] + externalIps.field.onChange(newExternalIps) + // The useEffect will update the poolSelector based on current form values + }} + > + Allocate and attach an ephemeral IP address + + {assignEphemeralIp && ( + + )} + + ) + })()}
From d49504913e44a0905d550d7f7626b5b2837e82b9 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 22 Jan 2026 21:21:32 -0800 Subject: [PATCH 30/45] only show NIC-version-matching IP pools --- app/components/form/fields/IpPoolSelector.tsx | 13 +++++++++---- app/components/form/fields/ip-pool-item.tsx | 3 +++ app/forms/instance-create.tsx | 1 - 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/app/components/form/fields/IpPoolSelector.tsx b/app/components/form/fields/IpPoolSelector.tsx index 8377e7222..be7e84284 100644 --- a/app/components/form/fields/IpPoolSelector.tsx +++ b/app/components/form/fields/IpPoolSelector.tsx @@ -50,6 +50,11 @@ export function IpPoolSelector({ disabled = false, compatibleVersions, }: IpPoolSelectorProps) { + // Filter pools by compatible versions for custom pool dropdown + const filteredPools = compatibleVersions + ? pools.filter((p) => compatibleVersions.includes(p.ipVersion)) + : pools + // Determine which default pool versions exist const hasV4Default = pools.some((p) => p.isDefault && p.ipVersion === 'v4') const hasV6Default = pools.some((p) => p.isDefault && p.ipVersion === 'v6') @@ -106,9 +111,9 @@ export function IpPoolSelector({ value="custom" checked={currentSelection === 'custom'} onChange={() => { - // Set to first pool in list so the dropdown shows with a valid selection - if (pools.length > 0) { - setValue(poolFieldName, pools[0].name) + // Set to first compatible pool in list so the dropdown shows with a valid selection + if (filteredPools.length > 0) { + setValue(poolFieldName, filteredPools[0].name) } }} disabled={disabled} @@ -121,7 +126,7 @@ export function IpPoolSelector({ {currentSelection === 'custom' && ( )} + + {p.ipVersion} +
{!!p.description && (
{p.description}
diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index 645a914d1..0d0e74da9 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -247,7 +247,6 @@ export default function CreateInstanceForm() { return hasV4Default && hasV6Default }, [unicastPools]) - const defaultSource = siloImages.length > 0 ? 'siloImage' : projectImages.length > 0 ? 'projectImage' : 'disk' From 640dd6f263f273b68f58a0097cf0fb38eb6a2d10 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 22 Jan 2026 21:29:00 -0800 Subject: [PATCH 31/45] fix crashing IP Pools list --- app/pages/system/networking/IpPoolsPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/pages/system/networking/IpPoolsPage.tsx b/app/pages/system/networking/IpPoolsPage.tsx index b35a05b0c..5a50014d3 100644 --- a/app/pages/system/networking/IpPoolsPage.tsx +++ b/app/pages/system/networking/IpPoolsPage.tsx @@ -66,7 +66,7 @@ const staticColumns = [ // TODO: add version column when API supports v6 pools colHelper.display({ header: 'IPs Remaining', - cell: (info) => , + cell: (info) => , }), colHelper.accessor('timeCreated', Columns.timeCreated), ] From e7f1920ec5d13019729d9641dd871ae111dc1a4d Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 22 Jan 2026 22:08:27 -0800 Subject: [PATCH 32/45] update tests --- app/components/form/fields/IpPoolSelector.tsx | 1 + mock-api/ip-pool.ts | 4 +-- test/e2e/floating-ip-create.e2e.ts | 18 ++++++++---- test/e2e/instance-create.e2e.ts | 28 +++++++++++++++---- test/e2e/instance-networking.e2e.ts | 2 ++ test/e2e/ip-pools.e2e.ts | 10 ++++++- test/e2e/silos.e2e.ts | 26 ++++++++++++++--- 7 files changed, 71 insertions(+), 18 deletions(-) diff --git a/app/components/form/fields/IpPoolSelector.tsx b/app/components/form/fields/IpPoolSelector.tsx index be7e84284..976ce13d2 100644 --- a/app/components/form/fields/IpPoolSelector.tsx +++ b/app/components/form/fields/IpPoolSelector.tsx @@ -127,6 +127,7 @@ export function IpPoolSelector({ = [ ip_pool_id: ipPool5Multicast.id, range: { first: '224.0.0.1', - last: '224.0.0.20', + last: '224.0.0.32', }, time_created: new Date().toISOString(), }, @@ -159,7 +159,7 @@ export const ipPoolRanges: Json = [ ip_pool_id: ipPool6Multicast.id, range: { first: 'ff00::1', - last: 'ff00::20', + last: 'ff00::ffff:ffff:ffff:ffff', }, time_created: new Date().toISOString(), }, diff --git a/test/e2e/floating-ip-create.e2e.ts b/test/e2e/floating-ip-create.e2e.ts index 4bedc596c..7fb7fb7f8 100644 --- a/test/e2e/floating-ip-create.e2e.ts +++ b/test/e2e/floating-ip-create.e2e.ts @@ -28,19 +28,27 @@ test('can create a floating IP', async ({ page }) => { .getByRole('textbox', { name: 'Description' }) .fill('A description for this Floating IP') - const label = page.getByLabel('IP pool') + const advancedAccordion = page.getByRole('button', { name: 'Advanced' }) + const poolRadio = page.getByRole('radio', { name: 'custom pool' }) + const poolDropdown = page.getByLabel('IP pool') // accordion content should be hidden - await expect(label).toBeHidden() + await expect(poolRadio).toBeHidden() // open accordion - await page.getByRole('button', { name: 'Advanced' }).click() + await advancedAccordion.click() // accordion content should be visible - await expect(label).toBeVisible() + await expect(poolRadio).toBeVisible() + + // select custom pool radio button + await poolRadio.click() + + // now the IP pool dropdown should be visible + await expect(poolDropdown).toBeVisible() // choose pool and submit - await label.click() + await poolDropdown.click() await page.getByRole('option', { name: 'ip-pool-1' }).click() await page.getByRole('button', { name: 'Create floating IP' }).click() diff --git a/test/e2e/instance-create.e2e.ts b/test/e2e/instance-create.e2e.ts index 305a678da..5d2e929dd 100644 --- a/test/e2e/instance-create.e2e.ts +++ b/test/e2e/instance-create.e2e.ts @@ -75,20 +75,36 @@ test('can create an instance', async ({ page }) => { const checkbox = page.getByRole('checkbox', { name: 'Allocate and attach an ephemeral IP address', }) - const label = page.getByLabel('IP pool for ephemeral IP') + const customPoolRadio = page.getByRole('radio', { name: 'custom pool' }) + const poolDropdown = page.getByLabel('IP pool') - // verify that the ip pool selector is visible and default is selected + // verify that the ephemeral IP checkbox is checked and default radio is selected await expect(checkbox).toBeChecked() - await label.click() + // IPv4 default should be selected by default + await expect( + page.getByRole('radio', { name: 'IPv4 default', checked: true }) + ).toBeVisible() + + // select custom pool to see the dropdown + await customPoolRadio.click() + await expect(poolDropdown).toBeVisible() + await poolDropdown.click() await expect(page.getByRole('option', { name: 'ip-pool-1' })).toBeEnabled() - // unchecking the box should disable the selector + // unchecking the box should hide the pool selector await checkbox.uncheck() - await expect(label).toBeHidden() + await expect(customPoolRadio).toBeHidden() // re-checking the box should re-enable the selector, and other options should be selectable await checkbox.check() - await selectOption(page, 'IP pool for ephemeral IP', 'ip-pool-2 default VPN IPs') + await customPoolRadio.click() + // Need to wait for the dropdown to be visible first + await expect(poolDropdown).toBeVisible() + // Click the dropdown to open it and wait for options to be available + await poolDropdown.click() + await expect(page.getByRole('option', { name: 'ip-pool-2' })).toBeVisible() + // Force click since there might be overlays + await page.getByRole('option', { name: 'ip-pool-2' }).click({ force: true }) // should be visible in accordion await expect(page.getByRole('radiogroup', { name: 'Network interface' })).toBeVisible() diff --git a/test/e2e/instance-networking.e2e.ts b/test/e2e/instance-networking.e2e.ts index 7f41656e9..94e1ff94c 100644 --- a/test/e2e/instance-networking.e2e.ts +++ b/test/e2e/instance-networking.e2e.ts @@ -142,6 +142,8 @@ test('Instance networking tab — Detach / Attach Ephemeral IPs', async ({ page await attachEphemeralIpButton.click() modal = page.getByRole('dialog', { name: 'Attach ephemeral IP' }) await expect(modal).toBeVisible() + // Select custom pool radio to show the dropdown + await page.getByRole('radio', { name: 'custom pool' }).click() await page.getByRole('button', { name: 'IP pool' }).click() await page.getByRole('option', { name: 'ip-pool-2' }).click() await page.getByRole('button', { name: 'Attach', exact: true }).click() diff --git a/test/e2e/ip-pools.e2e.ts b/test/e2e/ip-pools.e2e.ts index 8003a58fc..6c47cbaa6 100644 --- a/test/e2e/ip-pools.e2e.ts +++ b/test/e2e/ip-pools.e2e.ts @@ -19,7 +19,7 @@ test('IP pool list', async ({ page }) => { const table = page.getByRole('table') - await expect(table.getByRole('row')).toHaveCount(5) // header + 4 rows + await expect(table.getByRole('row')).toHaveCount(7) // header + 6 rows (includes multicast pools) await expectRowVisible(table, { name: 'ip-pool-1', @@ -37,6 +37,14 @@ test('IP pool list', async ({ page }) => { name: 'ip-pool-4', 'IPs Remaining': '18.4e18 / 18.4e18', }) + await expectRowVisible(table, { + name: 'ip-pool-5-multicast-v4', + 'IPs Remaining': '32 / 32', + }) + await expectRowVisible(table, { + name: 'ip-pool-6-multicast-v6', + 'IPs Remaining': '18.4e18 / 18.4e18', + }) }) test.describe('german locale', () => { diff --git a/test/e2e/silos.e2e.ts b/test/e2e/silos.e2e.ts index ef0d7aa15..f1b5ab3c1 100644 --- a/test/e2e/silos.e2e.ts +++ b/test/e2e/silos.e2e.ts @@ -264,10 +264,19 @@ test('Silo IP pools', async ({ page }) => { await page.goto('/system/silos/maze-war/ip-pools') const table = page.getByRole('table') - // Both pools start as default (one IPv4, one IPv6) - valid dual-default scenario + // Both unicast pools start as default (one IPv4, one IPv6) - valid dual-default scenario await expectRowVisible(table, { name: 'ip-pool-1', 'IP Version': 'v4default' }) await expectRowVisible(table, { name: 'ip-pool-2', 'IP Version': 'v6default' }) - await expect(table.getByRole('row')).toHaveCount(3) // header + 2 + // Multicast pools are also linked as defaults + await expectRowVisible(table, { + name: 'ip-pool-5-multicast-v4', + 'IP Version': 'v4default', + }) + await expectRowVisible(table, { + name: 'ip-pool-6-multicast-v6', + 'IP Version': 'v6default', + }) + await expect(table.getByRole('row')).toHaveCount(5) // header + 4 // clicking on pool goes to pool detail await page.getByRole('link', { name: 'ip-pool-1' }).click() @@ -301,10 +310,19 @@ test('Silo IP pools link pool', async ({ page }) => { await page.goto('/system/silos/maze-war/ip-pools') const table = page.getByRole('table') - // Both pools start as default (one IPv4, one IPv6) + // Both unicast pools start as default (one IPv4, one IPv6) await expectRowVisible(table, { name: 'ip-pool-1', 'IP Version': 'v4default' }) await expectRowVisible(table, { name: 'ip-pool-2', 'IP Version': 'v6default' }) - await expect(table.getByRole('row')).toHaveCount(3) // header + 2 + // Multicast pools are also linked + await expectRowVisible(table, { + name: 'ip-pool-5-multicast-v4', + 'IP Version': 'v4default', + }) + await expectRowVisible(table, { + name: 'ip-pool-6-multicast-v6', + 'IP Version': 'v6default', + }) + await expect(table.getByRole('row')).toHaveCount(5) // header + 4 const modal = page.getByRole('dialog', { name: 'Link pool' }) await expect(modal).toBeHidden() From 6405314edb25647e69ae5e05083823f6206589c4 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 22 Jan 2026 22:34:47 -0800 Subject: [PATCH 33/45] Fix issue with sometimes blank field --- app/components/form/fields/IpPoolSelector.tsx | 78 +++++++++++++++---- 1 file changed, 63 insertions(+), 15 deletions(-) diff --git a/app/components/form/fields/IpPoolSelector.tsx b/app/components/form/fields/IpPoolSelector.tsx index 976ce13d2..e58303dec 100644 --- a/app/components/form/fields/IpPoolSelector.tsx +++ b/app/components/form/fields/IpPoolSelector.tsx @@ -5,6 +5,7 @@ * * Copyright Oxide Computer Company */ +import { useEffect } from 'react' import type { Control, UseFormSetValue } from 'react-hook-form' import type { IpVersion, SiloIpPool } from '@oxide/api' @@ -50,10 +51,9 @@ export function IpPoolSelector({ disabled = false, compatibleVersions, }: IpPoolSelectorProps) { - // Filter pools by compatible versions for custom pool dropdown - const filteredPools = compatibleVersions - ? pools.filter((p) => compatibleVersions.includes(p.ipVersion)) - : pools + // Treat empty compatibleVersions array as "unknown" (same as undefined) + // This handles the case where NICs haven't loaded yet + const hasCompatibilityConstraints = compatibleVersions && compatibleVersions.length > 0 // Determine which default pool versions exist const hasV4Default = pools.some((p) => p.isDefault && p.ipVersion === 'v4') @@ -61,17 +61,65 @@ export function IpPoolSelector({ // Filter default options by compatible versions const showV4Default = - hasV4Default && (!compatibleVersions || compatibleVersions.includes('v4')) + hasV4Default && (!hasCompatibilityConstraints || compatibleVersions.includes('v4')) const showV6Default = - hasV6Default && (!compatibleVersions || compatibleVersions.includes('v6')) + hasV6Default && (!hasCompatibilityConstraints || compatibleVersions.includes('v6')) + + // Filter pools by compatible versions for custom pool dropdown + const filteredPools = hasCompatibilityConstraints + ? pools.filter((p) => compatibleVersions.includes(p.ipVersion)) + : pools - // Derive current selection from pool and ipVersion + // Derive current selection, ensuring it maps to a rendered option type SelectionType = 'v4-default' | 'v6-default' | 'custom' - const currentSelection: SelectionType = currentPool - ? 'custom' - : currentIpVersion === 'v6' - ? 'v6-default' - : 'v4-default' + let currentSelection: SelectionType + + if (currentPool && filteredPools.some((p) => p.name === currentPool)) { + // Valid custom pool selected + currentSelection = 'custom' + } else if (!currentPool && currentIpVersion === 'v6' && showV6Default) { + // v6 default requested and available + currentSelection = 'v6-default' + } else if (!currentPool && showV4Default) { + // v4 default (explicit or fallback) + currentSelection = 'v4-default' + } else if (showV6Default) { + // Fallback to v6 default + currentSelection = 'v6-default' + } else if (filteredPools.length > 0) { + // Fallback to custom + currentSelection = 'custom' + } else { + // No options available - pick v4-default as safe default + currentSelection = 'v4-default' + } + + const radioName = `pool-selection-type-${poolFieldName}` + + // Auto-correct form state when compatibility filtering changes the selection + useEffect(() => { + if (currentSelection === 'v4-default' && (currentPool || currentIpVersion !== 'v4')) { + setValue(poolFieldName, '') + setValue(ipVersionFieldName, 'v4') + } else if ( + currentSelection === 'v6-default' && + (currentPool || currentIpVersion !== 'v6') + ) { + setValue(poolFieldName, '') + setValue(ipVersionFieldName, 'v6') + } else if (currentSelection === 'custom' && !currentPool && filteredPools.length > 0) { + // Fell back to custom but no pool selected + setValue(poolFieldName, filteredPools[0].name) + } + }, [ + currentSelection, + currentPool, + currentIpVersion, + filteredPools, + poolFieldName, + ipVersionFieldName, + setValue, + ]) return (
@@ -80,7 +128,7 @@ export function IpPoolSelector({
{showV4Default && ( { @@ -94,7 +142,7 @@ export function IpPoolSelector({ )} {showV6Default && ( { @@ -107,7 +155,7 @@ export function IpPoolSelector({ )} { From a1a1006e616c23c37a4c4a16a60dce30a8f563a5 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 22 Jan 2026 22:42:38 -0800 Subject: [PATCH 34/45] Better handling of IP versions to show during create flow --- app/forms/instance-create.tsx | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index 0d0e74da9..e7a3b76e2 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -846,14 +846,18 @@ const AdvancedAccordion = ({ {/* Calculate compatible IP versions based on NIC type */} {(() => { const nicType = networkInterfaces?.type - const compatibleVersions: IpVersion[] = [] - - if (nicType === 'default_ipv4' || nicType === 'default_dual_stack') { - compatibleVersions.push('v4') - } - if (nicType === 'default_ipv6' || nicType === 'default_dual_stack') { - compatibleVersions.push('v6') + let compatibleVersions: IpVersion[] | undefined = undefined + + // Only set constraints for the default_* types + // For 'create' and 'none', leave undefined (treat as "unknown" - allow both) + if (nicType === 'default_ipv4') { + compatibleVersions = ['v4'] + } else if (nicType === 'default_ipv6') { + compatibleVersions = ['v6'] + } else if (nicType === 'default_dual_stack') { + compatibleVersions = ['v4', 'v6'] } + // nicType === 'create' or 'none': compatibleVersions stays undefined return ( <> From 7cafaf216a56e10c8a3d13001275840f175db369 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 22 Jan 2026 22:52:27 -0800 Subject: [PATCH 35/45] Better IP type compatability handling --- app/components/AttachEphemeralIpModal.tsx | 18 ++++++++++++++---- app/components/form/fields/IpPoolSelector.tsx | 11 ++++------- mock-api/ip-pool.ts | 6 +++--- 3 files changed, 21 insertions(+), 14 deletions(-) diff --git a/app/components/AttachEphemeralIpModal.tsx b/app/components/AttachEphemeralIpModal.tsx index 76f166020..c828a6501 100644 --- a/app/components/AttachEphemeralIpModal.tsx +++ b/app/components/AttachEphemeralIpModal.tsx @@ -45,8 +45,9 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) // Determine compatible IP versions based on instance's network interfaces // External IP version must match the NIC's private IP stack - const compatibleVersions: IpVersion[] = useMemo(() => { - if (!nics) return [] + const compatibleVersions: IpVersion[] | undefined = useMemo(() => { + // Before NICs load, return undefined (treat as "unknown" - allow all) + if (!nics) return undefined const nicItems = nics.items const hasV4Nic = nicItems.some( @@ -59,6 +60,7 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) const versions: IpVersion[] = [] if (hasV4Nic) versions.push('v4') if (hasV6Nic) versions.push('v6') + // Return the array (could be empty if instance has no NICs with compatible stacks) return versions }, [nics]) @@ -83,7 +85,7 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) // Update ipVersion if only one version is compatible useEffect(() => { - if (compatibleVersions.length === 1) { + if (compatibleVersions && compatibleVersions.length === 1) { form.setValue('ipVersion', compatibleVersions[0]) } }, [compatibleVersions, form]) @@ -92,6 +94,10 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) const getDisabledReason = () => { if (!siloPools) return 'Loading pools...' + if (!nics) return 'Loading network interfaces...' + if (compatibleVersions && compatibleVersions.length === 0) { + return 'Instance has no network interfaces with compatible IP stacks' + } if (unicastPools.length === 0) return 'No unicast pools available' if (!pool && !hasDefaultUnicastPool) { return 'No default pool available; select a pool to continue' @@ -121,7 +127,11 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) { diff --git a/app/components/form/fields/IpPoolSelector.tsx b/app/components/form/fields/IpPoolSelector.tsx index e58303dec..878600be1 100644 --- a/app/components/form/fields/IpPoolSelector.tsx +++ b/app/components/form/fields/IpPoolSelector.tsx @@ -51,22 +51,19 @@ export function IpPoolSelector({ disabled = false, compatibleVersions, }: IpPoolSelectorProps) { - // Treat empty compatibleVersions array as "unknown" (same as undefined) - // This handles the case where NICs haven't loaded yet - const hasCompatibilityConstraints = compatibleVersions && compatibleVersions.length > 0 - // Determine which default pool versions exist const hasV4Default = pools.some((p) => p.isDefault && p.ipVersion === 'v4') const hasV6Default = pools.some((p) => p.isDefault && p.ipVersion === 'v6') // Filter default options by compatible versions + // undefined = no filtering, [] = filter out everything const showV4Default = - hasV4Default && (!hasCompatibilityConstraints || compatibleVersions.includes('v4')) + hasV4Default && (!compatibleVersions || compatibleVersions.includes('v4')) const showV6Default = - hasV6Default && (!hasCompatibilityConstraints || compatibleVersions.includes('v6')) + hasV6Default && (!compatibleVersions || compatibleVersions.includes('v6')) // Filter pools by compatible versions for custom pool dropdown - const filteredPools = hasCompatibilityConstraints + const filteredPools = compatibleVersions ? pools.filter((p) => compatibleVersions.includes(p.ipVersion)) : pools diff --git a/mock-api/ip-pool.ts b/mock-api/ip-pool.ts index 3ed866731..f8544be7a 100644 --- a/mock-api/ip-pool.ts +++ b/mock-api/ip-pool.ts @@ -63,7 +63,7 @@ export const ipPool5Multicast: Json = { } export const ipPool6Multicast: Json = { - id: 'c7d5b7ca-872f-4e39-95d1-fe4e8849fg2e', + id: 'c7d5b7ca-872f-4e39-95d1-fe4e8849f02e', name: 'ip-pool-6-multicast-v6', description: 'Multicast v6 pool', time_created: new Date().toISOString(), @@ -146,7 +146,7 @@ export const ipPoolRanges: Json = [ }, // Multicast pool ranges (should NOT be used for ephemeral/floating IPs) { - id: 'e8f6c8db-983g-4f4a-a6e2-gf5f9960gh3f', + id: 'e8f6c8db-9830-4f4a-a6e2-0f5f99600b3f', ip_pool_id: ipPool5Multicast.id, range: { first: '224.0.0.1', @@ -155,7 +155,7 @@ export const ipPoolRanges: Json = [ time_created: new Date().toISOString(), }, { - id: 'f9g7d9ec-a94h-5g5b-b7f3-hg6ga071hi4g', + id: 'f9a7d9ec-a940-5a5b-b7f3-0a6aa0710b4a', ip_pool_id: ipPool6Multicast.id, range: { first: 'ff00::1', From 0c75dd6139cb4b7776ef7773d1f58199d9187d0c Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 22 Jan 2026 23:02:29 -0800 Subject: [PATCH 36/45] Better default-pool + ipVersion handling --- app/components/AttachEphemeralIpModal.tsx | 17 ++++++++++++++++- app/forms/floating-ip-create.tsx | 17 ++++++++++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/app/components/AttachEphemeralIpModal.tsx b/app/components/AttachEphemeralIpModal.tsx index c828a6501..cd033bdcf 100644 --- a/app/components/AttachEphemeralIpModal.tsx +++ b/app/components/AttachEphemeralIpModal.tsx @@ -135,12 +135,27 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) } disabledReason={getDisabledReason()} onAction={() => { + // When using default pool, derive ipVersion from available defaults + let effectiveIpVersion = ipVersion + if (!pool) { + const v4Default = unicastPools.find((p) => p.isDefault && p.ipVersion === 'v4') + const v6Default = unicastPools.find((p) => p.isDefault && p.ipVersion === 'v6') + + // If only one default exists, use that version + if (v4Default && !v6Default) { + effectiveIpVersion = 'v4' + } else if (v6Default && !v4Default) { + effectiveIpVersion = 'v6' + } + // If both exist, use form's ipVersion (user's choice) + } + instanceEphemeralIpAttach.mutate({ path: { instance }, query: { project }, body: pool ? { poolSelector: { type: 'explicit', pool } } - : { poolSelector: { type: 'auto', ipVersion } }, + : { poolSelector: { type: 'auto', ipVersion: effectiveIpVersion } }, }) }} onDismiss={onDismiss} diff --git a/app/forms/floating-ip-create.tsx b/app/forms/floating-ip-create.tsx index d545023bf..12657d25d 100644 --- a/app/forms/floating-ip-create.tsx +++ b/app/forms/floating-ip-create.tsx @@ -86,6 +86,21 @@ export default function CreateFloatingIpSideModalForm() { resourceName="floating IP" onDismiss={() => navigate(pb.floatingIps(projectSelector))} onSubmit={({ pool, ipVersion, ...values }) => { + // When using default pool, derive ipVersion from available defaults + let effectiveIpVersion = ipVersion + if (!pool) { + const v4Default = unicastPools.find((p) => p.isDefault && p.ipVersion === 'v4') + const v6Default = unicastPools.find((p) => p.isDefault && p.ipVersion === 'v6') + + // If only one default exists, use that version + if (v4Default && !v6Default) { + effectiveIpVersion = 'v4' + } else if (v6Default && !v4Default) { + effectiveIpVersion = 'v6' + } + // If both exist, use form's ipVersion (user's choice) + } + const body: FloatingIpCreate = { ...values, addressAllocator: pool @@ -95,7 +110,7 @@ export default function CreateFloatingIpSideModalForm() { } : { type: 'auto' as const, - poolSelector: { type: 'auto' as const, ipVersion }, + poolSelector: { type: 'auto' as const, ipVersion: effectiveIpVersion }, }, } createFloatingIp.mutate({ query: projectSelector, body }) From 909b19698b4ba53acd8901d96101014cf8fb178b Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 22 Jan 2026 23:09:44 -0800 Subject: [PATCH 37/45] Better handle defaults and empty states --- app/components/form/fields/IpPoolSelector.tsx | 96 ++++++++++--------- 1 file changed, 52 insertions(+), 44 deletions(-) diff --git a/app/components/form/fields/IpPoolSelector.tsx b/app/components/form/fields/IpPoolSelector.tsx index 878600be1..bab09147e 100644 --- a/app/components/form/fields/IpPoolSelector.tsx +++ b/app/components/form/fields/IpPoolSelector.tsx @@ -81,16 +81,18 @@ export function IpPoolSelector({ // v4 default (explicit or fallback) currentSelection = 'v4-default' } else if (showV6Default) { - // Fallback to v6 default + // Fallback to v6 default if rendered currentSelection = 'v6-default' - } else if (filteredPools.length > 0) { - // Fallback to custom - currentSelection = 'custom' - } else { - // No options available - pick v4-default as safe default + } else if (showV4Default) { + // Fallback to v4 default if rendered currentSelection = 'v4-default' + } else { + // Final fallback: custom radio is always rendered, so this is safe + currentSelection = 'custom' } + const hasNoPools = filteredPools.length === 0 && !showV4Default && !showV6Default + const radioName = `pool-selection-type-${poolFieldName}` // Auto-correct form state when compatibility filtering changes the selection @@ -122,53 +124,59 @@ export function IpPoolSelector({
Select IP pool -
- {showV4Default && ( - { - setValue(poolFieldName, '') - setValue(ipVersionFieldName, 'v4') - }} - disabled={disabled} - > - IPv4 default - - )} - {showV6Default && ( + {hasNoPools ? ( +
+ No IP pools available for this network interface type +
+ ) : ( +
+ {showV4Default && ( + { + setValue(poolFieldName, '') + setValue(ipVersionFieldName, 'v4') + }} + disabled={disabled} + > + IPv4 default + + )} + {showV6Default && ( + { + setValue(poolFieldName, '') + setValue(ipVersionFieldName, 'v6') + }} + disabled={disabled} + > + IPv6 default + + )} { - setValue(poolFieldName, '') - setValue(ipVersionFieldName, 'v6') + // Set to first compatible pool in list so the dropdown shows with a valid selection + if (filteredPools.length > 0) { + setValue(poolFieldName, filteredPools[0].name) + } }} disabled={disabled} > - IPv6 default + custom pool - )} - { - // Set to first compatible pool in list so the dropdown shows with a valid selection - if (filteredPools.length > 0) { - setValue(poolFieldName, filteredPools[0].name) - } - }} - disabled={disabled} - > - custom pool - -
+
+ )}
- {currentSelection === 'custom' && ( + {currentSelection === 'custom' && filteredPools.length > 0 && ( Date: Fri, 23 Jan 2026 08:00:45 -0800 Subject: [PATCH 38/45] refactor / copy --- app/components/form/fields/IpPoolSelector.tsx | 2 +- app/forms/instance-create.tsx | 24 +++++++------------ 2 files changed, 10 insertions(+), 16 deletions(-) diff --git a/app/components/form/fields/IpPoolSelector.tsx b/app/components/form/fields/IpPoolSelector.tsx index bab09147e..0a9adba69 100644 --- a/app/components/form/fields/IpPoolSelector.tsx +++ b/app/components/form/fields/IpPoolSelector.tsx @@ -170,7 +170,7 @@ export function IpPoolSelector({ }} disabled={disabled} > - custom pool + select pool
)} diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index e7a3b76e2..e68de5d40 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -239,22 +239,16 @@ export default function CreateInstanceForm() { [siloPools] ) + const defaultUnicastPools = unicastPools.filter((pool) => pool.isDefault) + const hasV4Default = defaultUnicastPools.some((p) => p.ipVersion === 'v4') + const hasV6Default = defaultUnicastPools.some((p) => p.ipVersion === 'v6') + // Detect if both IPv4 and IPv6 default unicast pools exist - const hasDualDefaults = useMemo(() => { - const defaultUnicastPools = unicastPools.filter((pool) => pool.isDefault) - const hasV4Default = defaultUnicastPools.some((p) => p.ipVersion === 'v4') - const hasV6Default = defaultUnicastPools.some((p) => p.ipVersion === 'v6') - return hasV4Default && hasV6Default - }, [unicastPools]) + const hasDualDefaults = hasV4Default && hasV6Default const defaultSource = siloImages.length > 0 ? 'siloImage' : projectImages.length > 0 ? 'projectImage' : 'disk' - // Calculate if there's a single default pool (not dual defaults) - const singleDefaultPool = !hasDualDefaults - ? unicastPools.find((p) => p.isDefault)?.name - : undefined - const defaultValues: InstanceCreateInput = { ...baseDefaultValues, bootDiskSourceType: defaultSource, @@ -264,10 +258,10 @@ export default function CreateInstanceForm() { ephemeralIpVersion: 'v4', // Set ephemeralIpPool if there's a single default, otherwise leave empty (for radio "use default") ephemeralIpPool: '', - externalIps: singleDefaultPool - ? [{ type: 'ephemeral', poolSelector: { type: 'explicit', pool: singleDefaultPool } }] - : hasDualDefaults - ? [{ type: 'ephemeral', poolSelector: { type: 'auto', ipVersion: 'v4' } }] + externalIps: hasV4Default + ? [{ type: 'ephemeral', poolSelector: { type: 'auto', ipVersion: 'v4' } }] + : hasV6Default + ? [{ type: 'ephemeral', poolSelector: { type: 'auto', ipVersion: 'v6' } }] : [{ type: 'ephemeral' }], } From e3d52c8f99cd525cd3950c1fb1e276b5787e4303 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 23 Jan 2026 09:52:00 -0800 Subject: [PATCH 39/45] another refactor to defaults to match API --- app/forms/instance-create.tsx | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index e68de5d40..a7166fd22 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -245,6 +245,8 @@ export default function CreateInstanceForm() { // Detect if both IPv4 and IPv6 default unicast pools exist const hasDualDefaults = hasV4Default && hasV6Default + // Use default version if available; fall back to v4 + const ephemeralIpVersion: IpVersion = hasV4Default ? 'v4' : hasV6Default ? 'v6' : 'v4' const defaultSource = siloImages.length > 0 ? 'siloImage' : projectImages.length > 0 ? 'projectImage' : 'disk' @@ -254,15 +256,22 @@ export default function CreateInstanceForm() { bootDiskSourceType: defaultSource, sshPublicKeys: allKeys, bootDiskSize: diskSizeNearest10(defaultImage?.size / GiB), - // When dual defaults exist and no explicit pool, default to v4 for dual_stack - ephemeralIpVersion: 'v4', - // Set ephemeralIpPool if there's a single default, otherwise leave empty (for radio "use default") + ephemeralIpVersion, + // Set ephemeralIpPool empty (for radio "use default") ephemeralIpPool: '', - externalIps: hasV4Default - ? [{ type: 'ephemeral', poolSelector: { type: 'auto', ipVersion: 'v4' } }] - : hasV6Default - ? [{ type: 'ephemeral', poolSelector: { type: 'auto', ipVersion: 'v6' } }] - : [{ type: 'ephemeral' }], + // API behavior: + // - Single default: { type: 'ephemeral' } → API auto-picks the one default + // - Dual defaults: { poolSelector: { type: 'auto', ipVersion } } → Must specify version + // right now we default to 'v4' when both exist + // - No defaults: { type: 'ephemeral' } → Will fail, but user will see error + externalIps: hasDualDefaults + ? [ + { + type: 'ephemeral', + poolSelector: { type: 'auto', ipVersion: 'v4' }, + }, + ] + : [{ type: 'ephemeral' }], } const form = useForm({ defaultValues }) From 8d211a118dbecd8d3b40e1ed5748eb9594ef32a5 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 23 Jan 2026 12:35:20 -0800 Subject: [PATCH 40/45] revert copy for now --- app/components/form/fields/IpPoolSelector.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/form/fields/IpPoolSelector.tsx b/app/components/form/fields/IpPoolSelector.tsx index 0a9adba69..bab09147e 100644 --- a/app/components/form/fields/IpPoolSelector.tsx +++ b/app/components/form/fields/IpPoolSelector.tsx @@ -170,7 +170,7 @@ export function IpPoolSelector({ }} disabled={disabled} > - select pool + custom pool
)} From f0ee1c77cc56e036695eac0fb90dd60d63746f00 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 23 Jan 2026 15:15:13 -0800 Subject: [PATCH 41/45] external IP version compatibility should consider the primary NIC; add tests for Ephemeral IP attachment --- app/components/AttachEphemeralIpModal.tsx | 30 ++-- mock-api/msw/handlers.ts | 38 +++++ test/e2e/instance-networking.e2e.ts | 191 ++++++++++++++++++++++ 3 files changed, 248 insertions(+), 11 deletions(-) diff --git a/app/components/AttachEphemeralIpModal.tsx b/app/components/AttachEphemeralIpModal.tsx index cd033bdcf..f955760a8 100644 --- a/app/components/AttachEphemeralIpModal.tsx +++ b/app/components/AttachEphemeralIpModal.tsx @@ -43,24 +43,32 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) return unicastPools.some((p) => p.isDefault) }, [unicastPools]) - // Determine compatible IP versions based on instance's network interfaces - // External IP version must match the NIC's private IP stack + // Determine compatible IP versions based on instance's primary network interface + // External IPs route through the primary interface, so only its IP stack matters + // https://github.com/oxidecomputer/omicron/blob/d52aad0/nexus/db-queries/src/db/datastore/external_ip.rs#L544-L661 const compatibleVersions: IpVersion[] | undefined = useMemo(() => { // Before NICs load, return undefined (treat as "unknown" - allow all) if (!nics) return undefined const nicItems = nics.items - const hasV4Nic = nicItems.some( - (nic) => nic.ipStack.type === 'v4' || nic.ipStack.type === 'dual_stack' - ) - const hasV6Nic = nicItems.some( - (nic) => nic.ipStack.type === 'v6' || nic.ipStack.type === 'dual_stack' - ) + const primaryNic = nicItems.find((nic) => nic.primary) + + // If no primary NIC found (defensive), return empty array + if (!primaryNic) return [] const versions: IpVersion[] = [] - if (hasV4Nic) versions.push('v4') - if (hasV6Nic) versions.push('v6') - // Return the array (could be empty if instance has no NICs with compatible stacks) + if ( + primaryNic.ipStack.type === 'v4' || + primaryNic.ipStack.type === 'dual_stack' + ) { + versions.push('v4') + } + if ( + primaryNic.ipStack.type === 'v6' || + primaryNic.ipStack.type === 'dual_stack' + ) { + versions.push('v6') + } return versions }, [nics]) diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index f43154375..c7fb6c59e 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -861,6 +861,44 @@ export const handlers = makeHandlers({ const pool = resolvePoolSelector(body.pool_selector, 'unicast') const ip = getIpFromPool(pool) + // Validate that external IP version matches primary NIC's IP stack + // Based on Omicron validation in nexus/db-queries/src/db/datastore/external_ip.rs:544-661 + const nics = db.networkInterfaces.filter((n) => n.instance_id === instance.id) + const primaryNic = nics.find((n) => n.primary) + + if (!primaryNic) { + throw json( + { + error_code: 'InvalidRequest', + message: `Instance ${instance.name} has no primary network interface`, + }, + { status: 400 } + ) + } + + const ipVersion = pool.ip_version + const stackType = primaryNic.ip_stack.type + + if (ipVersion === 'v4' && stackType !== 'v4' && stackType !== 'dual_stack') { + throw json( + { + error_code: 'InvalidRequest', + message: `The ephemeral external IP is an IPv4 address, but the instance with ID ${instance.name} does not have a primary network interface with a VPC-private IPv4 address. Add a VPC-private IPv4 address to the interface, or attach a different IP address`, + }, + { status: 400 } + ) + } + + if (ipVersion === 'v6' && stackType !== 'v6' && stackType !== 'dual_stack') { + throw json( + { + error_code: 'InvalidRequest', + message: `The ephemeral external IP is an IPv6 address, but the instance with ID ${instance.name} does not have a primary network interface with a VPC-private IPv6 address. Add a VPC-private IPv6 address to the interface, or attach a different IP address`, + }, + { status: 400 } + ) + } + const externalIp = { ip, ip_pool_id: pool.id, kind: 'ephemeral' as const } db.ephemeralIps.push({ instance_id: instance.id, diff --git a/test/e2e/instance-networking.e2e.ts b/test/e2e/instance-networking.e2e.ts index 94e1ff94c..bcb120dc4 100644 --- a/test/e2e/instance-networking.e2e.ts +++ b/test/e2e/instance-networking.e2e.ts @@ -13,8 +13,15 @@ import { expectRowVisible, expectVisible, stopInstance, + type Page, } from './utils' +const selectASiloImage = async (page: Page, name: string) => { + await page.getByRole('tab', { name: 'Silo images' }).click() + await page.getByPlaceholder('Select a silo image', { exact: true }).click() + await page.getByRole('option', { name }).click() +} + test('Instance networking tab — NIC table', async ({ page }) => { await page.goto('/projects/mock-project/instances/db1') @@ -294,3 +301,187 @@ test('Edit network interface - Transit IPs', async ({ page }) => { page.getByRole('tooltip', { name: 'Other transit IPs 192.168.0.0/16 ::/64' }) ).toBeVisible() }) + +test('IPv4-only instance cannot attach IPv6 ephemeral IP', async ({ page }) => { + // Create an IPv4-only instance + await page.goto('/projects/mock-project/instances-new') + const instanceName = 'ipv4-only-test' + await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) + await selectASiloImage(page, 'ubuntu-22-04') + + // Open networking accordion and select IPv4-only + await page.getByRole('button', { name: 'Networking' }).click() + await page.getByRole('radio', { name: 'Default IPv4', exact: true }).click() + + // Don't attach ephemeral IP at creation + await page + .getByRole('checkbox', { name: 'Allocate and attach an ephemeral IP address' }) + .uncheck() + + // Create instance + await page.getByRole('button', { name: 'Create instance' }).click() + await expect(page).toHaveURL(/\/instances\/ipv4-only-test/) + + // Navigate to networking tab + await page.getByRole('tab', { name: 'Networking' }).click() + + // Try to attach ephemeral IP + const attachButton = page.getByRole('button', { name: 'Attach ephemeral IP' }) + await expect(attachButton).toBeEnabled() + await attachButton.click() + + const modal = page.getByRole('dialog', { name: 'Attach ephemeral IP' }) + await expect(modal).toBeVisible() + + // Verify that IPv6 default radio is NOT shown (filtered out by compatibility check) + await expect(page.getByRole('radio', { name: 'IPv6 default' })).toBeHidden() + + // Verify IPv4 default radio IS shown + await expect(page.getByRole('radio', { name: 'IPv4 default' })).toBeVisible() + + // Check custom pool - IPv6 pools should be filtered out + await page.getByRole('radio', { name: 'custom pool' }).click() + await page.getByRole('button', { name: 'IP pool' }).click() + + // ip-pool-2 is IPv6, should not appear + await expect(page.getByRole('option', { name: 'ip-pool-2' })).toBeHidden() + + // ip-pool-1 is IPv4, should appear + await expect(page.getByRole('option', { name: 'ip-pool-1' })).toBeVisible() +}) + +test('IPv6-only instance cannot attach IPv4 ephemeral IP', async ({ page }) => { + // Create an IPv6-only instance + await page.goto('/projects/mock-project/instances-new') + const instanceName = 'ipv6-only-test' + await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) + await selectASiloImage(page, 'ubuntu-22-04') + + // Open networking accordion and select IPv6-only + await page.getByRole('button', { name: 'Networking' }).click() + await page.getByRole('radio', { name: 'Default IPv6', exact: true }).click() + + // Don't attach ephemeral IP at creation + await page + .getByRole('checkbox', { name: 'Allocate and attach an ephemeral IP address' }) + .uncheck() + + // Create instance + await page.getByRole('button', { name: 'Create instance' }).click() + await expect(page).toHaveURL(/\/instances\/ipv6-only-test/) + + // Navigate to networking tab + await page.getByRole('tab', { name: 'Networking' }).click() + + // Try to attach ephemeral IP + const attachButton = page.getByRole('button', { name: 'Attach ephemeral IP' }) + await expect(attachButton).toBeEnabled() + await attachButton.click() + + const modal = page.getByRole('dialog', { name: 'Attach ephemeral IP' }) + await expect(modal).toBeVisible() + + // Verify that IPv4 default radio is NOT shown (filtered out by compatibility check) + await expect(page.getByRole('radio', { name: 'IPv4 default' })).toBeHidden() + + // Verify IPv6 default radio IS shown + await expect(page.getByRole('radio', { name: 'IPv6 default' })).toBeVisible() + + // Check custom pool - IPv4 pools should be filtered out + await page.getByRole('radio', { name: 'custom pool' }).click() + await page.getByRole('button', { name: 'IP pool' }).click() + + // ip-pool-1 is IPv4, should not appear + await expect(page.getByRole('option', { name: 'ip-pool-1' })).toBeHidden() + + // ip-pool-2 is IPv6, should appear + await expect(page.getByRole('option', { name: 'ip-pool-2' })).toBeVisible() +}) + +test('IPv4-only instance can attach IPv4 ephemeral IP', async ({ page }) => { + // Create an IPv4-only instance + await page.goto('/projects/mock-project/instances-new') + const instanceName = 'ipv4-success-test' + await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) + await selectASiloImage(page, 'ubuntu-22-04') + + // Open networking accordion and select IPv4-only + await page.getByRole('button', { name: 'Networking' }).click() + await page.getByRole('radio', { name: 'Default IPv4', exact: true }).click() + + // Don't attach ephemeral IP at creation + await page + .getByRole('checkbox', { name: 'Allocate and attach an ephemeral IP address' }) + .uncheck() + + // Create instance + await page.getByRole('button', { name: 'Create instance' }).click() + await expect(page).toHaveURL(/\/instances\/ipv4-success-test/) + + // Navigate to networking tab + await page.getByRole('tab', { name: 'Networking' }).click() + + // Attach IPv4 ephemeral IP (using default pool) + const attachButton = page.getByRole('button', { name: 'Attach ephemeral IP' }) + await expect(attachButton).toBeEnabled() + await attachButton.click() + + const modal = page.getByRole('dialog', { name: 'Attach ephemeral IP' }) + await expect(modal).toBeVisible() + + // Use default IPv4 pool + await page.getByRole('button', { name: 'Attach', exact: true }).click() + + // Modal should close and IP should be attached + await expect(modal).toBeHidden() + + // Verify ephemeral IP appears in table + const externalIpTable = page.getByRole('table', { name: 'External IPs' }) + await expect(externalIpTable.getByRole('cell', { name: 'ephemeral' })).toBeVisible() +}) + +test('IPv6-only instance can attach IPv6 ephemeral IP', async ({ page }) => { + // Create an IPv6-only instance + await page.goto('/projects/mock-project/instances-new') + const instanceName = 'ipv6-success-test' + await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) + await selectASiloImage(page, 'ubuntu-22-04') + + // Open networking accordion and select IPv6-only + await page.getByRole('button', { name: 'Networking' }).click() + await page.getByRole('radio', { name: 'Default IPv6', exact: true }).click() + + // Don't attach ephemeral IP at creation + await page + .getByRole('checkbox', { name: 'Allocate and attach an ephemeral IP address' }) + .uncheck() + + // Create instance + await page.getByRole('button', { name: 'Create instance' }).click() + await expect(page).toHaveURL(/\/instances\/ipv6-success-test/) + + // Navigate to networking tab + await page.getByRole('tab', { name: 'Networking' }).click() + + // Attach IPv6 ephemeral IP + const attachButton = page.getByRole('button', { name: 'Attach ephemeral IP' }) + await expect(attachButton).toBeEnabled() + await attachButton.click() + + const modal = page.getByRole('dialog', { name: 'Attach ephemeral IP' }) + await expect(modal).toBeVisible() + + // Select IPv6 pool (ip-pool-2) + await page.getByRole('radio', { name: 'custom pool' }).click() + await page.getByRole('button', { name: 'IP pool' }).click() + await page.getByRole('option', { name: 'ip-pool-2' }).click() + + await page.getByRole('button', { name: 'Attach', exact: true }).click() + + // Modal should close and IP should be attached + await expect(modal).toBeHidden() + + // Verify ephemeral IP appears in table + const externalIpTable = page.getByRole('table', { name: 'External IPs' }) + await expect(externalIpTable.getByRole('cell', { name: 'ephemeral' })).toBeVisible() +}) From 8645fa1cf4c681b0427d089fe04917d15d49329c Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 23 Jan 2026 15:28:08 -0800 Subject: [PATCH 42/45] Ensure that when custom NIC is created, Ephemeral IP options match first custom NIC in list --- app/components/AttachEphemeralIpModal.tsx | 10 +- app/forms/instance-create.tsx | 22 ++- test/e2e/instance-create.e2e.ts | 182 ++++++++++++++++++++++ 3 files changed, 203 insertions(+), 11 deletions(-) diff --git a/app/components/AttachEphemeralIpModal.tsx b/app/components/AttachEphemeralIpModal.tsx index f955760a8..31454857c 100644 --- a/app/components/AttachEphemeralIpModal.tsx +++ b/app/components/AttachEphemeralIpModal.tsx @@ -57,16 +57,10 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) if (!primaryNic) return [] const versions: IpVersion[] = [] - if ( - primaryNic.ipStack.type === 'v4' || - primaryNic.ipStack.type === 'dual_stack' - ) { + if (primaryNic.ipStack.type === 'v4' || primaryNic.ipStack.type === 'dual_stack') { versions.push('v4') } - if ( - primaryNic.ipStack.type === 'v6' || - primaryNic.ipStack.type === 'dual_stack' - ) { + if (primaryNic.ipStack.type === 'v6' || primaryNic.ipStack.type === 'dual_stack') { versions.push('v6') } return versions diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index a7166fd22..fc1534299 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -851,16 +851,32 @@ const AdvancedAccordion = ({ const nicType = networkInterfaces?.type let compatibleVersions: IpVersion[] | undefined = undefined - // Only set constraints for the default_* types - // For 'create' and 'none', leave undefined (treat as "unknown" - allow both) + // Set constraints based on primary NIC configuration if (nicType === 'default_ipv4') { compatibleVersions = ['v4'] } else if (nicType === 'default_ipv6') { compatibleVersions = ['v6'] } else if (nicType === 'default_dual_stack') { compatibleVersions = ['v4', 'v6'] + } else if ( + nicType === 'create' && + networkInterfaces && + networkInterfaces.params.length > 0 + ) { + // Derive from the first NIC's ipConfig (first NIC becomes primary) + const primaryNicConfig = networkInterfaces.params[0].ipConfig + if (primaryNicConfig?.type === 'v4') { + compatibleVersions = ['v4'] + } else if (primaryNicConfig?.type === 'v6') { + compatibleVersions = ['v6'] + } else if (primaryNicConfig?.type === 'dual_stack') { + compatibleVersions = ['v4', 'v6'] + } else { + // ipConfig not provided = defaults to dual-stack + compatibleVersions = ['v4', 'v6'] + } } - // nicType === 'create' or 'none': compatibleVersions stays undefined + // nicType === 'none': compatibleVersions stays undefined (instance has no NICs) return ( <> diff --git a/test/e2e/instance-create.e2e.ts b/test/e2e/instance-create.e2e.ts index 5d2e929dd..b7f782417 100644 --- a/test/e2e/instance-create.e2e.ts +++ b/test/e2e/instance-create.e2e.ts @@ -801,3 +801,185 @@ test('create instance with dual-stack networking shows both IPs', async ({ page expect(cellText).toMatch(/127\.0\.0\.1/) // IPv4 expect(cellText).toMatch(/::1/) // IPv6 }) + +test('create instance with custom IPv4-only NIC constrains ephemeral IP to IPv4', async ({ + page, +}) => { + await page.goto('/projects/mock-project/instances-new') + + const instanceName = 'custom-ipv4-nic-test' + await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) + await selectASiloImage(page, 'ubuntu-22-04') + + // Open networking accordion + await page.getByRole('button', { name: 'Networking' }).click() + + // Select "Custom" network interface (use exact match and first to disambiguate from "custom pool") + await page.getByRole('radio', { name: 'Custom', exact: true }).first().click() + + // Add a custom NIC with IPv4-only configuration + await page.getByRole('button', { name: 'Add network interface' }).click() + + const modal = page.getByRole('dialog', { name: 'Add network interface' }) + await expect(modal).toBeVisible() + + await modal.getByRole('textbox', { name: 'Name' }).fill('my-ipv4-nic') + await modal.getByLabel('VPC', { exact: true }).click() + await page.getByRole('option', { name: 'mock-vpc' }).click() + await modal.getByLabel('Subnet').click() + await page.getByRole('option', { name: 'mock-subnet' }).click() + + // Select IPv4-only IP configuration + await modal.getByRole('radio', { name: 'IPv4', exact: true }).click() + + await modal.getByRole('button', { name: 'Add network interface' }).click() + await expect(modal).toBeHidden() + + // Verify the NIC was added + const nicTable = page.getByRole('table', { name: 'Network Interfaces' }) + await expect( + nicTable.getByRole('cell', { name: 'my-ipv4-nic', exact: true }) + ).toBeVisible() + + // Verify that ephemeral IP options are constrained to IPv4 only + const ephemeralCheckbox = page.getByRole('checkbox', { + name: 'Allocate and attach an ephemeral IP address', + }) + await expect(ephemeralCheckbox).toBeVisible() + + // IPv4 default should be available + await expect(page.getByRole('radio', { name: 'IPv4 default' })).toBeVisible() + + // IPv6 default should NOT be available (filtered out) + await expect(page.getByRole('radio', { name: 'IPv6 default' })).toBeHidden() + + // Check custom pool - IPv6 pools should be filtered out + await page.getByRole('radio', { name: 'custom pool' }).click() + await page.getByRole('button', { name: 'IP pool' }).click() + + // ip-pool-1 is IPv4, should appear + await expect(page.getByRole('option', { name: 'ip-pool-1' })).toBeVisible() + + // ip-pool-2 is IPv6, should NOT appear + await expect(page.getByRole('option', { name: 'ip-pool-2' })).toBeHidden() +}) + +test('create instance with custom IPv6-only NIC constrains ephemeral IP to IPv6', async ({ + page, +}) => { + await page.goto('/projects/mock-project/instances-new') + + const instanceName = 'custom-ipv6-nic-test' + await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) + await selectASiloImage(page, 'ubuntu-22-04') + + // Open networking accordion + await page.getByRole('button', { name: 'Networking' }).click() + + // Select "Custom" network interface (use exact match and first to disambiguate from "custom pool") + await page.getByRole('radio', { name: 'Custom', exact: true }).first().click() + + // Add a custom NIC with IPv6-only configuration + await page.getByRole('button', { name: 'Add network interface' }).click() + + const modal = page.getByRole('dialog', { name: 'Add network interface' }) + await expect(modal).toBeVisible() + + await modal.getByRole('textbox', { name: 'Name' }).fill('my-ipv6-nic') + await modal.getByLabel('VPC', { exact: true }).click() + await page.getByRole('option', { name: 'mock-vpc' }).click() + await modal.getByLabel('Subnet').click() + await page.getByRole('option', { name: 'mock-subnet' }).click() + + // Select IPv6-only IP configuration + await modal.getByRole('radio', { name: 'IPv6', exact: true }).click() + + await modal.getByRole('button', { name: 'Add network interface' }).click() + await expect(modal).toBeHidden() + + // Verify the NIC was added + const nicTable = page.getByRole('table', { name: 'Network Interfaces' }) + await expect( + nicTable.getByRole('cell', { name: 'my-ipv6-nic', exact: true }) + ).toBeVisible() + + // Verify that ephemeral IP options are constrained to IPv6 only + const ephemeralCheckbox = page.getByRole('checkbox', { + name: 'Allocate and attach an ephemeral IP address', + }) + await expect(ephemeralCheckbox).toBeVisible() + + // IPv6 default should be available + await expect(page.getByRole('radio', { name: 'IPv6 default' })).toBeVisible() + + // IPv4 default should NOT be available (filtered out) + await expect(page.getByRole('radio', { name: 'IPv4 default' })).toBeHidden() + + // Check custom pool - IPv4 pools should be filtered out + await page.getByRole('radio', { name: 'custom pool' }).click() + await page.getByRole('button', { name: 'IP pool' }).click() + + // ip-pool-2 is IPv6, should appear + await expect(page.getByRole('option', { name: 'ip-pool-2' })).toBeVisible() + + // ip-pool-1 is IPv4, should NOT appear + await expect(page.getByRole('option', { name: 'ip-pool-1' })).toBeHidden() +}) + +test('create instance with custom dual-stack NIC allows both IPv4 and IPv6 ephemeral IPs', async ({ + page, +}) => { + await page.goto('/projects/mock-project/instances-new') + + const instanceName = 'custom-dual-stack-nic-test' + await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) + await selectASiloImage(page, 'ubuntu-22-04') + + // Open networking accordion + await page.getByRole('button', { name: 'Networking' }).click() + + // Select "Custom" network interface (use exact match and first to disambiguate from "custom pool") + await page.getByRole('radio', { name: 'Custom', exact: true }).first().click() + + // Add a custom NIC with dual-stack configuration + await page.getByRole('button', { name: 'Add network interface' }).click() + + const modal = page.getByRole('dialog', { name: 'Add network interface' }) + await expect(modal).toBeVisible() + + await modal.getByRole('textbox', { name: 'Name' }).fill('my-dual-stack-nic') + await modal.getByLabel('VPC', { exact: true }).click() + await page.getByRole('option', { name: 'mock-vpc' }).click() + await modal.getByLabel('Subnet').click() + await page.getByRole('option', { name: 'mock-subnet' }).click() + + // Select dual-stack IP configuration (should be default) + await modal.getByRole('radio', { name: 'IPv4 & IPv6', exact: true }).click() + + await modal.getByRole('button', { name: 'Add network interface' }).click() + await expect(modal).toBeHidden() + + // Verify the NIC was added + const nicTable = page.getByRole('table', { name: 'Network Interfaces' }) + await expect( + nicTable.getByRole('cell', { name: 'my-dual-stack-nic', exact: true }) + ).toBeVisible() + + // Verify that both IPv4 and IPv6 ephemeral IP options are available + const ephemeralCheckbox = page.getByRole('checkbox', { + name: 'Allocate and attach an ephemeral IP address', + }) + await expect(ephemeralCheckbox).toBeVisible() + + // Both IPv4 and IPv6 defaults should be available + await expect(page.getByRole('radio', { name: 'IPv4 default' })).toBeVisible() + await expect(page.getByRole('radio', { name: 'IPv6 default' })).toBeVisible() + + // Check custom pool - both IPv4 and IPv6 pools should be available + await page.getByRole('radio', { name: 'custom pool' }).click() + await page.getByRole('button', { name: 'IP pool' }).click() + + // Both pools should appear + await expect(page.getByRole('option', { name: 'ip-pool-1' })).toBeVisible() + await expect(page.getByRole('option', { name: 'ip-pool-2' })).toBeVisible() +}) From c9a792b125d78eafce070f3641823a25b147e8f3 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Mon, 26 Jan 2026 09:53:38 -0800 Subject: [PATCH 43/45] Disable ephemeral IP checkbox when instance has no compatible NICs --- app/forms/instance-create.tsx | 148 ++++++++++++++++++++++---------- test/e2e/instance-create.e2e.ts | 82 ++++++++++++++++++ 2 files changed, 186 insertions(+), 44 deletions(-) diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index fc1534299..9054350d9 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -6,7 +6,7 @@ * Copyright Oxide Computer Company */ import * as Accordion from '@radix-ui/react-accordion' -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { useController, useForm, @@ -83,6 +83,8 @@ import { Slash } from '~/ui/lib/Slash' import { Tabs } from '~/ui/lib/Tabs' import { TextInputHint } from '~/ui/lib/TextInput' import { TipIcon } from '~/ui/lib/TipIcon' +import { Tooltip } from '~/ui/lib/Tooltip' +import { Wrap } from '~/ui/util/wrap' import { ALL_ISH } from '~/util/consts' import { readBlobAsBase64 } from '~/util/file' import { docLinks, links } from '~/util/links' @@ -783,6 +785,87 @@ const AdvancedAccordion = ({ .map((ip) => attachableFloatingIps.find((fip) => fip.name === ip.floatingIp)) .filter((ip) => !!ip) + // Calculate compatible IP versions based on NIC type + const compatibleVersions: IpVersion[] | undefined = useMemo(() => { + const nicType = networkInterfaces?.type + let versions: IpVersion[] | undefined = undefined + + // Set constraints based on primary NIC configuration + if (nicType === 'default_ipv4') { + versions = ['v4'] + } else if (nicType === 'default_ipv6') { + versions = ['v6'] + } else if (nicType === 'default_dual_stack') { + versions = ['v4', 'v6'] + } else if ( + nicType === 'create' && + networkInterfaces && + networkInterfaces.params.length > 0 + ) { + // Derive from the first NIC's ipConfig (first NIC becomes primary) + const primaryNicConfig = networkInterfaces.params[0].ipConfig + if (primaryNicConfig?.type === 'v4') { + versions = ['v4'] + } else if (primaryNicConfig?.type === 'v6') { + versions = ['v6'] + } else if (primaryNicConfig?.type === 'dual_stack') { + versions = ['v4', 'v6'] + } else { + // ipConfig not provided = defaults to dual-stack + versions = ['v4', 'v6'] + } + } else if (nicType === 'none') { + // Instance has no NICs, cannot attach external IPs + versions = [] + } else if ( + nicType === 'create' && + networkInterfaces && + networkInterfaces.params.length === 0 + ) { + // Custom NICs selected but none added yet, cannot attach external IPs + versions = [] + } + + return versions + }, [networkInterfaces]) + + // Track previous compatible NICs state to detect transitions + const prevHasNicsRef = useRef(undefined) + + // Automatically manage ephemeral IP based on NIC availability + useEffect(() => { + const hasNics = compatibleVersions && compatibleVersions.length > 0 + const prevHasNics = prevHasNicsRef.current + + if (!hasNics && assignEphemeralIp) { + // Remove ephemeral IP when there are no compatible NICs + const newExternalIps = externalIps.field.value?.filter( + (ip) => ip.type !== 'ephemeral' + ) + externalIps.field.onChange(newExternalIps) + } else if (hasNics && !prevHasNics && !assignEphemeralIp) { + // Add ephemeral IP only when transitioning from no NICs to having NICs + // (prevHasNics === false means we had no NICs before) + externalIps.field.onChange([ + ...(externalIps.field.value || []), + { type: 'ephemeral' }, + ]) + } + + prevHasNicsRef.current = hasNics + }, [compatibleVersions, assignEphemeralIp, externalIps]) + + // Update ephemeralIpVersion when compatibleVersions changes + useEffect(() => { + if (!compatibleVersions || compatibleVersions.length === 0) return + + const currentVersion = ephemeralIpVersionField.field.value + // If current version is not compatible, switch to the first compatible version + if (!compatibleVersions.includes(currentVersion)) { + setValue('ephemeralIpVersion', compatibleVersions[0]) + } + }, [compatibleVersions, ephemeralIpVersionField.field.value, setValue]) + const closeFloatingIpModal = () => { setFloatingIpModalOpen(false) setSelectedFloatingIp(undefined) @@ -846,53 +929,30 @@ const AdvancedAccordion = ({ - {/* Calculate compatible IP versions based on NIC type */} {(() => { - const nicType = networkInterfaces?.type - let compatibleVersions: IpVersion[] | undefined = undefined - - // Set constraints based on primary NIC configuration - if (nicType === 'default_ipv4') { - compatibleVersions = ['v4'] - } else if (nicType === 'default_ipv6') { - compatibleVersions = ['v6'] - } else if (nicType === 'default_dual_stack') { - compatibleVersions = ['v4', 'v6'] - } else if ( - nicType === 'create' && - networkInterfaces && - networkInterfaces.params.length > 0 - ) { - // Derive from the first NIC's ipConfig (first NIC becomes primary) - const primaryNicConfig = networkInterfaces.params[0].ipConfig - if (primaryNicConfig?.type === 'v4') { - compatibleVersions = ['v4'] - } else if (primaryNicConfig?.type === 'v6') { - compatibleVersions = ['v6'] - } else if (primaryNicConfig?.type === 'dual_stack') { - compatibleVersions = ['v4', 'v6'] - } else { - // ipConfig not provided = defaults to dual-stack - compatibleVersions = ['v4', 'v6'] - } - } - // nicType === 'none': compatibleVersions stays undefined (instance has no NICs) + const hasCompatibleNics = compatibleVersions && compatibleVersions.length > 0 + const disabledReason = hasCompatibleNics + ? undefined + : 'Add a compatible network interface to attach an ephemeral IP address' return ( <> - { - const newExternalIps = assignEphemeralIp - ? externalIps.field.value?.filter((ip) => ip.type !== 'ephemeral') - : [...(externalIps.field.value || []), { type: 'ephemeral' }] - externalIps.field.onChange(newExternalIps) - // The useEffect will update the poolSelector based on current form values - }} - > - Allocate and attach an ephemeral IP address - + }> + { + const newExternalIps = assignEphemeralIp + ? externalIps.field.value?.filter((ip) => ip.type !== 'ephemeral') + : [...(externalIps.field.value || []), { type: 'ephemeral' }] + externalIps.field.onChange(newExternalIps) + // The useEffect will update the poolSelector based on current form values + }} + > + Allocate and attach an ephemeral IP address + + {assignEphemeralIp && ( { + await page.goto('/projects/mock-project/instances-new') + + const instanceName = 'ephemeral-ip-nic-test' + await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) + await selectASiloImage(page, 'ubuntu-22-04') + + // Open networking accordion + await page.getByRole('button', { name: 'Networking' }).click() + + const ephemeralCheckbox = page.getByRole('checkbox', { + name: 'Allocate and attach an ephemeral IP address', + }) + const defaultDualStackRadio = page.getByRole('radio', { + name: 'Default IPv4 & IPv6', + exact: true, + }) + const noneRadio = page.getByRole('radio', { name: 'None', exact: true }) + const customRadio = page.getByRole('radio', { name: 'Custom', exact: true }).first() + + // Verify default state: "Default IPv4 & IPv6" is checked and Ephemeral IP checkbox is checked + await expect(defaultDualStackRadio).toBeChecked() + await expect(ephemeralCheckbox).toBeChecked() + await expect(ephemeralCheckbox).toBeEnabled() + + // Select "None" radio → verify Ephemeral IP checkbox is unchecked and disabled + await noneRadio.click() + await expect(ephemeralCheckbox).not.toBeChecked() + await expect(ephemeralCheckbox).toBeDisabled() + + // Hover over the disabled checkbox to verify tooltip appears + await ephemeralCheckbox.hover() + await expect( + page.getByText('Add a compatible network interface to attach an ephemeral IP address') + ).toBeVisible() + + // Select "Custom" radio → verify Ephemeral IP checkbox is still unchecked and disabled + await customRadio.click() + await expect(ephemeralCheckbox).not.toBeChecked() + await expect(ephemeralCheckbox).toBeDisabled() + + // Click "Add network interface" button to open modal + await page.getByRole('button', { name: 'Add network interface' }).click() + + const modal = page.getByRole('dialog', { name: 'Add network interface' }) + await expect(modal).toBeVisible() + + // Create an IPv4 NIC named "new-v4-nic" + await modal.getByRole('textbox', { name: 'Name' }).fill('new-v4-nic') + await modal.getByLabel('VPC', { exact: true }).click() + await page.getByRole('option', { name: 'mock-vpc' }).click() + await modal.getByLabel('Subnet').click() + await page.getByRole('option', { name: 'mock-subnet' }).click() + + // Select IPv4 IP configuration + await modal.getByRole('radio', { name: 'IPv4', exact: true }).click() + + // Submit the modal + await modal.getByRole('button', { name: 'Add network interface' }).click() + await expect(modal).toBeHidden() + + // Verify the NIC was added to the table + const nicTable = page.getByRole('table', { name: 'Network Interfaces' }) + await expect( + nicTable.getByRole('cell', { name: 'new-v4-nic', exact: true }) + ).toBeVisible() + + // Verify Ephemeral IP checkbox is now checked and enabled + await expect(ephemeralCheckbox).toBeChecked() + await expect(ephemeralCheckbox).toBeEnabled() + + // Delete the NIC using the remove button + await page.getByRole('button', { name: 'remove network interface new-v4-nic' }).click() + + // Verify the NIC is no longer in the table + await expect(nicTable.getByRole('cell', { name: 'new-v4-nic', exact: true })).toBeHidden() + + // Verify Ephemeral IP checkbox is once again unchecked and disabled + await expect(ephemeralCheckbox).not.toBeChecked() + await expect(ephemeralCheckbox).toBeDisabled() +}) From a2e5b8b727c47a321f527ef44dcaafd39dba3083 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Mon, 26 Jan 2026 10:10:24 -0800 Subject: [PATCH 44/45] Add IP version to silo IP Pools table --- app/pages/project/instances/NetworkingTab.tsx | 6 ++++++ app/pages/system/networking/IpPoolsPage.tsx | 5 ++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/app/pages/project/instances/NetworkingTab.tsx b/app/pages/project/instances/NetworkingTab.tsx index f3a558d83..b5e1226fc 100644 --- a/app/pages/project/instances/NetworkingTab.tsx +++ b/app/pages/project/instances/NetworkingTab.tsx @@ -44,6 +44,7 @@ import { addToast } from '~/stores/toast' import { DescriptionCell } from '~/table/cells/DescriptionCell' import { EmptyCell, SkeletonCell } from '~/table/cells/EmptyCell' import { IpPoolCell } from '~/table/cells/IpPoolCell' +import { IpVersionCell } from '~/table/cells/IpVersionCell' import { LinkCell } from '~/table/cells/LinkCell' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' import { Columns } from '~/table/columns/common' @@ -238,6 +239,11 @@ const staticIpCols = [ ), cell: (info) => {info.getValue()}, }), + ipColHelper.accessor('ipPoolId', { + id: 'version', + header: 'Version', + cell: (info) => , + }), ipColHelper.accessor('ipPoolId', { header: 'IP pool', cell: (info) => , diff --git a/app/pages/system/networking/IpPoolsPage.tsx b/app/pages/system/networking/IpPoolsPage.tsx index 5a50014d3..87f926a08 100644 --- a/app/pages/system/networking/IpPoolsPage.tsx +++ b/app/pages/system/networking/IpPoolsPage.tsx @@ -59,11 +59,14 @@ const colHelper = createColumnHelper() const staticColumns = [ colHelper.accessor('name', { cell: makeLinkCell((pool) => pb.ipPool({ pool })) }), colHelper.accessor('description', Columns.description), + colHelper.accessor('ipVersion', { + header: 'Version', + cell: (info) => {info.getValue()}, + }), colHelper.accessor('poolType', { header: 'Pool type', cell: (info) => {info.getValue()}, }), - // TODO: add version column when API supports v6 pools colHelper.display({ header: 'IPs Remaining', cell: (info) => , From 043a9daf593683aa582d7a47961463e33e8e0f28 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Mon, 26 Jan 2026 11:50:16 -0800 Subject: [PATCH 45/45] Add IP version to IP Pool create flow --- app/forms/ip-pool-create.tsx | 20 ++++-- app/forms/ip-pool-range-add.tsx | 103 ++++++++++++++++++++---------- app/table/cells/IpVersionCell.tsx | 24 +++++++ test/e2e/ip-pools.e2e.ts | 63 ++++++++++++++---- 4 files changed, 155 insertions(+), 55 deletions(-) create mode 100644 app/table/cells/IpVersionCell.tsx diff --git a/app/forms/ip-pool-create.tsx b/app/forms/ip-pool-create.tsx index ef65c3d58..7fbec7c39 100644 --- a/app/forms/ip-pool-create.tsx +++ b/app/forms/ip-pool-create.tsx @@ -21,11 +21,7 @@ import { addToast } from '~/stores/toast' import { Message } from '~/ui/lib/Message' import { pb } from '~/util/path-builder' -// We are leaving the v4/v6 radio out for now because while you can -// create a v6 pool, you can't actually add any ranges to it until -// https://github.com/oxidecomputer/omicron/issues/8966 - -type IpPoolCreateForm = SetRequired +type IpPoolCreateForm = SetRequired const defaultValues: IpPoolCreateForm = { name: '', @@ -58,8 +54,8 @@ export default function CreateIpPoolSideModalForm() { formType="create" resourceName="IP pool" onDismiss={onDismiss} - onSubmit={({ name, description, poolType }) => { - createPool.mutate({ body: { name, description, poolType } }) + onSubmit={({ name, description, ipVersion, poolType }) => { + createPool.mutate({ body: { name, description, ipVersion, poolType } }) }} loading={createPool.isPending} submitError={createPool.error} @@ -67,6 +63,16 @@ export default function CreateIpPoolSideModalForm() { + = {} - - if (first.type === 'error') { - errors.first = { type: 'pattern', message: first.message } - } else if (first.type === 'v6') { - errors.first = ipv6Error - } - - if (last.type === 'error') { - errors.last = { type: 'pattern', message: last.message } - } else if (last.type === 'v6') { - errors.last = ipv6Error +function createResolver(poolVersion: IpVersion) { + return (values: IpRange) => { + const first = parseIp(values.first) + const last = parseIp(values.last) + + const errors: FieldErrors = {} + + // Validate first address + if (first.type === 'error') { + errors.first = { type: 'pattern', message: first.message } + } else if (first.type === 'v4' && poolVersion === 'v6') { + errors.first = { + type: 'pattern', + message: 'IPv4 address not allowed in IPv6 pool', + } + } else if (first.type === 'v6' && poolVersion === 'v4') { + errors.first = { + type: 'pattern', + message: 'IPv6 address not allowed in IPv4 pool', + } + } + + // Validate last address + if (last.type === 'error') { + errors.last = { type: 'pattern', message: last.message } + } else if (last.type === 'v4' && poolVersion === 'v6') { + errors.last = { + type: 'pattern', + message: 'IPv4 address not allowed in IPv6 pool', + } + } else if (last.type === 'v6' && poolVersion === 'v4') { + errors.last = { + type: 'pattern', + message: 'IPv6 address not allowed in IPv4 pool', + } + } + + // Check that both addresses are the same version + if (first.type !== 'error' && last.type !== 'error' && first.type !== last.type) { + const versionMismatchError = { + type: 'pattern', + message: 'Both addresses must be the same IP version', + } + errors.first = versionMismatchError + errors.last = versionMismatchError + } + + // TODO: if we were really cool we could check first <= last but it would add + // 6k gzipped to the bundle with ip-num + + // no errors + return Object.keys(errors).length > 0 ? { values: {}, errors } : { values, errors: {} } } - - // TODO: once we support IPv6 we need to check for version mismatch here - - // TODO: if we were really cool we could check first <= last but it would add - // 6k gzipped to the bundle with ip-num - - // no errors - return Object.keys(errors).length > 0 ? { values: {}, errors } : { values, errors: {} } } export const handle = titleCrumb('Add Range') @@ -66,6 +98,9 @@ export default function IpPoolAddRange() { const { pool } = useIpPoolSelector() const navigate = useNavigate() + const { data: poolData } = usePrefetchedQuery(q(api.ipPoolView, { path: { pool } })) + const poolVersion = poolData.ipVersion + const onDismiss = () => navigate(pb.ipPool({ pool })) const addRange = useApiMutation(api.ipPoolRangeAdd, { @@ -78,7 +113,7 @@ export default function IpPoolAddRange() { }, }) - const form = useForm({ defaultValues, resolver }) + const form = useForm({ defaultValues, resolver: createResolver(poolVersion) }) return ( { + const { data: result } = useQuery( + qErrorsAllowed(api.projectIpPoolView, { path: { pool: ipPoolId } }) + ) + if (!result) return + if (result.type === 'error') return + const pool = result.data + return {pool.ipVersion} +} diff --git a/test/e2e/ip-pools.e2e.ts b/test/e2e/ip-pools.e2e.ts index 6c47cbaa6..edf56f541 100644 --- a/test/e2e/ip-pools.e2e.ts +++ b/test/e2e/ip-pools.e2e.ts @@ -212,22 +212,17 @@ test('IP pool edit', async ({ page }) => { // TODO: update this to reflect that a given pool is now v4 or v6 only test('IP range validation and add', async ({ page }) => { - await page.goto('/system/networking/ip-pools/ip-pool-2') - - // check the utilization bar - await expect(page.getByText('Allocated(IPs)')).toBeVisible() - await expect(page.getByText('Allocated0')).toBeVisible() - await expect(page.getByText('Capacity32')).toBeVisible() + await page.goto('/system/networking/ip-pools/ip-pool-3') - await page.getByRole('link', { name: 'Add range' }).click() + await page.getByRole('link', { name: 'Add range' }).first().click() const dialog = page.getByRole('dialog', { name: 'Add IP range' }) const first = dialog.getByRole('textbox', { name: 'First' }) const last = dialog.getByRole('textbox', { name: 'Last' }) const submit = dialog.getByRole('button', { name: 'Add IP range' }) const invalidMsg = dialog.getByText('Not a valid IP address') - // exact to differentiate from same text in help message at the top of the form - const ipv6Msg = dialog.getByText('IPv6 ranges are not yet supported') + // ip-pool-3 is an IPv4 pool, so IPv6 addresses should be rejected + const ipv6Msg = dialog.getByText('IPv6 address not allowed in IPv4 pool') const v4Addr = '192.1.2.3' const v6Addr = '2001:db8::1234:5678' @@ -240,12 +235,12 @@ test('IP range validation and add', async ({ page }) => { await expect(invalidMsg).toHaveCount(2) - // change last to v6, not allowed + // change last to v6, not allowed in IPv4 pool await last.fill(v6Addr) await expect(invalidMsg).toHaveCount(1) await expect(ipv6Msg).toHaveCount(1) - // change first to v6, still not allowed + // change first to v6, still not allowed in IPv4 pool await first.fill(v6Addr) await expect(ipv6Msg).toHaveCount(2) await expect(invalidMsg).toBeHidden() @@ -265,18 +260,58 @@ test('IP range validation and add', async ({ page }) => { // now the utilization bar shows the single IP added await expect(page.getByText('Allocated(IPs)')).toBeVisible() await expect(page.getByText('Allocated0')).toBeVisible() - await expect(page.getByText('Capacity33')).toBeVisible() + await expect(page.getByText('Capacity1')).toBeVisible() // go back to the pool and verify the remaining/capacity columns changed // use the sidebar nav to get there const sidebar = page.getByRole('navigation', { name: 'Sidebar navigation' }) await sidebar.getByRole('link', { name: 'IP Pools' }).click() await expectRowVisible(table, { - name: 'ip-pool-2', - 'IPs Remaining': '33 / 33', + name: 'ip-pool-3', + 'IPs Remaining': '1 / 1', }) }) +test('IPv4 addresses cannot be added to IPv6 pool', async ({ page }) => { + // ip-pool-4 is an IPv6 pool + await page.goto('/system/networking/ip-pools/ip-pool-4') + + await page.getByRole('link', { name: 'Add range' }).first().click() + + const dialog = page.getByRole('dialog', { name: 'Add IP range' }) + const first = dialog.getByRole('textbox', { name: 'First' }) + const last = dialog.getByRole('textbox', { name: 'Last' }) + const submit = dialog.getByRole('button', { name: 'Add IP range' }) + // ip-pool-4 is an IPv6 pool, so IPv4 addresses should be rejected + const ipv4Msg = dialog.getByText('IPv4 address not allowed in IPv6 pool') + + const v4Addr = '192.168.1.1' + const v6Addr = 'fd12:3456:789a:1::1' + + await expect(dialog).toBeVisible() + + // Try to add IPv4 address - should be rejected + await first.fill(v4Addr) + await last.fill(v4Addr) + await submit.click() // trigger validation + await expect(ipv4Msg).toHaveCount(2) + + // Change first to v6 + await first.fill(v6Addr) + await expect(ipv4Msg).toHaveCount(1) + + // Change last to v6 - should now be valid + await last.fill(v6Addr) + await expect(ipv4Msg).toBeHidden() + + // Submit successfully + await submit.click() + await expect(dialog).toBeHidden() + + const table = page.getByRole('table') + await expectRowVisible(table, { First: v6Addr, Last: v6Addr }) +}) + test('remove range', async ({ page }) => { await page.goto('/system/networking/ip-pools/ip-pool-1')