From 3b86046d12041fb9cb3a1dd2867e2307867cd410 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 3 Dec 2025 16:32:51 -0600 Subject: [PATCH 01/10] bump API with versioning --- OMICRON_VERSION | 2 +- app/api/__generated__/Api.ts | 421 +++++++++++++++++++++++++- app/api/__generated__/OMICRON_VERSION | 2 +- app/api/__generated__/msw-handlers.ts | 160 ++++++++++ app/api/__generated__/validate.ts | 245 ++++++++++++++- mock-api/disk.ts | 12 + mock-api/msw/handlers.ts | 14 + tools/generate_api_client.sh | 6 +- 8 files changed, 843 insertions(+), 19 deletions(-) diff --git a/OMICRON_VERSION b/OMICRON_VERSION index ad0d5dd2e..05e386415 100644 --- a/OMICRON_VERSION +++ b/OMICRON_VERSION @@ -1 +1 @@ -a6e111cf72ab987b2ea5acd7d26610ea2a55bf0f +f65b0e47d593c7e6a7f328d7130b0361d85cab9a diff --git a/app/api/__generated__/Api.ts b/app/api/__generated__/Api.ts index e4aa49ac4..f2be319a4 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 = 'crucible' + /** * 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 */ @@ -1885,9 +1892,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 +1909,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 } @@ -2427,6 +2434,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 +2558,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 +3029,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 +3073,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 +5748,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 +5970,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 +6369,10 @@ export interface SystemMetricQueryParams { silo?: NameOrId } +export interface LookupMulticastGroupByIpPathParams { + address: string +} + export interface NetworkingAddressLotListQueryParams { limit?: number | null pageToken?: string | null @@ -6652,7 +6851,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 = '2025112000.0.0' constructor({ host = '', baseParams = {}, token }: ApiConfig = {}) { this.host = host @@ -8102,6 +8301,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 +8801,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 +9890,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..feecf019a 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 +f65b0e47d593c7e6a7f328d7130b0361d85cab9a 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..69cba6169 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(['crucible'])) + /** * 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, @@ -1725,9 +1728,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 +1748,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(), }) @@ -2234,6 +2237,7 @@ 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({ @@ -2332,6 +2336,7 @@ export const InstanceUpdate = z.preprocess( bootDisk: NameOrId.nullable(), cpuPlatform: InstanceCpuPlatform.nullable(), memory: ByteCount, + multicastGroups: NameOrId.array().default(null).optional(), ncpus: InstanceCpuCount, }) ) @@ -2791,6 +2796,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().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().optional(), + }) +) + /** * The type of network interface */ @@ -5639,6 +5735,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 +6127,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 +6934,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/mock-api/disk.ts b/mock-api/disk.ts index 2a6f8e003..330b1dff3 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: 'crucible', } export const disk2: Json = { @@ -77,6 +78,7 @@ export const disk2: Json = { device_path: '/def', size: 4 * GiB, block_size: 2048, + disk_type: 'crucible', } export const disks: Json[] = [ @@ -94,6 +96,7 @@ export const disks: Json[] = [ device_path: '/ghi', size: 6 * GiB, block_size: 2048, + disk_type: 'crucible', }, { 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: 'crucible', }, { 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: 'crucible', }, { 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: 'crucible', }, { 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: 'crucible', }, { 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: 'crucible', }, { 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: 'crucible', }, { 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: 'crucible', }, { 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: 'crucible', }, // 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: 'crucible' as const, } }), ] diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index b6995b6f2..c6d53693d 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -161,6 +161,7 @@ export const handlers = makeHandlers({ // 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, + disk_type: 'crucible', ...getTimestamps(), } db.disks.push(newDisk) @@ -490,6 +491,7 @@ export const handlers = makeHandlers({ state: { state: 'attached', instance: instanceId }, device_path: '/mnt/disk', block_size: disk_source.type === 'blank' ? disk_source.block_size : 4096, + disk_type: 'crucible', ...getTimestamps(), } db.disks.push(newDisk) @@ -1961,6 +1963,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 +1983,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/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" From 8aae3c9f38f9531e330fd7eb7ab61a01d42d4176 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 3 Dec 2025 16:58:41 -0600 Subject: [PATCH 02/10] tentative validators fix (need the generator to do it) --- app/api/__generated__/validate.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/api/__generated__/validate.ts b/app/api/__generated__/validate.ts index 69cba6169..1cbc6020d 100644 --- a/app/api/__generated__/validate.ts +++ b/app/api/__generated__/validate.ts @@ -2336,7 +2336,7 @@ export const InstanceUpdate = z.preprocess( bootDisk: NameOrId.nullable(), cpuPlatform: InstanceCpuPlatform.nullable(), memory: ByteCount, - multicastGroups: NameOrId.array().default(null).optional(), + multicastGroups: NameOrId.array().nullable().default(null).optional(), ncpus: InstanceCpuCount, }) ) @@ -2826,7 +2826,7 @@ export const MulticastGroupCreate = z.preprocess( mvlan: z.number().min(0).max(65535).nullable().optional(), name: Name, pool: NameOrId.nullable().default(null).optional(), - sourceIps: z.ipv4().array().default(null).optional(), + sourceIps: z.ipv4().array().nullable().default(null).optional(), }) ) From fe507b4ae29d07799400afed0b01200ed6cf173e Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 3 Dec 2025 17:33:33 -0600 Subject: [PATCH 03/10] actually fix it through the generator --- app/api/__generated__/validate.ts | 14 +++++++------- package-lock.json | 8 ++++---- package.json | 2 +- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/app/api/__generated__/validate.ts b/app/api/__generated__/validate.ts index 1cbc6020d..9833d2d00 100644 --- a/app/api/__generated__/validate.ts +++ b/app/api/__generated__/validate.ts @@ -2243,7 +2243,7 @@ export const InstanceCreate = z.preprocess( 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(), }) @@ -2705,7 +2705,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(), }) ) @@ -2883,7 +2883,7 @@ export const MulticastGroupUpdate = z.preprocess( description: z.string().nullable().optional(), mvlan: z.number().min(0).max(65535).nullable().optional(), name: Name.nullable().optional(), - sourceIps: z.ipv4().array().optional(), + sourceIps: z.ipv4().array().nullable().optional(), }) ) @@ -2960,7 +2960,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(), }) @@ -4425,9 +4425,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(), }) ) 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", From 49b757d1733690c1355ab9ab9352702d6f77fd56 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 10 Dec 2025 12:36:56 -0600 Subject: [PATCH 04/10] update api to latest for distributed/local disks --- OMICRON_VERSION | 2 +- app/api/__generated__/Api.ts | 25 ++++++++++++++++++------- app/api/__generated__/OMICRON_VERSION | 2 +- app/api/__generated__/validate.ts | 24 ++++++++++++++++++++---- 4 files changed, 40 insertions(+), 13 deletions(-) diff --git a/OMICRON_VERSION b/OMICRON_VERSION index 05e386415..1eb5dc7c1 100644 --- a/OMICRON_VERSION +++ b/OMICRON_VERSION @@ -1 +1 @@ -f65b0e47d593c7e6a7f328d7130b0361d85cab9a +5d08d2fdec25807e367ff6cab043a0b27b30228e diff --git a/app/api/__generated__/Api.ts b/app/api/__generated__/Api.ts index f2be319a4..35b85f3a3 100644 --- a/app/api/__generated__/Api.ts +++ b/app/api/__generated__/Api.ts @@ -1781,7 +1781,7 @@ export type DeviceAuthVerify = { userCode: string } export type Digest = { type: 'sha256'; value: string } -export type DiskType = 'crucible' +export type DiskType = 'distributed' | 'local' /** * State of a Disk @@ -1839,7 +1839,7 @@ export type Disk = { } /** - * Different sources for a disk + * Different sources for a Distributed Disk */ export type DiskSource = /** Create a blank disk */ @@ -1855,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 @@ -2354,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 @@ -6851,7 +6862,7 @@ export class Api { * Pulled from info.version in the OpenAPI schema. Sent in the * `api-version` header on all requests. */ - apiVersion = '2025112000.0.0' + apiVersion = '2025120300.0.0' constructor({ host = '', baseParams = {}, token }: ApiConfig = {}) { this.host = host diff --git a/app/api/__generated__/OMICRON_VERSION b/app/api/__generated__/OMICRON_VERSION index feecf019a..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 -f65b0e47d593c7e6a7f328d7130b0361d85cab9a +5d08d2fdec25807e367ff6cab043a0b27b30228e diff --git a/app/api/__generated__/validate.ts b/app/api/__generated__/validate.ts index 9833d2d00..76dc7ed43 100644 --- a/app/api/__generated__/validate.ts +++ b/app/api/__generated__/validate.ts @@ -1640,7 +1640,7 @@ export const Digest = z.preprocess( z.object({ type: z.enum(['sha256']), value: z.string() }) ) -export const DiskType = z.preprocess(processResponseBody, z.enum(['crucible'])) +export const DiskType = z.preprocess(processResponseBody, z.enum(['distributed', 'local'])) /** * State of a Disk @@ -1686,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, @@ -1698,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 })) @@ -2186,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']), From e4e418016bfd03f21aedb53a8873437dd10fcec0 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 10 Dec 2025 12:39:17 -0600 Subject: [PATCH 05/10] provisionally fix most type errors --- app/api/__tests__/client.spec.tsx | 5 ++++- app/forms/disk-create.tsx | 15 +++++++++------ app/forms/image-upload.tsx | 5 ++++- app/forms/instance-create.tsx | 5 ++++- mock-api/disk.ts | 24 ++++++++++++------------ mock-api/msw/handlers.ts | 23 ++++++++++++++++------- mock-api/msw/util.ts | 24 ++++++++++++++---------- 7 files changed, 63 insertions(+), 38 deletions(-) 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/forms/disk-create.tsx b/app/forms/disk-create.tsx index 0530a8f86..55e93c91a 100644 --- a/app/forms/disk-create.tsx +++ b/app/forms/disk-create.tsx @@ -17,8 +17,8 @@ import { useApiMutation, type BlockSize, type Disk, + type DiskBackend, type DiskCreate, - type DiskSource, type Image, } from '@oxide/api' @@ -41,16 +41,19 @@ import { toLocaleDateString } from '~/util/date' import { diskSizeNearest10 } from '~/util/math' import { bytesToGiB, GiB } from '~/util/units' -const blankDiskSource: DiskSource = { - type: 'blank', - blockSize: 4096, +const blankDiskBackend: DiskBackend = { + type: 'distributed', + diskSource: { + type: 'blank', + blockSize: 4096, + }, } const defaultValues: DiskCreate = { name: '', description: '', size: 10, - diskSource: blankDiskSource, + diskBackend: blankDiskBackend, } type CreateSideModalFormProps = { @@ -185,7 +188,7 @@ const DiskSourceField = ({ const newType = event.target.value as DiskCreate['diskSource']['type'] // need to include blockSize when switching back to blank - onChange(newType === 'blank' ? blankDiskSource : { type: newType }) + onChange(newType === 'blank' ? blankDiskBackend : { type: newType }) }} > Blank 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/mock-api/disk.ts b/mock-api/disk.ts index 330b1dff3..b0239335d 100644 --- a/mock-api/disk.ts +++ b/mock-api/disk.ts @@ -64,7 +64,7 @@ export const disk1: Json = { device_path: '/abc', size: 2 * GiB, block_size: 2048, - disk_type: 'crucible', + disk_type: 'distributed', } export const disk2: Json = { @@ -78,7 +78,7 @@ export const disk2: Json = { device_path: '/def', size: 4 * GiB, block_size: 2048, - disk_type: 'crucible', + disk_type: 'distributed', } export const disks: Json[] = [ @@ -96,7 +96,7 @@ export const disks: Json[] = [ device_path: '/ghi', size: 6 * GiB, block_size: 2048, - disk_type: 'crucible', + disk_type: 'distributed', }, { id: '5695b16d-e1d6-44b0-a75c-7b4299831540', @@ -109,7 +109,7 @@ export const disks: Json[] = [ device_path: '/jkl', size: 64 * GiB, block_size: 2048, - disk_type: 'crucible', + disk_type: 'distributed', }, { id: '4d6f4c76-675f-4cda-b609-f3b8b301addb', @@ -122,7 +122,7 @@ export const disks: Json[] = [ device_path: '/jkl', size: 128 * GiB, block_size: 2048, - disk_type: 'crucible', + disk_type: 'distributed', }, { id: '41481936-5a6b-4dcd-8dec-26c3bdc343bd', @@ -135,7 +135,7 @@ export const disks: Json[] = [ device_path: '/jkl', size: 20 * GiB, block_size: 2048, - disk_type: 'crucible', + disk_type: 'distributed', }, { id: '704cd392-9f6b-4a2b-8410-1f1e0794db80', @@ -148,7 +148,7 @@ export const disks: Json[] = [ device_path: '/jkl', size: 24 * GiB, block_size: 2048, - disk_type: 'crucible', + disk_type: 'distributed', }, { id: '305ee9c7-1930-4a8f-86d7-ed9eece9598e', @@ -161,7 +161,7 @@ export const disks: Json[] = [ device_path: '/jkl', size: 16 * GiB, block_size: 2048, - disk_type: 'crucible', + disk_type: 'distributed', }, { id: 'ccad8d48-df21-4a80-8c16-683ee6bfb290', @@ -174,7 +174,7 @@ export const disks: Json[] = [ device_path: '/jkl', size: 32 * GiB, block_size: 2048, - disk_type: 'crucible', + disk_type: 'distributed', }, { id: 'a028160f-603c-4562-bb71-d2d76f1ac2a8', @@ -187,7 +187,7 @@ export const disks: Json[] = [ device_path: '/jkl', size: 24 * GiB, block_size: 2048, - disk_type: 'crucible', + disk_type: 'distributed', }, { id: '3f23c80f-c523-4d86-8292-2ca3f807bb12', @@ -200,7 +200,7 @@ export const disks: Json[] = [ device_path: '/jkl', size: 12 * GiB, block_size: 2048, - disk_type: 'crucible', + 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) => { @@ -216,7 +216,7 @@ export const disks: Json[] = [ device_path: '/jkl', size: 12 * GiB, block_size: 2048, - disk_type: 'crucible' as const, + disk_type: 'distributed' as const, } }), ] diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index c6d53693d..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,8 +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, - disk_type: 'crucible', + 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) @@ -481,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, @@ -490,8 +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, - disk_type: 'crucible', + // 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) 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( From 218c5a7fc541b67c8930f0b0525cbf324f4c760e Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 10 Dec 2025 12:47:53 -0600 Subject: [PATCH 06/10] claude fix type errors --- app/forms/disk-create.tsx | 52 +++++++++++++++++++-------------------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/app/forms/disk-create.tsx b/app/forms/disk-create.tsx index 55e93c91a..207ba6eba 100644 --- a/app/forms/disk-create.tsx +++ b/app/forms/disk-create.tsx @@ -17,8 +17,8 @@ import { useApiMutation, type BlockSize, type Disk, - type DiskBackend, type DiskCreate, + type DiskSource, type Image, } from '@oxide/api' @@ -41,19 +41,16 @@ import { toLocaleDateString } from '~/util/date' import { diskSizeNearest10 } from '~/util/math' import { bytesToGiB, GiB } from '~/util/units' -const blankDiskBackend: DiskBackend = { - type: 'distributed', - diskSource: { - type: 'blank', - blockSize: 4096, - }, +const blankDiskSource: DiskSource = { + type: 'blank', + blockSize: 4096, } const defaultValues: DiskCreate = { name: '', description: '', size: 10, - diskBackend: blankDiskBackend, + diskBackend: { type: 'distributed', diskSource: blankDiskSource }, } type CreateSideModalFormProps = { @@ -100,19 +97,22 @@ 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 ( @@ -153,7 +153,7 @@ export function CreateDiskSideModalForm({ control={form.control} validate={(diskSizeGiB: number) => { 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)` } }} /> @@ -172,7 +172,7 @@ const DiskSourceField = ({ }) => { const { field: { value, onChange }, - } = useController({ control, name: 'diskSource' }) + } = useController({ control, name: 'diskBackend.diskSource' }) const diskSizeField = useController({ control, name: 'size' }).field return ( @@ -185,10 +185,10 @@ const DiskSourceField = ({ column defaultChecked={value.type} onChange={(event) => { - const newType = event.target.value as DiskCreate['diskSource']['type'] + const newType = event.target.value as DiskSource['type'] // need to include blockSize when switching back to blank - onChange(newType === 'blank' ? blankDiskBackend : { type: newType }) + onChange(newType === 'blank' ? blankDiskSource : { type: newType }) }} > Blank @@ -200,7 +200,7 @@ const DiskSourceField = ({ {value.type === 'blank' && ( }) => { return ( { From 5cb4504b7a7768b4a12fbc8fe999985d312dc8b2 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 10 Dec 2025 15:09:30 -0600 Subject: [PATCH 07/10] disk type radio --- app/forms/disk-create.tsx | 95 +++++++++++++++++++++++++++++++-------- test/e2e/disks.e2e.ts | 14 ++++++ 2 files changed, 90 insertions(+), 19 deletions(-) diff --git a/app/forms/disk-create.tsx b/app/forms/disk-create.tsx index 207ba6eba..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' @@ -110,7 +110,9 @@ export function CreateDiskSideModalForm({ )?.size validateSizeGiB = selectedSnapshotSize ? bytesToGiB(selectedSnapshotSize) : undefined } else if (diskSource.type === 'image') { - const selectedImageSize = images.find((image) => image.id === diskSource.imageId)?.size + const selectedImageSize = images.find( + (image) => image.id === diskSource.imageId + )?.size validateSizeGiB = selectedImageSize ? bytesToGiB(selectedImageSize) : undefined } } @@ -142,12 +144,6 @@ export function CreateDiskSideModalForm({ }} /> - - + ) } -const DiskSourceField = ({ +const DiskBackendField = ({ control, images, areImagesLoading, @@ -171,10 +172,64 @@ const DiskSourceField = ({ areImagesLoading: boolean }) => { const { - field: { value, onChange }, - } = useController({ control, name: 'diskBackend.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 ( <>
@@ -183,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 DiskSource['type'] - - // need to include blockSize when switching back to blank - onChange(newType === 'blank' ? blankDiskSource : { type: newType }) + // 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 @@ -197,7 +254,7 @@ 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)) @@ -231,7 +288,7 @@ const DiskSourceField = ({ /> )} - {value.type === 'snapshot' && } + {diskSource.type === 'snapshot' && }
) 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 */ }) From fb7af7bdfe43f4721413220c79bf4d6f9bb45198 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Thu, 11 Dec 2025 17:52:09 -0600 Subject: [PATCH 08/10] disk type column on disk lists --- app/components/StateBadge.tsx | 7 +++++++ app/pages/project/disks/DisksPage.tsx | 6 +++++- app/pages/project/instances/StorageTab.tsx | 6 +++++- 3 files changed, 17 insertions(+), 2 deletions(-) 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/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', From 6363460a6e0075a4768f32fb693a3404e3095730 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Sat, 20 Dec 2025 09:52:18 -0600 Subject: [PATCH 09/10] update API with disk type object --- OMICRON_VERSION | 2 +- app/api/__generated__/Api.ts | 24 +++++++++---- app/api/__generated__/OMICRON_VERSION | 2 +- app/api/__generated__/validate.ts | 16 ++++++--- app/components/StateBadge.tsx | 2 +- .../project/disks/DiskDetailSideModal.tsx | 6 ++-- mock-api/disk.ts | 36 +++++++------------ mock-api/msw/handlers.ts | 20 ++++++++--- 8 files changed, 64 insertions(+), 44 deletions(-) diff --git a/OMICRON_VERSION b/OMICRON_VERSION index 1eb5dc7c1..886233ba4 100644 --- a/OMICRON_VERSION +++ b/OMICRON_VERSION @@ -1 +1 @@ -5d08d2fdec25807e367ff6cab043a0b27b30228e +8f96790a5f67e4ffdee83e5b9b98a81fddd2035a diff --git a/app/api/__generated__/Api.ts b/app/api/__generated__/Api.ts index 35b85f3a3..41d96b93f 100644 --- a/app/api/__generated__/Api.ts +++ b/app/api/__generated__/Api.ts @@ -956,6 +956,9 @@ export type BgpPeerState = /** Waiting for keepaliave or notification from peer. */ | 'open_confirm' + /** There is an ongoing Connection Collision that hasn't yet been resolved. Two connections are maintained until one connection receives an Open or is able to progress into Established. */ + | 'connection_collision' + /** Synchronizing with peer. */ | 'session_setup' @@ -1781,7 +1784,19 @@ export type DeviceAuthVerify = { userCode: string } export type Digest = { type: 'sha256'; value: string } -export type DiskType = 'distributed' | 'local' +export type DiskType = + | { + /** ID of image from which disk was created, if any */ + imageId?: string | null + /** ID of snapshot from which disk was created, if any */ + snapshotId?: string | null + type: 'distributed' + } + | { + /** ID of the sled this local disk is allocated on, if it has been allocated. Once allocated it cannot be changed or migrated. */ + sledId?: string | null + type: 'local' + } /** * State of a Disk @@ -1819,18 +1834,13 @@ export type Disk = { blockSize: ByteCount /** 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 */ - imageId?: string | null /** unique, mutable, user-controlled identifier for each resource */ name: Name projectId: string size: ByteCount - /** ID of snapshot from which disk was created, if any */ - snapshotId?: string | null state: DiskState /** timestamp when this resource was created */ timeCreated: Date @@ -6862,7 +6872,7 @@ export class Api { * Pulled from info.version in the OpenAPI schema. Sent in the * `api-version` header on all requests. */ - apiVersion = '2025120300.0.0' + apiVersion = '2025121800.0.0' constructor({ host = '', baseParams = {}, token }: ApiConfig = {}) { this.host = host diff --git a/app/api/__generated__/OMICRON_VERSION b/app/api/__generated__/OMICRON_VERSION index 56b55a2f3..015f7c594 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 -5d08d2fdec25807e367ff6cab043a0b27b30228e +8f96790a5f67e4ffdee83e5b9b98a81fddd2035a diff --git a/app/api/__generated__/validate.ts b/app/api/__generated__/validate.ts index 76dc7ed43..8d9f7ca2e 100644 --- a/app/api/__generated__/validate.ts +++ b/app/api/__generated__/validate.ts @@ -875,6 +875,7 @@ export const BgpPeerState = z.preprocess( 'active', 'open_sent', 'open_confirm', + 'connection_collision', 'session_setup', 'established', ]) @@ -1640,7 +1641,17 @@ export const Digest = z.preprocess( z.object({ type: z.enum(['sha256']), value: z.string() }) ) -export const DiskType = z.preprocess(processResponseBody, z.enum(['distributed', 'local'])) +export const DiskType = z.preprocess( + processResponseBody, + z.union([ + z.object({ + imageId: z.uuid().nullable().optional(), + snapshotId: z.uuid().nullable().optional(), + type: z.enum(['distributed']), + }), + z.object({ sledId: z.uuid().nullable().optional(), type: z.enum(['local']) }), + ]) +) /** * State of a Disk @@ -1671,14 +1682,11 @@ export const Disk = z.preprocess( z.object({ blockSize: ByteCount, description: z.string(), - devicePath: z.string(), diskType: DiskType, id: z.uuid(), - imageId: z.uuid().nullable().optional(), name: Name, projectId: z.uuid(), size: ByteCount, - snapshotId: z.uuid().nullable().optional(), state: DiskState, timeCreated: z.coerce.date(), timeModified: z.coerce.date(), diff --git a/app/components/StateBadge.tsx b/app/components/StateBadge.tsx index 3d0e664ff..4070ac50b 100644 --- a/app/components/StateBadge.tsx +++ b/app/components/StateBadge.tsx @@ -87,6 +87,6 @@ export const SnapshotStateBadge = (props: { state: SnapshotState; className?: st export const DiskTypeBadge = (props: { diskType: DiskType; className?: string }) => ( - {props.diskType} + {props.diskType.type} ) diff --git a/app/pages/project/disks/DiskDetailSideModal.tsx b/app/pages/project/disks/DiskDetailSideModal.tsx index 5d3facb7d..859dddceb 100644 --- a/app/pages/project/disks/DiskDetailSideModal.tsx +++ b/app/pages/project/disks/DiskDetailSideModal.tsx @@ -92,10 +92,12 @@ export function DiskDetailSideModal({ {disk.blockSize.toLocaleString()} bytes - {disk.imageId ?? } + {(disk.diskType.type === 'distributed' && disk.diskType.imageId) ?? } - {disk.snapshotId ?? } + {(disk.diskType.type === 'distributed' && disk.diskType.snapshotId) ?? ( + + )} diff --git a/mock-api/disk.ts b/mock-api/disk.ts index 4178dd9cd..f2abffee4 100644 --- a/mock-api/disk.ts +++ b/mock-api/disk.ts @@ -61,10 +61,9 @@ export const disk1: Json = { time_created: new Date().toISOString(), time_modified: new Date().toISOString(), state: { state: 'attached', instance: instance.id }, - device_path: '/abc', size: 2 * GiB, block_size: 2048, - disk_type: 'distributed', + disk_type: { type: 'distributed' }, } export const disk2: Json = { @@ -75,10 +74,9 @@ export const disk2: Json = { time_created: new Date().toISOString(), time_modified: new Date().toISOString(), state: { state: 'attached', instance: instance.id }, - device_path: '/def', size: 4 * GiB, block_size: 2048, - disk_type: 'distributed', + disk_type: { type: 'distributed' }, } export const disks: Json[] = [ @@ -93,10 +91,9 @@ export const disks: Json[] = [ time_created: '2025-02-13T01:02:03.134789034233Z', time_modified: new Date().toISOString(), state: { state: 'detached' }, - device_path: '/ghi', size: 6 * GiB, block_size: 2048, - disk_type: 'distributed', + disk_type: { type: 'distributed' }, }, { id: '5695b16d-e1d6-44b0-a75c-7b4299831540', @@ -106,10 +103,9 @@ export const disks: Json[] = [ time_created: new Date().toISOString(), time_modified: new Date().toISOString(), state: { state: 'detached' }, - device_path: '/jkl', size: 64 * GiB, block_size: 2048, - disk_type: 'distributed', + disk_type: { type: 'distributed' }, }, { id: '4d6f4c76-675f-4cda-b609-f3b8b301addb', @@ -120,10 +116,9 @@ export const disks: Json[] = [ time_created: new Date().toISOString(), time_modified: new Date().toISOString(), state: { state: 'detached' }, - device_path: '/jkl', size: 128 * GiB, block_size: 2048, - disk_type: 'distributed', + disk_type: { type: 'distributed' }, }, { id: '41481936-5a6b-4dcd-8dec-26c3bdc343bd', @@ -133,10 +128,9 @@ export const disks: Json[] = [ time_created: new Date().toISOString(), time_modified: new Date().toISOString(), state: { state: 'detached' }, - device_path: '/jkl', size: 20 * GiB, block_size: 2048, - disk_type: 'distributed', + disk_type: { type: 'distributed' }, }, { id: '704cd392-9f6b-4a2b-8410-1f1e0794db80', @@ -146,10 +140,9 @@ export const disks: Json[] = [ time_created: new Date().toISOString(), time_modified: new Date().toISOString(), state: { state: 'detached' }, - device_path: '/jkl', size: 24 * GiB, block_size: 2048, - disk_type: 'distributed', + disk_type: { type: 'distributed' }, }, { id: '305ee9c7-1930-4a8f-86d7-ed9eece9598e', @@ -159,10 +152,9 @@ export const disks: Json[] = [ time_created: new Date().toISOString(), time_modified: new Date().toISOString(), state: { state: 'detached' }, - device_path: '/jkl', size: 16 * GiB, block_size: 2048, - disk_type: 'distributed', + disk_type: { type: 'distributed' }, }, { id: 'ccad8d48-df21-4a80-8c16-683ee6bfb290', @@ -172,10 +164,9 @@ export const disks: Json[] = [ time_created: new Date().toISOString(), time_modified: new Date().toISOString(), state: { state: 'detached' }, - device_path: '/jkl', size: 32 * GiB, block_size: 2048, - disk_type: 'distributed', + disk_type: { type: 'distributed' }, }, { id: 'a028160f-603c-4562-bb71-d2d76f1ac2a8', @@ -185,10 +176,9 @@ export const disks: Json[] = [ time_created: new Date().toISOString(), time_modified: new Date().toISOString(), state: { state: 'detached' }, - device_path: '/jkl', size: 24 * GiB, block_size: 2048, - disk_type: 'distributed', + disk_type: { type: 'distributed' }, }, { id: '3f23c80f-c523-4d86-8292-2ca3f807bb12', @@ -198,10 +188,9 @@ export const disks: Json[] = [ time_created: new Date().toISOString(), time_modified: new Date().toISOString(), state: { state: 'detached' }, - device_path: '/jkl', size: 12 * GiB, block_size: 2048, - disk_type: 'distributed', + disk_type: { type: 'distributed' }, }, // put a ton of disks in project 2 so we can use it to test comboboxes ...Array.from({ length: 1010 }).map((_, i) => { @@ -214,10 +203,9 @@ export const disks: Json[] = [ time_created: new Date().toISOString(), time_modified: new Date().toISOString(), state: randomDiskState(), - device_path: '/jkl', size: 12 * GiB, block_size: 2048, - disk_type: 'distributed' as const, + disk_type: { type: 'distributed' } as const, } }), ] diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index fa22c7883..f67d22583 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -157,7 +157,6 @@ export const handlers = makeHandlers({ disk_backend.disk_source.type === 'importing_blocks' ? { state: 'import_ready' } : { state: 'detached' }, - device_path: '/mnt/disk', name, description, size, @@ -167,7 +166,14 @@ export const handlers = makeHandlers({ disk_backend.type === 'distributed' && disk_backend.disk_source.type === 'blank' ? disk_backend.disk_source.block_size : 512, - disk_type: disk_backend.type, + disk_type: match(disk_backend) + .with({ type: 'distributed' }, ({ disk_source }) => ({ + type: 'distributed' as const, + image_id: disk_source.type === 'image' ? disk_source.image_id : null, + snapshot_id: disk_source.type === 'snapshot' ? disk_source.snapshot_id : null, + })) + .with({ type: 'local' }, () => ({ type: 'local' as const })) + .exhaustive(), ...getTimestamps(), } db.disks.push(newDisk) @@ -499,13 +505,19 @@ export const handlers = makeHandlers({ size, project_id: project.id, state: { state: 'attached', instance: instanceId }, - device_path: '/mnt/disk', // 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, + disk_type: match(disk_backend) + .with({ type: 'distributed' }, ({ disk_source }) => ({ + type: 'distributed' as const, + image_id: disk_source.type === 'image' ? disk_source.image_id : null, + snapshot_id: disk_source.type === 'snapshot' ? disk_source.snapshot_id : null, + })) + .with({ type: 'local' }, () => ({ type: 'local' as const })) + .exhaustive(), ...getTimestamps(), } db.disks.push(newDisk) From 2d242238e42641f2a4d3af9c1c2b6d540efcee55 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Tue, 23 Dec 2025 13:53:52 -0500 Subject: [PATCH 10/10] add disk type to disk details side modal --- app/pages/project/disks/DiskDetailSideModal.tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/app/pages/project/disks/DiskDetailSideModal.tsx b/app/pages/project/disks/DiskDetailSideModal.tsx index 859dddceb..423dc5538 100644 --- a/app/pages/project/disks/DiskDetailSideModal.tsx +++ b/app/pages/project/disks/DiskDetailSideModal.tsx @@ -16,7 +16,7 @@ import { api, q, queryClient, usePrefetchedQuery, type Disk } from '@oxide/api' import { Storage16Icon } from '@oxide/design-system/icons/react' import { ReadOnlySideModalForm } from '~/components/form/ReadOnlySideModalForm' -import { DiskStateBadge } from '~/components/StateBadge' +import { DiskStateBadge, DiskTypeBadge } from '~/components/StateBadge' import { titleCrumb } from '~/hooks/use-crumbs' import { getDiskSelector, useDiskSelector } from '~/hooks/use-params' import { EmptyCell } from '~/table/cells/EmptyCell' @@ -87,15 +87,24 @@ export function DiskDetailSideModal({ + + + {/* TODO: show attached instance by name like the table does? */} {disk.blockSize.toLocaleString()} bytes - {(disk.diskType.type === 'distributed' && disk.diskType.imageId) ?? } + {disk.diskType.type === 'distributed' && disk.diskType.imageId ? ( + disk.diskType.imageId + ) : ( + + )} - {(disk.diskType.type === 'distributed' && disk.diskType.snapshotId) ?? ( + {disk.diskType.type === 'distributed' && disk.diskType.snapshotId ? ( + disk.diskType.snapshotId + ) : ( )}