diff --git a/OMICRON_VERSION b/OMICRON_VERSION index cd93756d9..be6a09b19 100644 --- a/OMICRON_VERSION +++ b/OMICRON_VERSION @@ -1 +1 @@ -dd74446cbe12d52540d92b62f2de7eaf6520d591 +26b33a11962d82b29fde9a2e3233d038d5495c44 diff --git a/app/api/__generated__/Api.ts b/app/api/__generated__/Api.ts index d3abd0aec..445f5de6d 100644 --- a/app/api/__generated__/Api.ts +++ b/app/api/__generated__/Api.ts @@ -58,6 +58,49 @@ export type Address = { vlanId?: number | null } +/** + * The IP address version. + */ +export type IpVersion = 'v4' | 'v6' + +/** + * Specify which IP pool to allocate from. + */ +export type PoolSelector = + /** Use the specified pool by name or ID. */ + | { + /** The pool to allocate from. */ + pool: NameOrId + type: 'explicit' + } + /** Use the default pool for the silo. */ + | { + /** IP version to use when multiple default pools exist. Required if both IPv4 and IPv6 default pools are configured. */ + ipVersion?: IpVersion | null + type: 'auto' + } + +/** + * Specify how to allocate a floating IP address. + */ +export type AddressAllocator = + /** Reserve a specific IP address. */ + | { + /** The IP address to reserve. Must be available in the pool. */ + ip: string + /** The pool containing this address. If not specified, the default pool for the address's IP version is used. */ + pool?: NameOrId | null + type: 'explicit' + } + /** Automatically allocate an IP address from a specified pool. */ + | { + /** Pool selection. + +If omitted, this field uses the silo's default pool. If the silo has default pools for both IPv4 and IPv6, the request will fail unless `ip_version` is specified in the pool selector. */ + poolSelector?: PoolSelector + type: 'auto' + } + /** * A set of addresses associated with a port configuration. */ @@ -632,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 */ @@ -658,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` */ @@ -1934,8 +1992,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 +2039,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 +2188,10 @@ export type FloatingIpAttach = { * Parameters for creating a new floating IP address for instances. */ export type FloatingIpCreate = { + /** IP address allocation method. */ + addressAllocator?: AddressAllocator 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 +2442,91 @@ 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. + */ +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 +2539,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' } @@ -2448,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 @@ -2467,6 +2610,49 @@ 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 + */ +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 +2670,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 +2684,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 +2712,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[] } @@ -2576,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 } @@ -2698,11 +2884,6 @@ export type InternetGatewayResultsPage = { nextPage?: string | null } -/** - * The IP address version. - */ -export type IpVersion = 'v4' | 'v6' - /** * Type of IP pool. */ @@ -2727,7 +2908,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 +2935,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 +2990,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 +3008,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 } @@ -3103,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 @@ -3113,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) */ @@ -3145,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 */ @@ -3155,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 */ @@ -3184,16 +3351,48 @@ export type MulticastGroupResultsPage = { } /** - * Update-time parameters for a multicast group. + * VPC-private IPv4 configuration for a network interface. */ -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 +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 +3414,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 +3578,9 @@ export type Probe = { */ export type ProbeCreate = { description: string - ipPool?: NameOrId | null name: Name + /** Pool to allocate from. */ + poolSelector?: PoolSelector sled: string } @@ -3820,10 +4018,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 */ @@ -5767,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 { @@ -5781,7 +5988,7 @@ export interface InstanceMulticastGroupJoinQueryParams { export interface InstanceMulticastGroupLeavePathParams { instance: NameOrId - multicastGroup: NameOrId + multicastGroup: MulticastGroupIdentifier } export interface InstanceMulticastGroupLeaveQueryParams { @@ -5991,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 { @@ -6012,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 @@ -6383,10 +6565,6 @@ export interface SystemMetricQueryParams { silo?: NameOrId } -export interface LookupMulticastGroupByIpPathParams { - address: string -} - export interface NetworkingAddressLotListQueryParams { limit?: number | null pageToken?: string | null @@ -6865,7 +7043,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 = '2026011600.0.0' constructor({ host = '', baseParams = {}, token }: ApiConfig = {}) { this.host = host @@ -7014,7 +7192,7 @@ export class Api { }) }, /** - * View a support bundle + * View support bundle */ supportBundleView: ( { path }: { path: SupportBundleViewPathParams }, @@ -7697,7 +7875,7 @@ export class Api { }) }, /** - * Create a disk + * Create disk */ diskCreate: ( { query, body }: { query: DiskCreateQueryParams; body: DiskCreate }, @@ -8316,7 +8494,7 @@ export class Api { }) }, /** - * List multicast groups for instance + * List multicast groups for an instance */ instanceMulticastGroupList: ( { @@ -8336,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: ( { @@ -8376,7 +8557,7 @@ export class Api { }) }, /** - * Reboot an instance + * Reboot instance */ instanceReboot: ( { @@ -8816,7 +8997,7 @@ export class Api { }) }, /** - * List all multicast groups. + * List multicast groups */ multicastGroupList: ( { query = {} }: { query?: MulticastGroupListQueryParams }, @@ -8830,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 }, @@ -8857,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: ( { @@ -8903,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 */ @@ -9111,7 +9208,7 @@ export class Api { }) }, /** - * Update a project + * Update project */ projectUpdate: ( { path, body }: { path: ProjectUpdatePathParams; body: ProjectUpdate }, @@ -9256,7 +9353,7 @@ export class Api { }) }, /** - * Get a physical disk + * Get physical disk */ physicalDiskView: ( { path }: { path: PhysicalDiskViewPathParams }, @@ -9743,7 +9840,7 @@ export class Api { }) }, /** - * Add range to IP pool. + * Add range to an IP pool */ ipPoolRangeAdd: ( { path, body }: { path: IpPoolRangeAddPathParams; body: IpRange }, @@ -9904,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 */ @@ -10016,7 +10100,7 @@ export class Api { }) }, /** - * Disable a BFD session + * Disable BFD session */ networkingBfdDisable: ( { body }: { body: BfdSessionDisable }, @@ -10030,7 +10114,7 @@ export class Api { }) }, /** - * Enable a BFD session + * Enable BFD session */ networkingBfdEnable: ( { body }: { body: BfdSessionEnable }, @@ -10426,7 +10510,7 @@ export class Api { }) }, /** - * Create a silo + * Create silo */ siloCreate: ({ body }: { body: SiloCreate }, params: FetchParams = {}) => { return this.request({ @@ -10447,7 +10531,7 @@ export class Api { }) }, /** - * Delete a silo + * Delete silo */ siloDelete: ({ path }: { path: SiloDeletePathParams }, params: FetchParams = {}) => { return this.request({ @@ -11196,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 ee1f8feeb..26a3df73c 100644 --- a/app/api/__generated__/OMICRON_VERSION +++ b/app/api/__generated__/OMICRON_VERSION @@ -1,2 +1,2 @@ # generated file. do not update manually. see docs/update-pinned-api.md -dd74446cbe12d52540d92b62f2de7eaf6520d591 +26b33a11962d82b29fde9a2e3233d038d5495c44 diff --git a/app/api/__generated__/msw-handlers.ts b/app/api/__generated__/msw-handlers.ts index 3d0aa5ace..a29025911 100644 --- a/app/api/__generated__/msw-handlers.ts +++ b/app/api/__generated__/msw-handlers.ts @@ -639,6 +639,7 @@ export interface MSWHandlers { instanceMulticastGroupJoin: (params: { path: Api.InstanceMulticastGroupJoinPathParams query: Api.InstanceMulticastGroupJoinQueryParams + body: Json req: Request cookies: Record }) => Promisable> @@ -842,31 +843,12 @@ export interface MSWHandlers { req: Request cookies: Record }) => Promisable> - /** `POST /v1/multicast-groups` */ - multicastGroupCreate: (params: { - body: Json - req: Request - cookies: Record - }) => Promisable> /** `GET /v1/multicast-groups/:multicastGroup` */ multicastGroupView: (params: { path: Api.MulticastGroupViewPathParams req: Request cookies: Record }) => Promisable> - /** `PUT /v1/multicast-groups/:multicastGroup` */ - multicastGroupUpdate: (params: { - path: Api.MulticastGroupUpdatePathParams - body: Json - req: Request - cookies: Record - }) => Promisable> - /** `DELETE /v1/multicast-groups/:multicastGroup` */ - multicastGroupDelete: (params: { - path: Api.MulticastGroupDeletePathParams - req: Request - cookies: Record - }) => Promisable /** `GET /v1/multicast-groups/:multicastGroup/members` */ multicastGroupMemberList: (params: { path: Api.MulticastGroupMemberListPathParams @@ -874,21 +856,6 @@ export interface MSWHandlers { req: Request cookies: Record }) => Promisable> - /** `POST /v1/multicast-groups/:multicastGroup/members` */ - multicastGroupMemberAdd: (params: { - path: Api.MulticastGroupMemberAddPathParams - query: Api.MulticastGroupMemberAddQueryParams - body: Json - req: Request - cookies: Record - }) => Promisable> - /** `DELETE /v1/multicast-groups/:multicastGroup/members/:instance` */ - multicastGroupMemberRemove: (params: { - path: Api.MulticastGroupMemberRemovePathParams - query: Api.MulticastGroupMemberRemoveQueryParams - req: Request - cookies: Record - }) => Promisable /** `GET /v1/network-interfaces` */ instanceNetworkInterfaceList: (params: { query: Api.InstanceNetworkInterfaceListQueryParams @@ -1305,12 +1272,6 @@ export interface MSWHandlers { req: Request cookies: Record }) => Promisable> - /** `GET /v1/system/multicast-groups/by-ip/:address` */ - lookupMulticastGroupByIp: (params: { - path: Api.LookupMulticastGroupByIpPathParams - req: Request - cookies: Record - }) => Promisable> /** `GET /v1/system/networking/address-lot` */ networkingAddressLotList: (params: { query: Api.NetworkingAddressLotListQueryParams @@ -2498,7 +2459,7 @@ export function makeHandlers(handlers: MSWHandlers): HttpHandler[] { handler( handlers['instanceMulticastGroupJoin'], schema.InstanceMulticastGroupJoinParams, - null + schema.InstanceMulticastGroupJoin ) ), http.delete( @@ -2675,26 +2636,10 @@ export function makeHandlers(handlers: MSWHandlers): HttpHandler[] { '/v1/multicast-groups', handler(handlers['multicastGroupList'], schema.MulticastGroupListParams, null) ), - http.post( - '/v1/multicast-groups', - handler(handlers['multicastGroupCreate'], null, schema.MulticastGroupCreate) - ), http.get( '/v1/multicast-groups/:multicastGroup', handler(handlers['multicastGroupView'], schema.MulticastGroupViewParams, null) ), - http.put( - '/v1/multicast-groups/:multicastGroup', - handler( - handlers['multicastGroupUpdate'], - schema.MulticastGroupUpdateParams, - schema.MulticastGroupUpdate - ) - ), - http.delete( - '/v1/multicast-groups/:multicastGroup', - handler(handlers['multicastGroupDelete'], schema.MulticastGroupDeleteParams, null) - ), http.get( '/v1/multicast-groups/:multicastGroup/members', handler( @@ -2703,22 +2648,6 @@ export function makeHandlers(handlers: MSWHandlers): HttpHandler[] { null ) ), - http.post( - '/v1/multicast-groups/:multicastGroup/members', - handler( - handlers['multicastGroupMemberAdd'], - schema.MulticastGroupMemberAddParams, - schema.MulticastGroupMemberAdd - ) - ), - http.delete( - '/v1/multicast-groups/:multicastGroup/members/:instance', - handler( - handlers['multicastGroupMemberRemove'], - schema.MulticastGroupMemberRemoveParams, - null - ) - ), http.get( '/v1/network-interfaces', handler( @@ -3054,14 +2983,6 @@ export function makeHandlers(handlers: MSWHandlers): HttpHandler[] { '/v1/system/metrics/:metricName', handler(handlers['systemMetric'], schema.SystemMetricParams, null) ), - http.get( - '/v1/system/multicast-groups/by-ip/:address', - handler( - handlers['lookupMulticastGroupByIp'], - schema.LookupMulticastGroupByIpParams, - null - ) - ), http.get( '/v1/system/networking/address-lot', handler( diff --git a/app/api/__generated__/validate.ts b/app/api/__generated__/validate.ts index 6b776098e..08eb03a9c 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.union([z.ipv4(), z.ipv6()]), + 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. */ @@ -118,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()]), + }) ) /** @@ -126,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()]), + }) ) /** @@ -604,6 +645,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 */ @@ -628,13 +677,14 @@ 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(), 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(), @@ -684,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 }) ) /** @@ -694,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, }) @@ -711,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, @@ -838,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, @@ -887,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, @@ -1778,7 +1828,7 @@ 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' }) }) ) /** @@ -1794,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, @@ -1821,7 +1875,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' }), + type: z.enum(['ephemeral']), + }), z.object({ floatingIp: NameOrId, type: z.enum(['floating']) }), ]) ) @@ -1888,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 }), ]) @@ -1944,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(), @@ -1972,10 +2029,12 @@ export const FloatingIpAttach = z.preprocess( export const FloatingIpCreate = z.preprocess( processResponseBody, z.object({ + addressAllocator: AddressAllocator.default({ + poolSelector: { ipVersion: null, type: 'auto' }, + type: 'auto', + }), description: z.string(), - ip: z.ipv4().nullable().optional(), name: Name, - pool: NameOrId.nullable().optional(), }) ) @@ -2212,6 +2271,80 @@ 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.union([z.ipv4(), z.ipv6()]).array().nullable().default(null), + }) +) + +/** + * 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([]) }) +) + +/** + * 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([]) }) +) + +/** + * 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 +2352,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: [] }, + }, + }), name: Name, subnetName: Name, - transitIps: IpNet.array().default([]).optional(), vpcName: Name, }) ) @@ -2234,7 +2372,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']) }), ]) ) @@ -2245,27 +2385,71 @@ 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: MulticastGroupJoinSpec.array().default([]), name: Name, ncpus: InstanceCpuCount, networkInterfaces: InstanceNetworkInterfaceAttachment.default({ - type: 'default', - }).optional(), + type: 'default_dual_stack', + }), sshPublicKeys: NameOrId.array().nullable().optional(), - start: SafeBoolean.default(true).optional(), - userData: z.string().default('').optional(), + start: SafeBoolean.default(true), + userData: z.string().default(''), + }) +) + +/** + * 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.union([z.ipv4(), z.ipv6()]).array().nullable().default(null), }) ) +/** + * 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 +2473,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(), }) ) @@ -2322,8 +2505,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([]), }) ) @@ -2353,7 +2536,7 @@ export const InstanceUpdate = z.preprocess( bootDisk: NameOrId.nullable(), cpuPlatform: InstanceCpuPlatform.nullable(), memory: ByteCount, - multicastGroups: NameOrId.array().nullable().default(null).optional(), + multicastGroups: MulticastGroupJoinSpec.array().nullable().default(null), ncpus: InstanceCpuCount, }) ) @@ -2396,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(), @@ -2411,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 }) ) /** @@ -2468,11 +2651,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. */ @@ -2508,9 +2686,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'), }) ) @@ -2638,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(), }) @@ -2703,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(), }) @@ -2712,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() }), ]) ) @@ -2772,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), @@ -2822,31 +3000,16 @@ 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(), }) ) -/** - * Create-time parameters for a multicast group. - */ -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(), - name: Name, - pool: NameOrId.nullable().default(null).optional(), - sourceIps: z.ipv4().array().nullable().default(null).optional(), - }) -) - /** * View of a Multicast Group Member (instance belonging to a multicast group) */ @@ -2857,21 +3020,15 @@ export const MulticastGroupMember = z.preprocess( id: z.uuid(), instanceId: z.uuid(), multicastGroupId: z.uuid(), + multicastIp: z.union([z.ipv4(), z.ipv6()]), name: Name, + sourceIps: z.union([z.ipv4(), z.ipv6()]).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 */ @@ -2892,16 +3049,34 @@ export const MulticastGroupResultsPage = z.preprocess( ) /** - * Update-time parameters for a multicast group. + * VPC-private IPv4 configuration for a network interface. */ -export const MulticastGroupUpdate = z.preprocess( +export const PrivateIpv4Config = 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(), - }) + z.object({ ip: z.ipv4(), subnet: Ipv4Net, transitIps: Ipv4Net.array().default([]) }) +) + +/** + * 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 }), + }), + ]) ) /** @@ -2928,14 +3103,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 +3270,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' }), sled: z.uuid(), }) ) @@ -3112,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), }) @@ -3226,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(), }) @@ -3248,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 }), @@ -3261,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 }), @@ -3365,7 +3538,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(), @@ -3481,9 +3654,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(), @@ -3498,8 +3669,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(), }) @@ -3990,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(), @@ -4043,14 +4216,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([]), }) ) @@ -4419,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 }), ]) ) @@ -4462,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 }), ]) ) @@ -4510,7 +4683,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([]) }) ) /** @@ -4648,7 +4821,7 @@ export const WebhookCreate = z.preprocess( endpoint: z.string(), name: Name, secrets: z.string().array(), - subscriptions: AlertSubscription.array().default([]).optional(), + subscriptions: AlertSubscription.array().default([]), }) ) @@ -5759,7 +5932,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(), }), }) ) @@ -5769,7 +5945,7 @@ export const InstanceMulticastGroupJoinParams = z.preprocess( z.object({ path: z.object({ instance: NameOrId, - multicastGroup: NameOrId, + multicastGroup: MulticastGroupIdentifier, }), query: z.object({ project: NameOrId.optional(), @@ -5782,7 +5958,7 @@ export const InstanceMulticastGroupLeaveParams = z.preprocess( z.object({ path: z.object({ instance: NameOrId, - multicastGroup: NameOrId, + multicastGroup: MulticastGroupIdentifier, }), query: z.object({ project: NameOrId.optional(), @@ -6156,39 +6332,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({}), }) @@ -6198,7 +6346,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(), @@ -6208,31 +6356,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({ @@ -6951,16 +7074,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({ @@ -7201,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, diff --git a/app/components/AttachEphemeralIpModal.tsx b/app/components/AttachEphemeralIpModal.tsx index af8a384c1..24f8aec03 100644 --- a/app/components/AttachEphemeralIpModal.tsx +++ b/app/components/AttachEphemeralIpModal.tsx @@ -65,13 +65,14 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) + onAction={() => { + if (!pool) return instanceEphemeralIpAttach.mutate({ path: { instance }, query: { project }, - body: { pool }, + body: { poolSelector: { type: 'explicit', pool } }, }) - } + }} onDismiss={onDismiss} > diff --git a/app/components/form/fields/NetworkInterfaceField.tsx b/app/components/form/fields/NetworkInterfaceField.tsx index 3de33ddbe..6893c6215 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' @@ -55,22 +52,24 @@ export function NetworkInterfaceField({ className="pt-1" defaultChecked={value.type} onChange={(event) => { - const newType = event.target.value as InstanceNetworkInterfaceAttachment['type'] + const newType = event.target.value if (value.type === 'create') { setOldParams(value.params) } if (newType === 'create') { - onChange({ type: newType, params: oldParams }) + onChange({ type: 'create', params: oldParams }) } else { - onChange({ type: newType }) + onChange({ type: newType as typeof value.type }) } }} disabled={disabled} > + Default IPv4 & IPv6 + Default IPv4 + Default IPv6 None - Default Custom {value.type === 'create' && ( diff --git a/app/forms/floating-ip-create.tsx b/app/forms/floating-ip-create.tsx index 5ff9086f7..7e5366334 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: Omit = { +type FloatingIpCreateFormData = { + name: string + description: string + pool?: string +} + +const defaultValues: FloatingIpCreateFormData = { name: '', description: '', - pool: undefined, } export const handle = titleCrumb('New Floating IP') @@ -65,7 +70,18 @@ export default function CreateFloatingIpSideModalForm() { formType="create" resourceName="floating IP" onDismiss={() => navigate(pb.floatingIps(projectSelector))} - onSubmit={(body) => createFloatingIp.mutate({ query: projectSelector, body })} + onSubmit={({ pool, ...values }) => { + const body: FloatingIpCreate = { + ...values, + addressAllocator: pool + ? { + type: 'auto' as const, + poolSelector: { type: 'explicit' as const, pool }, + } + : undefined, + } + createFloatingIp.mutate({ query: projectSelector, body }) + }} loading={createFloatingIp.isPending} submitError={createFloatingIp.error} > diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index 59172dba8..e594a8dc7 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -151,7 +151,7 @@ const baseDefaultValues: InstanceCreateInput = { diskSource: '', otherDisks: [], - networkInterfaces: { type: 'default' }, + networkInterfaces: { type: 'default_dual_stack' }, sshPublicKeys: [], @@ -232,7 +232,9 @@ export default function CreateInstanceForm() { bootDiskSourceType: defaultSource, sshPublicKeys: allKeys, bootDiskSize: diskSizeNearest10(defaultImage?.size / GiB), - externalIps: [{ type: 'ephemeral', pool: defaultPool }], + externalIps: defaultPool + ? [{ type: 'ephemeral', poolSelector: { type: 'explicit', pool: defaultPool } }] + : [], } const form = useForm({ defaultValues }) diff --git a/app/forms/network-interface-create.tsx b/app/forms/network-interface-create.tsx index b4fc54b74..b675beb02 100644 --- a/app/forms/network-interface-create.tsx +++ b/app/forms/network-interface-create.tsx @@ -8,25 +8,46 @@ import { useQuery } from '@tanstack/react-query' import { useMemo } from 'react' import { useForm } from 'react-hook-form' -import type { SetNonNullable, SetRequired } from 'type-fest' +import { match } from 'ts-pattern' import { api, q, type ApiError, type InstanceNetworkInterfaceCreate } from '@oxide/api' 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' -const defaultValues: SetRequired, 'ip'> = { +type IpStackType = 'v4' | 'v6' | 'dual_stack' + +const defaultValues = { name: '', description: '', - ip: '', subnetName: '', vpcName: '', + ipStackType: 'dual_stack' as IpStackType, + ipv4: '', + 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 = { @@ -52,6 +73,7 @@ export function CreateNetworkInterfaceForm({ const vpcs = useMemo(() => vpcsData?.items || [], [vpcsData]) const form = useForm({ defaultValues }) + const ipStackType = form.watch('ipStackType') return ( onSubmit({ ip: ip.trim() || undefined, ...rest })} + onSubmit={({ ipStackType, ipv4, ipv6, ...rest }) => { + 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: buildIpStack(ipv4), + v6: buildIpStack(ipv6), + }, + })) + .exhaustive() + + onSubmit({ ...rest, ipConfig }) + }} loading={loading} submitError={submitError} > @@ -83,7 +125,45 @@ export function CreateNetworkInterfaceForm({ required control={form.control} /> - + + + + {(ipStackType === 'v4' || ipStackType === 'dual_stack') && ( + + )} + + {(ipStackType === 'v6' || ipStackType === 'dual_stack') && ( + + )} ) } diff --git a/app/forms/network-interface-edit.tsx b/app/forms/network-interface-edit.tsx index cc7d2a482..619e082b8 100644 --- a/app/forms/network-interface-edit.tsx +++ b/app/forms/network-interface-edit.tsx @@ -7,7 +7,6 @@ */ import { useEffect } from 'react' import { useForm } from 'react-hook-form' -import * as R from 'remeda' import { api, @@ -52,11 +51,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.v6.transitIps] + : editing.ipStack.value.transitIps + + const defaultValues = { + name: editing.name, + description: editing.description, + transitIps: extractedTransitIps, + } satisfies InstanceNetworkInterfaceUpdate const form = useForm({ defaultValues }) const transitIps = form.watch('transitIps') || [] diff --git a/app/pages/project/instances/NetworkingTab.tsx b/app/pages/project/instances/NetworkingTab.tsx index e968ea31f..f3a558d83 100644 --- a/app/pages/project/instances/NetworkingTab.tsx +++ b/app/pages/project/instances/NetworkingTab.tsx @@ -152,9 +152,24 @@ 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 { ipStack } = nic + + if (ipStack.type === 'dual_stack') { + return ( +
+ + +
+ ) + } + + return + }, }), colHelper.accessor('vpcId', { header: 'vpc', @@ -164,15 +179,28 @@ 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 { 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) => ( +
{ip}
+ ))} +
+ ) + }, }), ] diff --git a/mock-api/msw/db.ts b/mock-api/msw/db.ts index 95ce6718d..7262cbb36 100644 --- a/mock-api/msw/db.ts +++ b/mock-api/msw/db.ts @@ -62,6 +62,13 @@ function ensureNoParentSelectors( export const resolveIpPool = (pool: string | undefined | null) => pool ? lookup.ipPool({ pool }) : lookup.siloDefaultIpPool({ silo: defaultSilo.id }) +export const resolvePoolSelector = ( + poolSelector: { pool: string; type: 'explicit' } | { type: 'auto' } | undefined +) => + poolSelector?.type === 'explicit' + ? lookup.ipPool({ pool: poolSelector.pool }) + : lookup.siloDefaultIpPool({ silo: defaultSilo.id }) + export const getIpFromPool = (pool: Json) => { const ipPoolRange = db.ipPoolRanges.find((range) => range.ip_pool_id === pool.id) if (!ipPoolRange) throw notFoundErr(`IP range for pool '${pool.name}'`) diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 50cd157b7..5b870b3be 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -30,6 +30,7 @@ import { import { json, makeHandlers, type Json } from '~/api/__generated__/msw-handlers' import { instanceCan, OXQL_GROUP_BY_ERROR } from '~/api/util' +import { parseIpNet } from '~/util/ip' import { commaSeries } from '~/util/str' import { GiB } from '~/util/units' @@ -42,7 +43,7 @@ import { lookup, lookupById, notFoundErr, - resolveIpPool, + resolvePoolSelector, utilizationForSilo, } from './db' import { @@ -72,6 +73,57 @@ 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 } + }, + defaultV4Ip = '127.0.0.1', + defaultV6Ip = '::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, defaultV4Ip), + transit_ips: config.value.v4.transitIps || [], + }, + v6: { + ip: resolveIp(config.value.v6.ip, defaultV6Ip), + transit_ips: config.value.v6.transitIps || [], + }, + }, + } + } + return { + type: config.type, + value: { + ip: resolveIp(config.value.ip, config.type === 'v6' ? defaultV6Ip : defaultV4Ip), + transit_ips: config.value.transitIps || [], + }, + } +} + export const handlers = makeHandlers({ logout: () => 204, ping: () => ({ status: 'ok' }), @@ -254,16 +306,24 @@ 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 addressAllocator = body.address_allocator || { type: 'auto' } + const pool = + addressAllocator.type === 'explicit' && addressAllocator.pool + ? lookup.siloIpPool({ pool: addressAllocator.pool, silo: defaultSilo.id }) + : addressAllocator.type === 'auto' && + addressAllocator.pool_selector?.type === 'explicit' + ? lookup.siloIpPool({ + pool: addressAllocator.pool_selector.pool, + silo: defaultSilo.id, + }) + : lookup.siloDefaultIpPool({ silo: defaultSilo.id }) 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 || + (addressAllocator.type === 'explicit' && addressAllocator.ip) || Array.from({ length: 4 }) .map(() => Math.floor(Math.random() * 256)) .join('.'), @@ -473,7 +533,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 +577,32 @@ 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 +610,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 +618,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 +644,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 +826,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 +878,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 +890,16 @@ 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', 'fd12:3456::') + : // 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: '', @@ -842,7 +934,18 @@ export const handlers = makeHandlers({ } if (body.transit_ips) { - nic.transit_ips = body.transit_ips + if (nic.ip_stack.type === 'dual_stack') { + // 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 { + nic.ip_stack.value.transit_ips = body.transit_ips + } } return nic @@ -1999,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, diff --git a/mock-api/network-interface.ts b/mock-api/network-interface.ts index 5c28fafba..3734b51ae 100644 --- a/mock-api/network-interface.ts +++ b/mock-api/network-interface.ts @@ -17,11 +17,22 @@ export const networkInterface: Json = { description: 'a network interface', primary: true, instance_id: instance.id, - ip: '172.30.0.10', + ip_stack: { + type: 'dual_stack', + value: { + v4: { + ip: '172.30.0.10', + transit_ips: ['172.30.0.0/22'], + }, + v6: { + ip: '::1', + transit_ips: ['::/64'], + }, + }, + }, 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/package-lock.json b/package-lock.json index 921fafc1e..8c68ce424 100644 --- a/package-lock.json +++ b/package-lock.json @@ -58,7 +58,7 @@ "devDependencies": { "@ianvs/prettier-plugin-sort-imports": "^4.7.0", "@mswjs/http-middleware": "^0.10.3", - "@oxide/openapi-gen-ts": "~0.12.0", + "@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", @@ -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.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": { - "minimist": "^1.2.8", + "@commander-js/extra-typings": "^14.0.0", "prettier": "2.7.1", "swagger-parser": "^10.0.3", "ts-pattern": "^5.1.1" @@ -7045,6 +7055,17 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/commander": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz", + "integrity": "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", diff --git a/package.json b/package.json index 85af5cf5e..114c8cd6b 100644 --- a/package.json +++ b/package.json @@ -83,7 +83,7 @@ "devDependencies": { "@ianvs/prettier-plugin-sort-imports": "^4.7.0", "@mswjs/http-middleware": "^0.10.3", - "@oxide/openapi-gen-ts": "~0.12.0", + "@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", diff --git a/test/e2e/instance-create.e2e.ts b/test/e2e/instance-create.e2e.ts index ee3c74890..cc653ff48 100644 --- a/test/e2e/instance-create.e2e.ts +++ b/test/e2e/instance-create.e2e.ts @@ -676,3 +676,112 @@ test('Validate CPU and RAM', async ({ page }) => { await expect(cpuMsg).toBeVisible() await expect(memMsg).toBeVisible() }) + +test('create instance with IPv6-only networking', async ({ page }) => { + await page.goto('/projects/mock-project/instances-new') + + const instanceName = 'ipv6-only-instance' + await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) + await selectASiloImage(page, 'ubuntu-22-04') + + // Open networking accordion + await page.getByRole('button', { name: 'Networking' }).click() + + // Select "Default IPv6" network interface + await page.getByRole('radio', { name: 'Default IPv6', exact: true }).click() + + // Create instance + await page.getByRole('button', { name: 'Create instance' }).click() + + await expect(page).toHaveURL(/\/instances\/ipv6-only-instance/) + + // Navigate to the Networking tab + await page.getByRole('tab', { name: 'Networking' }).click() + + // Check that the network interfaces table shows up + const nicTable = page.getByRole('table', { name: 'Network interfaces' }) + await expect(nicTable).toBeVisible() + + // Verify the Private IP column exists and contains an IPv6 address + const privateIpCell = nicTable.getByRole('cell').filter({ hasText: /::/ }) + await expect(privateIpCell.first()).toBeVisible() + + // Verify no IPv4 address is shown (no periods in a dotted-decimal format within the Private IP) + // We check that the cell with IPv6 doesn't also contain IPv4 + const cellText = await privateIpCell.first().textContent() + expect(cellText).toMatch(/::/) + expect(cellText).not.toMatch(/\d+\.\d+\.\d+\.\d+/) +}) + +test('create instance with IPv4-only networking', async ({ page }) => { + await page.goto('/projects/mock-project/instances-new') + + const instanceName = 'ipv4-only-instance' + await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) + await selectASiloImage(page, 'ubuntu-22-04') + + // Open networking accordion + await page.getByRole('button', { name: 'Networking' }).click() + + // Select "Default IPv4" network interface + await page.getByRole('radio', { name: 'Default IPv4', exact: true }).click() + + // Create instance + await page.getByRole('button', { name: 'Create instance' }).click() + + await expect(page).toHaveURL(/\/instances\/ipv4-only-instance/) + + // Navigate to the Networking tab + await page.getByRole('tab', { name: 'Networking' }).click() + + // Check that the network interfaces table shows up + const nicTable = page.getByRole('table', { name: 'Network interfaces' }) + await expect(nicTable).toBeVisible() + + // Verify the Private IP column exists and contains an IPv4 address + const privateIpCell = nicTable.getByRole('cell').filter({ hasText: /127\.0\.0\.1/ }) + await expect(privateIpCell.first()).toBeVisible() + + // Verify no IPv6 address is shown (no colons in IPv6 format within the Private IP) + const cellText = await privateIpCell.first().textContent() + expect(cellText).toMatch(/\d+\.\d+\.\d+\.\d+/) + expect(cellText).not.toMatch(/::/) +}) + +test('create instance with dual-stack networking shows both IPs', async ({ page }) => { + await page.goto('/projects/mock-project/instances-new') + + const instanceName = 'dual-stack-instance' + await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) + await selectASiloImage(page, 'ubuntu-22-04') + + // Open networking accordion + await page.getByRole('button', { name: 'Networking' }).click() + + // Default is already "Default IPv4 & IPv6", so no need to select it + + // Create instance + await page.getByRole('button', { name: 'Create instance' }).click() + + await expect(page).toHaveURL(/\/instances\/dual-stack-instance/) + + // Navigate to the Networking tab + await page.getByRole('tab', { name: 'Networking' }).click() + + // Check that the network interfaces table shows up + const nicTable = page.getByRole('table', { name: 'Network interfaces' }) + await expect(nicTable).toBeVisible() + + // Verify both IPv4 and IPv6 addresses are shown + const privateIpCells = nicTable + .locator('tbody tr') + .first() + .locator('td') + .filter({ hasText: /127\.0\.0\.1/ }) + await expect(privateIpCells.first()).toBeVisible() + + // Check that the same cell contains IPv6 + const cellText = await privateIpCells.first().textContent() + expect(cellText).toMatch(/127\.0\.0\.1/) // IPv4 + expect(cellText).toMatch(/::1/) // IPv6 +}) diff --git a/test/e2e/instance-networking.e2e.ts b/test/e2e/instance-networking.e2e.ts index dfa036e82..3079160e9 100644 --- a/test/e2e/instance-networking.e2e.ts +++ b/test/e2e/instance-networking.e2e.ts @@ -51,7 +51,8 @@ test('Instance networking tab — NIC table', async ({ page }) => { await expectVisible(page, [ 'role=heading[name="Add network interface"]', 'role=textbox[name="Description"]', - 'role=textbox[name="IP Address"]', + 'role=textbox[name="IPv4 Address"]', + 'role=textbox[name="IPv6 Address"]', ]) await page.getByRole('textbox', { name: 'Name' }).fill('nic-2') @@ -266,11 +267,12 @@ test('Edit network interface - Transit IPs', async ({ page }) => { await modal.getByRole('button', { name: 'Update network interface' }).click() // Assert the transit IP is in the NICs table + // The NIC now has 3 transit IPs: 172.30.0.0/22 (v4), 192.168.0.0/16 (v4), and ::/64 (v6) const nicTable = page.getByRole('table', { name: 'Network interfaces' }) - await expectRowVisible(nicTable, { 'Transit IPs': '172.30.0.0/22+1' }) + await expectRowVisible(nicTable, { 'Transit IPs': '172.30.0.0/22+2' }) - await page.getByText('+1').hover() + await page.getByText('+2').hover() await expect( - page.getByRole('tooltip', { name: 'Other transit IPs 192.168.0.0/16' }) + page.getByRole('tooltip', { name: 'Other transit IPs 192.168.0.0/16 ::/64' }) ).toBeVisible() }) diff --git a/test/e2e/network-interface-create.e2e.ts b/test/e2e/network-interface-create.e2e.ts index c735553fe..b68f427b4 100644 --- a/test/e2e/network-interface-create.e2e.ts +++ b/test/e2e/network-interface-create.e2e.ts @@ -10,7 +10,7 @@ import { test } from '@playwright/test' import { expect, expectRowVisible, stopInstance } from './utils' test('can create a NIC with a specified IP address', async ({ page }) => { - // go to an instance’s Network Interfaces page + // go to an instance's Network Interfaces page await page.goto('/projects/mock-project/instances/db1/networking') await stopInstance(page) @@ -24,7 +24,10 @@ test('can create a NIC with a specified IP address', async ({ page }) => { await page.getByRole('option', { name: 'mock-vpc' }).click() await page.getByRole('button', { name: 'Subnet' }).click() await page.getByRole('option', { name: 'mock-subnet' }).click() - await page.getByLabel('IP Address').fill('1.2.3.4') + + // Select IPv4 only + await page.getByRole('radio', { name: 'IPv4', exact: true }).click() + await page.getByLabel('IPv4 Address').fill('1.2.3.4') const sidebar = page.getByRole('dialog', { name: 'Add network interface' }) @@ -37,7 +40,7 @@ test('can create a NIC with a specified IP address', async ({ page }) => { }) test('can create a NIC with a blank IP address', async ({ page }) => { - // go to an instance’s Network Interfaces page + // go to an instance's Network Interfaces page await page.goto('/projects/mock-project/instances/db1/networking') await stopInstance(page) @@ -52,8 +55,9 @@ test('can create a NIC with a blank IP address', async ({ page }) => { await page.getByRole('button', { name: 'Subnet' }).click() await page.getByRole('option', { name: 'mock-subnet' }).click() - // make sure the IP address field has a non-conforming bit of text in it - await page.getByLabel('IP Address').fill('x') + // Dual-stack is selected by default, so both fields should be visible + // make sure the IPv4 address field has a non-conforming bit of text in it + await page.getByLabel('IPv4 Address').fill('x') // try to submit it const sidebar = page.getByRole('dialog', { name: 'Add network interface' }) @@ -62,14 +66,65 @@ 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() 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.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-2', 'Private IP': '123.45.68.8' }) + await expectRowVisible(table, { name: 'nic-4', 'Private IP': '10.0.0.5fd00::5' }) })