From e685eb49bc77ce656cb97701d8b16e8f13e9875a Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 8 Jan 2026 16:00:47 -0800 Subject: [PATCH 01/28] 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 cd93756d91..2a5ae4903a 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 d3abd0aec1..db95aa3336 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 ee1f8feeb2..cc761a2040 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 6b776098e8..5795882563 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 af8a384c1a..24f8aec037 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 5ff9086f71..4ff110568d 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 cc7d2a4820..431815a709 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 e968ea31f2..6f0395121c 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 0000000000..d264611129 --- /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 95ce6718d1..7262cbb368 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 3f678f5c26..42f2f9af0a 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 5c28fafbaf..bb0f3bc7c6 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 dfa036e825..11cdf6b5c1 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/28] 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 5795882563..596067ef05 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 431815a709..1a23c24d14 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 42f2f9af0a..4037a80a2a 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 921fafc1e3..5c68025478 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 85af5cf5ef..0ebbfddda2 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/28] 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 d264611129..0000000000 --- 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/28] 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 24f8aec037..fd4bb9768a 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/28] 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 1a23c24d14..619e082b8a 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 6f0395121c..fed676c51a 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 bb0f3bc7c6..3734b51ae1 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/28] 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 fed676c51a..f3a558d831 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 4037a80a2a..efc531fefd 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 11cdf6b5c1..53d3df6213 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 c735553fea..8565716ce8 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/28] 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 2fc75a634d..edb4f99c9b 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 22bd59a08f..82dde2650a 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 53d3df6213..3079160e9f 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 8565716ce8..b68f427b42 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/28] 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 fd4bb9768a..24f8aec037 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 4ff110568d..5c30c3f7c8 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/28] 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 5c30c3f7c8..9ff4dae77a 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 edb4f99c9b..b675beb02f 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/28] 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 3de33ddbed..b12fb17bc0 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/28] 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 b12fb17bc0..6893c62158 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/28] 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 ee3c74890e..cc653ff483 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/28] 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 2a5ae4903a..be6a09b191 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 db95aa3336..445f5de6de 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 cc761a2040..26a3df73c6 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 3d0aa5ace3..a290259118 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 596067ef05..672c41e857 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 9ff4dae77a..7e53663340 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 71def2c2b2..5b870b3be7 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/28] 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 5c68025478..8c68ce424f 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 0ebbfddda2..114c8cd6b6 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/28] 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 672c41e857..08eb03a9c8 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/28] 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 24f8aec037..e4c3c69d5e 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 3079160e9f..7f41656e9f 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/28] 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 b68f427b42..87308afee3 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/28] 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 e594a8dc7d..8b6891523b 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/28] 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 e4c3c69d5e..c568f9c0e3 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/28] 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 c568f9c0e3..2a30ce5b7b 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 8b6891523b..702991c740 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/28] 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 3e5286d66a..93c3f270c8 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 2226f3aee0..3bec42f853 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 066db6402e..192d4c44ae 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 ee7d26a635..564f59f7a6 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 5b870b3be7..71ab43ad17 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 cc653ff483..305a678da0 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 1c76229a3a..41439e84a0 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/28] 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 3bec42f853..4043ea4c01 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 41439e84a0..ef0d7aa154 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/28] 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 2a30ce5b7b..492ba26f54 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/28] 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 93c3f270c8..f8ef4ee601 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 192d4c44ae..0000000000 --- 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/28] 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 492ba26f54..4ce3f3d3da 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 0f90eb8b90..60e1168498 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/28] 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 7e53663340..a6a33ddeea 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 702991c740..8b8c3ffae0 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 71ab43ad17..47d29b4918 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/28] 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 8b8c3ffae0..dacd6da4c1 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/28] 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 dacd6da4c1..97b7c7af42 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 564f59f7a6..c58813344b 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 0cb481a4f2..648ddac524 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 47d29b4918..f431543755 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 }