diff --git a/OMICRON_VERSION b/OMICRON_VERSION index cd93756d9..dc774ee9e 100644 --- a/OMICRON_VERSION +++ b/OMICRON_VERSION @@ -1 +1 @@ -dd74446cbe12d52540d92b62f2de7eaf6520d591 +92e0ae0c98dc50452d888a238cca86d162fd2d40 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..e220c4937 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 +92e0ae0c98dc50452d888a238cca86d162fd2d40 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/components/AttachEphemeralIpModal.tsx b/app/components/AttachEphemeralIpModal.tsx index af8a384c1..31454857c 100644 --- a/app/components/AttachEphemeralIpModal.tsx +++ b/app/components/AttachEphemeralIpModal.tsx @@ -6,28 +6,66 @@ * Copyright Oxide Computer Company */ -import { useMemo } from 'react' +import { 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, + q, + queryClient, + useApiMutation, + usePrefetchedQuery, + type IpVersion, +} from '~/api' +import { IpPoolSelector } from '~/components/form/fields/IpPoolSelector' import { HL } from '~/components/HL' import { useInstanceSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { Modal } from '~/ui/lib/Modal' import { ALL_ISH } from '~/util/consts' -import { toIpPoolItem } from './form/fields/ip-pool-item' - export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) => { const { project, instance } = useInstanceSelector() const { data: siloPools } = usePrefetchedQuery( q(api.projectIpPoolList, { query: { limit: ALL_ISH } }) ) - const defaultPool = useMemo( - () => siloPools?.items.find((pool) => pool.isDefault), - [siloPools] + const { data: nics } = usePrefetchedQuery( + q(api.instanceNetworkInterfaceList, { query: { project, instance } }) ) + + // Only unicast pools can be used for ephemeral IPs + const unicastPools = useMemo(() => { + if (!siloPools) return [] + return siloPools.items.filter((p) => p.poolType === 'unicast') + }, [siloPools]) + + const hasDefaultUnicastPool = useMemo(() => { + return unicastPools.some((p) => p.isDefault) + }, [unicastPools]) + + // 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 no primary NIC found (defensive), return empty array + 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]) + const instanceEphemeralIpAttach = useApiMutation(api.instanceEphemeralIpAttach, { onSuccess(ephemeralIp) { queryClient.invalidateEndpoint('instanceExternalIpList') @@ -39,39 +77,89 @@ 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 getDisabledReason = () => { + if (!siloPools) return 'Loading pools...' + if (!nics) return 'Loading network interfaces...' + if (compatibleVersions && compatibleVersions.length === 0) { + return 'Instance has no network interfaces with compatible IP stacks' + } + if (unicastPools.length === 0) return 'No unicast pools available' + if (!pool && !hasDefaultUnicastPool) { + return 'No default pool available; select a pool to continue' + } + return undefined + } return (
- 0 - ? 'Select a pool' - : 'No pools available' - } - items={siloPools.items.map(toIpPoolItem)} - required + poolFieldName="pool" + ipVersionFieldName="ipVersion" + pools={unicastPools} + currentPool={pool} + currentIpVersion={ipVersion} + setValue={form.setValue} + disabled={unicastPools.length === 0} + compatibleVersions={compatibleVersions} />
+ disabled={ + !siloPools || + !nics || + (compatibleVersions && compatibleVersions.length === 0) || + unicastPools.length === 0 || + (!pool && !hasDefaultUnicastPool) + } + disabledReason={getDisabledReason()} + onAction={() => { + // When using default pool, derive ipVersion from available defaults + let effectiveIpVersion = ipVersion + if (!pool) { + const v4Default = unicastPools.find((p) => p.isDefault && p.ipVersion === 'v4') + const v6Default = unicastPools.find((p) => p.isDefault && p.ipVersion === 'v6') + + // If only one default exists, use that version + if (v4Default && !v6Default) { + effectiveIpVersion = 'v4' + } else if (v6Default && !v4Default) { + effectiveIpVersion = 'v6' + } + // If both exist, use form's ipVersion (user's choice) + } + instanceEphemeralIpAttach.mutate({ path: { instance }, query: { project }, - body: { pool }, + body: pool + ? { poolSelector: { type: 'explicit', pool } } + : { poolSelector: { type: 'auto', ipVersion: effectiveIpVersion } }, }) - } + }} onDismiss={onDismiss} >
diff --git a/app/components/form/fields/IpPoolSelector.tsx b/app/components/form/fields/IpPoolSelector.tsx new file mode 100644 index 000000000..bab09147e --- /dev/null +++ b/app/components/form/fields/IpPoolSelector.tsx @@ -0,0 +1,192 @@ +/* + * 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 } from 'react' +import type { Control, UseFormSetValue } from 'react-hook-form' + +import type { IpVersion, SiloIpPool } from '@oxide/api' + +import { Radio } from '~/ui/lib/Radio' + +import { toIpPoolItem } from './ip-pool-item' +import { ListboxField } from './ListboxField' + +type IpPoolSelectorProps = { + control: Control + poolFieldName: string + ipVersionFieldName: string + pools: SiloIpPool[] + /** Current value of the pool field - used to determine radio selection */ + currentPool: string | undefined + /** Current value of the IP version field - used to determine radio selection */ + currentIpVersion: IpVersion + /** Function to update form values */ + setValue: UseFormSetValue + disabled?: boolean + /** + * Compatible IP versions based on network interface type + * If not provided, both v4 and v6 are allowed + */ + compatibleVersions?: IpVersion[] +} + +/** + * IP Pool selector with radio button pattern: + * - "IPv4 default" (if v4 default exists and is compatible) + * - "IPv6 default" (if v6 default exists and is compatible) + * - "Use custom pool" (with pool dropdown) + */ +export function IpPoolSelector({ + control, + poolFieldName, + ipVersionFieldName, + pools, + currentPool, + currentIpVersion, + setValue, + disabled = false, + compatibleVersions, +}: IpPoolSelectorProps) { + // Determine which default pool versions exist + const hasV4Default = pools.some((p) => p.isDefault && p.ipVersion === 'v4') + const hasV6Default = pools.some((p) => p.isDefault && p.ipVersion === 'v6') + + // Filter default options by compatible versions + // undefined = no filtering, [] = filter out everything + const showV4Default = + hasV4Default && (!compatibleVersions || compatibleVersions.includes('v4')) + const showV6Default = + hasV6Default && (!compatibleVersions || compatibleVersions.includes('v6')) + + // Filter pools by compatible versions for custom pool dropdown + const filteredPools = compatibleVersions + ? pools.filter((p) => compatibleVersions.includes(p.ipVersion)) + : pools + + // Derive current selection, ensuring it maps to a rendered option + type SelectionType = 'v4-default' | 'v6-default' | 'custom' + let currentSelection: SelectionType + + if (currentPool && filteredPools.some((p) => p.name === currentPool)) { + // Valid custom pool selected + currentSelection = 'custom' + } else if (!currentPool && currentIpVersion === 'v6' && showV6Default) { + // v6 default requested and available + currentSelection = 'v6-default' + } else if (!currentPool && showV4Default) { + // v4 default (explicit or fallback) + currentSelection = 'v4-default' + } else if (showV6Default) { + // Fallback to v6 default if rendered + currentSelection = 'v6-default' + } else if (showV4Default) { + // Fallback to v4 default if rendered + currentSelection = 'v4-default' + } else { + // Final fallback: custom radio is always rendered, so this is safe + currentSelection = 'custom' + } + + const hasNoPools = filteredPools.length === 0 && !showV4Default && !showV6Default + + const radioName = `pool-selection-type-${poolFieldName}` + + // Auto-correct form state when compatibility filtering changes the selection + useEffect(() => { + if (currentSelection === 'v4-default' && (currentPool || currentIpVersion !== 'v4')) { + setValue(poolFieldName, '') + setValue(ipVersionFieldName, 'v4') + } else if ( + currentSelection === 'v6-default' && + (currentPool || currentIpVersion !== 'v6') + ) { + setValue(poolFieldName, '') + setValue(ipVersionFieldName, 'v6') + } else if (currentSelection === 'custom' && !currentPool && filteredPools.length > 0) { + // Fell back to custom but no pool selected + setValue(poolFieldName, filteredPools[0].name) + } + }, [ + currentSelection, + currentPool, + currentIpVersion, + filteredPools, + poolFieldName, + ipVersionFieldName, + setValue, + ]) + + return ( +
+
+ Select IP pool + {hasNoPools ? ( +
+ No IP pools available for this network interface type +
+ ) : ( +
+ {showV4Default && ( + { + setValue(poolFieldName, '') + setValue(ipVersionFieldName, 'v4') + }} + disabled={disabled} + > + IPv4 default + + )} + {showV6Default && ( + { + setValue(poolFieldName, '') + setValue(ipVersionFieldName, 'v6') + }} + disabled={disabled} + > + IPv6 default + + )} + { + // Set to first compatible pool in list so the dropdown shows with a valid selection + if (filteredPools.length > 0) { + setValue(poolFieldName, filteredPools[0].name) + } + }} + disabled={disabled} + > + custom pool + +
+ )} +
+ + {currentSelection === 'custom' && filteredPools.length > 0 && ( + + )} +
+ ) +} diff --git a/app/components/form/fields/NetworkInterfaceField.tsx b/app/components/form/fields/NetworkInterfaceField.tsx index 3de33ddbe..6893c6215 100644 --- a/app/components/form/fields/NetworkInterfaceField.tsx +++ b/app/components/form/fields/NetworkInterfaceField.tsx @@ -8,10 +8,7 @@ import { useState } from 'react' import { useController, type Control } from 'react-hook-form' -import type { - InstanceNetworkInterfaceAttachment, - InstanceNetworkInterfaceCreate, -} from '@oxide/api' +import type { InstanceNetworkInterfaceCreate } from '@oxide/api' import type { InstanceCreateInput } from '~/forms/instance-create' import { CreateNetworkInterfaceForm } from '~/forms/network-interface-create' @@ -55,22 +52,24 @@ export function NetworkInterfaceField({ className="pt-1" defaultChecked={value.type} onChange={(event) => { - const newType = event.target.value as InstanceNetworkInterfaceAttachment['type'] + const newType = event.target.value if (value.type === 'create') { setOldParams(value.params) } if (newType === 'create') { - onChange({ type: newType, params: oldParams }) + onChange({ type: 'create', params: oldParams }) } else { - onChange({ type: newType }) + onChange({ type: newType as typeof value.type }) } }} disabled={disabled} > + Default IPv4 & IPv6 + Default IPv4 + Default IPv6 None - Default Custom {value.type === 'create' && ( diff --git a/app/components/form/fields/ip-pool-item.tsx b/app/components/form/fields/ip-pool-item.tsx index a48325753..c6e0fbc8f 100644 --- a/app/components/form/fields/ip-pool-item.tsx +++ b/app/components/form/fields/ip-pool-item.tsx @@ -21,6 +21,9 @@ export function toIpPoolItem(p: SiloIpPool) { default )} + + {p.ipVersion} + {!!p.description && (
{p.description}
diff --git a/app/forms/floating-ip-create.tsx b/app/forms/floating-ip-create.tsx index 5ff9086f7..12657d25d 100644 --- a/app/forms/floating-ip-create.tsx +++ b/app/forms/floating-ip-create.tsx @@ -7,30 +7,42 @@ */ 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 } from '~/components/form/fields/IpPoolSelector' import { NameField } from '~/components/form/fields/NameField' import { SideModalForm } from '~/components/form/SideModalForm' import { HL } from '~/components/HL' import { titleCrumb } from '~/hooks/use-crumbs' import { useProjectSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' -import { Message } from '~/ui/lib/Message' import { ALL_ISH } from '~/util/consts' import { pb } from '~/util/path-builder' -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 +54,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') + }, [allPools]) + const projectSelector = useProjectSelector() const navigate = useNavigate() @@ -56,6 +74,8 @@ export default function CreateFloatingIpSideModalForm() { }) const form = useForm({ defaultValues }) + const pool = form.watch('pool') + const ipVersion = form.watch('ipVersion') const [openItems, setOpenItems] = useState([]) @@ -65,7 +85,36 @@ 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 v4Default = unicastPools.find((p) => p.isDefault && p.ipVersion === 'v4') + const v6Default = unicastPools.find((p) => p.isDefault && p.ipVersion === 'v6') + + // If only one default exists, use that version + if (v4Default && !v6Default) { + effectiveIpVersion = 'v4' + } else if (v6Default && !v4Default) { + effectiveIpVersion = 'v6' + } + // If both exist, use form's ipVersion (user's choice) + } + + const body: FloatingIpCreate = { + ...values, + addressAllocator: pool + ? { + 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 +132,14 @@ export default function CreateFloatingIpSideModalForm() { label="Advanced" value="advanced" > - - - diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index 59172dba8..e908245be 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -6,8 +6,14 @@ * 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 { useEffect, useMemo, useRef, useState } from 'react' +import { + useController, + useForm, + useWatch, + type Control, + type UseFormSetValue, +} from 'react-hook-form' import { useNavigate, type LoaderFunctionArgs } from 'react-router' import type { SetRequired } from 'type-fest' @@ -26,6 +32,7 @@ import { type Image, type InstanceCreate, type InstanceDiskAttachment, + type IpVersion, type NameOrId, type SiloIpPool, } from '@oxide/api' @@ -49,7 +56,7 @@ import { } from '~/components/form/fields/DisksTableField' import { FileField } from '~/components/form/fields/FileField' import { BootDiskImageSelectField as ImageSelectField } from '~/components/form/fields/ImageSelectField' -import { toIpPoolItem } from '~/components/form/fields/ip-pool-item' +import { IpPoolSelector } from '~/components/form/fields/IpPoolSelector' import { NameField } from '~/components/form/fields/NameField' import { NetworkInterfaceField } from '~/components/form/fields/NetworkInterfaceField' import { NumberField } from '~/components/form/fields/NumberField' @@ -76,6 +83,8 @@ import { Slash } from '~/ui/lib/Slash' import { Tabs } from '~/ui/lib/Tabs' import { TextInputHint } from '~/ui/lib/TextInput' import { TipIcon } from '~/ui/lib/TipIcon' +import { Tooltip } from '~/ui/lib/Tooltip' +import { Wrap } from '~/ui/util/wrap' import { ALL_ISH } from '~/util/consts' import { readBlobAsBase64 } from '~/util/file' import { docLinks, links } from '~/util/links' @@ -127,6 +136,10 @@ 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 } > @@ -151,7 +164,7 @@ const baseDefaultValues: InstanceCreateInput = { diskSource: '', otherDisks: [], - networkInterfaces: { type: 'default' }, + networkInterfaces: { type: 'default_dual_stack' }, sshPublicKeys: [], @@ -159,6 +172,8 @@ const baseDefaultValues: InstanceCreateInput = { userData: null, externalIps: [{ type: 'ephemeral' }], + ephemeralIpVersion: 'v4', + ephemeralIpPool: '', } export async function clientLoader({ params }: LoaderFunctionArgs) { @@ -219,11 +234,22 @@ export default function CreateInstanceForm() { const { data: siloPools } = usePrefetchedQuery( q(api.projectIpPoolList, { query: { limit: ALL_ISH } }) ) - const defaultPool = useMemo( - () => (siloPools ? siloPools.items.find((p) => p.isDefault)?.name : undefined), + + // Only unicast pools can be used for ephemeral IPs + const unicastPools = useMemo( + () => siloPools?.items.filter((p) => p.poolType === 'unicast') || [], [siloPools] ) + const defaultUnicastPools = unicastPools.filter((pool) => pool.isDefault) + const hasV4Default = defaultUnicastPools.some((p) => p.ipVersion === 'v4') + const hasV6Default = defaultUnicastPools.some((p) => p.ipVersion === 'v6') + + // Detect if both IPv4 and IPv6 default unicast pools exist + const hasDualDefaults = hasV4Default && hasV6Default + // Use default version if available; fall back to v4 + const ephemeralIpVersion: IpVersion = hasV4Default ? 'v4' : hasV6Default ? 'v6' : 'v4' + const defaultSource = siloImages.length > 0 ? 'siloImage' : projectImages.length > 0 ? 'projectImage' : 'disk' @@ -232,7 +258,22 @@ export default function CreateInstanceForm() { 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 +299,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 +650,9 @@ export default function CreateInstanceForm() { Create instance @@ -631,11 +688,15 @@ const FloatingIpLabel = ({ ip }: { ip: FloatingIp }) => ( const AdvancedAccordion = ({ control, isSubmitting, - siloPools, + unicastPools, + networkInterfaces, + setValue, }: { control: Control isSubmitting: boolean - siloPools: Array + unicastPools: Array + networkInterfaces: InstanceCreate['networkInterfaces'] + setValue: UseFormSetValue }) => { // we track this state manually for the sole reason that we need to be able to // tell, inside AccordionItem, when an accordion is opened so we can scroll its @@ -646,10 +707,63 @@ 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 + useEffect(() => { + const initialPool = + ephemeralIp?.poolSelector?.type === 'explicit' + ? ephemeralIp.poolSelector.pool + : undefined + if (initialPool && !ephemeralIpPool) { + ephemeralIpPoolField.field.onChange(initialPool) + } + // Only run on mount + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // Update externalIps when ephemeralIpPool or ephemeralIpVersion changes + useEffect(() => { + if (!assignEphemeralIp) return + + const pool = ephemeralIpPoolField.field.value + const ipVersion = ephemeralIpVersionField.field.value || 'v4' + + const newExternalIps = externalIps.field.value?.map((ip) => { + 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() @@ -671,6 +785,87 @@ const AdvancedAccordion = ({ .map((ip) => attachableFloatingIps.find((fip) => fip.name === ip.floatingIp)) .filter((ip) => !!ip) + // Calculate compatible IP versions based on NIC type + const compatibleVersions: IpVersion[] | undefined = useMemo(() => { + const nicType = networkInterfaces?.type + let versions: IpVersion[] | undefined = undefined + + // Set constraints based on primary NIC configuration + if (nicType === 'default_ipv4') { + versions = ['v4'] + } else if (nicType === 'default_ipv6') { + versions = ['v6'] + } else if (nicType === 'default_dual_stack') { + versions = ['v4', 'v6'] + } else if ( + nicType === 'create' && + networkInterfaces && + networkInterfaces.params.length > 0 + ) { + // Derive from the first NIC's ipConfig (first NIC becomes primary) + const primaryNicConfig = networkInterfaces.params[0].ipConfig + if (primaryNicConfig?.type === 'v4') { + versions = ['v4'] + } else if (primaryNicConfig?.type === 'v6') { + versions = ['v6'] + } else if (primaryNicConfig?.type === 'dual_stack') { + versions = ['v4', 'v6'] + } else { + // ipConfig not provided = defaults to dual-stack + versions = ['v4', 'v6'] + } + } else if (nicType === 'none') { + // Instance has no NICs, cannot attach external IPs + versions = [] + } else if ( + nicType === 'create' && + networkInterfaces && + networkInterfaces.params.length === 0 + ) { + // Custom NICs selected but none added yet, cannot attach external IPs + versions = [] + } + + return versions + }, [networkInterfaces]) + + // Track previous compatible NICs state to detect transitions + const prevHasNicsRef = useRef(undefined) + + // Automatically manage ephemeral IP based on NIC availability + useEffect(() => { + const hasNics = compatibleVersions && compatibleVersions.length > 0 + const prevHasNics = prevHasNicsRef.current + + if (!hasNics && assignEphemeralIp) { + // Remove ephemeral IP when there are no compatible NICs + const newExternalIps = externalIps.field.value?.filter( + (ip) => ip.type !== 'ephemeral' + ) + externalIps.field.onChange(newExternalIps) + } else if (hasNics && !prevHasNics && !assignEphemeralIp) { + // Add ephemeral IP only when transitioning from no NICs to having NICs + // (prevHasNics === false means we had no NICs before) + externalIps.field.onChange([ + ...(externalIps.field.value || []), + { type: 'ephemeral' }, + ]) + } + + prevHasNicsRef.current = hasNics + }, [compatibleVersions, assignEphemeralIp, externalIps]) + + // Update ephemeralIpVersion when compatibleVersions changes + useEffect(() => { + if (!compatibleVersions || compatibleVersions.length === 0) return + + const currentVersion = ephemeralIpVersionField.field.value + // If current version is not compatible, switch to the first compatible version + if (!compatibleVersions.includes(currentVersion)) { + setValue('ephemeralIpVersion', compatibleVersions[0]) + } + }, [compatibleVersions, ephemeralIpVersionField.field.value, setValue]) + const closeFloatingIpModal = () => { setFloatingIpModalOpen(false) setSelectedFloatingIp(undefined) @@ -733,38 +928,51 @@ const AdvancedAccordion = ({ it is deleted - { - 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 - ) - externalIps.field.onChange(newExternalIps) - }} - /> - )} + + {(() => { + const hasCompatibleNics = compatibleVersions && compatibleVersions.length > 0 + const disabledReason = hasCompatibleNics ? undefined : ( + <> + Add a compatible network interface +
+ to attach an ephemeral IP address + + ) + + return ( + <> + }> + { + const newExternalIps = assignEphemeralIp + ? externalIps.field.value?.filter((ip) => ip.type !== 'ephemeral') + : [...(externalIps.field.value || []), { type: 'ephemeral' }] + externalIps.field.onChange(newExternalIps) + // The useEffect will update the poolSelector based on current form values + }} + > + Allocate and attach an ephemeral IP address + + + {assignEphemeralIp && ( + + )} + + ) + })()}
diff --git a/app/forms/ip-pool-create.tsx b/app/forms/ip-pool-create.tsx index ef65c3d58..7fbec7c39 100644 --- a/app/forms/ip-pool-create.tsx +++ b/app/forms/ip-pool-create.tsx @@ -21,11 +21,7 @@ import { addToast } from '~/stores/toast' import { Message } from '~/ui/lib/Message' import { pb } from '~/util/path-builder' -// We are leaving the v4/v6 radio out for now because while you can -// create a v6 pool, you can't actually add any ranges to it until -// https://github.com/oxidecomputer/omicron/issues/8966 - -type IpPoolCreateForm = SetRequired +type IpPoolCreateForm = SetRequired const defaultValues: IpPoolCreateForm = { name: '', @@ -58,8 +54,8 @@ export default function CreateIpPoolSideModalForm() { formType="create" resourceName="IP pool" onDismiss={onDismiss} - onSubmit={({ name, description, poolType }) => { - createPool.mutate({ body: { name, description, poolType } }) + onSubmit={({ name, description, ipVersion, poolType }) => { + createPool.mutate({ body: { name, description, ipVersion, poolType } }) }} loading={createPool.isPending} submitError={createPool.error} @@ -67,6 +63,16 @@ export default function CreateIpPoolSideModalForm() { + = {} - - if (first.type === 'error') { - errors.first = { type: 'pattern', message: first.message } - } else if (first.type === 'v6') { - errors.first = ipv6Error - } - - if (last.type === 'error') { - errors.last = { type: 'pattern', message: last.message } - } else if (last.type === 'v6') { - errors.last = ipv6Error +function createResolver(poolVersion: IpVersion) { + return (values: IpRange) => { + const first = parseIp(values.first) + const last = parseIp(values.last) + + const errors: FieldErrors = {} + + // Validate first address 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: {} } } - - // 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 +87,9 @@ export default function IpPoolAddRange() { const { pool } = useIpPoolSelector() const navigate = useNavigate() + const { data: poolData } = usePrefetchedQuery(q(api.ipPoolView, { path: { pool } })) + const poolVersion = poolData.ipVersion + const onDismiss = () => navigate(pb.ipPool({ pool })) const addRange = useApiMutation(api.ipPoolRangeAdd, { @@ -78,7 +102,7 @@ export default function IpPoolAddRange() { }, }) - const form = useForm({ defaultValues, resolver }) + const form = useForm({ defaultValues, resolver: createResolver(poolVersion) }) 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..906dc75c8 100644 --- a/app/pages/project/instances/NetworkingTab.tsx +++ b/app/pages/project/instances/NetworkingTab.tsx @@ -44,6 +44,7 @@ import { addToast } from '~/stores/toast' import { DescriptionCell } from '~/table/cells/DescriptionCell' import { EmptyCell, SkeletonCell } from '~/table/cells/EmptyCell' import { IpPoolCell } from '~/table/cells/IpPoolCell' +import { IpVersionCell } from '~/table/cells/IpVersionCell' import { LinkCell } from '~/table/cells/LinkCell' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' import { Columns } from '~/table/columns/common' @@ -152,9 +153,41 @@ 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('ipStack.value', { + header: 'IP Version', + cell: (info) => { + const nic = info.row.original + const { ipStack } = nic + return ( +
+ {(ipStack.type === 'v4' || ipStack.type === 'dual_stack') && ( + v4 + )} + {(ipStack.type === 'v6' || ipStack.type === 'dual_stack') && ( + v6 + )} +
+ ) + }, }), colHelper.accessor('vpcId', { header: 'vpc', @@ -164,15 +197,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 +256,11 @@ const staticIpCols = [ ), cell: (info) => {info.getValue()}, }), + ipColHelper.accessor('ipPoolId', { + id: 'version', + header: 'Version', + cell: (info) => , + }), ipColHelper.accessor('ipPoolId', { header: 'IP pool', cell: (info) => , @@ -553,6 +604,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..87f926a08 100644 --- a/app/pages/system/networking/IpPoolsPage.tsx +++ b/app/pages/system/networking/IpPoolsPage.tsx @@ -59,14 +59,17 @@ const colHelper = createColumnHelper() const staticColumns = [ colHelper.accessor('name', { cell: makeLinkCell((pool) => pb.ipPool({ pool })) }), colHelper.accessor('description', Columns.description), + colHelper.accessor('ipVersion', { + header: 'Version', + cell: (info) => {info.getValue()}, + }), colHelper.accessor('poolType', { header: 'Pool type', cell: (info) => {info.getValue()}, }), - // TODO: add version column when API supports v6 pools colHelper.display({ header: 'IPs Remaining', - cell: (info) => , + 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..4043ea4c0 100644 --- a/app/pages/system/silos/SiloIpPoolsTab.tsx +++ b/app/pages/system/silos/SiloIpPoolsTab.tsx @@ -14,6 +14,7 @@ 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' @@ -21,7 +22,6 @@ import { makeCrumb } from '~/hooks/use-crumbs' import { getSiloSelector, useSiloSelector } from '~/hooks/use-params' import { confirmAction } from '~/stores/confirm-action' import { addToast } from '~/stores/toast' -import { DefaultPoolCell } from '~/table/cells/DefaultPoolCell' import { makeLinkCell } from '~/table/cells/LinkCell' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' import { Columns } from '~/table/columns/common' @@ -46,15 +46,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 +69,36 @@ 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 - // used in change default confirm modal - const defaultPool = useMemo( - () => (allPools ? allPools.items.find((p) => p.isDefault)?.name : undefined), + const staticCols = useMemo( + () => [ + colHelper.accessor('name', { cell: makeLinkCell((pool) => pb.ipPool({ pool })) }), + colHelper.accessor('description', Columns.description), + colHelper.accessor('ipVersion', { + header: 'IP Version', + cell: (info) => ( + <> + {info.getValue()} + {info.row.original.isDefault && default} + + ), + }), + colHelper.accessor('poolType', { + header: 'Type', + cell: (info) => {info.getValue()}, + }), + ], + [] + ) + + // 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 +141,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 +191,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..0ba65ad2a --- /dev/null +++ b/app/table/cells/IpVersionCell.tsx @@ -0,0 +1,24 @@ +/* + * 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 { Badge } from '@oxide/design-system/ui' + +import { api, qErrorsAllowed } from '~/api' + +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 {pool.ipVersion} +} 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/util/ip.ts b/app/util/ip.ts index 9d060aab5..039eab615 100644 --- a/app/util/ip.ts +++ b/app/util/ip.ts @@ -6,6 +6,8 @@ * Copyright Oxide Computer Company */ +import type { IpVersion } from '~/api' + // 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 +18,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 +40,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 = { 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..648ddac52 100644 --- a/mock-api/msw/db.ts +++ b/mock-api/msw/db.ts @@ -10,7 +10,7 @@ import * as R from 'remeda' import { validate as isUuid } from 'uuid' -import type { ApiTypes as Api } 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' @@ -62,6 +62,46 @@ 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 + }) + + 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}'`) diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 50cd157b7..1f2a6ab34 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,14 @@ export const handlers = makeHandlers({ } // validate floating IP attachments before we actually do anything + // Determine what IP stacks the instance will have based on network interfaces + const hasIpv4Nic = + body.network_interfaces?.type === 'default_ipv4' || + body.network_interfaces?.type === 'default_dual_stack' + const hasIpv6Nic = + body.network_interfaces?.type === 'default_ipv6' || + body.network_interfaces?.type === 'default_dual_stack' + body.external_ips?.forEach((ip) => { if (ip.type === 'floating') { // throw if floating IP doesn't exist @@ -473,8 +556,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 +623,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 +656,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 +664,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 +690,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 +873,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 +964,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 +976,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 +1020,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 +1165,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 +2199,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 +2245,8 @@ export const handlers = makeHandlers({ probeList: NotImplemented, probeView: NotImplemented, rackView: NotImplemented, + rackMembershipStatus: NotImplemented, + rackMembershipAddSleds: NotImplemented, siloPolicyUpdate: NotImplemented, siloPolicyView: NotImplemented, siloUserList: NotImplemented, @@ -2058,6 +2254,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/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/floating-ip-create.e2e.ts b/test/e2e/floating-ip-create.e2e.ts index 4bedc596c..7fb7fb7f8 100644 --- a/test/e2e/floating-ip-create.e2e.ts +++ b/test/e2e/floating-ip-create.e2e.ts @@ -28,19 +28,27 @@ test('can create a floating IP', async ({ page }) => { .getByRole('textbox', { name: 'Description' }) .fill('A description for this Floating IP') - const label = page.getByLabel('IP pool') + const advancedAccordion = page.getByRole('button', { name: 'Advanced' }) + const poolRadio = page.getByRole('radio', { name: 'custom pool' }) + const poolDropdown = page.getByLabel('IP pool') // accordion content should be hidden - await expect(label).toBeHidden() + await expect(poolRadio).toBeHidden() // open accordion - await page.getByRole('button', { name: 'Advanced' }).click() + await advancedAccordion.click() // accordion content should be visible - await expect(label).toBeVisible() + await expect(poolRadio).toBeVisible() + + // select custom pool radio button + await poolRadio.click() + + // now the IP pool dropdown should be visible + await expect(poolDropdown).toBeVisible() // choose pool and submit - await label.click() + await poolDropdown.click() await page.getByRole('option', { name: 'ip-pool-1' }).click() await page.getByRole('button', { name: 'Create floating IP' }).click() diff --git a/test/e2e/instance-create.e2e.ts b/test/e2e/instance-create.e2e.ts index ee3c74890..9fad257e7 100644 --- a/test/e2e/instance-create.e2e.ts +++ b/test/e2e/instance-create.e2e.ts @@ -75,20 +75,36 @@ test('can create an instance', async ({ page }) => { const checkbox = page.getByRole('checkbox', { name: 'Allocate and attach an ephemeral IP address', }) - const label = page.getByLabel('IP pool for ephemeral IP') + const customPoolRadio = page.getByRole('radio', { name: 'custom pool' }) + const poolDropdown = page.getByLabel('IP pool') - // verify that the ip pool selector is visible and default is selected + // verify that the ephemeral IP checkbox is checked and default radio is selected await expect(checkbox).toBeChecked() - await label.click() + // IPv4 default should be selected by default + await expect( + page.getByRole('radio', { name: 'IPv4 default', checked: true }) + ).toBeVisible() + + // select custom pool to see the dropdown + await customPoolRadio.click() + await expect(poolDropdown).toBeVisible() + await poolDropdown.click() await expect(page.getByRole('option', { name: 'ip-pool-1' })).toBeEnabled() - // unchecking the box should disable the selector + // unchecking the box should hide the pool selector await checkbox.uncheck() - await expect(label).toBeHidden() + await expect(customPoolRadio).toBeHidden() // re-checking the box should re-enable the selector, and other options should be selectable await checkbox.check() - await selectOption(page, 'IP pool for ephemeral IP', 'ip-pool-2 VPN IPs') + await customPoolRadio.click() + // Need to wait for the dropdown to be visible first + await expect(poolDropdown).toBeVisible() + // Click the dropdown to open it and wait for options to be available + await poolDropdown.click() + await expect(page.getByRole('option', { name: 'ip-pool-2' })).toBeVisible() + // Force click since there might be overlays + await page.getByRole('option', { name: 'ip-pool-2' }).click({ force: true }) // should be visible in accordion await expect(page.getByRole('radiogroup', { name: 'Network interface' })).toBeVisible() @@ -676,3 +692,375 @@ 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() + + // IPv4 default should be available + await expect(page.getByRole('radio', { name: 'IPv4 default' })).toBeVisible() + + // IPv6 default should NOT be available (filtered out) + await expect(page.getByRole('radio', { name: 'IPv6 default' })).toBeHidden() + + // Check custom pool - IPv6 pools should be filtered out + await page.getByRole('radio', { name: 'custom pool' }).click() + await page.getByRole('button', { name: 'IP pool' }).click() + + // ip-pool-1 is IPv4, should appear + await expect(page.getByRole('option', { name: 'ip-pool-1' })).toBeVisible() + + // ip-pool-2 is IPv6, should NOT appear + await expect(page.getByRole('option', { name: 'ip-pool-2' })).toBeHidden() +}) + +test('create instance with custom IPv6-only NIC constrains ephemeral IP to IPv6', async ({ + page, +}) => { + await page.goto('/projects/mock-project/instances-new') + + const instanceName = 'custom-ipv6-nic-test' + await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) + await selectASiloImage(page, 'ubuntu-22-04') + + // Open networking accordion + await page.getByRole('button', { name: 'Networking' }).click() + + // Select "Custom" network interface (use exact match and first to disambiguate from "custom pool") + await page.getByRole('radio', { name: 'Custom', exact: true }).first().click() + + // Add a custom NIC with IPv6-only configuration + await page.getByRole('button', { name: 'Add network interface' }).click() + + const modal = page.getByRole('dialog', { name: 'Add network interface' }) + await expect(modal).toBeVisible() + + await modal.getByRole('textbox', { name: 'Name' }).fill('my-ipv6-nic') + await modal.getByLabel('VPC', { exact: true }).click() + await page.getByRole('option', { name: 'mock-vpc' }).click() + await modal.getByLabel('Subnet').click() + await page.getByRole('option', { name: 'mock-subnet' }).click() + + // Select IPv6-only IP configuration + await modal.getByRole('radio', { name: 'IPv6', exact: true }).click() + + await modal.getByRole('button', { name: 'Add network interface' }).click() + await expect(modal).toBeHidden() + + // Verify the NIC was added + const nicTable = page.getByRole('table', { name: 'Network Interfaces' }) + await expect( + nicTable.getByRole('cell', { name: 'my-ipv6-nic', exact: true }) + ).toBeVisible() + + // Verify that ephemeral IP options are constrained to IPv6 only + const ephemeralCheckbox = page.getByRole('checkbox', { + name: 'Allocate and attach an ephemeral IP address', + }) + await expect(ephemeralCheckbox).toBeVisible() + + // IPv6 default should be available + await expect(page.getByRole('radio', { name: 'IPv6 default' })).toBeVisible() + + // IPv4 default should NOT be available (filtered out) + await expect(page.getByRole('radio', { name: 'IPv4 default' })).toBeHidden() + + // Check custom pool - IPv4 pools should be filtered out + await page.getByRole('radio', { name: 'custom pool' }).click() + await page.getByRole('button', { name: 'IP pool' }).click() + + // ip-pool-2 is IPv6, should appear + await expect(page.getByRole('option', { name: 'ip-pool-2' })).toBeVisible() + + // ip-pool-1 is IPv4, should NOT appear + await expect(page.getByRole('option', { name: 'ip-pool-1' })).toBeHidden() +}) + +test('create instance with custom dual-stack NIC allows both IPv4 and IPv6 ephemeral IPs', async ({ + page, +}) => { + await page.goto('/projects/mock-project/instances-new') + + const instanceName = 'custom-dual-stack-nic-test' + await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) + await selectASiloImage(page, 'ubuntu-22-04') + + // Open networking accordion + await page.getByRole('button', { name: 'Networking' }).click() + + // Select "Custom" network interface (use exact match and first to disambiguate from "custom pool") + await page.getByRole('radio', { name: 'Custom', exact: true }).first().click() + + // Add a custom NIC with dual-stack configuration + await page.getByRole('button', { name: 'Add network interface' }).click() + + const modal = page.getByRole('dialog', { name: 'Add network interface' }) + await expect(modal).toBeVisible() + + await modal.getByRole('textbox', { name: 'Name' }).fill('my-dual-stack-nic') + await modal.getByLabel('VPC', { exact: true }).click() + await page.getByRole('option', { name: 'mock-vpc' }).click() + await modal.getByLabel('Subnet').click() + await page.getByRole('option', { name: 'mock-subnet' }).click() + + // Select dual-stack IP configuration (should be default) + await modal.getByRole('radio', { name: 'IPv4 & IPv6', exact: true }).click() + + await modal.getByRole('button', { name: 'Add network interface' }).click() + await expect(modal).toBeHidden() + + // Verify the NIC was added + const nicTable = page.getByRole('table', { name: 'Network Interfaces' }) + await expect( + nicTable.getByRole('cell', { name: 'my-dual-stack-nic', exact: true }) + ).toBeVisible() + + // Verify that both IPv4 and IPv6 ephemeral IP options are available + const ephemeralCheckbox = page.getByRole('checkbox', { + name: 'Allocate and attach an ephemeral IP address', + }) + await expect(ephemeralCheckbox).toBeVisible() + + // Both IPv4 and IPv6 defaults should be available + await expect(page.getByRole('radio', { name: 'IPv4 default' })).toBeVisible() + await expect(page.getByRole('radio', { name: 'IPv6 default' })).toBeVisible() + + // Check custom pool - both IPv4 and IPv6 pools should be available + await page.getByRole('radio', { name: 'custom pool' }).click() + await page.getByRole('button', { name: 'IP pool' }).click() + + // Both pools should appear + await expect(page.getByRole('option', { name: 'ip-pool-1' })).toBeVisible() + await expect(page.getByRole('option', { name: 'ip-pool-2' })).toBeVisible() +}) + +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() +}) diff --git a/test/e2e/instance-networking.e2e.ts b/test/e2e/instance-networking.e2e.ts index dfa036e82..bcb120dc4 100644 --- a/test/e2e/instance-networking.e2e.ts +++ b/test/e2e/instance-networking.e2e.ts @@ -13,8 +13,15 @@ import { expectRowVisible, expectVisible, stopInstance, + type Page, } from './utils' +const selectASiloImage = async (page: Page, name: string) => { + await page.getByRole('tab', { name: 'Silo images' }).click() + await page.getByPlaceholder('Select a silo image', { exact: true }).click() + await page.getByRole('option', { name }).click() +} + test('Instance networking tab — NIC table', async ({ page }) => { await page.goto('/projects/mock-project/instances/db1') @@ -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,10 +129,28 @@ test('Instance networking tab — Detach / Attach Ephemeral IPs', async ({ page // The 'Attach ephemeral IP' button should be visible and enabled now that the existing ephemeral IP has been detached await expect(attachEphemeralIpButton).toBeEnabled() - // Attach a new ephemeral IP + // Attach a new ephemeral IP using the default pool (don't select a pool) await attachEphemeralIpButton.click() - const modal = page.getByRole('dialog', { name: 'Attach ephemeral IP' }) + let modal = page.getByRole('dialog', { name: 'Attach ephemeral IP' }) await expect(modal).toBeVisible() + // Click Attach without selecting a pool - should use default pool + await page.getByRole('button', { name: 'Attach', exact: true }).click() + await expect(modal).toBeHidden() + await expect(ephemeralCell).toBeVisible() + + // The 'Attach ephemeral IP' button should be hidden after attaching an ephemeral IP + await expect(attachEphemeralIpButton).toBeHidden() + + // Detach and test with explicit pool selection + await clickRowAction(page, 'ephemeral', 'Detach') + await page.getByRole('button', { name: 'Confirm' }).click() + await expect(ephemeralCell).toBeHidden() + + await attachEphemeralIpButton.click() + modal = page.getByRole('dialog', { name: 'Attach ephemeral IP' }) + await expect(modal).toBeVisible() + // Select custom pool radio to show the dropdown + await page.getByRole('radio', { name: 'custom pool' }).click() await page.getByRole('button', { name: 'IP pool' }).click() await page.getByRole('option', { name: 'ip-pool-2' }).click() await page.getByRole('button', { name: 'Attach', exact: true }).click() @@ -266,11 +292,196 @@ 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() + + // Verify that IPv6 default radio is NOT shown (filtered out by compatibility check) + await expect(page.getByRole('radio', { name: 'IPv6 default' })).toBeHidden() + + // Verify IPv4 default radio IS shown + await expect(page.getByRole('radio', { name: 'IPv4 default' })).toBeVisible() + + // Check custom pool - IPv6 pools should be filtered out + await page.getByRole('radio', { name: 'custom pool' }).click() + await page.getByRole('button', { name: 'IP pool' }).click() + + // ip-pool-2 is IPv6, should not appear + await expect(page.getByRole('option', { name: 'ip-pool-2' })).toBeHidden() + + // ip-pool-1 is IPv4, should appear + await expect(page.getByRole('option', { name: 'ip-pool-1' })).toBeVisible() +}) + +test('IPv6-only instance cannot attach IPv4 ephemeral IP', async ({ page }) => { + // Create an IPv6-only instance + await page.goto('/projects/mock-project/instances-new') + const instanceName = 'ipv6-only-test' + await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) + await selectASiloImage(page, 'ubuntu-22-04') + + // Open networking accordion and select IPv6-only + await page.getByRole('button', { name: 'Networking' }).click() + await page.getByRole('radio', { name: 'Default IPv6', exact: true }).click() + + // Don't attach ephemeral IP at creation + await page + .getByRole('checkbox', { name: 'Allocate and attach an ephemeral IP address' }) + .uncheck() + + // Create instance + await page.getByRole('button', { name: 'Create instance' }).click() + await expect(page).toHaveURL(/\/instances\/ipv6-only-test/) + + // Navigate to networking tab + await page.getByRole('tab', { name: 'Networking' }).click() + + // Try to attach ephemeral IP + const attachButton = page.getByRole('button', { name: 'Attach ephemeral IP' }) + await expect(attachButton).toBeEnabled() + await attachButton.click() + + const modal = page.getByRole('dialog', { name: 'Attach ephemeral IP' }) + await expect(modal).toBeVisible() + + // Verify that IPv4 default radio is NOT shown (filtered out by compatibility check) + await expect(page.getByRole('radio', { name: 'IPv4 default' })).toBeHidden() + + // Verify IPv6 default radio IS shown + await expect(page.getByRole('radio', { name: 'IPv6 default' })).toBeVisible() + + // Check custom pool - IPv4 pools should be filtered out + await page.getByRole('radio', { name: 'custom pool' }).click() + await page.getByRole('button', { name: 'IP pool' }).click() + + // ip-pool-1 is IPv4, should not appear + await expect(page.getByRole('option', { name: 'ip-pool-1' })).toBeHidden() + + // ip-pool-2 is IPv6, should appear + await expect(page.getByRole('option', { name: 'ip-pool-2' })).toBeVisible() +}) + +test('IPv4-only instance can attach IPv4 ephemeral IP', async ({ page }) => { + // Create an IPv4-only instance + await page.goto('/projects/mock-project/instances-new') + const instanceName = 'ipv4-success-test' + await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) + await selectASiloImage(page, 'ubuntu-22-04') + + // Open networking accordion and select IPv4-only + await page.getByRole('button', { name: 'Networking' }).click() + await page.getByRole('radio', { name: 'Default IPv4', exact: true }).click() + + // Don't attach ephemeral IP at creation + await page + .getByRole('checkbox', { name: 'Allocate and attach an ephemeral IP address' }) + .uncheck() + + // Create instance + await page.getByRole('button', { name: 'Create instance' }).click() + await expect(page).toHaveURL(/\/instances\/ipv4-success-test/) + + // Navigate to networking tab + await page.getByRole('tab', { name: 'Networking' }).click() + + // Attach IPv4 ephemeral IP (using default pool) + const attachButton = page.getByRole('button', { name: 'Attach ephemeral IP' }) + await expect(attachButton).toBeEnabled() + await attachButton.click() + + const modal = page.getByRole('dialog', { name: 'Attach ephemeral IP' }) + await expect(modal).toBeVisible() + + // Use default IPv4 pool + await page.getByRole('button', { name: 'Attach', exact: true }).click() + + // Modal should close and IP should be attached + await expect(modal).toBeHidden() + + // Verify ephemeral IP appears in table + const externalIpTable = page.getByRole('table', { name: 'External IPs' }) + await expect(externalIpTable.getByRole('cell', { name: 'ephemeral' })).toBeVisible() +}) + +test('IPv6-only instance can attach IPv6 ephemeral IP', async ({ page }) => { + // Create an IPv6-only instance + await page.goto('/projects/mock-project/instances-new') + const instanceName = 'ipv6-success-test' + await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) + await selectASiloImage(page, 'ubuntu-22-04') + + // Open networking accordion and select IPv6-only + await page.getByRole('button', { name: 'Networking' }).click() + await page.getByRole('radio', { name: 'Default IPv6', exact: true }).click() + + // Don't attach ephemeral IP at creation + await page + .getByRole('checkbox', { name: 'Allocate and attach an ephemeral IP address' }) + .uncheck() + + // Create instance + await page.getByRole('button', { name: 'Create instance' }).click() + await expect(page).toHaveURL(/\/instances\/ipv6-success-test/) + + // Navigate to networking tab + await page.getByRole('tab', { name: 'Networking' }).click() + + // Attach IPv6 ephemeral IP + const attachButton = page.getByRole('button', { name: 'Attach ephemeral IP' }) + await expect(attachButton).toBeEnabled() + await attachButton.click() + + const modal = page.getByRole('dialog', { name: 'Attach ephemeral IP' }) + await expect(modal).toBeVisible() + + // Select IPv6 pool (ip-pool-2) + await page.getByRole('radio', { name: 'custom pool' }).click() + await page.getByRole('button', { name: 'IP pool' }).click() + await page.getByRole('option', { name: 'ip-pool-2' }).click() + + await page.getByRole('button', { name: 'Attach', exact: true }).click() + + // Modal should close and IP should be attached + await expect(modal).toBeHidden() + + // Verify ephemeral IP appears in table + const externalIpTable = page.getByRole('table', { name: 'External IPs' }) + await expect(externalIpTable.getByRole('cell', { name: 'ephemeral' })).toBeVisible() +}) diff --git a/test/e2e/ip-pools.e2e.ts b/test/e2e/ip-pools.e2e.ts index 8003a58fc..edf56f541 100644 --- a/test/e2e/ip-pools.e2e.ts +++ b/test/e2e/ip-pools.e2e.ts @@ -19,7 +19,7 @@ test('IP pool list', async ({ page }) => { const table = page.getByRole('table') - await expect(table.getByRole('row')).toHaveCount(5) // header + 4 rows + await expect(table.getByRole('row')).toHaveCount(7) // header + 6 rows (includes multicast pools) await expectRowVisible(table, { name: 'ip-pool-1', @@ -37,6 +37,14 @@ test('IP pool list', async ({ page }) => { name: 'ip-pool-4', 'IPs Remaining': '18.4e18 / 18.4e18', }) + await expectRowVisible(table, { + name: 'ip-pool-5-multicast-v4', + 'IPs Remaining': '32 / 32', + }) + await expectRowVisible(table, { + name: 'ip-pool-6-multicast-v6', + 'IPs Remaining': '18.4e18 / 18.4e18', + }) }) test.describe('german locale', () => { @@ -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') diff --git a/test/e2e/network-interface-create.e2e.ts b/test/e2e/network-interface-create.e2e.ts index c735553fe..87308afee 100644 --- a/test/e2e/network-interface-create.e2e.ts +++ b/test/e2e/network-interface-create.e2e.ts @@ -10,7 +10,7 @@ import { test } from '@playwright/test' import { expect, expectRowVisible, stopInstance } from './utils' test('can create a NIC with a specified IP address', async ({ page }) => { - // go to an instance’s Network Interfaces page + // go to an instance's Network Interfaces page await page.goto('/projects/mock-project/instances/db1/networking') await stopInstance(page) @@ -24,7 +24,10 @@ test('can create a NIC with a specified IP address', async ({ page }) => { await page.getByRole('option', { name: 'mock-vpc' }).click() await page.getByRole('button', { name: 'Subnet' }).click() await page.getByRole('option', { name: 'mock-subnet' }).click() - await page.getByLabel('IP Address').fill('1.2.3.4') + + // Select IPv4 only + await page.getByRole('radio', { name: 'IPv4', exact: true }).click() + await page.getByLabel('IPv4 Address').fill('1.2.3.4') const sidebar = page.getByRole('dialog', { name: 'Add network interface' }) @@ -37,7 +40,7 @@ test('can create a NIC with a specified IP address', async ({ page }) => { }) test('can create a NIC with a blank IP address', async ({ page }) => { - // go to an instance’s Network Interfaces page + // go to an instance's Network Interfaces page await page.goto('/projects/mock-project/instances/db1/networking') await stopInstance(page) @@ -52,8 +55,9 @@ test('can create a NIC with a blank IP address', async ({ page }) => { await page.getByRole('button', { name: 'Subnet' }).click() await page.getByRole('option', { name: 'mock-subnet' }).click() - // make sure the IP address field has a non-conforming bit of text in it - await page.getByLabel('IP Address').fill('x') + // Dual-stack is selected by default, so both fields should be visible + // make sure the IPv4 address field has a non-conforming bit of text in it + await page.getByLabel('IPv4 Address').fill('x') // try to submit it const sidebar = page.getByRole('dialog', { name: 'Add network interface' }) @@ -62,14 +66,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(/123\.45\.68\.8\s*fd12:3456::/), + }) +}) + +test('can create a NIC with IPv6 only', async ({ page }) => { + await page.goto('/projects/mock-project/instances/db1/networking') + + await stopInstance(page) + + await page.getByRole('button', { name: 'Add network interface' }).click() + + await page.getByLabel('Name').fill('nic-3') + await page.getByLabel('VPC', { exact: true }).click() + await page.getByRole('option', { name: 'mock-vpc' }).click() + await page.getByRole('button', { name: 'Subnet' }).click() + await page.getByRole('option', { name: 'mock-subnet' }).click() + + // Select IPv6 only + await page.getByRole('radio', { name: 'IPv6', exact: true }).click() + await page.getByLabel('IPv6 Address').fill('::1') + + const sidebar = page.getByRole('dialog', { name: 'Add network interface' }) + await sidebar.getByRole('button', { name: 'Add network interface' }).click() + await expect(sidebar).toBeHidden() + + const table = page.getByRole('table', { name: 'Network interfaces' }) + await expectRowVisible(table, { name: 'nic-3', 'Private IP': '::1' }) +}) + +test('can create a NIC with dual-stack and explicit IPs', async ({ page }) => { + await page.goto('/projects/mock-project/instances/db1/networking') + + await stopInstance(page) + + await page.getByRole('button', { name: 'Add network interface' }).click() + + await page.getByLabel('Name').fill('nic-4') + await page.getByLabel('VPC', { exact: true }).click() + await page.getByRole('option', { name: 'mock-vpc' }).click() + await page.getByRole('button', { name: 'Subnet' }).click() + await page.getByRole('option', { name: 'mock-subnet' }).click() + + // Dual-stack is selected by default + await page.getByLabel('IPv4 Address').fill('10.0.0.5') + await page.getByLabel('IPv6 Address').fill('fd00::5') + + const sidebar = page.getByRole('dialog', { name: 'Add network interface' }) + await sidebar.getByRole('button', { name: 'Add network interface' }).click() + await expect(sidebar).toBeHidden() + const table = page.getByRole('table', { name: 'Network interfaces' }) - await expectRowVisible(table, { name: 'nic-2', 'Private IP': '123.45.68.8' }) + await expectRowVisible(table, { + name: 'nic-4', + 'Private IP': expect.stringMatching(/10\.0\.0\.5\s*fd00::5/), + }) }) diff --git a/test/e2e/silos.e2e.ts b/test/e2e/silos.e2e.ts index 1c76229a3..f1b5ab3c1 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-1', 'IP Version': 'v4default' }) + await expectRowVisible(table, { name: 'ip-pool-2', 'IP Version': 'v6default' }) + // Multicast pools are also linked as defaults + await expectRowVisible(table, { + name: 'ip-pool-5-multicast-v4', + 'IP Version': 'v4default', + }) + await expectRowVisible(table, { + name: 'ip-pool-6-multicast-v6', + 'IP Version': 'v6default', + }) + await expect(table.getByRole('row')).toHaveCount(5) // header + 4 // clicking on pool goes to pool detail await page.getByRole('link', { name: 'ip-pool-1' }).click() 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-2', 'IP Version': 'v6default' }) - // 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', 'IP 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-1', 'IP Version': 'v4default' }) + await expectRowVisible(table, { name: 'ip-pool-2', 'IP Version': 'v6default' }) + // Multicast pools are also linked + await expectRowVisible(table, { + name: 'ip-pool-5-multicast-v4', + 'IP Version': 'v4default', + }) + await expectRowVisible(table, { + name: 'ip-pool-6-multicast-v6', + 'IP Version': 'v6default', + }) + await expect(table.getByRole('row')).toHaveCount(5) // header + 4 const modal = page.getByRole('dialog', { name: 'Link pool' }) await expect(modal).toBeHidden() @@ -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', 'IP Version': 'v4' }) }) // just a convenient form to test this with because it's tall