From e685eb49bc77ce656cb97701d8b16e8f13e9875a Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 8 Jan 2026 16:00:47 -0800 Subject: [PATCH 01/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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,