diff --git a/OMICRON_VERSION b/OMICRON_VERSION index ad0d5dd2e..1eb5dc7c1 100644 --- a/OMICRON_VERSION +++ b/OMICRON_VERSION @@ -1 +1 @@ -a6e111cf72ab987b2ea5acd7d26610ea2a55bf0f +5d08d2fdec25807e367ff6cab043a0b27b30228e diff --git a/app/api/__generated__/Api.ts b/app/api/__generated__/Api.ts index e4aa49ac4..35b85f3a3 100644 --- a/app/api/__generated__/Api.ts +++ b/app/api/__generated__/Api.ts @@ -1769,7 +1769,11 @@ export type DeviceAccessTokenResultsPage = { export type DeviceAuthRequest = { clientId: string - /** Optional lifetime for the access token in seconds. If not specified, the silo's max TTL will be used (if set). */ + /** Optional lifetime for the access token in seconds. + +This value will be validated during the confirmation step. If not specified, it defaults to the silo's max TTL, which can be seen at `/v1/auth-settings`. If specified, must not exceed the silo's max TTL. + +Some special logic applies when authenticating the confirmation request with an existing device token: the requested TTL must not produce an expiration time later than the authenticating token's expiration. If no TTL is specified, the expiration will be the lesser of the silo max and the authenticating token's expiration time. To get the longest allowed lifetime, omit the TTL and authenticate with a web console session. */ ttlSeconds?: number | null } @@ -1777,6 +1781,8 @@ export type DeviceAuthVerify = { userCode: string } export type Digest = { type: 'sha256'; value: string } +export type DiskType = 'distributed' | 'local' + /** * State of a Disk */ @@ -1814,6 +1820,7 @@ export type Disk = { /** human-readable free-form text about a resource */ description: string devicePath: string + diskType: DiskType /** unique, immutable, system-controlled identifier for each resource */ id: string /** ID of image from which disk was created, if any */ @@ -1832,7 +1839,7 @@ export type Disk = { } /** - * Different sources for a disk + * Different sources for a Distributed Disk */ export type DiskSource = /** Create a blank disk */ @@ -1848,13 +1855,24 @@ export type DiskSource = /** Create a blank disk that will accept bulk writes or pull blocks from an external source. */ | { blockSize: BlockSize; type: 'importing_blocks' } +/** + * The source of a `Disk`'s blocks + */ +export type DiskBackend = + | { type: 'local' } + | { + /** The initial source for this disk */ + diskSource: DiskSource + type: 'distributed' + } + /** * Create-time parameters for a `Disk` */ export type DiskCreate = { description: string - /** The initial source for this disk */ - diskSource: DiskSource + /** The source for this `Disk`'s blocks */ + diskBackend: DiskBackend name: Name /** The total size of the Disk (in bytes) */ size: ByteCount @@ -1885,9 +1903,9 @@ export type Distributiondouble = { counts: number[] max?: number | null min?: number | null - p50?: Quantile | null - p90?: Quantile | null - p99?: Quantile | null + p50?: number | null + p90?: number | null + p99?: number | null squaredMean: number sumOfSamples: number } @@ -1902,9 +1920,9 @@ export type Distributionint64 = { counts: number[] max?: number | null min?: number | null - p50?: Quantile | null - p90?: Quantile | null - p99?: Quantile | null + p50?: number | null + p90?: number | null + p99?: number | null squaredMean: number sumOfSamples: number } @@ -2347,8 +2365,8 @@ export type InstanceDiskAttachment = /** During instance creation, create and attach disks */ | { description: string - /** The initial source for this disk */ - diskSource: DiskSource + /** The source for this `Disk`'s blocks */ + diskBackend: DiskBackend name: Name /** The total size of the Disk (in bytes) */ size: ByteCount @@ -2427,6 +2445,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. + +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[] name: Name /** The number of vCPUs to be allocated to the instance */ ncpus: InstanceCpuCount @@ -2547,6 +2569,12 @@ An instance that does not have a boot disk set will use the boot options specifi cpuPlatform: InstanceCpuPlatform | null /** The amount of RAM (in bytes) to be allocated to the instance */ memory: ByteCount + /** Multicast groups this instance should join. + +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 /** The number of vCPUs to be allocated to the instance */ ncpus: InstanceCpuCount } @@ -3012,7 +3040,7 @@ export type LoopbackAddressCreate = { anycast: boolean /** The subnet mask to use for the address. */ mask: number - /** The containing the switch this loopback address will be configured on. */ + /** The rack containing the switch this loopback address will be configured on. */ rackId: string /** The location of the switch within the rack this loopback address will be configured on. */ switchLocation: Name @@ -3056,6 +3084,113 @@ export type MetricType = /** The value represents an accumulation between two points in time. */ | 'cumulative' +/** + * View of a Multicast Group + */ +export type MulticastGroup = { + /** human-readable free-form text about a resource */ + description: string + /** unique, immutable, system-controlled identifier for each resource */ + id: string + /** The ID of the IP pool this resource belongs to. */ + ipPoolId: string + /** The multicast IP address held by this resource. */ + multicastIp: string + /** Multicast VLAN (MVLAN) for egress multicast traffic to upstream networks. None means no VLAN tagging on egress. */ + 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. */ + sourceIps: string[] + /** Current state of the multicast group. */ + state: string + /** timestamp when this resource was created */ + timeCreated: Date + /** timestamp when this resource was last modified */ + 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) + */ +export type MulticastGroupMember = { + /** human-readable free-form text about a resource */ + description: string + /** unique, immutable, system-controlled identifier for each resource */ + id: string + /** The ID of the instance that is a member of this group. */ + instanceId: string + /** The ID of the multicast group this member belongs to. */ + multicastGroupId: string + /** unique, mutable, user-controlled identifier for each resource */ + name: Name + /** Current state of the multicast group membership. */ + state: string + /** timestamp when this resource was created */ + timeCreated: Date + /** timestamp when this resource was last modified */ + 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 + */ +export type MulticastGroupMemberResultsPage = { + /** list of items on this page of results */ + items: MulticastGroupMember[] + /** token used to fetch the next page of results (if any) */ + nextPage?: string | null +} + +/** + * A single page of results + */ +export type MulticastGroupResultsPage = { + /** list of items on this page of results */ + items: MulticastGroup[] + /** token used to fetch the next page of results (if any) */ + nextPage?: string | null +} + +/** + * Update-time parameters for a multicast group. + */ +export type MulticastGroupUpdate = { + description?: string | null + /** Multicast VLAN (MVLAN) for egress multicast traffic to upstream networks. Set to null to clear the MVLAN. Valid range: 2-4094 when provided. Omit the field to leave mvlan unchanged. */ + mvlan?: number | null + name?: Name | null + sourceIps?: string[] | null +} + /** * The type of network interface */ @@ -5624,6 +5759,32 @@ export interface InstanceEphemeralIpDetachQueryParams { project?: NameOrId } +export interface InstanceMulticastGroupListPathParams { + instance: NameOrId +} + +export interface InstanceMulticastGroupListQueryParams { + project?: NameOrId +} + +export interface InstanceMulticastGroupJoinPathParams { + instance: NameOrId + multicastGroup: NameOrId +} + +export interface InstanceMulticastGroupJoinQueryParams { + project?: NameOrId +} + +export interface InstanceMulticastGroupLeavePathParams { + instance: NameOrId + multicastGroup: NameOrId +} + +export interface InstanceMulticastGroupLeaveQueryParams { + project?: NameOrId +} + export interface InstanceRebootPathParams { instance: NameOrId } @@ -5820,6 +5981,51 @@ export interface SiloMetricQueryParams { project?: NameOrId } +export interface MulticastGroupListQueryParams { + limit?: number | null + pageToken?: string | null + sortBy?: NameOrIdSortMode +} + +export interface MulticastGroupViewPathParams { + multicastGroup: NameOrId +} + +export interface MulticastGroupUpdatePathParams { + multicastGroup: NameOrId +} + +export interface MulticastGroupDeletePathParams { + multicastGroup: NameOrId +} + +export interface MulticastGroupMemberListPathParams { + multicastGroup: NameOrId +} + +export interface MulticastGroupMemberListQueryParams { + limit?: number | null + pageToken?: string | null + 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 @@ -6174,6 +6380,10 @@ export interface SystemMetricQueryParams { silo?: NameOrId } +export interface LookupMulticastGroupByIpPathParams { + address: string +} + export interface NetworkingAddressLotListQueryParams { limit?: number | null pageToken?: string | null @@ -6652,7 +6862,7 @@ export class Api { * Pulled from info.version in the OpenAPI schema. Sent in the * `api-version` header on all requests. */ - apiVersion = '20251008.0.0' + apiVersion = '2025120300.0.0' constructor({ host = '', baseParams = {}, token }: ApiConfig = {}) { this.host = host @@ -8102,6 +8312,66 @@ export class Api { ...params, }) }, + /** + * List multicast groups for instance + */ + instanceMulticastGroupList: ( + { + path, + query = {}, + }: { + path: InstanceMulticastGroupListPathParams + query?: InstanceMulticastGroupListQueryParams + }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/instances/${path.instance}/multicast-groups`, + method: 'GET', + query, + ...params, + }) + }, + /** + * Join multicast group. + */ + instanceMulticastGroupJoin: ( + { + path, + query = {}, + }: { + path: InstanceMulticastGroupJoinPathParams + query?: InstanceMulticastGroupJoinQueryParams + }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/instances/${path.instance}/multicast-groups/${path.multicastGroup}`, + method: 'PUT', + query, + ...params, + }) + }, + /** + * Leave multicast group. + */ + instanceMulticastGroupLeave: ( + { + path, + query = {}, + }: { + path: InstanceMulticastGroupLeavePathParams + query?: InstanceMulticastGroupLeaveQueryParams + }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/instances/${path.instance}/multicast-groups/${path.multicastGroup}`, + method: 'DELETE', + query, + ...params, + }) + }, /** * Reboot an instance */ @@ -8542,6 +8812,137 @@ export class Api { ...params, }) }, + /** + * List all multicast groups. + */ + multicastGroupList: ( + { query = {} }: { query?: MulticastGroupListQueryParams }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/multicast-groups`, + method: 'GET', + query, + ...params, + }) + }, + /** + * 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. + */ + multicastGroupView: ( + { path }: { path: MulticastGroupViewPathParams }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/multicast-groups/${path.multicastGroup}`, + method: 'GET', + ...params, + }) + }, + /** + * 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. + */ + multicastGroupMemberList: ( + { + path, + query = {}, + }: { + path: MulticastGroupMemberListPathParams + query?: MulticastGroupMemberListQueryParams + }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/multicast-groups/${path.multicastGroup}/members`, + method: 'GET', + query, + ...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 */ @@ -9500,6 +9901,19 @@ 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 */ diff --git a/app/api/__generated__/OMICRON_VERSION b/app/api/__generated__/OMICRON_VERSION index c092490dd..56b55a2f3 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 -a6e111cf72ab987b2ea5acd7d26610ea2a55bf0f +5d08d2fdec25807e367ff6cab043a0b27b30228e diff --git a/app/api/__generated__/msw-handlers.ts b/app/api/__generated__/msw-handlers.ts index 7f8f3563b..3d0aa5ace 100644 --- a/app/api/__generated__/msw-handlers.ts +++ b/app/api/__generated__/msw-handlers.ts @@ -628,6 +628,27 @@ export interface MSWHandlers { req: Request cookies: Record }) => Promisable + /** `GET /v1/instances/:instance/multicast-groups` */ + instanceMulticastGroupList: (params: { + path: Api.InstanceMulticastGroupListPathParams + query: Api.InstanceMulticastGroupListQueryParams + req: Request + cookies: Record + }) => Promisable> + /** `PUT /v1/instances/:instance/multicast-groups/:multicastGroup` */ + instanceMulticastGroupJoin: (params: { + path: Api.InstanceMulticastGroupJoinPathParams + query: Api.InstanceMulticastGroupJoinQueryParams + req: Request + cookies: Record + }) => Promisable> + /** `DELETE /v1/instances/:instance/multicast-groups/:multicastGroup` */ + instanceMulticastGroupLeave: (params: { + path: Api.InstanceMulticastGroupLeavePathParams + query: Api.InstanceMulticastGroupLeaveQueryParams + req: Request + cookies: Record + }) => Promisable /** `POST /v1/instances/:instance/reboot` */ instanceReboot: (params: { path: Api.InstanceRebootPathParams @@ -815,6 +836,59 @@ export interface MSWHandlers { req: Request cookies: Record }) => Promisable> + /** `GET /v1/multicast-groups` */ + multicastGroupList: (params: { + query: Api.MulticastGroupListQueryParams + 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 + query: Api.MulticastGroupMemberListQueryParams + 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 @@ -1231,6 +1305,12 @@ 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 @@ -2405,6 +2485,30 @@ export function makeHandlers(handlers: MSWHandlers): HttpHandler[] { null ) ), + http.get( + '/v1/instances/:instance/multicast-groups', + handler( + handlers['instanceMulticastGroupList'], + schema.InstanceMulticastGroupListParams, + null + ) + ), + http.put( + '/v1/instances/:instance/multicast-groups/:multicastGroup', + handler( + handlers['instanceMulticastGroupJoin'], + schema.InstanceMulticastGroupJoinParams, + null + ) + ), + http.delete( + '/v1/instances/:instance/multicast-groups/:multicastGroup', + handler( + handlers['instanceMulticastGroupLeave'], + schema.InstanceMulticastGroupLeaveParams, + null + ) + ), http.post( '/v1/instances/:instance/reboot', handler(handlers['instanceReboot'], schema.InstanceRebootParams, null) @@ -2567,6 +2671,54 @@ export function makeHandlers(handlers: MSWHandlers): HttpHandler[] { '/v1/metrics/:metricName', handler(handlers['siloMetric'], schema.SiloMetricParams, null) ), + http.get( + '/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( + handlers['multicastGroupMemberList'], + schema.MulticastGroupMemberListParams, + 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( @@ -2902,6 +3054,14 @@ export function makeHandlers(handlers: MSWHandlers): HttpHandler[] { '/v1/system/metrics/:metricName', handler(handlers['systemMetric'], schema.SystemMetricParams, null) ), + http.get( + '/v1/system/multicast-groups/by-ip/:address', + handler( + handlers['lookupMulticastGroupByIp'], + schema.LookupMulticastGroupByIpParams, + null + ) + ), http.get( '/v1/system/networking/address-lot', handler( diff --git a/app/api/__generated__/validate.ts b/app/api/__generated__/validate.ts index a1af9104e..76dc7ed43 100644 --- a/app/api/__generated__/validate.ts +++ b/app/api/__generated__/validate.ts @@ -1640,6 +1640,8 @@ export const Digest = z.preprocess( z.object({ type: z.enum(['sha256']), value: z.string() }) ) +export const DiskType = z.preprocess(processResponseBody, z.enum(['distributed', 'local'])) + /** * State of a Disk */ @@ -1670,6 +1672,7 @@ export const Disk = z.preprocess( blockSize: ByteCount, description: z.string(), devicePath: z.string(), + diskType: DiskType, id: z.uuid(), imageId: z.uuid().nullable().optional(), name: Name, @@ -1683,7 +1686,7 @@ export const Disk = z.preprocess( ) /** - * Different sources for a disk + * Different sources for a Distributed Disk */ export const DiskSource = z.preprocess( processResponseBody, @@ -1695,12 +1698,28 @@ export const DiskSource = z.preprocess( ]) ) +/** + * The source of a `Disk`'s blocks + */ +export const DiskBackend = z.preprocess( + processResponseBody, + z.union([ + z.object({ type: z.enum(['local']) }), + z.object({ diskSource: DiskSource, type: z.enum(['distributed']) }), + ]) +) + /** * Create-time parameters for a `Disk` */ export const DiskCreate = z.preprocess( processResponseBody, - z.object({ description: z.string(), diskSource: DiskSource, name: Name, size: ByteCount }) + z.object({ + description: z.string(), + diskBackend: DiskBackend, + name: Name, + size: ByteCount, + }) ) export const DiskPath = z.preprocess(processResponseBody, z.object({ disk: NameOrId })) @@ -1725,9 +1744,9 @@ export const Distributiondouble = z.preprocess( counts: z.number().min(0).array(), max: z.number().nullable().optional(), min: z.number().nullable().optional(), - p50: Quantile.nullable().optional(), - p90: Quantile.nullable().optional(), - p99: Quantile.nullable().optional(), + p50: z.number().nullable().optional(), + p90: z.number().nullable().optional(), + p99: z.number().nullable().optional(), squaredMean: z.number(), sumOfSamples: z.number(), }) @@ -1745,9 +1764,9 @@ export const Distributionint64 = z.preprocess( counts: z.number().min(0).array(), max: z.number().nullable().optional(), min: z.number().nullable().optional(), - p50: Quantile.nullable().optional(), - p90: Quantile.nullable().optional(), - p99: Quantile.nullable().optional(), + p50: z.number().nullable().optional(), + p90: z.number().nullable().optional(), + p99: z.number().nullable().optional(), squaredMean: z.number(), sumOfSamples: z.number(), }) @@ -2183,7 +2202,7 @@ export const InstanceDiskAttachment = z.preprocess( z.union([ z.object({ description: z.string(), - diskSource: DiskSource, + diskBackend: DiskBackend, name: Name, size: ByteCount, type: z.enum(['create']), @@ -2234,12 +2253,13 @@ export const InstanceCreate = z.preprocess( externalIps: ExternalIpCreate.array().default([]).optional(), hostname: Hostname, memory: ByteCount, + multicastGroups: NameOrId.array().default([]).optional(), name: Name, ncpus: InstanceCpuCount, networkInterfaces: InstanceNetworkInterfaceAttachment.default({ type: 'default', }).optional(), - sshPublicKeys: NameOrId.array().optional(), + sshPublicKeys: NameOrId.array().nullable().optional(), start: SafeBoolean.default(true).optional(), userData: z.string().default('').optional(), }) @@ -2332,6 +2352,7 @@ export const InstanceUpdate = z.preprocess( bootDisk: NameOrId.nullable(), cpuPlatform: InstanceCpuPlatform.nullable(), memory: ByteCount, + multicastGroups: NameOrId.array().nullable().default(null).optional(), ncpus: InstanceCpuCount, }) ) @@ -2700,7 +2721,7 @@ export const ManagementAddress = z.preprocess( z.object({ addr: NetworkAddress, interfaceNum: InterfaceNum, - oid: z.number().min(0).max(255).array().optional(), + oid: z.number().min(0).max(255).array().nullable().optional(), }) ) @@ -2791,6 +2812,97 @@ export const MetricType = z.preprocess( z.enum(['gauge', 'delta', 'cumulative']) ) +/** + * View of a Multicast Group + */ +export const MulticastGroup = z.preprocess( + processResponseBody, + z.object({ + description: z.string(), + id: z.uuid(), + ipPoolId: z.uuid(), + multicastIp: z.ipv4(), + mvlan: z.number().min(0).max(65535).nullable().optional(), + name: Name, + sourceIps: z.ipv4().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) + */ +export const MulticastGroupMember = z.preprocess( + processResponseBody, + z.object({ + description: z.string(), + id: z.uuid(), + instanceId: z.uuid(), + multicastGroupId: z.uuid(), + name: Name, + 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 + */ +export const MulticastGroupMemberResultsPage = z.preprocess( + processResponseBody, + z.object({ + items: MulticastGroupMember.array(), + nextPage: z.string().nullable().optional(), + }) +) + +/** + * A single page of results + */ +export const MulticastGroupResultsPage = z.preprocess( + processResponseBody, + z.object({ items: MulticastGroup.array(), nextPage: z.string().nullable().optional() }) +) + +/** + * Update-time parameters for a multicast group. + */ +export const MulticastGroupUpdate = z.preprocess( + processResponseBody, + z.object({ + description: z.string().nullable().optional(), + mvlan: z.number().min(0).max(65535).nullable().optional(), + name: Name.nullable().optional(), + sourceIps: z.ipv4().array().nullable().optional(), + }) +) + /** * The type of network interface */ @@ -2864,7 +2976,7 @@ export const Values = z.preprocess( export const Points = z.preprocess( processResponseBody, z.object({ - startTimes: z.coerce.date().array().optional(), + startTimes: z.coerce.date().array().nullable().optional(), timestamps: z.coerce.date().array(), values: Values.array(), }) @@ -4329,9 +4441,9 @@ export const VpcFirewallRuleProtocol = z.preprocess( export const VpcFirewallRuleFilter = z.preprocess( processResponseBody, z.object({ - hosts: VpcFirewallRuleHostFilter.array().optional(), - ports: L4PortRange.array().optional(), - protocols: VpcFirewallRuleProtocol.array().optional(), + hosts: VpcFirewallRuleHostFilter.array().nullable().optional(), + ports: L4PortRange.array().nullable().optional(), + protocols: VpcFirewallRuleProtocol.array().nullable().optional(), }) ) @@ -5639,6 +5751,44 @@ export const InstanceEphemeralIpDetachParams = z.preprocess( }) ) +export const InstanceMulticastGroupListParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({ + instance: NameOrId, + }), + query: z.object({ + project: NameOrId.optional(), + }), + }) +) + +export const InstanceMulticastGroupJoinParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({ + instance: NameOrId, + multicastGroup: NameOrId, + }), + query: z.object({ + project: NameOrId.optional(), + }), + }) +) + +export const InstanceMulticastGroupLeaveParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({ + instance: NameOrId, + multicastGroup: NameOrId, + }), + query: z.object({ + project: NameOrId.optional(), + }), + }) +) + export const InstanceRebootParams = z.preprocess( processResponseBody, z.object({ @@ -5993,6 +6143,95 @@ export const SiloMetricParams = z.preprocess( }) ) +export const MulticastGroupListParams = 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 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, + }), + query: z.object({}), + }) +) + +export const MulticastGroupMemberListParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({ + multicastGroup: NameOrId, + }), + query: z.object({ + limit: z.number().min(1).max(4294967295).nullable().optional(), + pageToken: z.string().nullable().optional(), + sortBy: IdSortMode.optional(), + }), + }) +) + +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({ @@ -6711,6 +6950,16 @@ export const SystemMetricParams = z.preprocess( }) ) +export const LookupMulticastGroupByIpParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({ + address: z.ipv4(), + }), + query: z.object({}), + }) +) + export const NetworkingAddressLotListParams = z.preprocess( processResponseBody, z.object({ diff --git a/app/api/__tests__/client.spec.tsx b/app/api/__tests__/client.spec.tsx index 8f54f7ae5..f6ce942d1 100644 --- a/app/api/__tests__/client.spec.tsx +++ b/app/api/__tests__/client.spec.tsx @@ -197,7 +197,10 @@ describe('useApiMutation', () => { const diskCreate: DiskCreate = { name: 'will-fail', description: '', - diskSource: { type: 'blank', blockSize: 512 }, + diskBackend: { + type: 'distributed', + diskSource: { type: 'blank', blockSize: 512 }, + }, size: 10, } const diskCreate404Params = { diff --git a/app/components/StateBadge.tsx b/app/components/StateBadge.tsx index ac00ec087..3d0e664ff 100644 --- a/app/components/StateBadge.tsx +++ b/app/components/StateBadge.tsx @@ -11,6 +11,7 @@ import { diskTransitioning, instanceTransitioning, type DiskState, + type DiskType, type InstanceState, type SnapshotState, } from '@oxide/api' @@ -83,3 +84,9 @@ export const SnapshotStateBadge = (props: { state: SnapshotState; className?: st {props.state} ) + +export const DiskTypeBadge = (props: { diskType: DiskType; className?: string }) => ( + + {props.diskType} + +) diff --git a/app/forms/disk-create.tsx b/app/forms/disk-create.tsx index 0530a8f86..8a34cb2df 100644 --- a/app/forms/disk-create.tsx +++ b/app/forms/disk-create.tsx @@ -17,6 +17,7 @@ import { useApiMutation, type BlockSize, type Disk, + type DiskBackend, type DiskCreate, type DiskSource, type Image, @@ -32,7 +33,6 @@ import { SideModalForm } from '~/components/form/SideModalForm' import { HL } from '~/components/HL' import { useProjectSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' -import { FormDivider } from '~/ui/lib/Divider' import { FieldLabel } from '~/ui/lib/FieldLabel' import { Radio } from '~/ui/lib/Radio' import { RadioGroup } from '~/ui/lib/RadioGroup' @@ -50,7 +50,7 @@ const defaultValues: DiskCreate = { name: '', description: '', size: 10, - diskSource: blankDiskSource, + diskBackend: { type: 'distributed', diskSource: blankDiskSource }, } type CreateSideModalFormProps = { @@ -97,19 +97,24 @@ export function CreateDiskSideModalForm({ const snapshots = snapshotsQuery.data?.items || [] // validate disk source size - const diskSource = form.watch('diskSource').type + const diskBackend = form.watch('diskBackend') + const diskSourceType = + diskBackend.type === 'distributed' ? diskBackend.diskSource.type : undefined let validateSizeGiB: number | undefined = undefined - if (diskSource === 'snapshot') { - const selectedSnapshotId = form.watch('diskSource.snapshotId') - const selectedSnapshotSize = snapshots.find( - (snapshot) => snapshot.id === selectedSnapshotId - )?.size - validateSizeGiB = selectedSnapshotSize ? bytesToGiB(selectedSnapshotSize) : undefined - } else if (diskSource === 'image') { - const selectedImageId = form.watch('diskSource.imageId') - const selectedImageSize = images.find((image) => image.id === selectedImageId)?.size - validateSizeGiB = selectedImageSize ? bytesToGiB(selectedImageSize) : undefined + if (diskBackend.type === 'distributed') { + const diskSource = diskBackend.diskSource + if (diskSource.type === 'snapshot') { + const selectedSnapshotSize = snapshots.find( + (snapshot) => snapshot.id === diskSource.snapshotId + )?.size + validateSizeGiB = selectedSnapshotSize ? bytesToGiB(selectedSnapshotSize) : undefined + } else if (diskSource.type === 'image') { + const selectedImageSize = images.find( + (image) => image.id === diskSource.imageId + )?.size + validateSizeGiB = selectedImageSize ? bytesToGiB(selectedImageSize) : undefined + } } return ( @@ -139,26 +144,25 @@ export function CreateDiskSideModalForm({ }} /> - - { if (validateSizeGiB && diskSizeGiB < validateSizeGiB) { - return `Must be as large as selected ${diskSource} (min. ${validateSizeGiB} GiB)` + return `Must be as large as selected ${diskSourceType} (min. ${validateSizeGiB} GiB)` } }} /> + ) } -const DiskSourceField = ({ +const DiskBackendField = ({ control, images, areImagesLoading, @@ -168,10 +172,64 @@ const DiskSourceField = ({ areImagesLoading: boolean }) => { const { - field: { value, onChange }, - } = useController({ control, name: 'diskSource' }) + field: { value: diskBackend, onChange: setDiskBackend }, + } = useController({ control, name: 'diskBackend' }) const diskSizeField = useController({ control, name: 'size' }).field + return ( + <> +
+ Disk type + { + const newType = event.target.value as DiskBackend['type'] + if (newType === 'local') { + setDiskBackend({ type: 'local' }) + } else { + setDiskBackend({ type: 'distributed', diskSource: blankDiskSource }) + } + }} + > + Distributed + Local + +
+ + {diskBackend.type === 'distributed' && ( + + setDiskBackend({ type: 'distributed', diskSource: source }) + } + diskSizeField={diskSizeField} + images={images} + areImagesLoading={areImagesLoading} + /> + )} + + ) +} + +const DiskSourceField = ({ + control, + diskSource, + setDiskSource, + diskSizeField, + images, + areImagesLoading, +}: { + control: Control + diskSource: DiskSource + setDiskSource: (source: DiskSource) => void + diskSizeField: { value: number; onChange: (value: number) => void } + images: Image[] + areImagesLoading: boolean +}) => { return ( <>
@@ -180,12 +238,14 @@ const DiskSourceField = ({ aria-labelledby="disk-source-label" name="diskSource" column - defaultChecked={value.type} + defaultChecked={diskSource.type} onChange={(event) => { - const newType = event.target.value as DiskCreate['diskSource']['type'] - - // need to include blockSize when switching back to blank - onChange(newType === 'blank' ? blankDiskSource : { type: newType }) + const newType = event.target.value as DiskSource['type'] + // need to include blockSize when switching back to blank. other + // source types get their required fields from form inputs + setDiskSource( + newType === 'blank' ? blankDiskSource : ({ type: newType } as DiskSource) + ) }} > Blank @@ -194,10 +254,10 @@ const DiskSourceField = ({
- {value.type === 'blank' && ( + {diskSource.type === 'blank' && ( )} - {value.type === 'image' && ( + {diskSource.type === 'image' && ( toImageComboboxItem(i, true))} required onChange={(id) => { - const image = images.find((i) => i.id === id)! // if it's selected, it must be present + const image = images.find((i) => i.id === id)! const imageSizeGiB = image.size / GiB if (diskSizeField.value < imageSizeGiB) { diskSizeField.onChange(diskSizeNearest10(imageSizeGiB)) @@ -228,7 +288,7 @@ const DiskSourceField = ({ /> )} - {value.type === 'snapshot' && } + {diskSource.type === 'snapshot' && }
) @@ -253,7 +313,7 @@ const SnapshotSelectField = ({ control }: { control: Control }) => { return ( { diff --git a/app/forms/image-upload.tsx b/app/forms/image-upload.tsx index 68a352a62..8b3ec80d0 100644 --- a/app/forms/image-upload.tsx +++ b/app/forms/image-upload.tsx @@ -370,7 +370,10 @@ export default function ImageCreate() { body: { name: diskName, description: `temporary disk for importing image ${imageName}`, - diskSource: { type: 'importing_blocks', blockSize }, + diskBackend: { + type: 'distributed', + diskSource: { type: 'importing_blocks', blockSize }, + }, size: Math.ceil(imageFile.size / GiB) * GiB, }, }) diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index 6246a655e..c40ca8c90 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -100,7 +100,10 @@ const getBootDiskAttachment = ( name: values.bootDiskName || genName(values.name, sourceName || source), description: `Created as a boot disk for ${values.name}`, size: values.bootDiskSize * GiB, - diskSource: { type: 'image', imageId: source }, + diskBackend: { + type: 'distributed', + diskSource: { type: 'image', imageId: source }, + }, } } diff --git a/app/pages/project/disks/DisksPage.tsx b/app/pages/project/disks/DisksPage.tsx index e4530a6a4..30f691158 100644 --- a/app/pages/project/disks/DisksPage.tsx +++ b/app/pages/project/disks/DisksPage.tsx @@ -23,7 +23,7 @@ import { Storage16Icon, Storage24Icon } from '@oxide/design-system/icons/react' import { DocsPopover } from '~/components/DocsPopover' import { HL } from '~/components/HL' -import { DiskStateBadge } from '~/components/StateBadge' +import { DiskStateBadge, DiskTypeBadge } from '~/components/StateBadge' import { makeCrumb } from '~/hooks/use-crumbs' import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' import { confirmDelete } from '~/stores/confirm-delete' @@ -91,6 +91,10 @@ const staticCols = [ cell: (info) => , } ), + colHelper.accessor('diskType', { + header: 'Type', + cell: (info) => , + }), colHelper.accessor('size', Columns.size), colHelper.accessor('state.state', { header: 'state', diff --git a/app/pages/project/instances/StorageTab.tsx b/app/pages/project/instances/StorageTab.tsx index c5772a9de..dddbfa13c 100644 --- a/app/pages/project/instances/StorageTab.tsx +++ b/app/pages/project/instances/StorageTab.tsx @@ -25,7 +25,7 @@ import { import { Storage24Icon } from '@oxide/design-system/icons/react' import { HL } from '~/components/HL' -import { DiskStateBadge } from '~/components/StateBadge' +import { DiskStateBadge, DiskTypeBadge } from '~/components/StateBadge' import { AttachDiskModalForm } from '~/forms/disk-attach' import { CreateDiskSideModalForm } from '~/forms/disk-create' import { getInstanceSelector, useInstanceSelector } from '~/hooks/use-params' @@ -68,6 +68,10 @@ type InstanceDisk = Disk & { const colHelper = createColumnHelper() const staticCols = [ colHelper.accessor('name', { header: 'Disk' }), + colHelper.accessor('diskType', { + header: 'Type', + cell: (info) => , + }), colHelper.accessor('size', Columns.size), colHelper.accessor((row) => row.state.state, { header: 'state', diff --git a/mock-api/disk.ts b/mock-api/disk.ts index 2a6f8e003..b0239335d 100644 --- a/mock-api/disk.ts +++ b/mock-api/disk.ts @@ -64,6 +64,7 @@ export const disk1: Json = { device_path: '/abc', size: 2 * GiB, block_size: 2048, + disk_type: 'distributed', } export const disk2: Json = { @@ -77,6 +78,7 @@ export const disk2: Json = { device_path: '/def', size: 4 * GiB, block_size: 2048, + disk_type: 'distributed', } export const disks: Json[] = [ @@ -94,6 +96,7 @@ export const disks: Json[] = [ device_path: '/ghi', size: 6 * GiB, block_size: 2048, + disk_type: 'distributed', }, { id: '5695b16d-e1d6-44b0-a75c-7b4299831540', @@ -106,6 +109,7 @@ export const disks: Json[] = [ device_path: '/jkl', size: 64 * GiB, block_size: 2048, + disk_type: 'distributed', }, { id: '4d6f4c76-675f-4cda-b609-f3b8b301addb', @@ -118,6 +122,7 @@ export const disks: Json[] = [ device_path: '/jkl', size: 128 * GiB, block_size: 2048, + disk_type: 'distributed', }, { id: '41481936-5a6b-4dcd-8dec-26c3bdc343bd', @@ -130,6 +135,7 @@ export const disks: Json[] = [ device_path: '/jkl', size: 20 * GiB, block_size: 2048, + disk_type: 'distributed', }, { id: '704cd392-9f6b-4a2b-8410-1f1e0794db80', @@ -142,6 +148,7 @@ export const disks: Json[] = [ device_path: '/jkl', size: 24 * GiB, block_size: 2048, + disk_type: 'distributed', }, { id: '305ee9c7-1930-4a8f-86d7-ed9eece9598e', @@ -154,6 +161,7 @@ export const disks: Json[] = [ device_path: '/jkl', size: 16 * GiB, block_size: 2048, + disk_type: 'distributed', }, { id: 'ccad8d48-df21-4a80-8c16-683ee6bfb290', @@ -166,6 +174,7 @@ export const disks: Json[] = [ device_path: '/jkl', size: 32 * GiB, block_size: 2048, + disk_type: 'distributed', }, { id: 'a028160f-603c-4562-bb71-d2d76f1ac2a8', @@ -178,6 +187,7 @@ export const disks: Json[] = [ device_path: '/jkl', size: 24 * GiB, block_size: 2048, + disk_type: 'distributed', }, { id: '3f23c80f-c523-4d86-8292-2ca3f807bb12', @@ -190,6 +200,7 @@ export const disks: Json[] = [ device_path: '/jkl', size: 12 * GiB, block_size: 2048, + disk_type: 'distributed', }, // put a ton of disks in project 2 so we can use it to test comboboxes ...Array.from({ length: 1010 }).map((_, i) => { @@ -205,6 +216,7 @@ export const disks: Json[] = [ device_path: '/jkl', size: 12 * GiB, block_size: 2048, + disk_type: 'distributed' as const, } }), ] diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index b6995b6f2..62c93044b 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -146,12 +146,14 @@ export const handlers = makeHandlers({ if (body.name === 'disk-create-500') throw 500 - const { name, description, size, disk_source } = body + const { name, description, size, disk_backend } = body const newDisk: Json = { id: uuid(), project_id: project.id, + // TODO: confirm logic here state: - disk_source.type === 'importing_blocks' + disk_backend.type === 'distributed' && + disk_backend.disk_source.type === 'importing_blocks' ? { state: 'import_ready' } : { state: 'detached' }, device_path: '/mnt/disk', @@ -160,7 +162,11 @@ export const handlers = makeHandlers({ size, // TODO: for non-blank disk sources, look up image or snapshot by ID and // pull block size from there - block_size: disk_source.type === 'blank' ? disk_source.block_size : 512, + block_size: + disk_backend.type === 'distributed' && disk_backend.disk_source.type === 'blank' + ? disk_backend.disk_source.block_size + : 512, + disk_type: disk_backend.type, ...getTimestamps(), } db.disks.push(newDisk) @@ -480,7 +486,7 @@ export const handlers = makeHandlers({ for (const diskParams of allDisks) { if (diskParams.type === 'create') { - const { size, name, description, disk_source } = diskParams + const { size, name, description, disk_backend } = diskParams const newDisk: Json = { id: uuid(), name, @@ -489,7 +495,12 @@ export const handlers = makeHandlers({ project_id: project.id, state: { state: 'attached', instance: instanceId }, device_path: '/mnt/disk', - block_size: disk_source.type === 'blank' ? disk_source.block_size : 4096, + // TODO: this doesn't seem right, check the omicron source + block_size: + disk_backend.type === 'distributed' && disk_backend.disk_source.type === 'blank' + ? disk_backend.disk_source.block_size + : 4096, + disk_type: disk_backend.type, ...getTimestamps(), } db.disks.push(newDisk) @@ -1961,6 +1972,9 @@ export const handlers = makeHandlers({ certificateDelete: NotImplemented, certificateList: NotImplemented, certificateView: NotImplemented, + instanceMulticastGroupJoin: NotImplemented, + instanceMulticastGroupLeave: NotImplemented, + instanceMulticastGroupList: NotImplemented, instanceSerialConsole: NotImplemented, instanceSerialConsoleStream: NotImplemented, instanceSshPublicKeyList: NotImplemented, @@ -1978,6 +1992,15 @@ 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, networkingAddressLotDelete: NotImplemented, diff --git a/mock-api/msw/util.ts b/mock-api/msw/util.ts index 05580ec8e..bef65b7f0 100644 --- a/mock-api/msw/util.ts +++ b/mock-api/msw/util.ts @@ -135,23 +135,27 @@ export const errIfExists = >( } export const errIfInvalidDiskSize = (disk: Json) => { - const source = disk.disk_source if (disk.size < MIN_DISK_SIZE_GiB * GiB) { throw `Disk size must be greater than or equal to ${MIN_DISK_SIZE_GiB} GiB` } if (disk.size > MAX_DISK_SIZE_GiB * GiB) { throw `Disk size must be less than or equal to ${MAX_DISK_SIZE_GiB} GiB` } - if (source.type === 'snapshot') { - const snapshotSize = db.snapshots.find((s) => source.snapshot_id === s.id)?.size ?? 0 - if (disk.size >= snapshotSize) return - throw 'Disk size must be greater than or equal to the snapshot size' - } - if (source.type === 'image') { - const imageSize = db.images.find((i) => source.image_id === i.id)?.size ?? 0 - if (disk.size >= imageSize) return - throw 'Disk size must be greater than or equal to the image size' + const backend = disk.disk_backend + if (backend.type === 'distributed') { + const source = backend.disk_source + if (source.type === 'snapshot') { + const snapshotSize = db.snapshots.find((s) => source.snapshot_id === s.id)?.size ?? 0 + if (disk.size >= snapshotSize) return + throw 'Disk size must be greater than or equal to the snapshot size' + } + if (source.type === 'image') { + const imageSize = db.images.find((i) => source.image_id === i.id)?.size ?? 0 + if (disk.size >= imageSize) return + throw 'Disk size must be greater than or equal to the image size' + } } + // TODO: use exhaustive match and handle local too } export function generateUtilization( diff --git a/package-lock.json b/package-lock.json index ffe4b0ec7..ca5287b90 100644 --- a/package-lock.json +++ b/package-lock.json @@ -61,7 +61,7 @@ "@eslint/js": "^9.38.0", "@ianvs/prettier-plugin-sort-imports": "^4.7.0", "@mswjs/http-middleware": "^0.10.3", - "@oxide/openapi-gen-ts": "~0.11.0", + "@oxide/openapi-gen-ts": "~0.12.0", "@playwright/test": "^1.56.1", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", @@ -1831,9 +1831,9 @@ } }, "node_modules/@oxide/openapi-gen-ts": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@oxide/openapi-gen-ts/-/openapi-gen-ts-0.11.0.tgz", - "integrity": "sha512-qBmDgTxT0gVTUgNM7b+/1qxZFxaLcLBLAYRyzm6CaBG40phPRQsxCaPfFJJL+REJZHpJJG0YMQbj60FI+11esw==", + "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==", "dev": true, "license": "MPL-2.0", "dependencies": { diff --git a/package.json b/package.json index 6711b2e07..362313dab 100644 --- a/package.json +++ b/package.json @@ -86,7 +86,7 @@ "@eslint/js": "^9.38.0", "@ianvs/prettier-plugin-sort-imports": "^4.7.0", "@mswjs/http-middleware": "^0.10.3", - "@oxide/openapi-gen-ts": "~0.11.0", + "@oxide/openapi-gen-ts": "~0.12.0", "@playwright/test": "^1.56.1", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", diff --git a/test/e2e/disks.e2e.ts b/test/e2e/disks.e2e.ts index 13482ad3e..7577c2a37 100644 --- a/test/e2e/disks.e2e.ts +++ b/test/e2e/disks.e2e.ts @@ -101,5 +101,19 @@ test.describe('Disk create', () => { await page.getByRole('radio', { name: 'Snapshot' }).click() await page.getByRole('radio', { name: 'Blank' }).click() }) + + test('local disk', async ({ page }) => { + const source = page.getByRole('radiogroup', { name: 'Source' }) + const blockSize = page.getByRole('radiogroup', { name: 'Block size' }) + // verify source and block size are visible for distributed (default) + await expect(source).toBeVisible() + await expect(blockSize).toBeVisible() + + await page.getByRole('radio', { name: 'Local' }).click() + + // source and block size options should disappear when local is selected + await expect(source).toBeHidden() + await expect(blockSize).toBeHidden() + }) /* eslint-enable playwright/expect-expect */ }) diff --git a/tools/generate_api_client.sh b/tools/generate_api_client.sh index ab0086e7d..08e58f455 100755 --- a/tools/generate_api_client.sh +++ b/tools/generate_api_client.sh @@ -12,7 +12,7 @@ set -o xtrace OMICRON_SHA=$(head -n 1 OMICRON_VERSION) GEN_DIR="$PWD/app/api/__generated__" -SPEC_URL="https://raw.githubusercontent.com/oxidecomputer/omicron/$OMICRON_SHA/openapi/nexus.json" +SPEC_BASE="https://raw.githubusercontent.com/oxidecomputer/omicron/$OMICRON_SHA/openapi/nexus" HEADER=$(cat <<'EOF' /** @@ -25,8 +25,10 @@ HEADER=$(cat <<'EOF' EOF) +LATEST_SPEC=$(curl "$SPEC_BASE/nexus-latest.json") + # use versions of these packages specified in dev deps -npm run openapi-gen-ts -- $SPEC_URL $GEN_DIR --features msw +npm run openapi-gen-ts -- "$SPEC_BASE/$LATEST_SPEC" $GEN_DIR --features msw for f in Api.ts msw-handlers.ts validate.ts; do (printf '%s\n\n' "$HEADER"; cat "$GEN_DIR/$f") > "$GEN_DIR/$f.tmp"