diff --git a/OMICRON_VERSION b/OMICRON_VERSION index cd93756d9..b61e07388 100644 --- a/OMICRON_VERSION +++ b/OMICRON_VERSION @@ -1 +1 @@ -dd74446cbe12d52540d92b62f2de7eaf6520d591 +2b4ea30370131dc1dce31e52dc62f6f5b1e66552 diff --git a/app/api/__generated__/Api.ts b/app/api/__generated__/Api.ts index d3abd0aec..57119207d 100644 --- a/app/api/__generated__/Api.ts +++ b/app/api/__generated__/Api.ts @@ -58,6 +58,47 @@ export type Address = { vlanId?: number | null } +/** + * The IP address version. + */ +export type IpVersion = 'v4' | 'v6' + +/** + * Specify which IP or external subnet 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 pool is inferred from the address since IP pools cannot have overlapping ranges. */ + | { + /** The IP address to reserve. */ + ip: string + type: 'explicit' + } + /** Automatically allocate an IP address from a pool. */ + | { + /** Pool selection. + +If omitted, the silo's default pool is used. If the silo has default pools for both IPv4 and IPv6, the request will fail unless `ip_version` is specified. */ + poolSelector?: PoolSelector + type: 'auto' + } + /** * A set of addresses associated with a port configuration. */ @@ -632,6 +673,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 +712,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` */ @@ -713,6 +769,18 @@ export type AuthzScope = */ export type Baseboard = { part: string; revision: number; serial: string } +/** + * A representation of a Baseboard ID as used in the inventory subsystem. + * + * This type is essentially the same as a `Baseboard` except it doesn't have a revision or HW type (Gimlet, PC, Unknown). + */ +export type BaseboardId = { + /** Oxide Part Number */ + partNumber: string + /** Serial number (unique for a given part number) */ + serialNumber: string +} + /** * BFD connection mode. */ @@ -1934,8 +2002,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 +2049,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. */ @@ -1998,6 +2070,86 @@ export type ExternalIpResultsPage = { nextPage?: string | null } +/** + * An external subnet allocated from a subnet pool + */ +export type ExternalSubnet = { + /** human-readable free-form text about a resource */ + description: string + /** unique, immutable, system-controlled identifier for each resource */ + id: string + /** The instance this subnet is attached to, if any */ + instanceId?: string | null + /** unique, mutable, user-controlled identifier for each resource */ + name: Name + /** The project this subnet belongs to */ + projectId: string + /** The allocated subnet CIDR */ + subnet: IpNet + /** The subnet pool this was allocated from */ + subnetPoolId: string + /** The subnet pool member this subnet corresponds to */ + subnetPoolMemberId: string + /** timestamp when this resource was created */ + timeCreated: Date + /** timestamp when this resource was last modified */ + timeModified: Date +} + +/** + * Specify how to allocate an external subnet. + */ +export type ExternalSubnetAllocator = + /** Reserve a specific subnet. */ + | { + /** The subnet CIDR to reserve. Must be available in the pool. */ + subnet: IpNet + type: 'explicit' + } + /** Automatically allocate a subnet with the specified prefix length. */ + | { + /** 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 + /** The prefix length for the allocated subnet (e.g., 24 for a /24). */ + prefixLen: number + type: 'auto' + } + +/** + * Attach an external subnet to an instance + */ +export type ExternalSubnetAttach = { + /** Name or ID of the instance to attach to */ + instance: NameOrId +} + +/** + * Create an external subnet + */ +export type ExternalSubnetCreate = { + /** Subnet allocation method. */ + allocator: ExternalSubnetAllocator + description: string + name: Name +} + +/** + * A single page of results + */ +export type ExternalSubnetResultsPage = { + /** list of items on this page of results */ + items: ExternalSubnet[] + /** token used to fetch the next page of results (if any) */ + nextPage?: string | null +} + +/** + * Update an external subnet + */ +export type ExternalSubnetUpdate = { description?: string | null; name?: Name | null } + /** * The `FieldType` identifies the data type of a target or metric field. */ @@ -2126,12 +2278,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 +2532,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 +2629,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 +2681,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 +2700,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 +2760,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 +2774,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 +2802,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 +2850,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 +2974,6 @@ export type InternetGatewayResultsPage = { nextPage?: string | null } -/** - * The IP address version. - */ -export type IpVersion = 'v4' | 'v6' - /** * Type of IP pool. */ @@ -2727,7 +2998,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 +3025,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 +3080,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 +3098,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 +3380,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 +3392,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 +3404,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 +3420,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 +3441,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 +3504,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 +3668,9 @@ export type Probe = { */ export type ProbeCreate = { description: string - ipPool?: NameOrId | null name: Name + /** Pool to allocate from. */ + poolSelector?: PoolSelector sled: string } @@ -3484,6 +3772,32 @@ export type Rack = { timeModified: Date } +export type RackMembershipAddSledsRequest = { sledIds: BaseboardId[] } + +export type RackMembershipChangeState = 'in_progress' | 'committed' | 'aborted' + +/** + * A unique, monotonically increasing number representing the set of active sleds in a rack at a given point in time. + */ +export type RackMembershipVersion = number + +/** + * Status of the rack membership uniquely identified by the (rack_id, version) pair + */ +export type RackMembershipStatus = { + /** All members of the rack for this version */ + members: BaseboardId[] + rackId: string + state: RackMembershipChangeState + timeAborted?: Date | null + timeCommitted?: Date | null + timeCreated: Date + /** All members that have not yet confirmed this membership version */ + unacknowledgedMembers: BaseboardId[] + /** Version that uniquely identifies the rack membership at a given point in time */ + version: RackMembershipVersion +} + /** * A single page of results */ @@ -3820,10 +4134,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 */ @@ -4151,60 +4471,210 @@ export type SshKeyResultsPage = { nextPage?: string | null } -export type SupportBundleCreate = { - /** User comment for the support bundle */ - userComment?: string | null -} - -export type SupportBundleState = - /** Support Bundle still actively being collected. - -This is the initial state for a Support Bundle, and it will automatically transition to either "Failing" or "Active". - -If a user no longer wants to access a Support Bundle, they can request cancellation, which will transition to the "Destroying" state. */ - | 'collecting' - - /** Support Bundle is being destroyed. - -Once backing storage has been freed, this bundle is destroyed. */ - | 'destroying' - - /** Support Bundle was not created successfully, or was created and has lost backing storage. - -The record of the bundle still exists for readability, but the only valid operation on these bundles is to destroy them. */ - | 'failed' - - /** Support Bundle has been processed, and is ready for usage. */ - | 'active' - -export type SupportBundleInfo = { +/** + * A pool of subnets for external subnet allocation + */ +export type SubnetPool = { + /** human-readable free-form text about a resource */ + description: string + /** unique, immutable, system-controlled identifier for each resource */ id: string - reasonForCreation: string - reasonForFailure?: string | null - state: SupportBundleState + /** The IP version for this pool */ + ipVersion: IpVersion + /** unique, mutable, user-controlled identifier for each resource */ + name: Name + /** Type of subnet pool (unicast or multicast) */ + poolType: IpPoolType + /** timestamp when this resource was created */ timeCreated: Date - userComment?: string | null + /** timestamp when this resource was last modified */ + timeModified: Date } /** - * A single page of results + * Create a subnet pool */ -export type SupportBundleInfoResultsPage = { - /** list of items on this page of results */ - items: SupportBundleInfo[] - /** token used to fetch the next page of results (if any) */ - nextPage?: string | null +export type SubnetPoolCreate = { + description: string + /** The IP version for this pool (IPv4 or IPv6). All subnets in the pool must match this version. */ + ipVersion: IpVersion + name: Name } -export type SupportBundleUpdate = { - /** User comment for the support bundle */ - userComment?: string | null +/** + * Link a subnet pool to a silo + */ +export type SubnetPoolLinkSilo = { + /** Whether this is the default subnet pool for the silo. When true, external subnet allocations that don't specify a pool use this one. */ + isDefault: boolean + /** The silo to link */ + silo: NameOrId } /** - * An operator's view of a Switch. + * A member (subnet) within a subnet pool */ -export type Switch = { +export type SubnetPoolMember = { + /** human-readable free-form text about a resource */ + description: string + /** unique, immutable, system-controlled identifier for each resource */ + id: string + /** Maximum prefix length for allocations from this subnet; a larger prefix means smaller allocations are allowed (e.g. a /24 prefix yields smaller subnet allocations than a /16 prefix). */ + maxPrefixLength: number + /** Minimum prefix length for allocations from this subnet; a smaller prefix means larger allocations are allowed (e.g. a /16 prefix yields larger subnet allocations than a /24 prefix). */ + minPrefixLength: number + /** unique, mutable, user-controlled identifier for each resource */ + name: Name + /** The subnet CIDR */ + subnet: IpNet + /** ID of the parent subnet pool */ + subnetPoolId: string + /** timestamp when this resource was created */ + timeCreated: Date + /** timestamp when this resource was last modified */ + timeModified: Date +} + +/** + * Add a member (subnet) to a subnet pool + */ +export type SubnetPoolMemberAdd = { + /** Maximum prefix length for allocations from this subnet; a larger prefix means smaller allocations are allowed (e.g. a /24 prefix yields smaller subnet allocations than a /16 prefix). + +Valid values: 0-32 for IPv4, 0-128 for IPv6. Default if not specified is 32 for IPv4 and 128 for IPv6. */ + maxPrefixLength?: number | null + /** Minimum prefix length for allocations from this subnet; a smaller prefix means larger allocations are allowed (e.g. a /16 prefix yields larger subnet allocations than a /24 prefix). + +Valid values: 0-32 for IPv4, 0-128 for IPv6. Default if not specified is equal to the subnet's prefix length. */ + minPrefixLength?: number | null + /** The subnet to add to the pool */ + subnet: IpNet +} + +/** + * Remove a subnet from a pool + */ +export type SubnetPoolMemberRemove = { + /** The subnet to remove from the pool. Must match an existing entry exactly. */ + subnet: IpNet +} + +/** + * A single page of results + */ +export type SubnetPoolMemberResultsPage = { + /** list of items on this page of results */ + items: SubnetPoolMember[] + /** token used to fetch the next page of results (if any) */ + nextPage?: string | null +} + +/** + * A single page of results + */ +export type SubnetPoolResultsPage = { + /** list of items on this page of results */ + items: SubnetPool[] + /** token used to fetch the next page of results (if any) */ + nextPage?: string | null +} + +/** + * A link between a subnet pool and a silo + */ +export type SubnetPoolSiloLink = { + isDefault: boolean + siloId: string + subnetPoolId: string +} + +/** + * A single page of results + */ +export type SubnetPoolSiloLinkResultsPage = { + /** list of items on this page of results */ + items: SubnetPoolSiloLink[] + /** token used to fetch the next page of results (if any) */ + nextPage?: string | null +} + +/** + * Update a subnet pool's silo link + */ +export type SubnetPoolSiloUpdate = { + /** Whether this is the default subnet pool for the silo */ + isDefault: boolean +} + +/** + * Update a subnet pool + */ +export type SubnetPoolUpdate = { description?: string | null; name?: Name | null } + +/** + * Utilization information for a subnet pool + */ +export type SubnetPoolUtilization = { + /** Number of addresses allocated from this pool */ + allocated: number + /** Total capacity of this pool in addresses */ + capacity: number +} + +export type SupportBundleCreate = { + /** User comment for the support bundle */ + userComment?: string | null +} + +export type SupportBundleState = + /** Support Bundle still actively being collected. + +This is the initial state for a Support Bundle, and it will automatically transition to either "Failing" or "Active". + +If a user no longer wants to access a Support Bundle, they can request cancellation, which will transition to the "Destroying" state. */ + | 'collecting' + + /** Support Bundle is being destroyed. + +Once backing storage has been freed, this bundle is destroyed. */ + | 'destroying' + + /** Support Bundle was not created successfully, or was created and has lost backing storage. + +The record of the bundle still exists for readability, but the only valid operation on these bundles is to destroy them. */ + | 'failed' + + /** Support Bundle has been processed, and is ready for usage. */ + | 'active' + +export type SupportBundleInfo = { + id: string + reasonForCreation: string + reasonForFailure?: string | null + state: SupportBundleState + timeCreated: Date + userComment?: string | null +} + +/** + * A single page of results + */ +export type SupportBundleInfoResultsPage = { + /** list of items on this page of results */ + items: SupportBundleInfo[] + /** token used to fetch the next page of results (if any) */ + nextPage?: string | null +} + +export type SupportBundleUpdate = { + /** User comment for the support bundle */ + userComment?: string | null +} + +/** + * An operator's view of a Switch. + */ +export type Switch = { baseboard: Baseboard /** unique, immutable, system-controlled identifier for each resource */ id: string @@ -5550,6 +6020,57 @@ export interface DiskFinalizeImportQueryParams { project?: NameOrId } +export interface ExternalSubnetListQueryParams { + limit?: number | null + pageToken?: string | null + project?: NameOrId + sortBy?: NameOrIdSortMode +} + +export interface ExternalSubnetCreateQueryParams { + project: NameOrId +} + +export interface ExternalSubnetViewPathParams { + externalSubnet: NameOrId +} + +export interface ExternalSubnetViewQueryParams { + project?: NameOrId +} + +export interface ExternalSubnetUpdatePathParams { + externalSubnet: NameOrId +} + +export interface ExternalSubnetUpdateQueryParams { + project?: NameOrId +} + +export interface ExternalSubnetDeletePathParams { + externalSubnet: NameOrId +} + +export interface ExternalSubnetDeleteQueryParams { + project?: NameOrId +} + +export interface ExternalSubnetAttachPathParams { + externalSubnet: NameOrId +} + +export interface ExternalSubnetAttachQueryParams { + project?: NameOrId +} + +export interface ExternalSubnetDetachPathParams { + externalSubnet: NameOrId +} + +export interface ExternalSubnetDetachQueryParams { + project?: NameOrId +} + export interface FloatingIpListQueryParams { limit?: number | null pageToken?: string | null @@ -5759,6 +6280,7 @@ export interface InstanceEphemeralIpDetachPathParams { } export interface InstanceEphemeralIpDetachQueryParams { + ipVersion?: IpVersion project?: NameOrId } @@ -5767,12 +6289,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 +6306,7 @@ export interface InstanceMulticastGroupJoinQueryParams { export interface InstanceMulticastGroupLeavePathParams { instance: NameOrId - multicastGroup: NameOrId + multicastGroup: MulticastGroupIdentifier } export interface InstanceMulticastGroupLeaveQueryParams { @@ -5991,19 +6516,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 +6529,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 @@ -6162,6 +6662,18 @@ export interface RackViewPathParams { rackId: string } +export interface RackMembershipStatusPathParams { + rackId: string +} + +export interface RackMembershipStatusQueryParams { + version?: RackMembershipVersion +} + +export interface RackMembershipAddSledsPathParams { + rackId: string +} + export interface SledListQueryParams { limit?: number | null pageToken?: string | null @@ -6383,10 +6895,6 @@ export interface SystemMetricQueryParams { silo?: NameOrId } -export interface LookupMulticastGroupByIpPathParams { - address: string -} - export interface NetworkingAddressLotListQueryParams { limit?: number | null pageToken?: string | null @@ -6541,6 +7049,70 @@ export interface SiloQuotasUpdatePathParams { silo: NameOrId } +export interface SubnetPoolListQueryParams { + limit?: number | null + pageToken?: string | null + sortBy?: NameOrIdSortMode +} + +export interface SubnetPoolViewPathParams { + pool: NameOrId +} + +export interface SubnetPoolUpdatePathParams { + pool: NameOrId +} + +export interface SubnetPoolDeletePathParams { + pool: NameOrId +} + +export interface SubnetPoolMemberListPathParams { + pool: NameOrId +} + +export interface SubnetPoolMemberListQueryParams { + limit?: number | null + pageToken?: string | null + sortBy?: NameOrIdSortMode +} + +export interface SubnetPoolMemberAddPathParams { + pool: NameOrId +} + +export interface SubnetPoolMemberRemovePathParams { + pool: NameOrId +} + +export interface SubnetPoolSiloListPathParams { + pool: NameOrId +} + +export interface SubnetPoolSiloListQueryParams { + limit?: number | null + pageToken?: string | null + sortBy?: IdSortMode +} + +export interface SubnetPoolSiloLinkPathParams { + pool: NameOrId +} + +export interface SubnetPoolSiloUpdatePathParams { + pool: NameOrId + silo: NameOrId +} + +export interface SubnetPoolSiloUnlinkPathParams { + pool: NameOrId + silo: NameOrId +} + +export interface SubnetPoolUtilizationViewPathParams { + pool: NameOrId +} + export interface SystemTimeseriesSchemaListQueryParams { limit?: number | null pageToken?: string | null @@ -6865,7 +7437,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 = '2026012300.0.0' constructor({ host = '', baseParams = {}, token }: ApiConfig = {}) { this.host = host @@ -7014,7 +7586,7 @@ export class Api { }) }, /** - * View a support bundle + * View support bundle */ supportBundleView: ( { path }: { path: SupportBundleViewPathParams }, @@ -7697,7 +8269,7 @@ export class Api { }) }, /** - * Create a disk + * Create disk */ diskCreate: ( { query, body }: { query: DiskCreateQueryParams; body: DiskCreate }, @@ -7826,28 +8398,31 @@ export class Api { }) }, /** - * List floating IPs + * List external subnets in a project */ - floatingIpList: ( - { query = {} }: { query?: FloatingIpListQueryParams }, + externalSubnetList: ( + { query = {} }: { query?: ExternalSubnetListQueryParams }, params: FetchParams = {} ) => { - return this.request({ - path: `/v1/floating-ips`, + return this.request({ + path: `/v1/external-subnets`, method: 'GET', query, ...params, }) }, /** - * Create floating IP + * Create an external subnet */ - floatingIpCreate: ( - { query, body }: { query: FloatingIpCreateQueryParams; body: FloatingIpCreate }, + externalSubnetCreate: ( + { + query, + body, + }: { query: ExternalSubnetCreateQueryParams; body: ExternalSubnetCreate }, params: FetchParams = {} ) => { - return this.request({ - path: `/v1/floating-ips`, + return this.request({ + path: `/v1/external-subnets`, method: 'POST', body, query, @@ -7855,32 +8430,158 @@ export class Api { }) }, /** - * Fetch floating IP + * Fetch an external subnet */ - floatingIpView: ( + externalSubnetView: ( { path, query = {}, - }: { path: FloatingIpViewPathParams; query?: FloatingIpViewQueryParams }, + }: { path: ExternalSubnetViewPathParams; query?: ExternalSubnetViewQueryParams }, params: FetchParams = {} ) => { - return this.request({ - path: `/v1/floating-ips/${path.floatingIp}`, + return this.request({ + path: `/v1/external-subnets/${path.externalSubnet}`, method: 'GET', query, ...params, }) }, /** - * Update floating IP + * Update an external subnet */ - floatingIpUpdate: ( + externalSubnetUpdate: ( { path, query = {}, body, }: { - path: FloatingIpUpdatePathParams + path: ExternalSubnetUpdatePathParams + query?: ExternalSubnetUpdateQueryParams + body: ExternalSubnetUpdate + }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/external-subnets/${path.externalSubnet}`, + method: 'PUT', + body, + query, + ...params, + }) + }, + /** + * Delete an external subnet + */ + externalSubnetDelete: ( + { + path, + query = {}, + }: { path: ExternalSubnetDeletePathParams; query?: ExternalSubnetDeleteQueryParams }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/external-subnets/${path.externalSubnet}`, + method: 'DELETE', + query, + ...params, + }) + }, + /** + * Attach an external subnet to an instance + */ + externalSubnetAttach: ( + { + path, + query = {}, + body, + }: { + path: ExternalSubnetAttachPathParams + query?: ExternalSubnetAttachQueryParams + body: ExternalSubnetAttach + }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/external-subnets/${path.externalSubnet}/attach`, + method: 'POST', + body, + query, + ...params, + }) + }, + /** + * Detach an external subnet from an instance + */ + externalSubnetDetach: ( + { + path, + query = {}, + }: { path: ExternalSubnetDetachPathParams; query?: ExternalSubnetDetachQueryParams }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/external-subnets/${path.externalSubnet}/detach`, + method: 'POST', + query, + ...params, + }) + }, + /** + * List floating IPs + */ + floatingIpList: ( + { query = {} }: { query?: FloatingIpListQueryParams }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/floating-ips`, + method: 'GET', + query, + ...params, + }) + }, + /** + * Create a floating IP + */ + floatingIpCreate: ( + { query, body }: { query: FloatingIpCreateQueryParams; body: FloatingIpCreate }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/floating-ips`, + method: 'POST', + body, + query, + ...params, + }) + }, + /** + * Fetch floating IP + */ + floatingIpView: ( + { + path, + query = {}, + }: { path: FloatingIpViewPathParams; query?: FloatingIpViewQueryParams }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/floating-ips/${path.floatingIp}`, + method: 'GET', + query, + ...params, + }) + }, + /** + * Update floating IP + */ + floatingIpUpdate: ( + { + path, + query = {}, + body, + }: { + path: FloatingIpUpdatePathParams query?: FloatingIpUpdateQueryParams body: FloatingIpUpdate }, @@ -8316,7 +9017,7 @@ export class Api { }) }, /** - * List multicast groups for instance + * List multicast groups for an instance */ instanceMulticastGroupList: ( { @@ -8336,27 +9037,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 +9080,7 @@ export class Api { }) }, /** - * Reboot an instance + * Reboot instance */ instanceReboot: ( { @@ -8816,7 +9520,7 @@ export class Api { }) }, /** - * List all multicast groups. + * List multicast groups */ multicastGroupList: ( { query = {} }: { query?: MulticastGroupListQueryParams }, @@ -8830,21 +9534,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 +9547,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 +9566,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 +9731,7 @@ export class Api { }) }, /** - * Update a project + * Update project */ projectUpdate: ( { path, body }: { path: ProjectUpdatePathParams; body: ProjectUpdate }, @@ -9256,7 +9876,7 @@ export class Api { }) }, /** - * Get a physical disk + * Get physical disk */ physicalDiskView: ( { path }: { path: PhysicalDiskViewPathParams }, @@ -9312,6 +9932,40 @@ export class Api { ...params, }) }, + /** + * Retrieve the rack cluster membership status + */ + rackMembershipStatus: ( + { + path, + query = {}, + }: { path: RackMembershipStatusPathParams; query?: RackMembershipStatusQueryParams }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/system/hardware/racks/${path.rackId}/membership`, + method: 'GET', + query, + ...params, + }) + }, + /** + * Add new sleds to rack membership + */ + rackMembershipAddSleds: ( + { + path, + body, + }: { path: RackMembershipAddSledsPathParams; body: RackMembershipAddSledsRequest }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/system/hardware/racks/${path.rackId}/membership/add`, + method: 'POST', + body, + ...params, + }) + }, /** * List sleds */ @@ -9743,7 +10397,7 @@ export class Api { }) }, /** - * Add range to IP pool. + * Add range to an IP pool */ ipPoolRangeAdd: ( { path, body }: { path: IpPoolRangeAddPathParams; body: IpRange }, @@ -9904,19 +10558,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 +10657,7 @@ export class Api { }) }, /** - * Disable a BFD session + * Disable BFD session */ networkingBfdDisable: ( { body }: { body: BfdSessionDisable }, @@ -10030,7 +10671,7 @@ export class Api { }) }, /** - * Enable a BFD session + * Enable BFD session */ networkingBfdEnable: ( { body }: { body: BfdSessionEnable }, @@ -10426,7 +11067,7 @@ export class Api { }) }, /** - * Create a silo + * Create silo */ siloCreate: ({ body }: { body: SiloCreate }, params: FetchParams = {}) => { return this.request({ @@ -10447,7 +11088,7 @@ export class Api { }) }, /** - * Delete a silo + * Delete silo */ siloDelete: ({ path }: { path: SiloDeletePathParams }, params: FetchParams = {}) => { return this.request({ @@ -10527,6 +11168,190 @@ export class Api { ...params, }) }, + /** + * List subnet pools + */ + subnetPoolList: ( + { query = {} }: { query?: SubnetPoolListQueryParams }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/system/subnet-pools`, + method: 'GET', + query, + ...params, + }) + }, + /** + * Create a subnet pool + */ + subnetPoolCreate: ({ body }: { body: SubnetPoolCreate }, params: FetchParams = {}) => { + return this.request({ + path: `/v1/system/subnet-pools`, + method: 'POST', + body, + ...params, + }) + }, + /** + * Fetch a subnet pool + */ + subnetPoolView: ( + { path }: { path: SubnetPoolViewPathParams }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/system/subnet-pools/${path.pool}`, + method: 'GET', + ...params, + }) + }, + /** + * Update a subnet pool + */ + subnetPoolUpdate: ( + { path, body }: { path: SubnetPoolUpdatePathParams; body: SubnetPoolUpdate }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/system/subnet-pools/${path.pool}`, + method: 'PUT', + body, + ...params, + }) + }, + /** + * Delete a subnet pool + */ + subnetPoolDelete: ( + { path }: { path: SubnetPoolDeletePathParams }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/system/subnet-pools/${path.pool}`, + method: 'DELETE', + ...params, + }) + }, + /** + * List members in a subnet pool + */ + subnetPoolMemberList: ( + { + path, + query = {}, + }: { path: SubnetPoolMemberListPathParams; query?: SubnetPoolMemberListQueryParams }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/system/subnet-pools/${path.pool}/members`, + method: 'GET', + query, + ...params, + }) + }, + /** + * Add a member to a subnet pool + */ + subnetPoolMemberAdd: ( + { path, body }: { path: SubnetPoolMemberAddPathParams; body: SubnetPoolMemberAdd }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/system/subnet-pools/${path.pool}/members/add`, + method: 'POST', + body, + ...params, + }) + }, + /** + * Remove a member from a subnet pool + */ + subnetPoolMemberRemove: ( + { + path, + body, + }: { path: SubnetPoolMemberRemovePathParams; body: SubnetPoolMemberRemove }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/system/subnet-pools/${path.pool}/members/remove`, + method: 'POST', + body, + ...params, + }) + }, + /** + * List silos linked to a subnet pool + */ + subnetPoolSiloList: ( + { + path, + query = {}, + }: { path: SubnetPoolSiloListPathParams; query?: SubnetPoolSiloListQueryParams }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/system/subnet-pools/${path.pool}/silos`, + method: 'GET', + query, + ...params, + }) + }, + /** + * Link a subnet pool to a silo + */ + subnetPoolSiloLink: ( + { path, body }: { path: SubnetPoolSiloLinkPathParams; body: SubnetPoolLinkSilo }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/system/subnet-pools/${path.pool}/silos`, + method: 'POST', + body, + ...params, + }) + }, + /** + * Update a subnet pool's link to a silo + */ + subnetPoolSiloUpdate: ( + { path, body }: { path: SubnetPoolSiloUpdatePathParams; body: SubnetPoolSiloUpdate }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/system/subnet-pools/${path.pool}/silos/${path.silo}`, + method: 'PUT', + body, + ...params, + }) + }, + /** + * Unlink a subnet pool from a silo + */ + subnetPoolSiloUnlink: ( + { path }: { path: SubnetPoolSiloUnlinkPathParams }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/system/subnet-pools/${path.pool}/silos/${path.silo}`, + method: 'DELETE', + ...params, + }) + }, + /** + * Fetch subnet pool utilization + */ + subnetPoolUtilizationView: ( + { path }: { path: SubnetPoolUtilizationViewPathParams }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/system/subnet-pools/${path.pool}/utilization`, + method: 'GET', + ...params, + }) + }, /** * Run timeseries query */ @@ -11196,7 +12021,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..ab3fa864f 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 +2b4ea30370131dc1dce31e52dc62f6f5b1e66552 diff --git a/app/api/__generated__/msw-handlers.ts b/app/api/__generated__/msw-handlers.ts index 3d0aa5ace..1ced8edf7 100644 --- a/app/api/__generated__/msw-handlers.ts +++ b/app/api/__generated__/msw-handlers.ts @@ -431,6 +431,56 @@ export interface MSWHandlers { req: Request cookies: Record }) => Promisable + /** `GET /v1/external-subnets` */ + externalSubnetList: (params: { + query: Api.ExternalSubnetListQueryParams + req: Request + cookies: Record + }) => Promisable> + /** `POST /v1/external-subnets` */ + externalSubnetCreate: (params: { + query: Api.ExternalSubnetCreateQueryParams + body: Json + req: Request + cookies: Record + }) => Promisable> + /** `GET /v1/external-subnets/:externalSubnet` */ + externalSubnetView: (params: { + path: Api.ExternalSubnetViewPathParams + query: Api.ExternalSubnetViewQueryParams + req: Request + cookies: Record + }) => Promisable> + /** `PUT /v1/external-subnets/:externalSubnet` */ + externalSubnetUpdate: (params: { + path: Api.ExternalSubnetUpdatePathParams + query: Api.ExternalSubnetUpdateQueryParams + body: Json + req: Request + cookies: Record + }) => Promisable> + /** `DELETE /v1/external-subnets/:externalSubnet` */ + externalSubnetDelete: (params: { + path: Api.ExternalSubnetDeletePathParams + query: Api.ExternalSubnetDeleteQueryParams + req: Request + cookies: Record + }) => Promisable + /** `POST /v1/external-subnets/:externalSubnet/attach` */ + externalSubnetAttach: (params: { + path: Api.ExternalSubnetAttachPathParams + query: Api.ExternalSubnetAttachQueryParams + body: Json + req: Request + cookies: Record + }) => Promisable> + /** `POST /v1/external-subnets/:externalSubnet/detach` */ + externalSubnetDetach: (params: { + path: Api.ExternalSubnetDetachPathParams + query: Api.ExternalSubnetDetachQueryParams + req: Request + cookies: Record + }) => Promisable> /** `GET /v1/floating-ips` */ floatingIpList: (params: { query: Api.FloatingIpListQueryParams @@ -639,6 +689,7 @@ export interface MSWHandlers { instanceMulticastGroupJoin: (params: { path: Api.InstanceMulticastGroupJoinPathParams query: Api.InstanceMulticastGroupJoinQueryParams + body: Json req: Request cookies: Record }) => Promisable> @@ -842,31 +893,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 +906,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 @@ -1048,6 +1065,20 @@ export interface MSWHandlers { req: Request cookies: Record }) => Promisable> + /** `GET /v1/system/hardware/racks/:rackId/membership` */ + rackMembershipStatus: (params: { + path: Api.RackMembershipStatusPathParams + query: Api.RackMembershipStatusQueryParams + req: Request + cookies: Record + }) => Promisable> + /** `POST /v1/system/hardware/racks/:rackId/membership/add` */ + rackMembershipAddSleds: (params: { + path: Api.RackMembershipAddSledsPathParams + body: Json + req: Request + cookies: Record + }) => Promisable> /** `GET /v1/system/hardware/sleds` */ sledList: (params: { query: Api.SledListQueryParams @@ -1305,12 +1336,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 @@ -1587,6 +1612,91 @@ export interface MSWHandlers { req: Request cookies: Record }) => Promisable> + /** `GET /v1/system/subnet-pools` */ + subnetPoolList: (params: { + query: Api.SubnetPoolListQueryParams + req: Request + cookies: Record + }) => Promisable> + /** `POST /v1/system/subnet-pools` */ + subnetPoolCreate: (params: { + body: Json + req: Request + cookies: Record + }) => Promisable> + /** `GET /v1/system/subnet-pools/:pool` */ + subnetPoolView: (params: { + path: Api.SubnetPoolViewPathParams + req: Request + cookies: Record + }) => Promisable> + /** `PUT /v1/system/subnet-pools/:pool` */ + subnetPoolUpdate: (params: { + path: Api.SubnetPoolUpdatePathParams + body: Json + req: Request + cookies: Record + }) => Promisable> + /** `DELETE /v1/system/subnet-pools/:pool` */ + subnetPoolDelete: (params: { + path: Api.SubnetPoolDeletePathParams + req: Request + cookies: Record + }) => Promisable + /** `GET /v1/system/subnet-pools/:pool/members` */ + subnetPoolMemberList: (params: { + path: Api.SubnetPoolMemberListPathParams + query: Api.SubnetPoolMemberListQueryParams + req: Request + cookies: Record + }) => Promisable> + /** `POST /v1/system/subnet-pools/:pool/members/add` */ + subnetPoolMemberAdd: (params: { + path: Api.SubnetPoolMemberAddPathParams + body: Json + req: Request + cookies: Record + }) => Promisable> + /** `POST /v1/system/subnet-pools/:pool/members/remove` */ + subnetPoolMemberRemove: (params: { + path: Api.SubnetPoolMemberRemovePathParams + body: Json + req: Request + cookies: Record + }) => Promisable + /** `GET /v1/system/subnet-pools/:pool/silos` */ + subnetPoolSiloList: (params: { + path: Api.SubnetPoolSiloListPathParams + query: Api.SubnetPoolSiloListQueryParams + req: Request + cookies: Record + }) => Promisable> + /** `POST /v1/system/subnet-pools/:pool/silos` */ + subnetPoolSiloLink: (params: { + path: Api.SubnetPoolSiloLinkPathParams + body: Json + req: Request + cookies: Record + }) => Promisable> + /** `PUT /v1/system/subnet-pools/:pool/silos/:silo` */ + subnetPoolSiloUpdate: (params: { + path: Api.SubnetPoolSiloUpdatePathParams + body: Json + req: Request + cookies: Record + }) => Promisable> + /** `DELETE /v1/system/subnet-pools/:pool/silos/:silo` */ + subnetPoolSiloUnlink: (params: { + path: Api.SubnetPoolSiloUnlinkPathParams + req: Request + cookies: Record + }) => Promisable + /** `GET /v1/system/subnet-pools/:pool/utilization` */ + subnetPoolUtilizationView: (params: { + path: Api.SubnetPoolUtilizationViewPathParams + req: Request + cookies: Record + }) => Promisable> /** `POST /v1/system/timeseries/query` */ systemTimeseriesQuery: (params: { body: Json @@ -2335,6 +2445,46 @@ export function makeHandlers(handlers: MSWHandlers): HttpHandler[] { schema.FinalizeDisk ) ), + http.get( + '/v1/external-subnets', + handler(handlers['externalSubnetList'], schema.ExternalSubnetListParams, null) + ), + http.post( + '/v1/external-subnets', + handler( + handlers['externalSubnetCreate'], + schema.ExternalSubnetCreateParams, + schema.ExternalSubnetCreate + ) + ), + http.get( + '/v1/external-subnets/:externalSubnet', + handler(handlers['externalSubnetView'], schema.ExternalSubnetViewParams, null) + ), + http.put( + '/v1/external-subnets/:externalSubnet', + handler( + handlers['externalSubnetUpdate'], + schema.ExternalSubnetUpdateParams, + schema.ExternalSubnetUpdate + ) + ), + http.delete( + '/v1/external-subnets/:externalSubnet', + handler(handlers['externalSubnetDelete'], schema.ExternalSubnetDeleteParams, null) + ), + http.post( + '/v1/external-subnets/:externalSubnet/attach', + handler( + handlers['externalSubnetAttach'], + schema.ExternalSubnetAttachParams, + schema.ExternalSubnetAttach + ) + ), + http.post( + '/v1/external-subnets/:externalSubnet/detach', + handler(handlers['externalSubnetDetach'], schema.ExternalSubnetDetachParams, null) + ), http.get( '/v1/floating-ips', handler(handlers['floatingIpList'], schema.FloatingIpListParams, null) @@ -2498,7 +2648,7 @@ export function makeHandlers(handlers: MSWHandlers): HttpHandler[] { handler( handlers['instanceMulticastGroupJoin'], schema.InstanceMulticastGroupJoinParams, - null + schema.InstanceMulticastGroupJoin ) ), http.delete( @@ -2675,26 +2825,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 +2837,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( @@ -2842,6 +2960,18 @@ export function makeHandlers(handlers: MSWHandlers): HttpHandler[] { '/v1/system/hardware/racks/:rackId', handler(handlers['rackView'], schema.RackViewParams, null) ), + http.get( + '/v1/system/hardware/racks/:rackId/membership', + handler(handlers['rackMembershipStatus'], schema.RackMembershipStatusParams, null) + ), + http.post( + '/v1/system/hardware/racks/:rackId/membership/add', + handler( + handlers['rackMembershipAddSleds'], + schema.RackMembershipAddSledsParams, + schema.RackMembershipAddSledsRequest + ) + ), http.get( '/v1/system/hardware/sleds', handler(handlers['sledList'], schema.SledListParams, null) @@ -3054,14 +3184,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( @@ -3320,6 +3442,82 @@ export function makeHandlers(handlers: MSWHandlers): HttpHandler[] { schema.SiloQuotasUpdate ) ), + http.get( + '/v1/system/subnet-pools', + handler(handlers['subnetPoolList'], schema.SubnetPoolListParams, null) + ), + http.post( + '/v1/system/subnet-pools', + handler(handlers['subnetPoolCreate'], null, schema.SubnetPoolCreate) + ), + http.get( + '/v1/system/subnet-pools/:pool', + handler(handlers['subnetPoolView'], schema.SubnetPoolViewParams, null) + ), + http.put( + '/v1/system/subnet-pools/:pool', + handler( + handlers['subnetPoolUpdate'], + schema.SubnetPoolUpdateParams, + schema.SubnetPoolUpdate + ) + ), + http.delete( + '/v1/system/subnet-pools/:pool', + handler(handlers['subnetPoolDelete'], schema.SubnetPoolDeleteParams, null) + ), + http.get( + '/v1/system/subnet-pools/:pool/members', + handler(handlers['subnetPoolMemberList'], schema.SubnetPoolMemberListParams, null) + ), + http.post( + '/v1/system/subnet-pools/:pool/members/add', + handler( + handlers['subnetPoolMemberAdd'], + schema.SubnetPoolMemberAddParams, + schema.SubnetPoolMemberAdd + ) + ), + http.post( + '/v1/system/subnet-pools/:pool/members/remove', + handler( + handlers['subnetPoolMemberRemove'], + schema.SubnetPoolMemberRemoveParams, + schema.SubnetPoolMemberRemove + ) + ), + http.get( + '/v1/system/subnet-pools/:pool/silos', + handler(handlers['subnetPoolSiloList'], schema.SubnetPoolSiloListParams, null) + ), + http.post( + '/v1/system/subnet-pools/:pool/silos', + handler( + handlers['subnetPoolSiloLink'], + schema.SubnetPoolSiloLinkParams, + schema.SubnetPoolLinkSilo + ) + ), + http.put( + '/v1/system/subnet-pools/:pool/silos/:silo', + handler( + handlers['subnetPoolSiloUpdate'], + schema.SubnetPoolSiloUpdateParams, + schema.SubnetPoolSiloUpdate + ) + ), + http.delete( + '/v1/system/subnet-pools/:pool/silos/:silo', + handler(handlers['subnetPoolSiloUnlink'], schema.SubnetPoolSiloUnlinkParams, null) + ), + http.get( + '/v1/system/subnet-pools/:pool/utilization', + handler( + handlers['subnetPoolUtilizationView'], + schema.SubnetPoolUtilizationViewParams, + null + ) + ), http.post( '/v1/system/timeseries/query', handler(handlers['systemTimeseriesQuery'], null, schema.TimeseriesQuery) diff --git a/app/api/__generated__/validate.ts b/app/api/__generated__/validate.ts index 6b776098e..6d723200f 100644 --- a/app/api/__generated__/validate.ts +++ b/app/api/__generated__/validate.ts @@ -85,6 +85,36 @@ export const Address = z.preprocess( }) ) +/** + * The IP address version. + */ +export const IpVersion = z.preprocess(processResponseBody, z.enum(['v4', 'v6'])) + +/** + * Specify which IP or external subnet 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()]), 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 +148,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 +160,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 +641,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 +673,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(), @@ -671,6 +717,16 @@ export const Baseboard = z.preprocess( }) ) +/** + * A representation of a Baseboard ID as used in the inventory subsystem. + * + * This type is essentially the same as a `Baseboard` except it doesn't have a revision or HW type (Gimlet, PC, Unknown). + */ +export const BaseboardId = z.preprocess( + processResponseBody, + z.object({ partNumber: z.string(), serialNumber: z.string() }) +) + /** * BFD connection mode. */ @@ -684,7 +740,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 +750,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 +767,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 +894,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 +943,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 +1834,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 +1850,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 +1881,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']) }), ]) ) @@ -1834,6 +1897,75 @@ export const ExternalIpResultsPage = z.preprocess( z.object({ items: ExternalIp.array(), nextPage: z.string().nullable().optional() }) ) +/** + * An external subnet allocated from a subnet pool + */ +export const ExternalSubnet = z.preprocess( + processResponseBody, + z.object({ + description: z.string(), + id: z.uuid(), + instanceId: z.uuid().nullable().optional(), + name: Name, + projectId: z.uuid(), + subnet: IpNet, + subnetPoolId: z.uuid(), + subnetPoolMemberId: z.uuid(), + timeCreated: z.coerce.date(), + timeModified: z.coerce.date(), + }) +) + +/** + * Specify how to allocate an external subnet. + */ +export const ExternalSubnetAllocator = z.preprocess( + processResponseBody, + z.union([ + z.object({ subnet: IpNet, type: z.enum(['explicit']) }), + z.object({ + poolSelector: PoolSelector.default({ ipVersion: null, type: 'auto' }), + prefixLen: z.number().min(0).max(255), + type: z.enum(['auto']), + }), + ]) +) + +/** + * Attach an external subnet to an instance + */ +export const ExternalSubnetAttach = z.preprocess( + processResponseBody, + z.object({ instance: NameOrId }) +) + +/** + * Create an external subnet + */ +export const ExternalSubnetCreate = z.preprocess( + processResponseBody, + z.object({ allocator: ExternalSubnetAllocator, description: z.string(), name: Name }) +) + +/** + * A single page of results + */ +export const ExternalSubnetResultsPage = z.preprocess( + processResponseBody, + z.object({ items: ExternalSubnet.array(), nextPage: z.string().nullable().optional() }) +) + +/** + * Update an external subnet + */ +export const ExternalSubnetUpdate = z.preprocess( + processResponseBody, + z.object({ + description: z.string().nullable().optional(), + name: Name.nullable().optional(), + }) +) + /** * The `FieldType` identifies the data type of a target or metric field. */ @@ -1888,7 +2020,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 +2076,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 +2104,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 +2346,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 +2427,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 +2447,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 +2460,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 +2548,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 +2580,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 +2611,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 +2654,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 +2669,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 +2726,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 +2761,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 +2891,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 +2956,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 +2965,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 +3025,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 +3075,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 +3095,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 +3124,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 +3178,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 +3345,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 +3360,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), }) @@ -3211,6 +3459,38 @@ export const Rack = z.preprocess( z.object({ id: z.uuid(), timeCreated: z.coerce.date(), timeModified: z.coerce.date() }) ) +export const RackMembershipAddSledsRequest = z.preprocess( + processResponseBody, + z.object({ sledIds: BaseboardId.array().refine(...uniqueItems) }) +) + +export const RackMembershipChangeState = z.preprocess( + processResponseBody, + z.enum(['in_progress', 'committed', 'aborted']) +) + +/** + * A unique, monotonically increasing number representing the set of active sleds in a rack at a given point in time. + */ +export const RackMembershipVersion = z.preprocess(processResponseBody, z.number().min(0)) + +/** + * Status of the rack membership uniquely identified by the (rack_id, version) pair + */ +export const RackMembershipStatus = z.preprocess( + processResponseBody, + z.object({ + members: BaseboardId.array().refine(...uniqueItems), + rackId: z.uuid(), + state: RackMembershipChangeState, + timeAborted: z.coerce.date().nullable().optional(), + timeCommitted: z.coerce.date().nullable().optional(), + timeCreated: z.coerce.date(), + unacknowledgedMembers: BaseboardId.array().refine(...uniqueItems), + version: RackMembershipVersion, + }) +) + /** * A single page of results */ @@ -3226,7 +3506,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 +3528,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 +3541,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 +3645,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 +3761,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 +3776,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(), }) @@ -3778,38 +4058,170 @@ export const SshKeyResultsPage = z.preprocess( z.object({ items: SshKey.array(), nextPage: z.string().nullable().optional() }) ) -export const SupportBundleCreate = z.preprocess( - processResponseBody, - z.object({ userComment: z.string().nullable().optional() }) -) - -export const SupportBundleState = z.preprocess( - processResponseBody, - z.enum(['collecting', 'destroying', 'failed', 'active']) -) - -export const SupportBundleInfo = z.preprocess( +/** + * A pool of subnets for external subnet allocation + */ +export const SubnetPool = z.preprocess( processResponseBody, z.object({ + description: z.string(), id: z.uuid(), - reasonForCreation: z.string(), - reasonForFailure: z.string().nullable().optional(), - state: SupportBundleState, + ipVersion: IpVersion, + name: Name, + poolType: IpPoolType, timeCreated: z.coerce.date(), - userComment: z.string().nullable().optional(), + timeModified: z.coerce.date(), }) ) /** - * A single page of results + * Create a subnet pool */ -export const SupportBundleInfoResultsPage = z.preprocess( +export const SubnetPoolCreate = z.preprocess( processResponseBody, - z.object({ items: SupportBundleInfo.array(), nextPage: z.string().nullable().optional() }) + z.object({ description: z.string(), ipVersion: IpVersion, name: Name }) ) -export const SupportBundleUpdate = z.preprocess( - processResponseBody, +/** + * Link a subnet pool to a silo + */ +export const SubnetPoolLinkSilo = z.preprocess( + processResponseBody, + z.object({ isDefault: SafeBoolean, silo: NameOrId }) +) + +/** + * A member (subnet) within a subnet pool + */ +export const SubnetPoolMember = z.preprocess( + processResponseBody, + z.object({ + description: z.string(), + id: z.uuid(), + maxPrefixLength: z.number().min(0).max(255), + minPrefixLength: z.number().min(0).max(255), + name: Name, + subnet: IpNet, + subnetPoolId: z.uuid(), + timeCreated: z.coerce.date(), + timeModified: z.coerce.date(), + }) +) + +/** + * Add a member (subnet) to a subnet pool + */ +export const SubnetPoolMemberAdd = z.preprocess( + processResponseBody, + z.object({ + maxPrefixLength: z.number().min(0).max(255).nullable().optional(), + minPrefixLength: z.number().min(0).max(255).nullable().optional(), + subnet: IpNet, + }) +) + +/** + * Remove a subnet from a pool + */ +export const SubnetPoolMemberRemove = z.preprocess( + processResponseBody, + z.object({ subnet: IpNet }) +) + +/** + * A single page of results + */ +export const SubnetPoolMemberResultsPage = z.preprocess( + processResponseBody, + z.object({ items: SubnetPoolMember.array(), nextPage: z.string().nullable().optional() }) +) + +/** + * A single page of results + */ +export const SubnetPoolResultsPage = z.preprocess( + processResponseBody, + z.object({ items: SubnetPool.array(), nextPage: z.string().nullable().optional() }) +) + +/** + * A link between a subnet pool and a silo + */ +export const SubnetPoolSiloLink = z.preprocess( + processResponseBody, + z.object({ isDefault: SafeBoolean, siloId: z.uuid(), subnetPoolId: z.uuid() }) +) + +/** + * A single page of results + */ +export const SubnetPoolSiloLinkResultsPage = z.preprocess( + processResponseBody, + z.object({ + items: SubnetPoolSiloLink.array(), + nextPage: z.string().nullable().optional(), + }) +) + +/** + * Update a subnet pool's silo link + */ +export const SubnetPoolSiloUpdate = z.preprocess( + processResponseBody, + z.object({ isDefault: SafeBoolean }) +) + +/** + * Update a subnet pool + */ +export const SubnetPoolUpdate = z.preprocess( + processResponseBody, + z.object({ + description: z.string().nullable().optional(), + name: Name.nullable().optional(), + }) +) + +/** + * Utilization information for a subnet pool + */ +export const SubnetPoolUtilization = z.preprocess( + processResponseBody, + z.object({ allocated: z.number(), capacity: z.number() }) +) + +export const SupportBundleCreate = z.preprocess( + processResponseBody, + z.object({ userComment: z.string().nullable().optional() }) +) + +export const SupportBundleState = z.preprocess( + processResponseBody, + z.enum(['collecting', 'destroying', 'failed', 'active']) +) + +export const SupportBundleInfo = z.preprocess( + processResponseBody, + z.object({ + id: z.uuid(), + reasonForCreation: z.string(), + reasonForFailure: z.string().nullable().optional(), + state: SupportBundleState, + timeCreated: z.coerce.date(), + userComment: z.string().nullable().optional(), + }) +) + +/** + * A single page of results + */ +export const SupportBundleInfoResultsPage = z.preprocess( + processResponseBody, + z.object({ items: SupportBundleInfo.array(), nextPage: z.string().nullable().optional() }) +) + +export const SupportBundleUpdate = z.preprocess( + processResponseBody, z.object({ userComment: z.string().nullable().optional() }) ) @@ -3990,7 +4402,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 +4455,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 +4831,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 +4874,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 +4922,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 +5060,7 @@ export const WebhookCreate = z.preprocess( endpoint: z.string(), name: Name, secrets: z.string().array(), - subscriptions: AlertSubscription.array().default([]).optional(), + subscriptions: AlertSubscription.array().default([]), }) ) @@ -5412,6 +5824,89 @@ export const DiskFinalizeImportParams = z.preprocess( }) ) +export const ExternalSubnetListParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({}), + query: z.object({ + limit: z.number().min(1).max(4294967295).nullable().optional(), + pageToken: z.string().nullable().optional(), + project: NameOrId.optional(), + sortBy: NameOrIdSortMode.optional(), + }), + }) +) + +export const ExternalSubnetCreateParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({}), + query: z.object({ + project: NameOrId, + }), + }) +) + +export const ExternalSubnetViewParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({ + externalSubnet: NameOrId, + }), + query: z.object({ + project: NameOrId.optional(), + }), + }) +) + +export const ExternalSubnetUpdateParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({ + externalSubnet: NameOrId, + }), + query: z.object({ + project: NameOrId.optional(), + }), + }) +) + +export const ExternalSubnetDeleteParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({ + externalSubnet: NameOrId, + }), + query: z.object({ + project: NameOrId.optional(), + }), + }) +) + +export const ExternalSubnetAttachParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({ + externalSubnet: NameOrId, + }), + query: z.object({ + project: NameOrId.optional(), + }), + }) +) + +export const ExternalSubnetDetachParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({ + externalSubnet: NameOrId, + }), + query: z.object({ + project: NameOrId.optional(), + }), + }) +) + export const FloatingIpListParams = z.preprocess( processResponseBody, z.object({ @@ -5747,6 +6242,7 @@ export const InstanceEphemeralIpDetachParams = z.preprocess( instance: NameOrId, }), query: z.object({ + ipVersion: IpVersion.optional(), project: NameOrId.optional(), }), }) @@ -5759,7 +6255,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 +6268,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 +6281,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 +6655,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 +6669,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 +6679,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({ @@ -6512,6 +6958,28 @@ export const RackViewParams = z.preprocess( }) ) +export const RackMembershipStatusParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({ + rackId: z.uuid(), + }), + query: z.object({ + version: RackMembershipVersion.optional(), + }), + }) +) + +export const RackMembershipAddSledsParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({ + rackId: z.uuid(), + }), + query: z.object({}), + }) +) + export const SledListParams = z.preprocess( processResponseBody, z.object({ @@ -6951,16 +7419,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 +7659,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, @@ -7417,6 +7875,146 @@ export const SiloQuotasUpdateParams = z.preprocess( }) ) +export const SubnetPoolListParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({}), + query: z.object({ + limit: z.number().min(1).max(4294967295).nullable().optional(), + pageToken: z.string().nullable().optional(), + sortBy: NameOrIdSortMode.optional(), + }), + }) +) + +export const SubnetPoolCreateParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({}), + query: z.object({}), + }) +) + +export const SubnetPoolViewParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({ + pool: NameOrId, + }), + query: z.object({}), + }) +) + +export const SubnetPoolUpdateParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({ + pool: NameOrId, + }), + query: z.object({}), + }) +) + +export const SubnetPoolDeleteParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({ + pool: NameOrId, + }), + query: z.object({}), + }) +) + +export const SubnetPoolMemberListParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({ + pool: NameOrId, + }), + query: z.object({ + limit: z.number().min(1).max(4294967295).nullable().optional(), + pageToken: z.string().nullable().optional(), + sortBy: NameOrIdSortMode.optional(), + }), + }) +) + +export const SubnetPoolMemberAddParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({ + pool: NameOrId, + }), + query: z.object({}), + }) +) + +export const SubnetPoolMemberRemoveParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({ + pool: NameOrId, + }), + query: z.object({}), + }) +) + +export const SubnetPoolSiloListParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({ + pool: NameOrId, + }), + query: z.object({ + limit: z.number().min(1).max(4294967295).nullable().optional(), + pageToken: z.string().nullable().optional(), + sortBy: IdSortMode.optional(), + }), + }) +) + +export const SubnetPoolSiloLinkParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({ + pool: NameOrId, + }), + query: z.object({}), + }) +) + +export const SubnetPoolSiloUpdateParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({ + pool: NameOrId, + silo: NameOrId, + }), + query: z.object({}), + }) +) + +export const SubnetPoolSiloUnlinkParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({ + pool: NameOrId, + silo: NameOrId, + }), + query: z.object({}), + }) +) + +export const SubnetPoolUtilizationViewParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({ + pool: NameOrId, + }), + query: z.object({}), + }) +) + export const SystemTimeseriesQueryParams = z.preprocess( processResponseBody, z.object({ diff --git a/app/api/__tests__/client.spec.tsx b/app/api/__tests__/client.spec.tsx index f6ce942d1..b52c14d46 100644 --- a/app/api/__tests__/client.spec.tsx +++ b/app/api/__tests__/client.spec.tsx @@ -166,7 +166,7 @@ describe('useApiQuery', () => { const { result } = renderProjectList() await waitFor(() => { const items = result.current.data?.items - expect(items?.length).toEqual(2) + expect(items?.length).toEqual(3) expect(items?.[0].id).toEqual(project.id) }) }) diff --git a/app/api/util.ts b/app/api/util.ts index 527edf756..547802e6f 100644 --- a/app/api/util.ts +++ b/app/api/util.ts @@ -17,6 +17,8 @@ import type { DiskType, Instance, InstanceState, + IpPoolType, + IpVersion, Measurement, SiloUtilization, Sled, @@ -93,6 +95,35 @@ export const genName = (...parts: [string, ...string[]]) => { ) } +/** + * Filter IP pools by compatible IP versions and pool type. + * + * @param pools - Array of IP pools to filter + * @param compatibleVersions - Optional array of compatible IP versions (v4/v6). + * If undefined, all versions are considered compatible. + * If empty array, no pools are compatible. + * @param poolType - Optional pool type filter (unicast/multicast). + * If undefined, all pool types are included. + * @returns Filtered array of pools matching the compatibility criteria + */ +export function getCompatiblePools< + T extends { ipVersion: IpVersion; poolType: IpPoolType }, +>(pools: T[], compatibleVersions?: IpVersion[], poolType?: IpPoolType): T[] { + return pools.filter((pool) => { + // Filter by pool type if specified + if (poolType !== undefined && pool.poolType !== poolType) { + return false + } + + // Filter by compatible IP versions if specified + if (compatibleVersions !== undefined && !compatibleVersions.includes(pool.ipVersion)) { + return false + } + + return true + }) +} + const instanceActions = { // NoVmm maps to to Stopped: // https://github.com/oxidecomputer/omicron/blob/6dd9802/nexus/db-model/src/instance_state.rs#L55 diff --git a/app/components/AttachEphemeralIpModal.tsx b/app/components/AttachEphemeralIpModal.tsx index af8a384c1..ce4c89d50 100644 --- a/app/components/AttachEphemeralIpModal.tsx +++ b/app/components/AttachEphemeralIpModal.tsx @@ -6,28 +6,71 @@ * Copyright Oxide Computer Company */ -import { useMemo } from 'react' +import { useCallback, useEffect, useMemo } from 'react' import { useForm } from 'react-hook-form' -import { api, q, queryClient, useApiMutation, usePrefetchedQuery } from '~/api' -import { ListboxField } from '~/components/form/fields/ListboxField' +import { + api, + getCompatiblePools, + q, + queryClient, + useApiMutation, + usePrefetchedQuery, + type IpVersion, +} from '~/api' +import { IpPoolSelector, type UnicastIpPool } from '~/components/form/fields/IpPoolSelector' import { HL } from '~/components/HL' import { useInstanceSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { Modal } from '~/ui/lib/Modal' import { ALL_ISH } from '~/util/consts' - -import { toIpPoolItem } from './form/fields/ip-pool-item' +import { getDefaultIps } from '~/util/ip' export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) => { const { project, instance } = useInstanceSelector() const { data: siloPools } = usePrefetchedQuery( q(api.projectIpPoolList, { query: { limit: ALL_ISH } }) ) - const defaultPool = useMemo( - () => siloPools?.items.find((pool) => pool.isDefault), - [siloPools] + const { data: nics } = usePrefetchedQuery( + q(api.instanceNetworkInterfaceList, { query: { limit: ALL_ISH, project, instance } }) ) + + // Determine compatible IP versions based on instance's primary network interface + // External IPs route through the primary interface, so only its IP stack matters + // https://github.com/oxidecomputer/omicron/blob/d52aad0/nexus/db-queries/src/db/datastore/external_ip.rs#L544-L661 + const compatibleVersions: IpVersion[] | undefined = useMemo(() => { + // Before NICs load, return undefined (treat as "unknown" - allow all) + if (!nics) return undefined + + const nicItems = nics.items + const primaryNic = nicItems.find((nic) => nic.primary) + + if (!primaryNic) return [] + + const versions: IpVersion[] = [] + if (primaryNic.ipStack.type === 'v4' || primaryNic.ipStack.type === 'dual_stack') { + versions.push('v4') + } + if (primaryNic.ipStack.type === 'v6' || primaryNic.ipStack.type === 'dual_stack') { + versions.push('v6') + } + return versions + }, [nics]) + + // Only unicast pools can be used for ephemeral IPs + const compatibleUnicastPools = useMemo(() => { + if (!siloPools) return [] + return getCompatiblePools( + siloPools.items, + compatibleVersions, + 'unicast' + ) as UnicastIpPool[] + }, [siloPools, compatibleVersions]) + + const hasDefaultCompatiblePool = useMemo(() => { + return compatibleUnicastPools.some((p) => p.isDefault) + }, [compatibleUnicastPools]) + const instanceEphemeralIpAttach = useApiMutation(api.instanceEphemeralIpAttach, { onSuccess(ephemeralIp) { queryClient.invalidateEndpoint('instanceExternalIpList') @@ -39,39 +82,98 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) addToast({ title: 'Error', content: err.message, variant: 'error' }) }, }) - const form = useForm({ defaultValues: { pool: defaultPool?.name } }) + + const form = useForm<{ pool: string; ipVersion: IpVersion }>({ + defaultValues: { + pool: '', + ipVersion: 'v4', + }, + }) + + // Update ipVersion if only one version is compatible + useEffect(() => { + if (compatibleVersions && compatibleVersions.length === 1) { + form.setValue('ipVersion', compatibleVersions[0]) + } + }, [compatibleVersions, form]) const pool = form.watch('pool') + const ipVersion = form.watch('ipVersion') + + const disabledState = useMemo(() => { + if (!siloPools) return { disabled: true, reason: 'Loading pools...' } + if (!nics) return { disabled: true, reason: 'Loading network interfaces...' } + if (compatibleVersions && compatibleVersions.length === 0) { + return { + disabled: true, + reason: 'Instance has no network interfaces with compatible IP stacks', + } + } + if (compatibleUnicastPools.length === 0) { + return { + disabled: true, + reason: 'No compatible unicast pools available for this instance', + } + } + if (!pool && !hasDefaultCompatiblePool) { + return { + disabled: true, + reason: 'No default compatible pool available; select a pool to continue', + } + } + return { disabled: false, reason: undefined } + }, [ + siloPools, + nics, + compatibleVersions, + compatibleUnicastPools, + pool, + hasDefaultCompatiblePool, + ]) + + const getEffectiveIpVersion = useCallback(() => { + if (pool) return ipVersion + + const { hasV4Default, hasV6Default } = getDefaultIps(compatibleUnicastPools) + + if (hasV4Default && !hasV6Default) return 'v4' + if (hasV6Default && !hasV4Default) return 'v6' + + return ipVersion + }, [pool, ipVersion, compatibleUnicastPools]) return (
- 0 - ? 'Select a pool' - : 'No pools available' - } - items={siloPools.items.map(toIpPoolItem)} - required + poolFieldName="pool" + ipVersionFieldName="ipVersion" + pools={compatibleUnicastPools} + currentPool={pool} + setValue={form.setValue} + disabled={compatibleUnicastPools.length === 0} + compatibleVersions={compatibleVersions} />
+ disabled={disabledState.disabled} + disabledReason={disabledState.reason} + onAction={() => { + const effectiveIpVersion = getEffectiveIpVersion() + instanceEphemeralIpAttach.mutate({ path: { instance }, query: { project }, - body: { pool }, + body: pool + ? { poolSelector: { type: 'explicit', pool } } + : { poolSelector: { type: 'auto', ipVersion: effectiveIpVersion } }, }) - } + }} onDismiss={onDismiss} >
diff --git a/app/components/IpVersionBadge.tsx b/app/components/IpVersionBadge.tsx new file mode 100644 index 000000000..f7c300baa --- /dev/null +++ b/app/components/IpVersionBadge.tsx @@ -0,0 +1,22 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import cn from 'classnames' + +import type { IpVersion } from '@oxide/api' +import { Badge } from '@oxide/design-system/ui' + +type IpVersionBadgeProps = { + ipVersion: IpVersion + className?: string +} + +export const IpVersionBadge = ({ ipVersion, className }: IpVersionBadgeProps) => ( + + {ipVersion} + +) diff --git a/app/components/form/fields/IpPoolSelector.tsx b/app/components/form/fields/IpPoolSelector.tsx new file mode 100644 index 000000000..a747dd5a1 --- /dev/null +++ b/app/components/form/fields/IpPoolSelector.tsx @@ -0,0 +1,122 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import { useEffect, useMemo } from 'react' +import type { Control, UseFormSetValue } from 'react-hook-form' + +import { getCompatiblePools, type IpVersion, type SiloIpPool } from '@oxide/api' + +import { toIpPoolItem } from './ip-pool-item' +import { ListboxField } from './ListboxField' + +export type UnicastIpPool = SiloIpPool & { poolType: 'unicast' } + +type IpPoolSelectorProps = { + control: Control + poolFieldName: string + ipVersionFieldName: string + pools: UnicastIpPool[] + /** Current value of the pool field */ + currentPool: string | undefined + /** Function to update form values */ + setValue: UseFormSetValue + disabled?: boolean + /** + * Compatible IP versions based on network interface type + * If not provided, both v4 and v6 are allowed + */ + compatibleVersions?: IpVersion[] + /** + * If true, automatically select a default pool when none is selected. + * If false, allow the field to remain empty to use API defaults. + * Default to true, to automatically select a default pool if available. + */ + autoSelectDefault?: boolean +} + +export function IpPoolSelector({ + control, + poolFieldName, + ipVersionFieldName, + pools, + currentPool, + setValue, + disabled = false, + compatibleVersions, + // When both a default IPv4 and default IPv6 pool exist, the component picks the + // v4 default, to reduce user confusion. The selection is easily modified later + // (both in the form and later on the instance). + autoSelectDefault = true, +}: IpPoolSelectorProps) { + // Note: pools are already filtered by poolType before being passed to this component + const sortedPools = useMemo(() => { + return getCompatiblePools(pools, compatibleVersions).sort((a, b) => { + if (a.isDefault && a.ipVersion === 'v4') return -1 + if (b.isDefault && b.ipVersion === 'v4') return 1 + + if (a.isDefault && a.ipVersion === 'v6') return -1 + if (b.isDefault && b.ipVersion === 'v6') return 1 + + return a.name.localeCompare(b.name) + }) + }, [pools, compatibleVersions]) + + const hasNoPools = sortedPools.length === 0 + + // Set default pool selection on mount if none selected, or if current pool is no longer valid + useEffect(() => { + if (sortedPools.length > 0 && autoSelectDefault) { + const currentPoolValid = + currentPool && sortedPools.some((p) => p.name === currentPool) + + // Only auto-select when there's an actual default pool + const defaultPool = sortedPools.find((p) => p.isDefault) + + if (!currentPoolValid && defaultPool) { + setValue(poolFieldName, defaultPool.name) + setValue(ipVersionFieldName, defaultPool.ipVersion) + } + } + }, [ + currentPool, + sortedPools, + poolFieldName, + ipVersionFieldName, + setValue, + autoSelectDefault, + ]) + + // Update IP version when pool changes + useEffect(() => { + if (currentPool) { + const selectedPool = sortedPools.find((p) => p.name === currentPool) + if (selectedPool) { + setValue(ipVersionFieldName, selectedPool.ipVersion) + } + } + }, [currentPool, sortedPools, ipVersionFieldName, setValue]) + + return ( +
+ {hasNoPools ? ( +
+ No IP pools available for this network interface type +
+ ) : ( + + )} +
+ ) +} diff --git a/app/components/form/fields/NetworkInterfaceField.tsx b/app/components/form/fields/NetworkInterfaceField.tsx index 3de33ddbe..cd9056ff7 100644 --- a/app/components/form/fields/NetworkInterfaceField.tsx +++ b/app/components/form/fields/NetworkInterfaceField.tsx @@ -8,10 +8,8 @@ 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 { Badge } from '@oxide/design-system/ui' import type { InstanceCreateInput } from '~/forms/instance-create' import { CreateNetworkInterfaceForm } from '~/forms/network-interface-create' @@ -20,6 +18,7 @@ import { FieldLabel } from '~/ui/lib/FieldLabel' import { MiniTable } from '~/ui/lib/MiniTable' import { Radio } from '~/ui/lib/Radio' import { RadioGroup } from '~/ui/lib/RadioGroup' +import { TextInputHint } from '~/ui/lib/TextInput' /** * Designed less for reuse, more to encapsulate logic that would otherwise @@ -28,9 +27,11 @@ import { RadioGroup } from '~/ui/lib/RadioGroup' export function NetworkInterfaceField({ control, disabled, + hasVpcs, }: { control: Control disabled: boolean + hasVpcs: boolean }) { const [showForm, setShowForm] = useState(false) @@ -47,6 +48,13 @@ export function NetworkInterfaceField({ return (
Network interface + + Use the project’s{' '} + + default + {' '} + VPC and Subnet, using the selected IP version(s) +
{ - 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} > + {/* + Pre-selected default is dual-stack when VPCs exist (set in instance-create). + This matches the API default and works with both IPv4 and IPv6 subnets. + User can manually select a specific IP version if needed. + */} + + Default IPv4 + + + Default IPv6 + + + Default IPv4 & IPv6 + None - Default - Custom + {/* Custom follows None because of `Add network interface` button and table */} + + Custom + {value.type === 'create' && ( <> diff --git a/app/components/form/fields/ip-pool-item.tsx b/app/components/form/fields/ip-pool-item.tsx index a48325753..dd49af1f9 100644 --- a/app/components/form/fields/ip-pool-item.tsx +++ b/app/components/form/fields/ip-pool-item.tsx @@ -8,6 +8,7 @@ import { Badge } from '@oxide/design-system/ui' import type { SiloIpPool } from '~/api' +import { IpVersionBadge } from '~/components/IpVersionBadge' export function toIpPoolItem(p: SiloIpPool) { const value = p.name @@ -21,6 +22,7 @@ export function toIpPoolItem(p: SiloIpPool) { default )} +
{!!p.description && (
{p.description}
diff --git a/app/forms/floating-ip-create.tsx b/app/forms/floating-ip-create.tsx index 5ff9086f7..1dea71c07 100644 --- a/app/forms/floating-ip-create.tsx +++ b/app/forms/floating-ip-create.tsx @@ -7,30 +7,43 @@ */ import * as Accordion from '@radix-ui/react-accordion' import { useQuery } from '@tanstack/react-query' -import { useState } from 'react' +import { useMemo, useState } from 'react' import { useForm } from 'react-hook-form' import { useNavigate } from 'react-router' -import { api, q, queryClient, useApiMutation, type FloatingIpCreate } from '@oxide/api' +import { + api, + q, + queryClient, + useApiMutation, + type FloatingIpCreate, + type IpVersion, +} from '@oxide/api' import { AccordionItem } from '~/components/AccordionItem' import { DescriptionField } from '~/components/form/fields/DescriptionField' -import { toIpPoolItem } from '~/components/form/fields/ip-pool-item' -import { ListboxField } from '~/components/form/fields/ListboxField' +import { IpPoolSelector, type UnicastIpPool } from '~/components/form/fields/IpPoolSelector' import { NameField } from '~/components/form/fields/NameField' import { SideModalForm } from '~/components/form/SideModalForm' import { HL } from '~/components/HL' import { titleCrumb } from '~/hooks/use-crumbs' import { useProjectSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' -import { Message } from '~/ui/lib/Message' import { ALL_ISH } from '~/util/consts' +import { getDefaultIps } from '~/util/ip' import { pb } from '~/util/path-builder' -const defaultValues: Omit = { +type FloatingIpCreateFormData = { + name: string + description: string + pool?: string + ipVersion: IpVersion +} + +const defaultValues: FloatingIpCreateFormData = { name: '', description: '', - pool: undefined, + ipVersion: 'v4', } export const handle = titleCrumb('New Floating IP') @@ -42,6 +55,12 @@ export default function CreateFloatingIpSideModalForm() { q(api.projectIpPoolList, { query: { limit: ALL_ISH } }) ) + // Only unicast pools can be used for floating IPs + const unicastPools = useMemo(() => { + if (!allPools) return [] + return allPools.items.filter((p) => p.poolType === 'unicast') as UnicastIpPool[] + }, [allPools]) + const projectSelector = useProjectSelector() const navigate = useNavigate() @@ -56,6 +75,7 @@ export default function CreateFloatingIpSideModalForm() { }) const form = useForm({ defaultValues }) + const pool = form.watch('pool') const [openItems, setOpenItems] = useState([]) @@ -65,7 +85,35 @@ export default function CreateFloatingIpSideModalForm() { formType="create" resourceName="floating IP" onDismiss={() => navigate(pb.floatingIps(projectSelector))} - onSubmit={(body) => createFloatingIp.mutate({ query: projectSelector, body })} + onSubmit={({ pool, ipVersion, ...values }) => { + // When using default pool, derive ipVersion from available defaults + let effectiveIpVersion = ipVersion + if (!pool) { + const { hasV4Default, hasV6Default } = getDefaultIps(unicastPools) + + // If only one default exists, use that version + if (hasV4Default && !hasV6Default) { + effectiveIpVersion = 'v4' + } else if (hasV6Default && !hasV4Default) { + effectiveIpVersion = 'v6' + } + // If both exist, use form's ipVersion (user's choice) + } + + const body: FloatingIpCreate = { + ...values, + addressAllocator: pool + ? { + type: 'auto' as const, + poolSelector: { type: 'explicit' as const, pool }, + } + : { + type: 'auto' as const, + poolSelector: { type: 'auto' as const, ipVersion: effectiveIpVersion }, + }, + } + createFloatingIp.mutate({ query: projectSelector, body }) + }} loading={createFloatingIp.isPending} submitError={createFloatingIp.error} > @@ -83,17 +131,13 @@ export default function CreateFloatingIpSideModalForm() { label="Advanced" value="advanced" > - - - diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index 59172dba8..fc4e28688 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -6,15 +6,22 @@ * Copyright Oxide Computer Company */ import * as Accordion from '@radix-ui/react-accordion' -import { useEffect, useMemo, useState } from 'react' -import { useController, useForm, useWatch, type Control } from 'react-hook-form' -import { useNavigate, type LoaderFunctionArgs } from 'react-router' +import { useEffect, useMemo, useRef, useState } from 'react' +import { + useController, + useForm, + useWatch, + type Control, + type UseFormSetValue, +} from 'react-hook-form' +import { Link, useNavigate, type LoaderFunctionArgs } from 'react-router' import type { SetRequired } from 'type-fest' import { api, diskCan, genName, + getCompatiblePools, INSTANCE_MAX_CPU, INSTANCE_MAX_RAM_GiB, q, @@ -26,6 +33,7 @@ import { type Image, type InstanceCreate, type InstanceDiskAttachment, + type IpVersion, type NameOrId, type SiloIpPool, } from '@oxide/api' @@ -49,7 +57,7 @@ import { } from '~/components/form/fields/DisksTableField' import { FileField } from '~/components/form/fields/FileField' import { BootDiskImageSelectField as ImageSelectField } from '~/components/form/fields/ImageSelectField' -import { toIpPoolItem } from '~/components/form/fields/ip-pool-item' +import { IpPoolSelector, type UnicastIpPool } from '~/components/form/fields/IpPoolSelector' import { NameField } from '~/components/form/fields/NameField' import { NetworkInterfaceField } from '~/components/form/fields/NetworkInterfaceField' import { NumberField } from '~/components/form/fields/NumberField' @@ -76,8 +84,11 @@ import { Slash } from '~/ui/lib/Slash' import { Tabs } from '~/ui/lib/Tabs' import { TextInputHint } from '~/ui/lib/TextInput' import { TipIcon } from '~/ui/lib/TipIcon' +import { Tooltip } from '~/ui/lib/Tooltip' +import { Wrap } from '~/ui/util/wrap' import { ALL_ISH } from '~/util/consts' import { readBlobAsBase64 } from '~/util/file' +import { filterFloatingIpsByVersion, getDefaultIps } from '~/util/ip' import { docLinks, links } from '~/util/links' import { diskSizeNearest10 } from '~/util/math' import { pb } from '~/util/path-builder' @@ -127,9 +138,42 @@ export type InstanceCreateInput = Assign< userData: File | null // ssh keys are always specified. we do not need the undefined case sshPublicKeys: NonNullable + // IP version for ephemeral IP when dual defaults exist + ephemeralIpVersion: IpVersion + // Pool for ephemeral IP - used to sync with IpPoolSelector component + ephemeralIpPool: string } > +/** + * Determine compatible IP versions based on network interface configuration. + * External IPs route through the primary interface, so only its IP stack matters. + */ +function getCompatibleVersionsFromNicType( + networkInterfaces: InstanceCreate['networkInterfaces'] | undefined +): IpVersion[] | undefined { + const nicType = networkInterfaces?.type + + if (nicType === 'default_ipv4') return ['v4'] + if (nicType === 'default_ipv6') return ['v6'] + if (nicType === 'default_dual_stack') return ['v4', 'v6'] + if (nicType === 'none') return [] + + if (nicType === 'create' && networkInterfaces) { + if (networkInterfaces.params.length === 0) return [] + + // Derive from the first NIC's ipConfig (first NIC becomes primary) + const primaryNicConfig = networkInterfaces.params[0].ipConfig + if (primaryNicConfig?.type === 'v4') return ['v4'] + if (primaryNicConfig?.type === 'v6') return ['v6'] + if (primaryNicConfig?.type === 'dual_stack') return ['v4', 'v6'] + // ipConfig not provided = defaults to dual-stack + return ['v4', 'v6'] + } + + return undefined +} + const baseDefaultValues: InstanceCreateInput = { name: '', description: '', @@ -151,7 +195,7 @@ const baseDefaultValues: InstanceCreateInput = { diskSource: '', otherDisks: [], - networkInterfaces: { type: 'default' }, + networkInterfaces: { type: 'default_ipv4' }, sshPublicKeys: [], @@ -159,6 +203,8 @@ const baseDefaultValues: InstanceCreateInput = { userData: null, externalIps: [{ type: 'ephemeral' }], + ephemeralIpVersion: 'v4', + ephemeralIpPool: '', } export async function clientLoader({ params }: LoaderFunctionArgs) { @@ -173,6 +219,7 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { queryClient.prefetchQuery( q(api.floatingIpList, { query: { project, limit: ALL_ISH } }) ), + queryClient.prefetchQuery(q(api.vpcList, { query: { project, limit: ALL_ISH } })), ]) return null } @@ -219,20 +266,59 @@ export default function CreateInstanceForm() { const { data: siloPools } = usePrefetchedQuery( q(api.projectIpPoolList, { query: { limit: ALL_ISH } }) ) - const defaultPool = useMemo( - () => (siloPools ? siloPools.items.find((p) => p.isDefault)?.name : undefined), + + // Only unicast pools can be used for ephemeral IPs + const unicastPools = useMemo( + () => + getCompatiblePools(siloPools?.items || [], undefined, 'unicast') as UnicastIpPool[], [siloPools] ) + const { hasV4Default, hasV6Default, hasDualDefaults } = useMemo( + () => getDefaultIps(unicastPools), + [unicastPools] + ) + // Use default version if available; fall back to v4 + const ephemeralIpVersion: IpVersion = hasV4Default ? 'v4' : hasV6Default ? 'v6' : 'v4' + + // Check if VPCs exist to determine default network interface type + const { data: vpcs } = usePrefetchedQuery( + q(api.vpcList, { query: { project, limit: ALL_ISH } }) + ) + const hasVpcs = vpcs.items.length > 0 + + // Determine default network interface type: + // - If VPCs exist: default to dual-stack (API default, works with both IPv4 and IPv6 subnets) + // - If no VPCs exist: default to 'none' (user must create VPC first or use custom NICs) + // Note: Decoupled from external IP pool configuration, as NIC IP stack and external IPs are separate concerns + const defaultNetworkInterfaceType: InstanceCreateInput['networkInterfaces']['type'] = + hasVpcs ? 'default_dual_stack' : 'none' + const defaultSource = siloImages.length > 0 ? 'siloImage' : projectImages.length > 0 ? 'projectImage' : 'disk' const defaultValues: InstanceCreateInput = { ...baseDefaultValues, + networkInterfaces: { type: defaultNetworkInterfaceType }, bootDiskSourceType: defaultSource, sshPublicKeys: allKeys, bootDiskSize: diskSizeNearest10(defaultImage?.size / GiB), - externalIps: [{ type: 'ephemeral', pool: defaultPool }], + ephemeralIpVersion, + // Set ephemeralIpPool empty (for radio "use default") + ephemeralIpPool: '', + // API behavior: + // - Single default: { type: 'ephemeral' } → API auto-picks the one default + // - Dual defaults: { poolSelector: { type: 'auto', ipVersion } } → Must specify version + // right now we default to 'v4' when both exist + // - No defaults: { type: 'ephemeral' } → Will fail, but user will see error + externalIps: hasDualDefaults + ? [ + { + type: 'ephemeral', + poolSelector: { type: 'auto', ipVersion: 'v4' }, + }, + ] + : [{ type: 'ephemeral' }], } const form = useForm({ defaultValues }) @@ -258,6 +344,20 @@ export default function CreateInstanceForm() { } }, [createInstance.error]) + // Watch networkInterfaces and update ephemeralIpVersion when dual defaults exist + const networkInterfaces = useWatch({ control, name: 'networkInterfaces' }) + useEffect(() => { + if (!hasDualDefaults) return + + // Couple ephemeral IP version to network interface type when dual defaults exist + if (networkInterfaces.type === 'default_ipv6') { + setValue('ephemeralIpVersion', 'v6') + } else if (networkInterfaces.type === 'default_ipv4') { + setValue('ephemeralIpVersion', 'v4') + } + // For default_dual_stack, leave as-is (user can choose in UI) + }, [networkInterfaces, hasDualDefaults, setValue]) + const otherDisks = useWatch({ control, name: 'otherDisks' }) const unavailableDiskNames = [ ...allDisks, // existing disks from the API @@ -595,7 +695,10 @@ export default function CreateInstanceForm() { Create instance @@ -631,11 +734,17 @@ const FloatingIpLabel = ({ ip }: { ip: FloatingIp }) => ( const AdvancedAccordion = ({ control, isSubmitting, - siloPools, + unicastPools, + networkInterfaces, + hasVpcs, + setValue, }: { control: Control isSubmitting: boolean - siloPools: Array + unicastPools: Array + networkInterfaces: InstanceCreate['networkInterfaces'] + hasVpcs: boolean + setValue: UseFormSetValue }) => { // we track this state manually for the sole reason that we need to be able to // tell, inside AccordionItem, when an accordion is opened so we can scroll its @@ -646,10 +755,65 @@ const AdvancedAccordion = ({ const externalIps = useController({ control, name: 'externalIps' }) const ephemeralIp = externalIps.field.value?.find((ip) => ip.type === 'ephemeral') const assignEphemeralIp = !!ephemeralIp - const selectedPool = ephemeralIp && 'pool' in ephemeralIp ? ephemeralIp.pool : undefined - const defaultPool = siloPools.find((pool) => pool.isDefault)?.name const attachedFloatingIps = (externalIps.field.value || []).filter(isFloating) + const ephemeralIpVersionField = useController({ control, name: 'ephemeralIpVersion' }) + const ephemeralIpPoolField = useController({ control, name: 'ephemeralIpPool' }) + + const ephemeralIpPool = ephemeralIpPoolField.field.value + + // Initialize ephemeralIpPool once on mount if externalIps already has an explicit pool + const hasInitializedPoolRef = useRef(false) + useEffect(() => { + if (hasInitializedPoolRef.current) return + + const initialPool = + ephemeralIp?.poolSelector?.type === 'explicit' + ? ephemeralIp.poolSelector.pool + : undefined + if (initialPool && !ephemeralIpPool) { + ephemeralIpPoolField.field.onChange(initialPool) + } + hasInitializedPoolRef.current = true + }, [ephemeralIp, ephemeralIpPool, ephemeralIpPoolField]) + + // Update externalIps when ephemeralIpPool or ephemeralIpVersion changes + useEffect(() => { + if (!assignEphemeralIp) return + + const pool = ephemeralIpPoolField.field.value + const ipVersion = ephemeralIpVersionField.field.value || 'v4' + + const newExternalIps = externalIps.field.value?.map((ip) => { + if (ip.type !== 'ephemeral') return ip + + // Explicit pool selected + if (pool) { + return { + type: 'ephemeral', + poolSelector: { type: 'explicit', pool }, + } + } + + // No pool selected - use default with explicit IP version + // User selected v4 or v6 via radio button + return { + type: 'ephemeral', + poolSelector: { type: 'auto', ipVersion }, + } + }) + + if (newExternalIps) { + externalIps.field.onChange(newExternalIps) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + ephemeralIpPoolField.field.value, + ephemeralIpVersionField.field.value, + assignEphemeralIp, + // NOTE: Do not include externalIps in deps - it would cause infinite loop + ]) + const instanceName = useWatch({ control, name: 'name' }) const { project } = useProjectSelector() @@ -663,14 +827,96 @@ const AdvancedAccordion = ({ [floatingIpList] ) - // To find available floating IPs, we remove the ones that are already committed to this instance - const availableFloatingIps = attachableFloatingIps.filter( - (ip) => !attachedFloatingIps.find((attachedIp) => attachedIp.floatingIp === ip.name) + // Calculate compatible IP versions based on NIC type + const compatibleVersions: IpVersion[] | undefined = useMemo( + () => getCompatibleVersionsFromNicType(networkInterfaces), + [networkInterfaces] ) + + // To find available floating IPs, we remove the ones that are already committed to this instance + // and filter by IP version compatibility with configured NICs + const availableFloatingIps = useMemo(() => { + const notAttached = attachableFloatingIps.filter( + (ip) => !attachedFloatingIps.find((attachedIp) => attachedIp.floatingIp === ip.name) + ) + return filterFloatingIpsByVersion(notAttached, compatibleVersions) + }, [attachableFloatingIps, attachedFloatingIps, compatibleVersions]) + const attachedFloatingIpsData = attachedFloatingIps .map((ip) => attachableFloatingIps.find((fip) => fip.name === ip.floatingIp)) .filter((ip) => !!ip) + // Filter unicast pools by compatible IP versions + const compatibleUnicastPools = useMemo(() => { + return getCompatiblePools(unicastPools, compatibleVersions) as UnicastIpPool[] + }, [unicastPools, compatibleVersions]) + + // Track previous ability to attach ephemeral IP to detect transitions + const prevCanAttachRef = useRef(undefined) + + // Automatically manage ephemeral IP based on NIC and pool availability + useEffect(() => { + const hasNics = compatibleVersions && compatibleVersions.length > 0 + const hasPools = compatibleUnicastPools.length > 0 + const canAttach = hasNics && hasPools + const prevCanAttach = prevCanAttachRef.current + + if (!canAttach && assignEphemeralIp) { + // Remove ephemeral IP when there are no compatible NICs or pools + const newExternalIps = externalIps.field.value?.filter( + (ip) => ip.type !== 'ephemeral' + ) + externalIps.field.onChange(newExternalIps) + } else if (canAttach && prevCanAttach === false && !assignEphemeralIp) { + // Add ephemeral IP when transitioning from unable to able to attach + // (prevCanAttach === false means we couldn't attach before, either due to no NICs or no pools) + externalIps.field.onChange([ + ...(externalIps.field.value || []), + { type: 'ephemeral' }, + ]) + } + + prevCanAttachRef.current = canAttach + }, [compatibleVersions, compatibleUnicastPools, assignEphemeralIp, externalIps]) + + // Update ephemeralIpVersion when compatibleVersions changes + useEffect(() => { + if (!compatibleVersions || compatibleVersions.length === 0) return + + const currentVersion = ephemeralIpVersionField.field.value + // If current version is not compatible, switch to the first compatible version + if (!compatibleVersions.includes(currentVersion)) { + setValue('ephemeralIpVersion', compatibleVersions[0]) + } + }, [compatibleVersions, ephemeralIpVersionField.field.value, setValue]) + + const ephemeralIpCheckboxState = useMemo(() => { + const hasCompatibleNics = compatibleVersions && compatibleVersions.length > 0 + const hasCompatiblePools = compatibleUnicastPools.length > 0 + const canAttachEphemeralIp = hasCompatibleNics && hasCompatiblePools + + let disabledReason: React.ReactNode = undefined + if (!hasCompatibleNics) { + disabledReason = ( + <> + Add a compatible network interface +
+ to attach an ephemeral IP address + + ) + } else if (!hasCompatiblePools) { + disabledReason = ( + <> + No compatible IP pools available +
+ for this network interface type + + ) + } + + return { canAttachEphemeralIp, disabledReason } + }, [compatibleVersions, compatibleUnicastPools]) + const closeFloatingIpModal = () => { setFloatingIpModalOpen(false) setSelectedFloatingIp(undefined) @@ -713,7 +959,23 @@ const AdvancedAccordion = ({ label="Networking" isOpen={openItems.includes('networking')} > - + {!hasVpcs && ( + + A VPC is required to add network interfaces.{' '} + Create a VPC to enable networking. + + } + /> + )} +
- { - const newExternalIps = assignEphemeralIp - ? externalIps.field.value?.filter((ip) => ip.type !== 'ephemeral') - : [ - ...(externalIps.field.value || []), - { type: 'ephemeral', pool: selectedPool || defaultPool }, - ] - externalIps.field.onChange(newExternalIps) - }} + + } > - Allocate and attach an ephemeral IP address - - {assignEphemeralIp && ( - pool.name === selectedPool)?.name}`} - items={siloPools.map(toIpPoolItem)} - disabled={!assignEphemeralIp || isSubmitting} - required - onChange={(value) => { - const newExternalIps = externalIps.field.value?.map((ip) => - ip.type === 'ephemeral' ? { ...ip, pool: value } : ip - ) + { + const newExternalIps = assignEphemeralIp + ? externalIps.field.value?.filter((ip) => ip.type !== 'ephemeral') + : [...(externalIps.field.value || []), { type: 'ephemeral' }] externalIps.field.onChange(newExternalIps) + // The useEffect will update the poolSelector based on current form values }} + > + Allocate and attach an ephemeral IP address + + + {assignEphemeralIp && ( + )}
@@ -800,8 +1062,22 @@ const AdvancedAccordion = ({ variant="secondary" size="sm" className="shrink-0" - disabled={availableFloatingIps.length === 0} - disabledReason="No floating IPs available" + disabled={ + availableFloatingIps.length === 0 || + !compatibleVersions || + compatibleVersions.length === 0 + } + disabledReason={ + !compatibleVersions || compatibleVersions.length === 0 ? ( + <> + A network interface is required +
+ to attach a floating IP + + ) : availableFloatingIps.length === 0 ? ( + 'No floating IPs available' + ) : undefined + } onClick={() => setFloatingIpModalOpen(true)} > Attach floating IP diff --git a/app/forms/ip-pool-create.tsx b/app/forms/ip-pool-create.tsx index ef65c3d58..774bec51b 100644 --- a/app/forms/ip-pool-create.tsx +++ b/app/forms/ip-pool-create.tsx @@ -21,11 +21,7 @@ import { addToast } from '~/stores/toast' import { Message } from '~/ui/lib/Message' import { pb } from '~/util/path-builder' -// We are leaving the v4/v6 radio out for now because while you can -// create a v6 pool, you can't actually add any ranges to it until -// https://github.com/oxidecomputer/omicron/issues/8966 - -type IpPoolCreateForm = SetRequired +type IpPoolCreateForm = SetRequired const defaultValues: IpPoolCreateForm = { name: '', @@ -58,8 +54,8 @@ export default function CreateIpPoolSideModalForm() { formType="create" resourceName="IP pool" onDismiss={onDismiss} - onSubmit={({ name, description, poolType }) => { - createPool.mutate({ body: { name, description, poolType } }) + onSubmit={({ name, description, ipVersion, poolType }) => { + createPool.mutate({ body: { name, description, ipVersion, poolType } }) }} loading={createPool.isPending} submitError={createPool.error} @@ -67,9 +63,19 @@ export default function CreateIpPoolSideModalForm() { + = {} - - if (first.type === 'error') { - errors.first = { type: 'pattern', message: first.message } - } else if (first.type === 'v6') { - errors.first = ipv6Error +function createResolver(poolVersion: IpVersion) { + return (values: IpRange) => { + const first = parseIp(values.first) + const last = parseIp(values.last) + + const errors: FieldErrors = {} + + // Validate first address matches pool version + if (first.type === 'error') { + errors.first = { type: 'pattern', message: first.message } + } else if (first.type === 'v4' && poolVersion === 'v6') { + errors.first = { + type: 'pattern', + message: 'IPv4 address not allowed in IPv6 pool', + } + } else if (first.type === 'v6' && poolVersion === 'v4') { + errors.first = { + type: 'pattern', + message: 'IPv6 address not allowed in IPv4 pool', + } + } + + // Validate last address matches pool version + if (last.type === 'error') { + errors.last = { type: 'pattern', message: last.message } + } else if (last.type === 'v4' && poolVersion === 'v6') { + errors.last = { + type: 'pattern', + message: 'IPv4 address not allowed in IPv6 pool', + } + } else if (last.type === 'v6' && poolVersion === 'v4') { + errors.last = { + type: 'pattern', + message: 'IPv6 address not allowed in IPv4 pool', + } + } + + // TODO: if we were really cool we could check first <= last but it would add + // 6k gzipped to the bundle with ip-num + + // no errors + return Object.keys(errors).length > 0 ? { values: {}, errors } : { values, errors: {} } } - - if (last.type === 'error') { - errors.last = { type: 'pattern', message: last.message } - } else if (last.type === 'v6') { - errors.last = ipv6Error - } - - // TODO: once we support IPv6 we need to check for version mismatch here - - // TODO: if we were really cool we could check first <= last but it would add - // 6k gzipped to the bundle with ip-num - - // no errors - return Object.keys(errors).length > 0 ? { values: {}, errors } : { values, errors: {} } } export const handle = titleCrumb('Add Range') @@ -66,6 +88,8 @@ export default function IpPoolAddRange() { const { pool } = useIpPoolSelector() const navigate = useNavigate() + const { data: poolData } = usePrefetchedQuery(q(api.ipPoolView, { path: { pool } })) + const onDismiss = () => navigate(pb.ipPool({ pool })) const addRange = useApiMutation(api.ipPoolRangeAdd, { @@ -78,8 +102,17 @@ export default function IpPoolAddRange() { }, }) + // Derive pool version at validation time to ensure correct IP version rules + const resolver = useCallback( + (values: IpRange) => createResolver(poolData?.ipVersion ?? 'v4')(values), + [poolData?.ipVersion] + ) + const form = useForm({ defaultValues, resolver }) + // Guard against undefined poolData during initial load + if (!poolData) return null + return ( , 'ip'> = { +type IpStackType = IpVersion | '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 +79,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 +131,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..5c4be06bd 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, @@ -26,10 +25,11 @@ import { useInstanceSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { FormDivider } from '~/ui/lib/Divider' import { FieldLabel } from '~/ui/lib/FieldLabel' +import { Message } from '~/ui/lib/Message' import { ClearAndAddButtons, MiniTable } from '~/ui/lib/MiniTable' import { TextInputHint } from '~/ui/lib/TextInput' import { KEYS } from '~/ui/util/keys' -import { validateIpNet } from '~/util/ip' +import { parseIpNet, validateIpNet } from '~/util/ip' import { links } from '~/util/links' type EditNetworkInterfaceFormProps = { @@ -52,15 +52,34 @@ 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') || [] + // Determine what IP versions this NIC supports + const { ipStack } = editing + const supportsV4 = ipStack.type === 'v4' || ipStack.type === 'dual_stack' + const supportsV6 = ipStack.type === 'v6' || ipStack.type === 'dual_stack' + const supportedVersions = + supportsV4 && supportsV6 ? 'both IPv4 and IPv6' : supportsV4 ? 'IPv4' : 'IPv6' + const exampleIPs = + supportsV4 && supportsV6 + ? '192.168.0.0/16 or fd00::/64' + : supportsV4 + ? '192.168.0.0/16' + : 'fd00::/64' + const transitIpsForm = useForm({ defaultValues: { transitIp: '' } }) const transitIpValue = transitIpsForm.watch('transitIp') const { isSubmitSuccessful: transitIpSubmitSuccessful } = transitIpsForm.formState @@ -103,7 +122,8 @@ export function EditNetworkInterfaceForm({ Transit IPs - An IP network, like 192.168.0.0/16.{' '} + An IP network, like {exampleIPs}. +
Learn more about transit IPs. @@ -122,6 +142,15 @@ export function EditNetworkInterfaceForm({ const error = validateIpNet(value) if (error) return error + // Check if Transit IP version matches NIC's supported versions + const parsed = parseIpNet(value) + if (parsed.type === 'v4' && !supportsV4) { + return 'IPv4 transit IP not supported by this network interface' + } + if (parsed.type === 'v6' && !supportsV6) { + return 'IPv6 transit IP not supported by this network interface' + } + if (transitIps.includes(value)) return 'Transit IP already in list' }} placeholder="Enter an IP network" @@ -148,6 +177,10 @@ export function EditNetworkInterfaceForm({ }} removeLabel={(ip) => `remove IP ${ip}`} /> +
) } diff --git a/app/forms/vpc-router-route-common.tsx b/app/forms/vpc-router-route-common.tsx index ee2c218f2..2167f99fc 100644 --- a/app/forms/vpc-router-route-common.tsx +++ b/app/forms/vpc-router-route-common.tsx @@ -69,8 +69,8 @@ const destinationValuePlaceholder: Record = { - ip: 'An IP address, like 192.168.1.222', - ip_net: 'An IP network, like 192.168.0.0/16', + ip: 'An IP address, like 192.168.1.222 or fd00::1', + ip_net: 'An IP network, like 192.168.0.0/16 or fd00::/64', subnet: undefined, vpc: undefined, } @@ -86,7 +86,7 @@ const targetValuePlaceholder: Record = } const targetValueDescription: Record = { - ip: 'An IP address, like 10.0.1.5', + ip: 'An IP address, like 10.0.1.5 or fd00::2', instance: undefined, internet_gateway: undefined, drop: undefined, diff --git a/app/pages/project/instances/NetworkingTab.tsx b/app/pages/project/instances/NetworkingTab.tsx index e968ea31f..6a5db2e9c 100644 --- a/app/pages/project/instances/NetworkingTab.tsx +++ b/app/pages/project/instances/NetworkingTab.tsx @@ -22,6 +22,7 @@ import { type ExternalIp, type InstanceNetworkInterface, type InstanceState, + type IpVersion, } from '@oxide/api' import { IpGlobal24Icon, Networking24Icon } from '@oxide/design-system/icons/react' import { Badge } from '@oxide/design-system/ui' @@ -30,6 +31,7 @@ import { AttachEphemeralIpModal } from '~/components/AttachEphemeralIpModal' import { AttachFloatingIpModal } from '~/components/AttachFloatingIpModal' import { orderIps } from '~/components/ExternalIps' import { HL } from '~/components/HL' +import { IpVersionBadge } from '~/components/IpVersionBadge' import { ListPlusCell } from '~/components/ListPlusCell' import { CreateNetworkInterfaceForm } from '~/forms/network-interface-create' import { EditNetworkInterfaceForm } from '~/forms/network-interface-edit' @@ -44,6 +46,7 @@ import { addToast } from '~/stores/toast' import { DescriptionCell } from '~/table/cells/DescriptionCell' import { EmptyCell, SkeletonCell } from '~/table/cells/EmptyCell' import { IpPoolCell } from '~/table/cells/IpPoolCell' +import { IpVersionCell } from '~/table/cells/IpVersionCell' import { LinkCell } from '~/table/cells/LinkCell' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' import { Columns } from '~/table/columns/common' @@ -56,6 +59,7 @@ import { TableEmptyBox } from '~/ui/lib/Table' import { TipIcon } from '~/ui/lib/TipIcon' import { Tooltip } from '~/ui/lib/Tooltip' import { ALL_ISH } from '~/util/consts' +import { filterFloatingIpsByVersion, getCompatibleVersionsFromNics } from '~/util/ip' import { pb } from '~/util/path-builder' import { fancifyStates } from './common' @@ -97,6 +101,13 @@ const NonFloatingEmptyCell = ({ kind }: { kind: 'snat' | 'ephemeral' }) => ( ) +const PrivateIpCell = ({ ipVersion, ip }: { ipVersion: IpVersion; ip: string }) => ( +
+ + +
+) + export async function clientLoader({ params }: LoaderFunctionArgs) { const { project, instance } = getInstanceSelector(params) await Promise.all([ @@ -152,9 +163,23 @@ 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 +189,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}
+ ))} +
+ ) + }, }), ] @@ -210,6 +248,11 @@ const staticIpCols = [ ), cell: (info) => {info.getValue()}, }), + ipColHelper.accessor('ipPoolId', { + id: 'version', + header: 'Version', + cell: (info) => , + }), ipColHelper.accessor('ipPoolId', { header: 'IP pool', cell: (info) => , @@ -248,8 +291,22 @@ export default function NetworkingTab() { const { data: ips } = usePrefetchedQuery( q(api.floatingIpList, { query: { project, limit: ALL_ISH } }) ) - // Filter out the IPs that are already attached to an instance - const availableIps = useMemo(() => ips.items.filter((ip) => !ip.instanceId), [ips]) + + const nics = usePrefetchedQuery( + q(api.instanceNetworkInterfaceList, { + query: { ...instanceSelector, limit: ALL_ISH }, + }) + ).data.items + + // Determine compatible IP versions from the instance's primary NIC + // External IPs route through the primary interface, so only its IP stack matters + const compatibleVersions = useMemo(() => getCompatibleVersionsFromNics(nics), [nics]) + + // Filter out the IPs that are already attached to an instance and filter by IP version compatibility + const availableIps = useMemo(() => { + const notAttached = ips.items.filter((ip) => !ip.instanceId) + return filterFloatingIpsByVersion(notAttached, compatibleVersions) + }, [ips, compatibleVersions]) const createNic = useApiMutation(api.instanceNetworkInterfaceCreate, { onSuccess() { @@ -273,11 +330,6 @@ export default function NetworkingTab() { const { data: instance } = usePrefetchedQuery( q(api.instanceView, { path: { instance: instanceName }, query: { project } }) ) - const nics = usePrefetchedQuery( - q(api.instanceNetworkInterfaceList, { - query: { ...instanceSelector, limit: ALL_ISH }, - }) - ).data.items const multipleNics = nics.length > 1 @@ -553,6 +605,7 @@ export default function NetworkingTab() { aria-labelledby="nics-label" table={tableInstance} className="table-inline" + rowHeight="large" /> ) : ( diff --git a/app/pages/system/networking/IpPoolPage.tsx b/app/pages/system/networking/IpPoolPage.tsx index 3e5286d66..f8ef4ee60 100644 --- a/app/pages/system/networking/IpPoolPage.tsx +++ b/app/pages/system/networking/IpPoolPage.tsx @@ -22,7 +22,12 @@ import { type IpPoolRange, type IpPoolSiloLink, } from '@oxide/api' -import { IpGlobal16Icon, IpGlobal24Icon } from '@oxide/design-system/icons/react' +import { + IpGlobal16Icon, + IpGlobal24Icon, + Success12Icon, +} from '@oxide/design-system/icons/react' +import { Badge } from '@oxide/design-system/ui' import { CapacityBar } from '~/components/CapacityBar' import { DocsPopover } from '~/components/DocsPopover' @@ -35,7 +40,6 @@ import { getIpPoolSelector, useIpPoolSelector } from '~/hooks/use-params' import { confirmAction } from '~/stores/confirm-action' import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' -import { DefaultPoolCell } from '~/table/cells/DefaultPoolCell' import { SkeletonCell } from '~/table/cells/EmptyCell' import { LinkCell } from '~/table/cells/LinkCell' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' @@ -255,26 +259,6 @@ function SiloNameFromId({ value: siloId }: { value: string }) { } const silosColHelper = createColumnHelper() -const silosStaticCols = [ - silosColHelper.accessor('siloId', { - header: 'Silo', - cell: (info) => , - }), - silosColHelper.accessor('isDefault', { - header: () => { - return ( - - Pool is silo default - - IPs are allocated from the default pool when users ask for an IP without - specifying a pool - - - ) - }, - cell: (info) => , - }), -] function LinkedSilosTable() { const poolSelector = useIpPoolSelector() @@ -328,7 +312,37 @@ function LinkedSilosTable() { /> ) - const columns = useColsWithActions(silosStaticCols, makeActions) + const silosCols = useMemo( + () => [ + silosColHelper.accessor('siloId', { + header: 'Silo', + cell: (info) => , + }), + silosColHelper.accessor('isDefault', { + header: () => { + return ( + + Pool is silo default + + IPs are allocated from the default pool when users ask for an IP without + specifying a pool + + + ) + }, + cell: (info) => + info.getValue() ? ( + <> + + default + + ) : null, + }), + ], + [] + ) + + const columns = useColsWithActions(silosCols, makeActions) const { table } = useQueryTable({ query: ipPoolSiloList(poolSelector), columns, diff --git a/app/pages/system/networking/IpPoolsPage.tsx b/app/pages/system/networking/IpPoolsPage.tsx index b35a05b0c..c092ff668 100644 --- a/app/pages/system/networking/IpPoolsPage.tsx +++ b/app/pages/system/networking/IpPoolsPage.tsx @@ -17,6 +17,7 @@ import { Badge } from '@oxide/design-system/ui' import { DocsPopover } from '~/components/DocsPopover' import { HL } from '~/components/HL' +import { IpVersionBadge } from '~/components/IpVersionBadge' import { useQuickActions } from '~/hooks/use-quick-actions' import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' @@ -59,14 +60,18 @@ const colHelper = createColumnHelper() const staticColumns = [ colHelper.accessor('name', { cell: makeLinkCell((pool) => pb.ipPool({ pool })) }), colHelper.accessor('description', Columns.description), + colHelper.accessor('ipVersion', { + header: 'Version', + cell: (info) => , + }), colHelper.accessor('poolType', { - header: 'Pool type', + header: 'Type', cell: (info) => {info.getValue()}, }), - // TODO: add version column when API supports v6 pools colHelper.display({ - header: 'IPs Remaining', - cell: (info) => , + header: 'IPs REMAINING', + meta: { thClassName: 'normal-case' }, + cell: (info) => , }), colHelper.accessor('timeCreated', Columns.timeCreated), ] diff --git a/app/pages/system/silos/SiloIpPoolsTab.tsx b/app/pages/system/silos/SiloIpPoolsTab.tsx index 2226f3aee..501fccfc0 100644 --- a/app/pages/system/silos/SiloIpPoolsTab.tsx +++ b/app/pages/system/silos/SiloIpPoolsTab.tsx @@ -14,14 +14,15 @@ import { type LoaderFunctionArgs } from 'react-router' import { api, getListQFn, queryClient, useApiMutation, type SiloIpPool } from '@oxide/api' import { Networking24Icon } from '@oxide/design-system/icons/react' +import { Badge } from '@oxide/design-system/ui' import { ComboboxField } from '~/components/form/fields/ComboboxField' import { HL } from '~/components/HL' +import { IpVersionBadge } from '~/components/IpVersionBadge' import { makeCrumb } from '~/hooks/use-crumbs' import { getSiloSelector, useSiloSelector } from '~/hooks/use-params' import { confirmAction } from '~/stores/confirm-action' import { addToast } from '~/stores/toast' -import { DefaultPoolCell } from '~/table/cells/DefaultPoolCell' import { makeLinkCell } from '~/table/cells/LinkCell' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' import { Columns } from '~/table/columns/common' @@ -46,15 +47,6 @@ const EmptyState = () => ( const colHelper = createColumnHelper() -const staticCols = [ - colHelper.accessor('name', { cell: makeLinkCell((pool) => pb.ipPool({ pool })) }), - colHelper.accessor('description', Columns.description), - colHelper.accessor('isDefault', { - header: 'Default', - cell: (info) => , - }), -] - const allPoolsQuery = getListQFn(api.ipPoolList, { query: { limit: ALL_ISH } }) const allSiloPoolsQuery = (silo: string) => @@ -78,11 +70,41 @@ export default function SiloIpPoolsTab() { // because the prefetched one only gets 25 to match the query table. This req // is better to do async because they can't click make default that fast // anyway. - const { data: allPools } = useQuery(allSiloPoolsQuery(silo).optionsFn()) + const { data: allPoolsData } = useQuery(allSiloPoolsQuery(silo).optionsFn()) + const allPools = allPoolsData?.items + + const staticCols = useMemo( + () => [ + colHelper.accessor('name', { + cell: (info) => { + const LinkCell = makeLinkCell((pool) => pb.ipPool({ pool })) + return ( +
+ + {info.row.original.isDefault && default} +
+ ) + }, + }), + colHelper.accessor('description', Columns.description), + colHelper.accessor('ipVersion', { + header: 'Version', + cell: (info) => , + }), + colHelper.accessor('poolType', { + header: 'Type', + cell: (info) => {info.getValue()}, + }), + ], + [] + ) - // used in change default confirm modal - const defaultPool = useMemo( - () => (allPools ? allPools.items.find((p) => p.isDefault)?.name : undefined), + // used in change default confirm modal - find existing default for same version/type + const findDefaultForVersionType = useCallback( + (ipVersion: string, poolType: string) => + allPools?.find( + (p) => p.isDefault && p.ipVersion === ipVersion && p.poolType === poolType + )?.name, [allPools] ) @@ -125,18 +147,22 @@ export default function SiloIpPoolsTab() { actionType: 'danger', }) } else { - const modalContent = defaultPool ? ( + const existingDefault = findDefaultForVersionType(pool.ipVersion, pool.poolType) + const versionLabel = pool.ipVersion === 'v4' ? 'IPv4' : 'IPv6' + const typeLabel = pool.poolType === 'unicast' ? 'unicast' : 'multicast' + + const modalContent = existingDefault ? (

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

) : (

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

) - const verb = defaultPool ? 'change' : 'make' + const verb = existingDefault ? 'change' : 'make' confirmAction({ doAction: () => updatePoolLink({ @@ -171,7 +197,7 @@ export default function SiloIpPoolsTab() { }, }, ], - [defaultPool, silo, unlinkPool, updatePoolLink] + [findDefaultForVersionType, silo, unlinkPool, updatePoolLink] ) const columns = useColsWithActions(staticCols, makeActions) diff --git a/app/table/cells/DefaultPoolCell.tsx b/app/table/cells/DefaultPoolCell.tsx deleted file mode 100644 index 066db6402..000000000 --- a/app/table/cells/DefaultPoolCell.tsx +++ /dev/null @@ -1,17 +0,0 @@ -/* - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright Oxide Computer Company - */ -import { Success12Icon } from '@oxide/design-system/icons/react' -import { Badge } from '@oxide/design-system/ui' - -export const DefaultPoolCell = ({ isDefault }: { isDefault: boolean }) => - isDefault ? ( - <> - - default - - ) : null diff --git a/app/table/cells/IpVersionCell.tsx b/app/table/cells/IpVersionCell.tsx new file mode 100644 index 000000000..288eb4a92 --- /dev/null +++ b/app/table/cells/IpVersionCell.tsx @@ -0,0 +1,23 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import { useQuery } from '@tanstack/react-query' + +import { api, qErrorsAllowed } from '~/api' +import { IpVersionBadge } from '~/components/IpVersionBadge' + +import { EmptyCell, SkeletonCell } from './EmptyCell' + +export const IpVersionCell = ({ ipPoolId }: { ipPoolId: string }) => { + const { data: result } = useQuery( + qErrorsAllowed(api.projectIpPoolView, { path: { pool: ipPoolId } }) + ) + if (!result) return + if (result.type === 'error') return + const pool = result.data + return +} diff --git a/app/ui/lib/Modal.tsx b/app/ui/lib/Modal.tsx index 0f90eb8b9..60e116849 100644 --- a/app/ui/lib/Modal.tsx +++ b/app/ui/lib/Modal.tsx @@ -113,6 +113,7 @@ type FooterProps = { actionLoading?: boolean cancelText?: string disabled?: boolean + disabledReason?: React.ReactNode showCancel?: boolean } & MergeExclusive<{ formId: string }, { onAction: () => void }> @@ -125,6 +126,7 @@ Modal.Footer = ({ actionLoading, cancelText, disabled, + disabledReason, formId, showCancel = true, }: FooterProps) => ( @@ -143,6 +145,7 @@ Modal.Footer = ({ variant={actionType} onClick={onAction} disabled={!!disabled} + disabledReason={disabledReason} loading={actionLoading} > {actionText} diff --git a/app/ui/lib/RadioGroup.tsx b/app/ui/lib/RadioGroup.tsx index c1f76de5f..2f956d8cf 100644 --- a/app/ui/lib/RadioGroup.tsx +++ b/app/ui/lib/RadioGroup.tsx @@ -96,7 +96,8 @@ export const RadioGroup = ({ React.cloneElement(radio, { name, required, - disabled, + // Merge disabled: RadioGroup disabled OR individual Radio disabled + disabled: disabled || radio.props.disabled, defaultChecked: radio.props.value === defaultChecked ? true : undefined, }) )} diff --git a/app/util/ip.ts b/app/util/ip.ts index 9d060aab5..bc7f1b88c 100644 --- a/app/util/ip.ts +++ b/app/util/ip.ts @@ -6,6 +6,9 @@ * Copyright Oxide Computer Company */ +import type { InstanceNetworkInterface, IpVersion } from '~/api' +import type { UnicastIpPool } from '~/components/form/fields/IpPoolSelector' + // Borrowed from Valibot. I tried some from Zod and an O'Reilly regex cookbook // but they didn't match results with std::net on simple test cases // https://github.com/fabian-hiller/valibot/blob/2554aea5/library/src/regex.ts#L43-L54 @@ -16,7 +19,7 @@ const IPV4_REGEX = const IPV6_REGEX = /^(?:(?:[\da-f]{1,4}:){7}[\da-f]{1,4}|(?:[\da-f]{1,4}:){1,7}:|(?:[\da-f]{1,4}:){1,6}:[\da-f]{1,4}|(?:[\da-f]{1,4}:){1,5}(?::[\da-f]{1,4}){1,2}|(?:[\da-f]{1,4}:){1,4}(?::[\da-f]{1,4}){1,3}|(?:[\da-f]{1,4}:){1,3}(?::[\da-f]{1,4}){1,4}|(?:[\da-f]{1,4}:){1,2}(?::[\da-f]{1,4}){1,5}|[\da-f]{1,4}:(?::[\da-f]{1,4}){1,6}|:(?:(?::[\da-f]{1,4}){1,7}|:)|fe80:(?::[\da-f]{0,4}){0,4}%[\da-z]+|::(?:f{4}(?::0{1,4})?:)?(?:(?:25[0-5]|(?:2[0-4]|1?\d)?\d)\.){3}(?:25[0-5]|(?:2[0-4]|1?\d)?\d)|(?:[\da-f]{1,4}:){1,4}:(?:(?:25[0-5]|(?:2[0-4]|1?\d)?\d)\.){3}(?:25[0-5]|(?:2[0-4]|1?\d)?\d))$/iu -type ParsedIp = { type: 'v4' | 'v6'; address: string } | { type: 'error'; message: string } +type ParsedIp = { type: IpVersion; address: string } | { type: 'error'; message: string } export function parseIp(ip: string): ParsedIp { if (IPV4_REGEX.test(ip)) return { type: 'v4', address: ip } @@ -38,7 +41,7 @@ export function validateIp(ip: string): string | undefined { // https://github.com/oxidecomputer/oxnet/blob/7dacd265f1bcd0f8b47bd4805250c4f0812da206/src/ipnet.rs#L217-L223 type ParsedIpNet = - | { type: 'v4' | 'v6'; address: string; width: number } + | { type: IpVersion; address: string; width: number } | { type: 'error'; message: string } const nonsenseError = { @@ -84,3 +87,58 @@ export function validateIpNet(ipNet: string): string | undefined { const result = parseIpNet(ipNet) if (result.type === 'error') return result.message } + +/** + * Get compatible IP versions from an instance's NICs. External IPs route + * through the primary interface, so only its IP stack matters. + */ +export function getCompatibleVersionsFromNics( + nics: InstanceNetworkInterface[] +): IpVersion[] { + const primaryNic = nics.find((nic) => nic.primary) + if (!primaryNic) return [] + + const { ipStack } = primaryNic + if (ipStack.type === 'v4' || ipStack.type === 'v6') { + return [ipStack.type] + } + if (ipStack.type === 'dual_stack') { + return ['v4', 'v6'] + } + return [] +} + +/** + * Filters floating IPs by compatible IP versions. Only IPs whose version + * matches one of the compatible versions are returned. + */ +export function filterFloatingIpsByVersion( + floatingIps: T[], + compatibleVersions: IpVersion[] | undefined +): T[] { + // If no compatible versions, no floating IPs are compatible + if (!compatibleVersions || compatibleVersions.length === 0) { + return [] + } + + return floatingIps.filter((floatingIp) => { + const ipVersion = parseIp(floatingIp.ip) + if (ipVersion.type === 'error') return false + return compatibleVersions.includes(ipVersion.type) + }) +} + +export const getDefaultIps = (pools: UnicastIpPool[]) => { + const defaultPools = pools.filter((pool) => pool.isDefault) + const v4Default = defaultPools.find((p) => p.ipVersion === 'v4') + const hasV4Default = !!v4Default + const v6Default = defaultPools.find((p) => p.ipVersion === 'v6') + const hasV6Default = !!v6Default + return { + v4Default, + v6Default, + hasV4Default, + hasV6Default, + hasDualDefaults: hasV4Default && hasV6Default, + } +} diff --git a/mock-api/floating-ip.ts b/mock-api/floating-ip.ts index 787bff639..6f747ef1a 100644 --- a/mock-api/floating-ip.ts +++ b/mock-api/floating-ip.ts @@ -9,11 +9,12 @@ import type { FloatingIp } from '@oxide/api' import { instance } from './instance' -import { ipPool1 } from './ip-pool' +import { ipPool1, ipPool2 } from './ip-pool' import type { Json } from './json-type' import { project } from './project' -// Note that these addresses should come from ranges in ip-pool-1 +// Note that IPv4 addresses should come from ranges in ip-pool-1 +// Note that IPv6 addresses should come from ranges in ip-pool-2 // A floating IP from the default pool export const floatingIp: Json = { @@ -41,4 +42,17 @@ export const floatingIp2: Json = { time_modified: new Date().toISOString(), } -export const floatingIps = [floatingIp, floatingIp2] +// An IPv6 floating IP for testing IP version filtering (from ip-pool-2) +export const floatingIp3: Json = { + id: 'b1c2d3e4-5f6a-7b8c-9d0e-1f2a3b4c5d6e', + name: 'ipv6-float', + description: 'An IPv6 address.', + instance_id: undefined, + ip: 'fd00::2', + ip_pool_id: ipPool2.id, + project_id: project.id, + time_created: new Date().toISOString(), + time_modified: new Date().toISOString(), +} + +export const floatingIps = [floatingIp, floatingIp2, floatingIp3] diff --git a/mock-api/ip-pool.ts b/mock-api/ip-pool.ts index ee7d26a63..f8544be7a 100644 --- a/mock-api/ip-pool.ts +++ b/mock-api/ip-pool.ts @@ -51,7 +51,35 @@ export const ipPool4: Json = { pool_type: 'unicast', } -export const ipPools: Json[] = [ipPool1, ipPool2, ipPool3, ipPool4] +// Multicast pools for testing that they are NOT selected for ephemeral/floating IPs +export const ipPool5Multicast: Json = { + id: 'b6c4a6b9-761e-4d28-94c0-fd3d7738ef1d', + name: 'ip-pool-5-multicast-v4', + description: 'Multicast v4 pool', + time_created: new Date().toISOString(), + time_modified: new Date().toISOString(), + ip_version: 'v4', + pool_type: 'multicast', +} + +export const ipPool6Multicast: Json = { + id: 'c7d5b7ca-872f-4e39-95d1-fe4e8849f02e', + name: 'ip-pool-6-multicast-v6', + description: 'Multicast v6 pool', + time_created: new Date().toISOString(), + time_modified: new Date().toISOString(), + ip_version: 'v6', + pool_type: 'multicast', +} + +export const ipPools: Json[] = [ + ipPool1, + ipPool2, + ipPool3, + ipPool4, + ipPool5Multicast, + ipPool6Multicast, +] export const ipPoolSilos: Json[] = [ { @@ -62,7 +90,18 @@ export const ipPoolSilos: Json[] = [ { ip_pool_id: ipPool2.id, silo_id: defaultSilo.id, - is_default: false, + is_default: true, // Both v4 and v6 unicast pools are default - valid dual-default scenario + }, + // Make multicast pools also default to test that they are NOT selected + { + ip_pool_id: ipPool5Multicast.id, + silo_id: defaultSilo.id, + is_default: true, + }, + { + ip_pool_id: ipPool6Multicast.id, + silo_id: defaultSilo.id, + is_default: true, }, ] @@ -105,4 +144,23 @@ export const ipPoolRanges: Json = [ }, time_created: new Date().toISOString(), }, + // Multicast pool ranges (should NOT be used for ephemeral/floating IPs) + { + id: 'e8f6c8db-9830-4f4a-a6e2-0f5f99600b3f', + ip_pool_id: ipPool5Multicast.id, + range: { + first: '224.0.0.1', + last: '224.0.0.32', + }, + time_created: new Date().toISOString(), + }, + { + id: 'f9a7d9ec-a940-5a5b-b7f3-0a6aa0710b4a', + ip_pool_id: ipPool6Multicast.id, + range: { + first: 'ff00::1', + last: 'ff00::ffff:ffff:ffff:ffff', + }, + time_created: new Date().toISOString(), + }, ] diff --git a/mock-api/msw/db.ts b/mock-api/msw/db.ts index 95ce6718d..101185602 100644 --- a/mock-api/msw/db.ts +++ b/mock-api/msw/db.ts @@ -10,7 +10,7 @@ import * as R from 'remeda' import { validate as isUuid } from 'uuid' -import type { ApiTypes as Api } from '@oxide/api' +import type { ApiTypes as Api, IpPoolType, IpVersion } from '@oxide/api' import * as mock from '@oxide/api-mocks' import { json } from '~/api/__generated__/msw-handlers' @@ -18,6 +18,7 @@ import type * as Sel from '~/api/selectors' import { commaSeries } from '~/util/str' import type { Json } from '../json-type' +import { projects } from '../project' import { defaultSilo, siloSettings } from '../silo' import { internalError } from './util' @@ -62,6 +63,62 @@ 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'; ip_version?: IpVersion | null } + | undefined, + poolType?: IpPoolType +) => { + if (poolSelector?.type === 'explicit') { + return lookup.ipPool({ pool: poolSelector.pool }) + } + + // For 'auto' type, find the default pool for the specified IP version and pool type + const silo = lookup.silo({ silo: defaultSilo.id }) + const links = db.ipPoolSilos.filter((ips) => ips.silo_id === silo.id && ips.is_default) + + // Filter candidate pools by both IP version and pool type + const candidateLinks = links.filter((ips) => { + const pool = db.ipPools.find((p) => p.id === ips.ip_pool_id) + if (!pool) return false + + // If poolType specified, filter by it + if (poolType && pool.pool_type !== poolType) return false + + // If IP version specified, filter by it + if (poolSelector?.ip_version && pool.ip_version !== poolSelector.ip_version) { + return false + } + + return true + }) + + // Enforce API requirement: when type is 'auto' and ip_version is unset, + // reject if multiple default pools exist (ambiguous) + if ( + poolSelector?.type === 'auto' && + !poolSelector.ip_version && + candidateLinks.length > 1 + ) { + throw json( + { + error_code: 'InvalidRequest', + message: 'ip_version required when multiple default pools exist', + }, + { status: 400 } + ) + } + + const link = candidateLinks[0] + if (!link) { + const typeStr = poolType ? ` ${poolType}` : '' + const versionStr = poolSelector?.ip_version ? ` ${poolSelector.ip_version}` : '' + throw notFoundErr(`default${typeStr}${versionStr} pool for silo '${defaultSilo.id}'`) + } + return lookupById(db.ipPools, link.ip_pool_id) +} + 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}'`) @@ -501,7 +558,7 @@ const initDb = { ipPoolRanges: [...mock.ipPoolRanges], networkInterfaces: [mock.networkInterface], physicalDisks: [...mock.physicalDisks], - projects: [...mock.projects], + projects: [...projects], racks: [...mock.racks], roleAssignments: [...mock.roleAssignments], scimTokens: [...mock.scimTokens], diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 50cd157b7..629dbd370 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' }), @@ -249,24 +301,47 @@ export const handlers = makeHandlers({ return 204 }, + externalSubnetList: NotImplemented, + externalSubnetCreate: NotImplemented, + externalSubnetView: NotImplemented, + externalSubnetUpdate: NotImplemented, + externalSubnetDelete: NotImplemented, + externalSubnetAttach: NotImplemented, + externalSubnetDetach: NotImplemented, floatingIpCreate({ body, query }) { const project = lookup.project(query) errIfExists(db.floatingIps, { name: body.name, project_id: project.id }) - // TODO: when IP is specified, use ipInAnyRange to check that it is in the pool - const pool = body.pool - ? lookup.siloIpPool({ pool: body.pool, silo: defaultSilo.id }) - : lookup.siloDefaultIpPool({ silo: defaultSilo.id }) + const addressAllocator = body.address_allocator || { type: 'auto' } + + // Determine the pool and IP + // Floating IPs must use unicast pools + let pool: Json + let ip: string + + if (addressAllocator.type === 'explicit') { + // Pool is inferred from the IP address since IP pools cannot have overlapping ranges + ip = addressAllocator.ip + // Find the pool that contains this IP by checking all ranges + const poolWithIp = db.ipPools.find((p) => { + if (p.pool_type !== 'unicast') return false + const ranges = db.ipPoolRanges.filter((r) => r.ip_pool_id === p.id) + return ranges.some(() => { + // Simple check - in real API this would do proper IP range comparison + return true // For mock purposes, just use first unicast pool + }) + }) + pool = poolWithIp || resolvePoolSelector(undefined, 'unicast') + } else { + // type === 'auto' + pool = resolvePoolSelector(addressAllocator.pool_selector, 'unicast') + ip = getIpFromPool(pool) + } const newFloatingIp: Json = { id: uuid(), project_id: project.id, - // TODO: use ip-num to actually get the next available IP in the pool - ip: - body.ip || - Array.from({ length: 4 }) - .map(() => Math.floor(Math.random() * 256)) - .join('.'), + ip, ip_pool_id: pool.id, description: body.description, name: body.name, @@ -458,6 +533,36 @@ export const handlers = makeHandlers({ } // validate floating IP attachments before we actually do anything + // Determine what IP stacks the instance will have based on network interfaces + let hasIpv4Nic = false + let hasIpv6Nic = false + + const nicType = body.network_interfaces?.type + if (nicType === 'default_ipv4') { + hasIpv4Nic = true + } else if (nicType === 'default_ipv6') { + hasIpv6Nic = true + } else if (nicType === 'default_dual_stack') { + hasIpv4Nic = true + hasIpv6Nic = true + } else if (nicType === 'create' && body.network_interfaces) { + // Derive from the first NIC's ip_config (first NIC becomes primary) + const primaryNicConfig = body.network_interfaces.params[0]?.ip_config + if (primaryNicConfig?.type === 'v4') { + hasIpv4Nic = true + } else if (primaryNicConfig?.type === 'v6') { + hasIpv6Nic = true + } else if (primaryNicConfig?.type === 'dual_stack') { + hasIpv4Nic = true + hasIpv6Nic = true + } else { + // ip_config not provided = defaults to dual-stack + hasIpv4Nic = true + hasIpv6Nic = true + } + } + // If nicType is 'none' or undefined, both remain false + body.external_ips?.forEach((ip) => { if (ip.type === 'floating') { // throw if floating IP doesn't exist @@ -473,8 +578,31 @@ export const handlers = makeHandlers({ // if there are no ranges in the pool or if the pool doesn't exist, // which aren't quite as good as checking that there are actually IPs // available, but they are good things to check - const pool = resolveIpPool(ip.pool) + // Ephemeral IPs must use unicast pools + const pool = resolvePoolSelector(ip.pool_selector, 'unicast') getIpFromPool(pool) + + // Validate that external IP version matches NIC's IP stack + // Based on Omicron validation in nexus/db-queries/src/db/datastore/external_ip.rs:544-661 + const ipVersion = pool.ip_version + if (ipVersion === 'v4' && !hasIpv4Nic) { + throw json( + { + error_code: 'InvalidRequest', + message: `The ephemeral external IP is an IPv4 address, but the instance with ID ${body.name} does not have a primary network interface with a VPC-private IPv4 address. Add a VPC-private IPv4 address to the interface, or attach a different IP address`, + }, + { status: 400 } + ) + } + if (ipVersion === 'v6' && !hasIpv6Nic) { + throw json( + { + error_code: 'InvalidRequest', + message: `The ephemeral external IP is an IPv6 address, but the instance with ID ${body.name} does not have a primary network interface with a VPC-private IPv6 address. Add a VPC-private IPv6 address to the interface, or attach a different IP address`, + }, + { status: 400 } + ) + } } }) @@ -517,14 +645,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 +678,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 +686,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 +712,8 @@ export const handlers = makeHandlers({ // we've already validated that the IP isn't attached floatingIp.instance_id = instanceId } else if (ip.type === 'ephemeral') { - const pool = resolveIpPool(ip.pool) + // Ephemeral IPs must use unicast pools + const pool = resolvePoolSelector(ip.pool_selector, 'unicast') const firstAvailableAddress = getIpFromPool(pool) db.ephemeralIps.push({ @@ -743,9 +895,48 @@ export const handlers = makeHandlers({ }, instanceEphemeralIpAttach({ path, query: projectParams, body }) { const instance = lookup.instance({ ...path, ...projectParams }) - const pool = resolveIpPool(body.pool) + // Ephemeral IPs must use unicast pools + const pool = resolvePoolSelector(body.pool_selector, 'unicast') const ip = getIpFromPool(pool) + // Validate that external IP version matches primary NIC's IP stack + // Based on Omicron validation in nexus/db-queries/src/db/datastore/external_ip.rs:544-661 + const nics = db.networkInterfaces.filter((n) => n.instance_id === instance.id) + const primaryNic = nics.find((n) => n.primary) + + if (!primaryNic) { + throw json( + { + error_code: 'InvalidRequest', + message: `Instance ${instance.name} has no primary network interface`, + }, + { status: 400 } + ) + } + + const ipVersion = pool.ip_version + const stackType = primaryNic.ip_stack.type + + if (ipVersion === 'v4' && stackType !== 'v4' && stackType !== 'dual_stack') { + throw json( + { + error_code: 'InvalidRequest', + message: `The ephemeral external IP is an IPv4 address, but the instance with ID ${instance.name} does not have a primary network interface with a VPC-private IPv4 address. Add a VPC-private IPv4 address to the interface, or attach a different IP address`, + }, + { status: 400 } + ) + } + + if (ipVersion === 'v6' && stackType !== 'v6' && stackType !== 'dual_stack') { + throw json( + { + error_code: 'InvalidRequest', + message: `The ephemeral external IP is an IPv6 address, but the instance with ID ${instance.name} does not have a primary network interface with a VPC-private IPv6 address. Add a VPC-private IPv6 address to the interface, or attach a different IP address`, + }, + { status: 400 } + ) + } + const externalIp = { ip, ip_pool_id: pool.id, kind: 'ephemeral' as const } db.ephemeralIps.push({ instance_id: instance.id, @@ -795,7 +986,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 +998,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 +1042,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 @@ -976,11 +1187,22 @@ export const handlers = makeHandlers({ const ipPoolSilo = lookup.ipPoolSiloLink(path) // if we're setting default, we need to set is_default false on the existing default + // for the same IP version and pool type (a silo can have separate defaults for v4/v6) if (body.is_default) { const silo = lookup.silo(path) - const existingDefault = db.ipPoolSilos.find( - (ips) => ips.silo_id === silo.id && ips.is_default - ) + const currentPool = lookup.ipPool({ pool: ipPoolSilo.ip_pool_id }) + + // Find existing default with same version and type + const existingDefault = db.ipPoolSilos.find((ips) => { + if (ips.silo_id !== silo.id || !ips.is_default) return false + const pool = db.ipPools.find((p) => p.id === ips.ip_pool_id) + return ( + pool && + pool.ip_version === currentPool.ip_version && + pool.pool_type === currentPool.pool_type + ) + }) + if (existingDefault) { existingDefault.is_default = false } @@ -1999,14 +2221,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, @@ -2051,6 +2267,8 @@ export const handlers = makeHandlers({ probeList: NotImplemented, probeView: NotImplemented, rackView: NotImplemented, + rackMembershipStatus: NotImplemented, + rackMembershipAddSleds: NotImplemented, siloPolicyUpdate: NotImplemented, siloPolicyView: NotImplemented, siloUserList: NotImplemented, @@ -2058,6 +2276,19 @@ export const handlers = makeHandlers({ sledAdd: NotImplemented, sledListUninitialized: NotImplemented, sledSetProvisionPolicy: NotImplemented, + subnetPoolList: NotImplemented, + subnetPoolCreate: NotImplemented, + subnetPoolView: NotImplemented, + subnetPoolUpdate: NotImplemented, + subnetPoolDelete: NotImplemented, + subnetPoolMemberList: NotImplemented, + subnetPoolMemberAdd: NotImplemented, + subnetPoolMemberRemove: NotImplemented, + subnetPoolSiloList: NotImplemented, + subnetPoolSiloLink: NotImplemented, + subnetPoolSiloUnlink: NotImplemented, + subnetPoolSiloUpdate: NotImplemented, + subnetPoolUtilizationView: NotImplemented, supportBundleCreate: NotImplemented, supportBundleDelete: NotImplemented, supportBundleDownload: 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/mock-api/project.ts b/mock-api/project.ts index f514d7606..1f7eb2ae0 100644 --- a/mock-api/project.ts +++ b/mock-api/project.ts @@ -26,7 +26,15 @@ export const project2: Json = { time_modified: new Date(2021, 0, 16).toISOString(), } -export const projects: Json = [project, project2] +export const projectNoVpcs: Json = { + id: 'f8a5c3d2-9b1e-4f7a-8c2d-3e4b5f6a7c8d', + name: 'project-no-vpcs', + description: 'a project with no VPCs for testing', + time_created: new Date(2021, 0, 20).toISOString(), + time_modified: new Date(2021, 0, 21).toISOString(), +} + +export const projects: Json = [project, project2, projectNoVpcs] export const projectRolePolicy: Json = { role_assignments: [ 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/firewall-rules.e2e.ts b/test/e2e/firewall-rules.e2e.ts index 9114fdc5d..adc28d5cb 100644 --- a/test/e2e/firewall-rules.e2e.ts +++ b/test/e2e/firewall-rules.e2e.ts @@ -465,7 +465,7 @@ test('can update firewall rule', async ({ page }) => { // add host filter await selectOption(page, 'Host type', 'VPC subnet') await page.getByRole('combobox', { name: 'Subnet name' }).fill('edit-filter-subnet') - await page.getByText('edit-filter-subnet').click() + await page.getByRole('combobox', { name: 'Subnet name' }).press('Enter') await page.getByRole('button', { name: 'Add host filter' }).click() // new host is added to hosts table diff --git a/test/e2e/floating-ip-create.e2e.ts b/test/e2e/floating-ip-create.e2e.ts index 4bedc596c..757080864 100644 --- a/test/e2e/floating-ip-create.e2e.ts +++ b/test/e2e/floating-ip-create.e2e.ts @@ -28,19 +28,23 @@ test('can create a floating IP', async ({ page }) => { .getByRole('textbox', { name: 'Description' }) .fill('A description for this Floating IP') - const label = page.getByLabel('IP pool') + const advancedAccordion = page.getByRole('button', { name: 'Advanced' }) + const poolDropdown = page.getByLabel('Pool') // accordion content should be hidden - await expect(label).toBeHidden() + await expect(poolDropdown).toBeHidden() // open accordion - await page.getByRole('button', { name: 'Advanced' }).click() + await advancedAccordion.click() - // accordion content should be visible - await expect(label).toBeVisible() + // pool dropdown should now be visible + await expect(poolDropdown).toBeVisible() + + // Default pool should be selected (ip-pool-1 is the v4 default) + await expect(poolDropdown).toContainText('ip-pool-1') // choose pool and submit - await label.click() + await poolDropdown.click() await page.getByRole('option', { name: 'ip-pool-1' }).click() await page.getByRole('button', { name: 'Create floating IP' }).click() diff --git a/test/e2e/instance-create.e2e.ts b/test/e2e/instance-create.e2e.ts index ee3c74890..d11b64a07 100644 --- a/test/e2e/instance-create.e2e.ts +++ b/test/e2e/instance-create.e2e.ts @@ -75,20 +75,32 @@ test('can create an instance', async ({ page }) => { const checkbox = page.getByRole('checkbox', { name: 'Allocate and attach an ephemeral IP address', }) - const label = page.getByLabel('IP pool for ephemeral IP') + const poolDropdown = page.getByLabel('Pool') - // verify that the ip pool selector is visible and default is selected + // verify that the ephemeral IP checkbox is checked and pool dropdown is visible await expect(checkbox).toBeChecked() - await label.click() + await expect(poolDropdown).toBeVisible() + + // IPv4 default pool should be selected by default + await expect(poolDropdown).toContainText('ip-pool-1') + + // click the dropdown to open it and verify options are available + await poolDropdown.click() await expect(page.getByRole('option', { name: 'ip-pool-1' })).toBeEnabled() - // unchecking the box should disable the selector + // unchecking the box should hide the pool selector await checkbox.uncheck() - await expect(label).toBeHidden() + await expect(poolDropdown).toBeHidden() // re-checking the box should re-enable the selector, and other options should be selectable await checkbox.check() - await selectOption(page, 'IP pool for ephemeral IP', 'ip-pool-2 VPN IPs') + // Need to wait for the dropdown to be visible first + await expect(poolDropdown).toBeVisible() + // Click the dropdown to open it and wait for options to be available + await poolDropdown.click() + await expect(page.getByRole('option', { name: 'ip-pool-2' })).toBeVisible() + // Force click since there might be overlays + await page.getByRole('option', { name: 'ip-pool-2' }).click({ force: true }) // should be visible in accordion await expect(page.getByRole('radiogroup', { name: 'Network interface' })).toBeVisible() @@ -307,6 +319,8 @@ test('add ssh key from instance create form', async ({ page }) => { // pop over to the real SSH keys page and see it there, why not await page.getByLabel('User menu').click() await page.getByRole('menuitem', { name: 'Settings' }).click() + // Confirm navigation away from the form with unsaved changes + await page.getByRole('button', { name: 'Leave this page' }).click() await page.getByRole('link', { name: 'SSH Keys' }).click() await expectRowVisible(page.getByRole('table'), { name: newKey, description: 'hi' }) }) @@ -458,13 +472,27 @@ test('attaches a floating IP; disables button when no IPs available', async ({ p Name: floatingIp.name, IP: floatingIp.ip, }) + + // The button should still be enabled because there's still ipv6-float available + await expect(attachFloatingIpButton).toBeEnabled() + + // Attach the IPv6 floating IP too + await attachFloatingIpButton.click() + await selectFloatingIpButton.click() + await page.getByRole('option', { name: 'ipv6-float' }).click() + await attachButton.click() + + // Now the button should be disabled because both floating IPs are attached await expect(attachFloatingIpButton).toBeDisabled() - // removing the floating IP row should work, and should re-enable the "attach" button + // removing one floating IP row should work, and should re-enable the "attach" button await page.getByRole('button', { name: 'remove floating IP rootbeer-float' }).click() await expect(page.getByText(floatingIp.name)).toBeHidden() await expect(attachFloatingIpButton).toBeEnabled() + // Remove the IPv6 floating IP too + await page.getByRole('button', { name: 'remove floating IP ipv6-float' }).click() + // re-attach the floating IP await attachFloatingIpButton.click() await selectFloatingIpButton.click() @@ -676,3 +704,502 @@ 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 +}) + +test('create instance with custom IPv4-only NIC constrains ephemeral IP to IPv4', async ({ + page, +}) => { + await page.goto('/projects/mock-project/instances-new') + + const instanceName = 'custom-ipv4-nic-test' + await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) + await selectASiloImage(page, 'ubuntu-22-04') + + // Open networking accordion + await page.getByRole('button', { name: 'Networking' }).click() + + // Select "Custom" network interface (use exact match and first to disambiguate from "custom pool") + await page.getByRole('radio', { name: 'Custom', exact: true }).first().click() + + // Add a custom NIC with IPv4-only configuration + await page.getByRole('button', { name: 'Add network interface' }).click() + + const modal = page.getByRole('dialog', { name: 'Add network interface' }) + await expect(modal).toBeVisible() + + await modal.getByRole('textbox', { name: 'Name' }).fill('my-ipv4-nic') + await modal.getByLabel('VPC', { exact: true }).click() + await page.getByRole('option', { name: 'mock-vpc' }).click() + await modal.getByLabel('Subnet').click() + await page.getByRole('option', { name: 'mock-subnet' }).click() + + // Select IPv4-only IP configuration + await modal.getByRole('radio', { name: 'IPv4', exact: true }).click() + + await modal.getByRole('button', { name: 'Add network interface' }).click() + await expect(modal).toBeHidden() + + // Verify the NIC was added + const nicTable = page.getByRole('table', { name: 'Network Interfaces' }) + await expect( + nicTable.getByRole('cell', { name: 'my-ipv4-nic', exact: true }) + ).toBeVisible() + + // Verify that ephemeral IP options are constrained to IPv4 only + const ephemeralCheckbox = page.getByRole('checkbox', { + name: 'Allocate and attach an ephemeral IP address', + }) + await expect(ephemeralCheckbox).toBeVisible() + + // Pool dropdown should be visible + const poolDropdown = page.getByLabel('Pool') + await expect(poolDropdown).toBeVisible() + + // IPv4 default pool should be selected by default + await expect(poolDropdown).toContainText('ip-pool-1') + + // Open dropdown to check available options - IPv6 pools should be filtered out + await poolDropdown.click() + + // ip-pool-1 is IPv4, should appear + await expect(page.getByRole('option', { name: 'ip-pool-1' })).toBeVisible() + + // ip-pool-2 is IPv6, should NOT appear + await expect(page.getByRole('option', { name: 'ip-pool-2' })).toBeHidden() +}) + +test('create instance with custom IPv6-only NIC constrains ephemeral IP to IPv6', async ({ + page, +}) => { + await page.goto('/projects/mock-project/instances-new') + + const instanceName = 'custom-ipv6-nic-test' + await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) + await selectASiloImage(page, 'ubuntu-22-04') + + // Open networking accordion + await page.getByRole('button', { name: 'Networking' }).click() + + // Select "Custom" network interface (use exact match and first to disambiguate from "custom pool") + await page.getByRole('radio', { name: 'Custom', exact: true }).first().click() + + // Add a custom NIC with IPv6-only configuration + await page.getByRole('button', { name: 'Add network interface' }).click() + + const modal = page.getByRole('dialog', { name: 'Add network interface' }) + await expect(modal).toBeVisible() + + await modal.getByRole('textbox', { name: 'Name' }).fill('my-ipv6-nic') + await modal.getByLabel('VPC', { exact: true }).click() + await page.getByRole('option', { name: 'mock-vpc' }).click() + await modal.getByLabel('Subnet').click() + await page.getByRole('option', { name: 'mock-subnet' }).click() + + // Select IPv6-only IP configuration + await modal.getByRole('radio', { name: 'IPv6', exact: true }).click() + + await modal.getByRole('button', { name: 'Add network interface' }).click() + await expect(modal).toBeHidden() + + // Verify the NIC was added + const nicTable = page.getByRole('table', { name: 'Network Interfaces' }) + await expect( + nicTable.getByRole('cell', { name: 'my-ipv6-nic', exact: true }) + ).toBeVisible() + + // Verify that ephemeral IP options are constrained to IPv6 only + const ephemeralCheckbox = page.getByRole('checkbox', { + name: 'Allocate and attach an ephemeral IP address', + }) + await expect(ephemeralCheckbox).toBeVisible() + + // Pool dropdown should be visible + const poolDropdown = page.getByLabel('Pool') + await expect(poolDropdown).toBeVisible() + + // IPv6 default pool should be selected by default + await expect(poolDropdown).toContainText('ip-pool-2') + + // Open dropdown to check available options - IPv4 pools should be filtered out + await poolDropdown.click() + + // ip-pool-2 is IPv6, should appear + await expect(page.getByRole('option', { name: 'ip-pool-2' })).toBeVisible() + + // ip-pool-1 is IPv4, should NOT appear + await expect(page.getByRole('option', { name: 'ip-pool-1' })).toBeHidden() +}) + +test('create instance with custom dual-stack NIC allows both IPv4 and IPv6 ephemeral IPs', async ({ + page, +}) => { + await page.goto('/projects/mock-project/instances-new') + + const instanceName = 'custom-dual-stack-nic-test' + await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) + await selectASiloImage(page, 'ubuntu-22-04') + + // Open networking accordion + await page.getByRole('button', { name: 'Networking' }).click() + + // Select "Custom" network interface (use exact match and first to disambiguate from "custom pool") + await page.getByRole('radio', { name: 'Custom', exact: true }).first().click() + + // Add a custom NIC with dual-stack configuration + await page.getByRole('button', { name: 'Add network interface' }).click() + + const modal = page.getByRole('dialog', { name: 'Add network interface' }) + await expect(modal).toBeVisible() + + await modal.getByRole('textbox', { name: 'Name' }).fill('my-dual-stack-nic') + await modal.getByLabel('VPC', { exact: true }).click() + await page.getByRole('option', { name: 'mock-vpc' }).click() + await modal.getByLabel('Subnet').click() + await page.getByRole('option', { name: 'mock-subnet' }).click() + + // Select dual-stack IP configuration (should be default) + await modal.getByRole('radio', { name: 'IPv4 & IPv6', exact: true }).click() + + await modal.getByRole('button', { name: 'Add network interface' }).click() + await expect(modal).toBeHidden() + + // Verify the NIC was added + const nicTable = page.getByRole('table', { name: 'Network Interfaces' }) + await expect( + nicTable.getByRole('cell', { name: 'my-dual-stack-nic', exact: true }) + ).toBeVisible() + + // Verify that both IPv4 and IPv6 ephemeral IP options are available + const ephemeralCheckbox = page.getByRole('checkbox', { + name: 'Allocate and attach an ephemeral IP address', + }) + await expect(ephemeralCheckbox).toBeVisible() + + // Pool dropdown should be visible + const poolDropdown = page.getByLabel('Pool') + await expect(poolDropdown).toBeVisible() + + // IPv4 default pool should be selected by default (first in sorted order) + await expect(poolDropdown).toContainText('ip-pool-1') + + // Open dropdown to check available options - both IPv4 and IPv6 pools should be available + await poolDropdown.click() + + // Both pools should appear + await expect(page.getByRole('option', { name: 'ip-pool-1' })).toBeVisible() + await expect(page.getByRole('option', { name: 'ip-pool-2' })).toBeVisible() +}) + +test('ephemeral IP checkbox disabled when no NICs configured', async ({ page }) => { + await page.goto('/projects/mock-project/instances-new') + + const instanceName = 'ephemeral-ip-nic-test' + await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) + await selectASiloImage(page, 'ubuntu-22-04') + + // Open networking accordion + await page.getByRole('button', { name: 'Networking' }).click() + + const ephemeralCheckbox = page.getByRole('checkbox', { + name: 'Allocate and attach an ephemeral IP address', + }) + const defaultDualStackRadio = page.getByRole('radio', { + name: 'Default IPv4 & IPv6', + exact: true, + }) + const noneRadio = page.getByRole('radio', { name: 'None', exact: true }) + const customRadio = page.getByRole('radio', { name: 'Custom', exact: true }).first() + + // Verify default state: "Default IPv4 & IPv6" is checked and Ephemeral IP checkbox is checked + await expect(defaultDualStackRadio).toBeChecked() + await expect(ephemeralCheckbox).toBeChecked() + await expect(ephemeralCheckbox).toBeEnabled() + + // Select "None" radio → verify Ephemeral IP checkbox is unchecked and disabled + await noneRadio.click() + await expect(ephemeralCheckbox).not.toBeChecked() + await expect(ephemeralCheckbox).toBeDisabled() + + // Hover over the disabled checkbox to verify tooltip appears + await ephemeralCheckbox.hover() + await expect(page.getByText('Add a compatible network interface')).toBeVisible() + await expect(page.getByText('to attach an ephemeral IP address')).toBeVisible() + + // Select "Custom" radio → verify Ephemeral IP checkbox is still unchecked and disabled + await customRadio.click() + await expect(ephemeralCheckbox).not.toBeChecked() + await expect(ephemeralCheckbox).toBeDisabled() + + // Click "Add network interface" button to open modal + await page.getByRole('button', { name: 'Add network interface' }).click() + + const modal = page.getByRole('dialog', { name: 'Add network interface' }) + await expect(modal).toBeVisible() + + // Create an IPv4 NIC named "new-v4-nic" + await modal.getByRole('textbox', { name: 'Name' }).fill('new-v4-nic') + await modal.getByLabel('VPC', { exact: true }).click() + await page.getByRole('option', { name: 'mock-vpc' }).click() + await modal.getByLabel('Subnet').click() + await page.getByRole('option', { name: 'mock-subnet' }).click() + + // Select IPv4 IP configuration + await modal.getByRole('radio', { name: 'IPv4', exact: true }).click() + + // Submit the modal + await modal.getByRole('button', { name: 'Add network interface' }).click() + await expect(modal).toBeHidden() + + // Verify the NIC was added to the table + const nicTable = page.getByRole('table', { name: 'Network Interfaces' }) + await expect( + nicTable.getByRole('cell', { name: 'new-v4-nic', exact: true }) + ).toBeVisible() + + // Verify Ephemeral IP checkbox is now checked and enabled + await expect(ephemeralCheckbox).toBeChecked() + await expect(ephemeralCheckbox).toBeEnabled() + + // Delete the NIC using the remove button + await page.getByRole('button', { name: 'remove network interface new-v4-nic' }).click() + + // Verify the NIC is no longer in the table + await expect(nicTable.getByRole('cell', { name: 'new-v4-nic', exact: true })).toBeHidden() + + // Verify Ephemeral IP checkbox is once again unchecked and disabled + await expect(ephemeralCheckbox).not.toBeChecked() + await expect(ephemeralCheckbox).toBeDisabled() +}) + +test('network interface options disabled when no VPCs exist', async ({ page }) => { + // Use project-no-vpcs which has no VPCs by design for testing this scenario + await page.goto('/projects/project-no-vpcs/instances-new') + + const instanceName = 'test-no-vpc-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() + + // Get radio button elements + const defaultIpv4Radio = page.getByRole('radio', { name: 'Default IPv4', exact: true }) + const defaultIpv6Radio = page.getByRole('radio', { name: 'Default IPv6', exact: true }) + const defaultDualStackRadio = page.getByRole('radio', { + name: 'Default IPv4 & IPv6', + exact: true, + }) + const noneRadio = page.getByRole('radio', { name: 'None', exact: true }) + const customRadio = page.getByRole('radio', { name: 'Custom', exact: true }) + + // Verify the message is visible (indicating no VPCs) + await expect(page.getByText('A VPC is required to add network interfaces.')).toBeVisible() + await expect(page.getByRole('link', { name: 'Create a VPC' })).toBeVisible() + + // Verify Default IPv4, Default IPv6, Default Dual Stack, and Custom are disabled + await expect(defaultIpv4Radio).toBeDisabled() + await expect(defaultIpv6Radio).toBeDisabled() + await expect(defaultDualStackRadio).toBeDisabled() + await expect(customRadio).toBeDisabled() + + // Verify "None" is enabled and checked + await expect(noneRadio).toBeEnabled() + await expect(noneRadio).toBeChecked() +}) + +test('floating IPs are filtered by NIC IP version', async ({ page }) => { + await page.goto('/projects/mock-project/instances-new') + + // Open networking accordion + await page.getByRole('button', { name: 'Networking' }).click() + + // Select IPv4-only networking + await page.getByRole('radio', { name: 'Default IPv4', exact: true }).click() + + // Open the floating IP modal + await page.getByRole('button', { name: 'Attach floating IP' }).click() + + // Wait for modal to open + await expect(page.getByRole('dialog', { name: 'Attach floating IP' })).toBeVisible() + + // Get the listbox and open it + const listbox = page.getByRole('button', { name: 'Floating IP', exact: true }) + await listbox.click() + + // Verify only IPv4 floating IP is available (rootbeer-float with IP 123.4.56.4) + await expect(page.getByRole('option', { name: 'rootbeer-float' })).toBeVisible() + // IPv6 floating IP should not be in the list + await expect(page.getByRole('option', { name: 'ipv6-float' })).not.toBeVisible() + + // Close the listbox dropdown first by pressing Escape + await page.keyboard.press('Escape') + + // Close the modal + const dialog = page.getByRole('dialog', { name: 'Attach floating IP' }) + await dialog.getByRole('button', { name: 'Cancel' }).click() + + // Switch to IPv6-only networking + await page.getByRole('radio', { name: 'Default IPv6', exact: true }).click() + + // Open the floating IP modal again + await page.getByRole('button', { name: 'Attach floating IP' }).click() + + // Wait for modal to open + await expect(dialog).toBeVisible() + + // Get the listbox and open it + await listbox.click() + + // Verify only IPv6 floating IP is available (ipv6-float) + await expect(page.getByRole('option', { name: 'ipv6-float' })).toBeVisible() + // IPv4 floating IP should not be in the list + await expect(page.getByRole('option', { name: 'rootbeer-float' })).not.toBeVisible() + + // Close the listbox dropdown first by pressing Escape + await page.keyboard.press('Escape') + + // Close the modal + await dialog.getByRole('button', { name: 'Cancel' }).click() + + // Switch to dual-stack networking + await page.getByRole('radio', { name: 'Default IPv4 & IPv6', exact: true }).click() + + // Open the floating IP modal again + await page.getByRole('button', { name: 'Attach floating IP' }).click() + + // Wait for modal to open + await expect(dialog).toBeVisible() + + // Get the listbox and open it + await listbox.click() + + // Verify both IPv4 and IPv6 floating IPs are available + await expect(page.getByRole('option', { name: 'rootbeer-float' })).toBeVisible() + await expect(page.getByRole('option', { name: 'ipv6-float' })).toBeVisible() + + // Close the listbox dropdown first by pressing Escape + await page.keyboard.press('Escape') + + // Close the modal + await dialog.getByRole('button', { name: 'Cancel' }).click() + + // Switch to "None" networking + await page.getByRole('radio', { name: 'None' }).click() + + // Verify the "Attach floating IP" button is disabled when no NICs are configured + const attachFloatingIpButton = page.getByRole('button', { name: 'Attach floating IP' }) + await expect(attachFloatingIpButton).toBeDisabled() + + // Verify the disabled reason tooltip + await attachFloatingIpButton.hover() + await expect(page.getByText('A network interface is required')).toBeVisible() + await expect(page.getByText('to attach a floating IP')).toBeVisible() +}) diff --git a/test/e2e/instance-networking.e2e.ts b/test/e2e/instance-networking.e2e.ts index dfa036e82..47e99a1a0 100644 --- a/test/e2e/instance-networking.e2e.ts +++ b/test/e2e/instance-networking.e2e.ts @@ -13,8 +13,15 @@ import { expectRowVisible, expectVisible, stopInstance, + type Page, } from './utils' +const selectASiloImage = async (page: Page, name: string) => { + await page.getByRole('tab', { name: 'Silo images' }).click() + await page.getByPlaceholder('Select a silo image', { exact: true }).click() + await page.getByRole('option', { name }).click() +} + test('Instance networking tab — NIC table', async ({ page }) => { await page.goto('/projects/mock-project/instances/db1') @@ -51,7 +58,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') @@ -121,11 +129,31 @@ test('Instance networking tab — Detach / Attach Ephemeral IPs', async ({ page // The 'Attach ephemeral IP' button should be visible and enabled now that the existing ephemeral IP has been detached await expect(attachEphemeralIpButton).toBeEnabled() - // Attach a new ephemeral IP + // Attach a new ephemeral IP using the default pool (don't select a pool) await attachEphemeralIpButton.click() - const modal = page.getByRole('dialog', { name: 'Attach ephemeral IP' }) + let modal = page.getByRole('dialog', { name: 'Attach ephemeral IP' }) await expect(modal).toBeVisible() - await page.getByRole('button', { name: 'IP pool' }).click() + // Click Attach without selecting a pool - should use default pool + await page.getByRole('button', { name: 'Attach', exact: true }).click() + await expect(modal).toBeHidden() + await expect(ephemeralCell).toBeVisible() + + // The 'Attach ephemeral IP' button should be hidden after attaching an ephemeral IP + await expect(attachEphemeralIpButton).toBeHidden() + + // Detach and test with explicit pool selection + await clickRowAction(page, 'ephemeral', 'Detach') + await page.getByRole('button', { name: 'Confirm' }).click() + await expect(ephemeralCell).toBeHidden() + + await attachEphemeralIpButton.click() + modal = page.getByRole('dialog', { name: 'Attach ephemeral IP' }) + await expect(modal).toBeVisible() + // Pool dropdown should be visible + const poolDropdown = page.getByLabel('Pool') + await expect(poolDropdown).toBeVisible() + // Select a different pool + await poolDropdown.click() await page.getByRole('option', { name: 'ip-pool-2' }).click() await page.getByRole('button', { name: 'Attach', exact: true }).click() await expect(modal).toBeHidden() @@ -169,7 +197,20 @@ test('Instance networking tab — floating IPs', async ({ page }) => { await expect(page.getByRole('dialog')).toBeHidden() await expectRowVisible(externalIpTable, { name: 'rootbeer-float' }) - // Verify that the "Attach floating IP" button is disabled, since there shouldn't be any more IPs to attach + // Button should still be enabled because there's an IPv6 floating IP available + await expect(attachFloatingIpButton).toBeEnabled() + + // Attach the IPv6 floating IP as well + await attachFloatingIpButton.click() + await expectVisible(page, ['role=heading[name="Attach floating IP"]']) + await dialog.getByLabel('Floating IP').click() + await page.keyboard.press('ArrowDown') + await page.keyboard.press('Enter') + await dialog.getByRole('button', { name: 'Attach' }).click() + await expect(page.getByRole('dialog')).toBeHidden() + await expectRowVisible(externalIpTable, { name: 'ipv6-float' }) + + // Now the button should be disabled, since all available floating IPs are attached await expect(attachFloatingIpButton).toBeDisabled() // Verify that the External IPs table row has an ellipsis link in it @@ -178,13 +219,11 @@ test('Instance networking tab — floating IPs', async ({ page }) => { // Detach one of the external IPs await clickRowAction(page, 'cola-float', 'Detach') await page.getByRole('button', { name: 'Confirm' }).click() - await expect(page.getByText('123.4.56.5/…')).toBeHidden() - await expect(page.getByText('external IPs123.4.56.4/123.4.56.0')).toBeVisible() // Since we detached it, we don't expect to see the row any longer await expect(externalIpTable.getByRole('cell', { name: 'cola-float' })).toBeHidden() - // And that button should be enabled again + // And that button should be enabled again (cola-float is now available to attach) await expect(attachFloatingIpButton).toBeEnabled() }) @@ -266,11 +305,199 @@ 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() }) + +test('IPv4-only instance cannot attach IPv6 ephemeral IP', async ({ page }) => { + // Create an IPv4-only instance + await page.goto('/projects/mock-project/instances-new') + const instanceName = 'ipv4-only-test' + await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) + await selectASiloImage(page, 'ubuntu-22-04') + + // Open networking accordion and select IPv4-only + await page.getByRole('button', { name: 'Networking' }).click() + await page.getByRole('radio', { name: 'Default IPv4', exact: true }).click() + + // Don't attach ephemeral IP at creation + await page + .getByRole('checkbox', { name: 'Allocate and attach an ephemeral IP address' }) + .uncheck() + + // Create instance + await page.getByRole('button', { name: 'Create instance' }).click() + await expect(page).toHaveURL(/\/instances\/ipv4-only-test/) + + // Navigate to networking tab + await page.getByRole('tab', { name: 'Networking' }).click() + + // Try to attach ephemeral IP + const attachButton = page.getByRole('button', { name: 'Attach ephemeral IP' }) + await expect(attachButton).toBeEnabled() + await attachButton.click() + + const modal = page.getByRole('dialog', { name: 'Attach ephemeral IP' }) + await expect(modal).toBeVisible() + + // Pool dropdown should be visible + const poolDropdown = page.getByLabel('Pool') + await expect(poolDropdown).toBeVisible() + + // IPv4 default pool should be selected by default + await expect(poolDropdown).toContainText('ip-pool-1') + + // Check pool options - IPv6 pools should be filtered out + await poolDropdown.click() + + // ip-pool-2 is IPv6, should not appear + await expect(page.getByRole('option', { name: 'ip-pool-2' })).toBeHidden() + + // ip-pool-1 is IPv4, should appear + await expect(page.getByRole('option', { name: 'ip-pool-1' })).toBeVisible() +}) + +test('IPv6-only instance cannot attach IPv4 ephemeral IP', async ({ page }) => { + // Create an IPv6-only instance + await page.goto('/projects/mock-project/instances-new') + const instanceName = 'ipv6-only-test' + await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) + await selectASiloImage(page, 'ubuntu-22-04') + + // Open networking accordion and select IPv6-only + await page.getByRole('button', { name: 'Networking' }).click() + await page.getByRole('radio', { name: 'Default IPv6', exact: true }).click() + + // Don't attach ephemeral IP at creation + await page + .getByRole('checkbox', { name: 'Allocate and attach an ephemeral IP address' }) + .uncheck() + + // Create instance + await page.getByRole('button', { name: 'Create instance' }).click() + await expect(page).toHaveURL(/\/instances\/ipv6-only-test/) + + // Navigate to networking tab + await page.getByRole('tab', { name: 'Networking' }).click() + + // Try to attach ephemeral IP + const attachButton = page.getByRole('button', { name: 'Attach ephemeral IP' }) + await expect(attachButton).toBeEnabled() + await attachButton.click() + + const modal = page.getByRole('dialog', { name: 'Attach ephemeral IP' }) + await expect(modal).toBeVisible() + + // Pool dropdown should be visible + const poolDropdown = page.getByLabel('Pool') + await expect(poolDropdown).toBeVisible() + + // IPv6 default pool should be selected by default + await expect(poolDropdown).toContainText('ip-pool-2') + + // Check pool options - IPv4 pools should be filtered out + await poolDropdown.click() + + // ip-pool-1 is IPv4, should not appear + await expect(page.getByRole('option', { name: 'ip-pool-1' })).toBeHidden() + + // ip-pool-2 is IPv6, should appear + await expect(page.getByRole('option', { name: 'ip-pool-2' })).toBeVisible() +}) + +test('IPv4-only instance can attach IPv4 ephemeral IP', async ({ page }) => { + // Create an IPv4-only instance + await page.goto('/projects/mock-project/instances-new') + const instanceName = 'ipv4-success-test' + await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) + await selectASiloImage(page, 'ubuntu-22-04') + + // Open networking accordion and select IPv4-only + await page.getByRole('button', { name: 'Networking' }).click() + await page.getByRole('radio', { name: 'Default IPv4', exact: true }).click() + + // Don't attach ephemeral IP at creation + await page + .getByRole('checkbox', { name: 'Allocate and attach an ephemeral IP address' }) + .uncheck() + + // Create instance + await page.getByRole('button', { name: 'Create instance' }).click() + await expect(page).toHaveURL(/\/instances\/ipv4-success-test/) + + // Navigate to networking tab + await page.getByRole('tab', { name: 'Networking' }).click() + + // Attach IPv4 ephemeral IP (using default pool) + const attachButton = page.getByRole('button', { name: 'Attach ephemeral IP' }) + await expect(attachButton).toBeEnabled() + await attachButton.click() + + const modal = page.getByRole('dialog', { name: 'Attach ephemeral IP' }) + await expect(modal).toBeVisible() + + // Use default IPv4 pool + await page.getByRole('button', { name: 'Attach', exact: true }).click() + + // Modal should close and IP should be attached + await expect(modal).toBeHidden() + + // Verify ephemeral IP appears in table + const externalIpTable = page.getByRole('table', { name: 'External IPs' }) + await expect(externalIpTable.getByRole('cell', { name: 'ephemeral' })).toBeVisible() +}) + +test('IPv6-only instance can attach IPv6 ephemeral IP', async ({ page }) => { + // Create an IPv6-only instance + await page.goto('/projects/mock-project/instances-new') + const instanceName = 'ipv6-success-test' + await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) + await selectASiloImage(page, 'ubuntu-22-04') + + // Open networking accordion and select IPv6-only + await page.getByRole('button', { name: 'Networking' }).click() + await page.getByRole('radio', { name: 'Default IPv6', exact: true }).click() + + // Don't attach ephemeral IP at creation + await page + .getByRole('checkbox', { name: 'Allocate and attach an ephemeral IP address' }) + .uncheck() + + // Create instance + await page.getByRole('button', { name: 'Create instance' }).click() + await expect(page).toHaveURL(/\/instances\/ipv6-success-test/) + + // Navigate to networking tab + await page.getByRole('tab', { name: 'Networking' }).click() + + // Attach IPv6 ephemeral IP + const attachButton = page.getByRole('button', { name: 'Attach ephemeral IP' }) + await expect(attachButton).toBeEnabled() + await attachButton.click() + + const modal = page.getByRole('dialog', { name: 'Attach ephemeral IP' }) + await expect(modal).toBeVisible() + + // Pool dropdown should be visible + const poolDropdown = page.getByLabel('Pool') + await expect(poolDropdown).toBeVisible() + + // Select IPv6 pool (ip-pool-2) + await poolDropdown.click() + await page.getByRole('option', { name: 'ip-pool-2' }).click() + + await page.getByRole('button', { name: 'Attach', exact: true }).click() + + // Modal should close and IP should be attached + await expect(modal).toBeHidden() + + // Verify ephemeral IP appears in table + const externalIpTable = page.getByRole('table', { name: 'External IPs' }) + await expect(externalIpTable.getByRole('cell', { name: 'ephemeral' })).toBeVisible() +}) diff --git a/test/e2e/ip-pools.e2e.ts b/test/e2e/ip-pools.e2e.ts index 8003a58fc..e13b0db98 100644 --- a/test/e2e/ip-pools.e2e.ts +++ b/test/e2e/ip-pools.e2e.ts @@ -19,23 +19,31 @@ test('IP pool list', async ({ page }) => { const table = page.getByRole('table') - await expect(table.getByRole('row')).toHaveCount(5) // header + 4 rows + await expect(table.getByRole('row')).toHaveCount(7) // header + 6 rows (includes multicast pools) await expectRowVisible(table, { name: 'ip-pool-1', - 'IPs Remaining': '17 / 24', + 'IPs REMAINING': '17 / 24', }) await expectRowVisible(table, { name: 'ip-pool-2', - 'IPs Remaining': '32 / 32', + 'IPs REMAINING': '31 / 32', // floatingIp3 uses one IP from this pool }) await expectRowVisible(table, { name: 'ip-pool-3', - 'IPs Remaining': '0 / 0', + 'IPs REMAINING': '0 / 0', }) await expectRowVisible(table, { name: 'ip-pool-4', - 'IPs Remaining': '18.4e18 / 18.4e18', + 'IPs REMAINING': '18.4e18 / 18.4e18', + }) + await expectRowVisible(table, { + name: 'ip-pool-5-multicast-v4', + 'IPs REMAINING': '32 / 32', + }) + await expectRowVisible(table, { + name: 'ip-pool-6-multicast-v6', + 'IPs REMAINING': '18.4e18 / 18.4e18', }) }) @@ -47,7 +55,7 @@ test.describe('german locale', () => { const table = page.getByRole('table') await expectRowVisible(table, { name: 'ip-pool-4', - 'IPs Remaining': '18,4e18 / 18,4e18', + 'IPs REMAINING': '18,4e18 / 18,4e18', }) }) @@ -180,8 +188,8 @@ test('IP pool create v4', async ({ page }) => { await expectRowVisible(page.getByRole('table'), { name: 'another-pool', description: 'whatever', - 'Pool type': 'multicast', - 'IPs Remaining': '0 / 0', + Type: 'multicast', + 'IPs REMAINING': '0 / 0', }) }) @@ -204,22 +212,17 @@ test('IP pool edit', async ({ page }) => { // TODO: update this to reflect that a given pool is now v4 or v6 only test('IP range validation and add', async ({ page }) => { - await page.goto('/system/networking/ip-pools/ip-pool-2') - - // check the utilization bar - await expect(page.getByText('Allocated(IPs)')).toBeVisible() - await expect(page.getByText('Allocated0')).toBeVisible() - await expect(page.getByText('Capacity32')).toBeVisible() + await page.goto('/system/networking/ip-pools/ip-pool-3') - await page.getByRole('link', { name: 'Add range' }).click() + await page.getByRole('link', { name: 'Add range' }).first().click() const dialog = page.getByRole('dialog', { name: 'Add IP range' }) const first = dialog.getByRole('textbox', { name: 'First' }) const last = dialog.getByRole('textbox', { name: 'Last' }) const submit = dialog.getByRole('button', { name: 'Add IP range' }) const invalidMsg = dialog.getByText('Not a valid IP address') - // exact to differentiate from same text in help message at the top of the form - const ipv6Msg = dialog.getByText('IPv6 ranges are not yet supported') + // ip-pool-3 is an IPv4 pool, so IPv6 addresses should be rejected + const ipv6Msg = dialog.getByText('IPv6 address not allowed in IPv4 pool') const v4Addr = '192.1.2.3' const v6Addr = '2001:db8::1234:5678' @@ -232,12 +235,12 @@ test('IP range validation and add', async ({ page }) => { await expect(invalidMsg).toHaveCount(2) - // change last to v6, not allowed + // change last to v6, not allowed in IPv4 pool await last.fill(v6Addr) await expect(invalidMsg).toHaveCount(1) await expect(ipv6Msg).toHaveCount(1) - // change first to v6, still not allowed + // change first to v6, still not allowed in IPv4 pool await first.fill(v6Addr) await expect(ipv6Msg).toHaveCount(2) await expect(invalidMsg).toBeHidden() @@ -257,18 +260,58 @@ test('IP range validation and add', async ({ page }) => { // now the utilization bar shows the single IP added await expect(page.getByText('Allocated(IPs)')).toBeVisible() await expect(page.getByText('Allocated0')).toBeVisible() - await expect(page.getByText('Capacity33')).toBeVisible() + await expect(page.getByText('Capacity1')).toBeVisible() // go back to the pool and verify the remaining/capacity columns changed // use the sidebar nav to get there const sidebar = page.getByRole('navigation', { name: 'Sidebar navigation' }) await sidebar.getByRole('link', { name: 'IP Pools' }).click() await expectRowVisible(table, { - name: 'ip-pool-2', - 'IPs Remaining': '33 / 33', + name: 'ip-pool-3', + 'IPs REMAINING': '1 / 1', }) }) +test('IPv4 addresses cannot be added to IPv6 pool', async ({ page }) => { + // ip-pool-4 is an IPv6 pool + await page.goto('/system/networking/ip-pools/ip-pool-4') + + await page.getByRole('link', { name: 'Add range' }).first().click() + + const dialog = page.getByRole('dialog', { name: 'Add IP range' }) + const first = dialog.getByRole('textbox', { name: 'First' }) + const last = dialog.getByRole('textbox', { name: 'Last' }) + const submit = dialog.getByRole('button', { name: 'Add IP range' }) + // ip-pool-4 is an IPv6 pool, so IPv4 addresses should be rejected + const ipv4Msg = dialog.getByText('IPv4 address not allowed in IPv6 pool') + + const v4Addr = '192.168.1.1' + const v6Addr = 'fd12:3456:789a:1::1' + + await expect(dialog).toBeVisible() + + // Try to add IPv4 address - should be rejected + await first.fill(v4Addr) + await last.fill(v4Addr) + await submit.click() // trigger validation + await expect(ipv4Msg).toHaveCount(2) + + // Change first to v6 + await first.fill(v6Addr) + await expect(ipv4Msg).toHaveCount(1) + + // Change last to v6 - should now be valid + await last.fill(v6Addr) + await expect(ipv4Msg).toBeHidden() + + // Submit successfully + await submit.click() + await expect(dialog).toBeHidden() + + const table = page.getByRole('table') + await expectRowVisible(table, { First: v6Addr, Last: v6Addr }) +}) + test('remove range', async ({ page }) => { await page.goto('/system/networking/ip-pools/ip-pool-1') @@ -301,7 +344,7 @@ test('remove range', async ({ page }) => { await breadcrumbs.getByRole('link', { name: 'IP Pools' }).click() await expectRowVisible(table, { name: 'ip-pool-1', - 'IPs Remaining': '14 / 21', + 'IPs REMAINING': '14 / 21', }) }) @@ -310,7 +353,7 @@ test('deleting floating IP decrements utilization', async ({ page }) => { const table = page.getByRole('table') await expectRowVisible(table, { name: 'ip-pool-1', - 'IPs Remaining': '17 / 24', + 'IPs REMAINING': '17 / 24', }) // go delete a floating IP @@ -327,7 +370,7 @@ test('deleting floating IP decrements utilization', async ({ page }) => { await page.getByRole('link', { name: 'IP Pools' }).click() await expectRowVisible(table, { name: 'ip-pool-1', - 'IPs Remaining': '18 / 24', + 'IPs REMAINING': '18 / 24', }) }) diff --git a/test/e2e/network-interface-create.e2e.ts b/test/e2e/network-interface-create.e2e.ts index c735553fe..55fdea2cd 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' }) @@ -33,11 +36,11 @@ test('can create a NIC with a specified IP address', async ({ page }) => { await expect(sidebar).toBeHidden() const table = page.getByRole('table', { name: 'Network interfaces' }) - await expectRowVisible(table, { name: 'nic-1', 'Private IP': '1.2.3.4' }) + await expectRowVisible(table, { name: 'nic-1', 'Private IP': 'v41.2.3.4' }) }) 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,71 @@ 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': expect.stringMatching(/v4123\.45\.68\.8\s*v6fd12: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': 'v6::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': expect.stringMatching(/v410\.0\.0\.5\s*v6fd00::5/), + }) }) diff --git a/test/e2e/silos.e2e.ts b/test/e2e/silos.e2e.ts index 1c76229a3..dbb2f7303 100644 --- a/test/e2e/silos.e2e.ts +++ b/test/e2e/silos.e2e.ts @@ -264,29 +264,26 @@ test('Silo IP pools', async ({ page }) => { await page.goto('/system/silos/maze-war/ip-pools') const table = page.getByRole('table') - await expectRowVisible(table, { name: 'ip-pool-1', Default: 'default' }) - await expectRowVisible(table, { name: 'ip-pool-2', Default: '' }) - await expect(table.getByRole('row')).toHaveCount(3) // header + 2 + // Both unicast pools start as default (one IPv4, one IPv6) - valid dual-default scenario + await expectRowVisible(table, { name: 'ip-pool-1default', Version: 'v4' }) + await expectRowVisible(table, { name: 'ip-pool-2default', Version: 'v6' }) + // Multicast pools are also linked as defaults + await expectRowVisible(table, { + name: 'ip-pool-5-multicast-v4default', + Version: 'v4', + }) + await expectRowVisible(table, { + name: 'ip-pool-6-multicast-v6default', + Version: 'v6', + }) + await expect(table.getByRole('row')).toHaveCount(5) // header + 4 // clicking on pool goes to pool detail await page.getByRole('link', { name: 'ip-pool-1' }).click() await expect(page).toHaveURL('/system/networking/ip-pools/ip-pool-1') await page.goBack() - // make default - await clickRowAction(page, 'ip-pool-2', 'Make default') - await expect( - page - .getByRole('dialog', { name: 'Confirm change default' }) - .getByText( - 'Are you sure you want to change the default pool from ip-pool-1 to ip-pool-2?' - ) - ).toBeVisible() - await page.getByRole('button', { name: 'Confirm' }).click() - await expectRowVisible(table, { name: 'ip-pool-1', Default: '' }) - await expectRowVisible(table, { name: 'ip-pool-2', Default: 'default' }) - - // unlink + // unlink IPv4 pool await clickRowAction(page, 'ip-pool-1', 'Unlink') await expect( page @@ -295,9 +292,10 @@ test('Silo IP pools', async ({ page }) => { ).toBeVisible() await page.getByRole('button', { name: 'Confirm' }).click() await expect(page.getByRole('cell', { name: 'ip-pool-1' })).toBeHidden() - await expectRowVisible(table, { name: 'ip-pool-2', Default: 'default' }) + // ip-pool-2 should still be default + await expectRowVisible(table, { name: 'ip-pool-2default', Version: 'v6' }) - // clear default + // clear default for IPv6 pool await clickRowAction(page, 'ip-pool-2', 'Clear default') await expect( page @@ -305,16 +303,26 @@ test('Silo IP pools', async ({ page }) => { .getByText('Are you sure you want ip-pool-2 to stop being the default') ).toBeVisible() await page.getByRole('button', { name: 'Confirm' }).click() - await expectRowVisible(table, { name: 'ip-pool-2', Default: '' }) + await expectRowVisible(table, { name: 'ip-pool-2', Version: 'v6' }) }) test('Silo IP pools link pool', async ({ page }) => { await page.goto('/system/silos/maze-war/ip-pools') const table = page.getByRole('table') - await expectRowVisible(table, { name: 'ip-pool-1', Default: 'default' }) - await expectRowVisible(table, { name: 'ip-pool-2', Default: '' }) - await expect(table.getByRole('row')).toHaveCount(3) // header + 2 + // Both unicast pools start as default (one IPv4, one IPv6) + await expectRowVisible(table, { name: 'ip-pool-1default', Version: 'v4' }) + await expectRowVisible(table, { name: 'ip-pool-2default', Version: 'v6' }) + // Multicast pools are also linked + await expectRowVisible(table, { + name: 'ip-pool-5-multicast-v4default', + Version: 'v4', + }) + await expectRowVisible(table, { + name: 'ip-pool-6-multicast-v6default', + Version: 'v6', + }) + await expect(table.getByRole('row')).toHaveCount(5) // header + 4 const modal = page.getByRole('dialog', { name: 'Link pool' }) await expect(modal).toBeHidden() @@ -342,7 +350,7 @@ test('Silo IP pools link pool', async ({ page }) => { // modal closes and we see the thing in the table await expect(modal).toBeHidden() - await expectRowVisible(table, { name: 'ip-pool-3', Default: '' }) + await expectRowVisible(table, { name: 'ip-pool-3', Version: 'v4' }) }) // just a convenient form to test this with because it's tall diff --git a/test/e2e/vpcs.e2e.ts b/test/e2e/vpcs.e2e.ts index 952fc4d67..c127a24aa 100644 --- a/test/e2e/vpcs.e2e.ts +++ b/test/e2e/vpcs.e2e.ts @@ -12,7 +12,9 @@ import { clickRowAction, expectRowVisible, getPageAsUser, selectOption } from '. test('can nav to VpcPage from /', async ({ page }) => { await page.goto('/') await page.getByRole('table').getByRole('link', { name: 'mock-project' }).click() + await page.waitForURL('**/projects/mock-project/**') await page.getByRole('link', { name: 'VPCs' }).click() + await page.waitForURL('**/vpcs**') await expectRowVisible(page.getByRole('table'), { name: 'mock-vpc',