diff --git a/OMICRON_VERSION b/OMICRON_VERSION index f18fa05d6..02aaab0f8 100644 --- a/OMICRON_VERSION +++ b/OMICRON_VERSION @@ -1 +1 @@ -99ffcbe2b1f4bddc4be85e45d9d1a0d920e2201b +c65212d77d38581632bb972b606f581bd52c3298 diff --git a/app/api/__generated__/Api.ts b/app/api/__generated__/Api.ts index ba3609b9b..456a6fb4d 100644 --- a/app/api/__generated__/Api.ts +++ b/app/api/__generated__/Api.ts @@ -55,7 +55,7 @@ export type Address = { export type AddressConfig = { /** The set of addresses assigned to the port configuration. */ addresses: Address[] - /** Link to assign the address to */ + /** Link to assign the addresses to. On ports that are not broken out, this is always phy0. On a 2x breakout the options are phy0 and phy1, on 4x phy0-phy3, etc. */ linkName: Name } @@ -613,6 +613,18 @@ export type AntiAffinityGroupResultsPage = { */ export type AntiAffinityGroupUpdate = { description?: string | null; name?: Name | null } +/** + * An identifier for an artifact. + */ +export type ArtifactId = { + /** The kind of artifact this is. */ + kind: string + /** The artifact's name. */ + name: string + /** The artifact's version. */ + version: string +} + /** * Authorization scope for a timeseries. * @@ -837,7 +849,7 @@ export type BgpPeer = { /** How long to hold a peer in idle before attempting a new session (seconds). */ idleHoldTime: number /** The name of interface to peer on. This is relative to the port configuration this BGP peer configuration is a part of. For example this value could be phy0 to refer to a primary physical interface. Or it could be vlan47 to refer to a VLAN interface. */ - interfaceName: string + interfaceName: Name /** How often to send keepalive requests (seconds). */ keepalive: number /** Apply a local preference to routes received from this peer. */ @@ -855,7 +867,7 @@ export type BgpPeer = { } export type BgpPeerConfig = { - /** Link that the peer is reachable on */ + /** Link that the peer is reachable on. On ports that are not broken out, this is always phy0. On a 2x breakout the options are phy0 and phy1, on 4x phy0-phy3, etc. */ linkName: Name peers: BgpPeer[] } @@ -1646,6 +1658,7 @@ export type DeviceAccessToken = { /** A unique, immutable, system-controlled identifier for the token. Note that this ID is not the bearer token itself, which starts with "oxide-token-" */ id: string timeCreated: Date + /** Expiration timestamp. A null value means the token does not automatically expire. */ timeExpires?: Date | null } @@ -1816,7 +1829,7 @@ export type EphemeralIpCreate = { } export type ExternalIp = - | { ip: string; kind: 'ephemeral' } + | { ip: string; ipPoolId: string; kind: 'ephemeral' } /** A Floating IP is a well-known IP address which can be attached and detached from instances. */ | { /** human-readable free-form text about a resource */ @@ -2040,6 +2053,13 @@ export type GroupResultsPage = { */ export type Hostname = string +/** + * A range of ICMP(v6) types or codes + * + * An inclusive-inclusive range of ICMP(v6) types or codes. The second value may be omitted to represent a single parameter. + */ +export type IcmpParamRange = string + export type IdentityProviderType = 'saml' /** @@ -2259,7 +2279,7 @@ Currently, the global default auto-restart policy is "best-effort", so instances This disk can either be attached if it already exists or created along with the instance. -Specifying a boot disk is optional but recommended to ensure predictable boot behavior. The boot disk can be set during instance creation or later if the instance is stopped. +Specifying a boot disk is optional but recommended to ensure predictable boot behavior. The boot disk can be set during instance creation or later if the instance is stopped. The boot disk counts against the disk attachment limit. An instance that does not have a boot disk set will use the boot options specified in its UEFI settings, which are controlled by both the instance's UEFI firmware and the guest operating system. Boot options can change as disks are attached and detached, which may result in an instance that only boots to the EFI shell until a boot disk is set. */ bootDisk?: InstanceDiskAttachment | null @@ -2268,7 +2288,7 @@ An instance that does not have a boot disk set will use the boot options specifi Disk attachments of type "create" will be created, while those of type "attach" must already exist. -The order of this list does not guarantee a boot order for the instance. Use the boot_disk attribute to specify a boot disk. */ +The order of this list does not guarantee a boot order for the instance. Use the boot_disk attribute to specify a boot disk. When boot_disk is specified it will count against the disk attachment limit. */ disks?: InstanceDiskAttachment[] /** The external IP addresses provided to this instance. @@ -2726,11 +2746,11 @@ export type TxEqConfig = { * Switch link configuration. */ export type LinkConfigCreate = { - /** Whether or not to set autonegotiation */ + /** Whether or not to set autonegotiation. */ autoneg: boolean /** The requested forward-error correction method. If this is not specified, the standard FEC for the underlying media will be applied if it can be determined. */ fec?: LinkFec | null - /** Link name */ + /** Link name. On ports that are not broken out, this is always phy0. On a 2x breakout the options are phy0 and phy1, on 4x phy0-phy3, etc. */ linkName: Name /** The link-layer discovery protocol (LLDP) configuration for the link. */ lldp: LldpLinkConfigCreate @@ -2738,7 +2758,7 @@ export type LinkConfigCreate = { mtu: number /** The speed of the link. */ speed: LinkSpeed - /** Optional tx_eq settings */ + /** Optional tx_eq settings. */ txEq?: TxEqConfig | null } @@ -3173,28 +3193,6 @@ export type RackResultsPage = { nextPage?: string | null } -/** - * A name for a built-in role - * - * Role names consist of two string components separated by dot ("."). - */ -export type RoleName = string - -/** - * View of a Role - */ -export type Role = { description: string; name: RoleName } - -/** - * A single page of results - */ -export type RoleResultsPage = { - /** list of items on this page of results */ - items: Role[] - /** token used to fetch the next page of results (if any) */ - nextPage?: string | null -} - /** * A route to a destination network through a gateway address. */ @@ -3213,7 +3211,7 @@ export type Route = { * Route configuration data associated with a switch port configuration. */ export type RouteConfig = { - /** Link the route should be active on */ + /** Link name. On ports that are not broken out, this is always phy0. On a 2x breakout the options are phy0 and phy1, on 4x phy0-phy3, etc. */ linkName: Name /** The set of routes assigned to a switch port. */ routes: Route[] @@ -3389,6 +3387,14 @@ export type SamlIdentityProviderCreate = { technicalContactEmail: string } +/** + * Configuration of inbound ICMP allowed by API services. + */ +export type ServiceIcmpConfig = { + /** When enabled, Nexus is able to receive ICMP Destination Unreachable type 3 (port unreachable) and type 4 (fragmentation needed), Redirect, and Time Exceeded messages. These enable Nexus to perform Path MTU discovery and better cope with fragmentation issues. Otherwise all inbound ICMP traffic will be dropped. */ + enabled: boolean +} + /** * Parameters for PUT requests to `/v1/system/update/target-release`. */ @@ -3644,7 +3650,7 @@ An expunged sled is always non-provisionable. */ | { kind: 'expunged' } /** - * The current state of the sled, as determined by Nexus. + * The current state of the sled. */ export type SledState = /** The sled is currently active, and has resources allocated on it. */ @@ -3666,7 +3672,7 @@ export type Sled = { policy: SledPolicy /** The rack to which this Sled is currently attached */ rackId: string - /** The current state Nexus believes the sled to be in. */ + /** The current state of the sled. */ state: SledState /** timestamp when this resource was created */ timeCreated: Date @@ -3899,7 +3905,7 @@ export type SwitchInterfaceConfig = { /** A unique identifier for this switch interface. */ id: string /** The name of this switch interface. */ - interfaceName: string + interfaceName: Name /** The switch interface kind. */ kind: SwitchInterfaceKind2 /** The port settings object this switch interface configuration belongs to. */ @@ -3929,7 +3935,7 @@ export type SwitchInterfaceKind = export type SwitchInterfaceConfigCreate = { /** What kind of switch interface this configuration represents. */ kind: SwitchInterfaceKind - /** Link the interface will be assigned to */ + /** Link name. On ports that are not broken out, this is always phy0. On a 2x breakout the options are phy0 and phy1, on 4x phy0-phy3, etc. */ linkName: Name /** Whether or not IPv6 is enabled. */ v6Enabled: boolean @@ -3944,7 +3950,7 @@ export type SwitchPort = { /** The id of the switch port. */ id: string /** The name of this switch port. */ - portName: string + portName: Name /** The primary settings group of this switch port. Will be `None` until this switch port is configured. */ portSettingsId?: string | null /** The rack this switch port belongs to. */ @@ -3966,7 +3972,7 @@ export type SwitchPortAddressView = { /** The name of the address lot this address is drawn from. */ addressLotName: Name /** The interface name this address belongs to. */ - interfaceName: string + interfaceName: Name /** The port settings object this address configuration belongs to. */ portSettingsId: string /** An optional VLAN ID */ @@ -4050,7 +4056,7 @@ export type SwitchPortLinkConfig = { /** The requested forward-error correction method. If this is not specified, the standard FEC for the underlying media will be applied if it can be determined. */ fec?: LinkFec | null /** The name of this link. */ - linkName: string + linkName: Name /** The link-layer discovery protocol service configuration for this link. */ lldpLinkConfig?: LldpLinkConfig | null /** The maximum transmission unit for this link. */ @@ -4082,7 +4088,7 @@ export type SwitchPortRouteConfig = { /** The route's gateway address. */ gw: string /** The interface name this route configuration is assigned to. */ - interfaceName: string + interfaceName: Name /** The port settings object this route configuration belongs to. */ portSettingsId: string /** RIB Priority indicating priority within and across protocols. */ @@ -4147,19 +4153,19 @@ export type SwitchPortSettings = { * Parameters for creating switch port settings. Switch port settings are the central data structure for setting up external networking. Switch port settings include link, interface, route, address and dynamic network protocol configuration. */ export type SwitchPortSettingsCreate = { - /** Addresses indexed by interface name. */ + /** Address configurations. */ addresses: AddressConfig[] - /** BGP peers indexed by interface name. */ + /** BGP peer configurations. */ bgpPeers?: BgpPeerConfig[] description: string groups?: NameOrId[] - /** Interfaces indexed by link name. */ + /** Interface configurations. */ interfaces?: SwitchInterfaceConfigCreate[] - /** Links indexed by phy name. On ports that are not broken out, this is always phy0. On a 2x breakout the options are phy0 and phy1, on 4x phy0-phy3, etc. */ + /** Link configurations. */ links: LinkConfigCreate[] name: Name portConfig: SwitchPortConfigCreate - /** Routes indexed by interface name. */ + /** Route configurations. */ routes?: RouteConfig[] } @@ -4285,6 +4291,82 @@ export type TimeseriesSchemaResultsPage = { nextPage?: string | null } +/** + * Metadata about an individual TUF artifact. + * + * Found within a `TufRepoDescription`. + */ +export type TufArtifactMeta = { + /** The hash of the artifact. */ + hash: string + /** The artifact ID. */ + id: ArtifactId + /** The size of the artifact in bytes. */ + size: number +} + +/** + * Metadata about a TUF repository. + * + * Found within a `TufRepoDescription`. + */ +export type TufRepoMeta = { + /** The file name of the repository. + +This is purely used for debugging and may not always be correct (e.g. with wicket, we read the file contents from stdin so we don't know the correct file name). */ + fileName: string + /** The hash of the repository. + +This is a slight abuse of `ArtifactHash`, since that's the hash of individual artifacts within the repository. However, we use it here for convenience. */ + hash: string + /** The system version in artifacts.json. */ + systemVersion: string + /** The version of the targets role. */ + targetsRoleVersion: number + /** The time until which the repo is valid. */ + validUntil: Date +} + +/** + * A description of an uploaded TUF repository. + */ +export type TufRepoDescription = { + /** Information about the artifacts present in the repository. */ + artifacts: TufArtifactMeta[] + /** Information about the repository. */ + repo: TufRepoMeta +} + +/** + * Data about a successful TUF repo get from Nexus. + */ +export type TufRepoGetResponse = { + /** The description of the repository. */ + description: TufRepoDescription +} + +/** + * Status of a TUF repo import. + * + * Part of `TufRepoInsertResponse`. + */ +export type TufRepoInsertStatus = + /** The repository already existed in the database. */ + | 'already_exists' + + /** The repository did not exist, and was inserted into the database. */ + | 'inserted' + +/** + * Data about a successful TUF repo import into Nexus. + */ +export type TufRepoInsertResponse = { + /** The repository as present in the database. */ + recorded: TufRepoDescription + /** Whether this repository already existed or is new. */ + status: TufRepoInsertStatus +} + /** * A sled that has not been added to an initialized rack yet */ @@ -4305,6 +4387,28 @@ export type UninitializedSledResultsPage = { nextPage?: string | null } +/** + * Trusted root role used by the update system to verify update repositories. + */ +export type UpdatesTrustRoot = { + /** The UUID of this trusted root role. */ + id: string + /** The trusted root role itself, a JSON document as described by The Update Framework. */ + rootRole: Record + /** Time the trusted root role was added. */ + timeCreated: Date +} + +/** + * A single page of results + */ +export type UpdatesTrustRootResultsPage = { + /** list of items on this page of results */ + items: UpdatesTrustRoot[] + /** token used to fetch the next page of results (if any) */ + nextPage?: string | null +} + /** * View of a User */ @@ -4432,6 +4536,8 @@ All IPv6 subnets created from this VPC must be taken from this range, which shou name: Name } +export type VpcFirewallIcmpFilter = { code?: IcmpParamRange | null; icmpType: number } + export type VpcFirewallRuleAction = 'allow' | 'deny' export type VpcFirewallRuleDirection = 'inbound' | 'outbound' @@ -4454,7 +4560,10 @@ export type VpcFirewallRuleHostFilter = /** * The protocols that may be specified in a firewall rule's filter */ -export type VpcFirewallRuleProtocol = 'TCP' | 'UDP' | 'ICMP' +export type VpcFirewallRuleProtocol = + | { type: 'tcp' } + | { type: 'udp' } + | { type: 'icmp'; value: VpcFirewallIcmpFilter | null } /** * Filters reduce the scope of a firewall rule. Without filters, the rule applies to all packets to the targets (or from the targets, if it's an outbound rule). With multiple filters, the rule applies only to packets matching ALL filters. The maximum number of each type of filter is 256. @@ -4741,13 +4850,6 @@ export type NameOrIdSortMode = /** sort in increasing order of "id" */ | 'id_ascending' -/** - * Supported set of sort modes for scanning by id only. - * - * Currently, we only support scanning in ascending order. - */ -export type IdSortMode = 'id_ascending' - /** * Supported set of sort modes for scanning by timestamp and ID */ @@ -4771,6 +4873,13 @@ export type DiskMetricName = */ export type PaginationOrder = 'ascending' | 'descending' +/** + * Supported set of sort modes for scanning by id only. + * + * Currently, we only support scanning in ascending order. + */ +export type IdSortMode = 'id_ascending' + export type SystemMetricName = | 'virtual_disk_space_provisioned' | 'cpus_provisioned' @@ -4813,7 +4922,7 @@ export interface ProbeDeleteQueryParams { export interface SupportBundleListQueryParams { limit?: number | null pageToken?: string | null - sortBy?: IdSortMode + sortBy?: TimeAndIdSortMode } export interface SupportBundleViewPathParams { @@ -5976,15 +6085,6 @@ export interface NetworkingSwitchPortSettingsViewPathParams { port: NameOrId } -export interface RoleListQueryParams { - limit?: number | null - pageToken?: string | null -} - -export interface RoleViewPathParams { - roleName: string -} - export interface SystemQuotasListQueryParams { limit?: number | null pageToken?: string | null @@ -6036,6 +6136,28 @@ export interface SystemTimeseriesSchemaListQueryParams { pageToken?: string | null } +export interface SystemUpdatePutRepositoryQueryParams { + fileName: string +} + +export interface SystemUpdateGetRepositoryPathParams { + systemVersion: string +} + +export interface SystemUpdateTrustRootListQueryParams { + limit?: number | null + pageToken?: string | null + sortBy?: IdSortMode +} + +export interface SystemUpdateTrustRootViewPathParams { + trustRootId: string +} + +export interface SystemUpdateTrustRootDeletePathParams { + trustRootId: string +} + export interface SiloUserListQueryParams { limit?: number | null pageToken?: string | null @@ -9351,6 +9473,30 @@ export class Api extends HttpClient { ...params, }) }, + /** + * Return whether API services can receive limited ICMP traffic + */ + networkingInboundIcmpView: (_: EmptyObj, params: FetchParams = {}) => { + return this.request({ + path: `/v1/system/networking/inbound-icmp`, + method: 'GET', + ...params, + }) + }, + /** + * Set whether API services can receive limited ICMP traffic + */ + networkingInboundIcmpUpdate: ( + { body }: { body: ServiceIcmpConfig }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/system/networking/inbound-icmp`, + method: 'PUT', + body, + ...params, + }) + }, /** * List loopback addresses */ @@ -9468,30 +9614,6 @@ export class Api extends HttpClient { ...params, }) }, - /** - * List built-in roles - */ - roleList: ( - { query = {} }: { query?: RoleListQueryParams }, - params: FetchParams = {} - ) => { - return this.request({ - path: `/v1/system/roles`, - method: 'GET', - query, - ...params, - }) - }, - /** - * Fetch built-in role - */ - roleView: ({ path }: { path: RoleViewPathParams }, params: FetchParams = {}) => { - return this.request({ - path: `/v1/system/roles/${path.roleName}`, - method: 'GET', - ...params, - }) - }, /** * Lists resource quotas for all silos */ @@ -9650,6 +9772,33 @@ export class Api extends HttpClient { ...params, }) }, + /** + * Upload system release repository + */ + systemUpdatePutRepository: ( + { query }: { query: SystemUpdatePutRepositoryQueryParams }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/system/update/repository`, + method: 'PUT', + query, + ...params, + }) + }, + /** + * Fetch system release repository description by version + */ + systemUpdateGetRepository: ( + { path }: { path: SystemUpdateGetRepositoryPathParams }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/system/update/repository/${path.systemVersion}`, + method: 'GET', + ...params, + }) + }, /** * Get the current target release of the rack's system software */ @@ -9674,6 +9823,56 @@ export class Api extends HttpClient { ...params, }) }, + /** + * List root roles in the updates trust store + */ + systemUpdateTrustRootList: ( + { query = {} }: { query?: SystemUpdateTrustRootListQueryParams }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/system/update/trust-roots`, + method: 'GET', + query, + ...params, + }) + }, + /** + * Add trusted root role to updates trust store + */ + systemUpdateTrustRootCreate: (_: EmptyObj, params: FetchParams = {}) => { + return this.request({ + path: `/v1/system/update/trust-roots`, + method: 'POST', + ...params, + }) + }, + /** + * Fetch trusted root role + */ + systemUpdateTrustRootView: ( + { path }: { path: SystemUpdateTrustRootViewPathParams }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/system/update/trust-roots/${path.trustRootId}`, + method: 'GET', + ...params, + }) + }, + /** + * Delete trusted root role + */ + systemUpdateTrustRootDelete: ( + { path }: { path: SystemUpdateTrustRootDeletePathParams }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/system/update/trust-roots/${path.trustRootId}`, + method: 'DELETE', + ...params, + }) + }, /** * List built-in (system) users in silo */ diff --git a/app/api/__generated__/OMICRON_VERSION b/app/api/__generated__/OMICRON_VERSION index 907cbd5f8..b91cbc9fc 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 -99ffcbe2b1f4bddc4be85e45d9d1a0d920e2201b +c65212d77d38581632bb972b606f581bd52c3298 diff --git a/app/api/__generated__/msw-handlers.ts b/app/api/__generated__/msw-handlers.ts index 096491ef6..1588ee744 100644 --- a/app/api/__generated__/msw-handlers.ts +++ b/app/api/__generated__/msw-handlers.ts @@ -1341,6 +1341,17 @@ export interface MSWHandlers { req: Request cookies: Record }) => Promisable> + /** `GET /v1/system/networking/inbound-icmp` */ + networkingInboundIcmpView: (params: { + req: Request + cookies: Record + }) => Promisable> + /** `PUT /v1/system/networking/inbound-icmp` */ + networkingInboundIcmpUpdate: (params: { + body: Json + req: Request + cookies: Record + }) => Promisable /** `GET /v1/system/networking/loopback-address` */ networkingLoopbackAddressList: (params: { query: Api.NetworkingLoopbackAddressListQueryParams @@ -1394,18 +1405,6 @@ export interface MSWHandlers { req: Request cookies: Record }) => Promisable> - /** `GET /v1/system/roles` */ - roleList: (params: { - query: Api.RoleListQueryParams - req: Request - cookies: Record - }) => Promisable> - /** `GET /v1/system/roles/:roleName` */ - roleView: (params: { - path: Api.RoleViewPathParams - req: Request - cookies: Record - }) => Promisable> /** `GET /v1/system/silo-quotas` */ systemQuotasList: (params: { query: Api.SystemQuotasListQueryParams @@ -1481,6 +1480,18 @@ export interface MSWHandlers { req: Request cookies: Record }) => Promisable> + /** `PUT /v1/system/update/repository` */ + systemUpdatePutRepository: (params: { + query: Api.SystemUpdatePutRepositoryQueryParams + req: Request + cookies: Record + }) => Promisable> + /** `GET /v1/system/update/repository/:systemVersion` */ + systemUpdateGetRepository: (params: { + path: Api.SystemUpdateGetRepositoryPathParams + req: Request + cookies: Record + }) => Promisable> /** `GET /v1/system/update/target-release` */ targetReleaseView: (params: { req: Request @@ -1492,6 +1503,29 @@ export interface MSWHandlers { req: Request cookies: Record }) => Promisable> + /** `GET /v1/system/update/trust-roots` */ + systemUpdateTrustRootList: (params: { + query: Api.SystemUpdateTrustRootListQueryParams + req: Request + cookies: Record + }) => Promisable> + /** `POST /v1/system/update/trust-roots` */ + systemUpdateTrustRootCreate: (params: { + req: Request + cookies: Record + }) => Promisable> + /** `GET /v1/system/update/trust-roots/:trustRootId` */ + systemUpdateTrustRootView: (params: { + path: Api.SystemUpdateTrustRootViewPathParams + req: Request + cookies: Record + }) => Promisable> + /** `DELETE /v1/system/update/trust-roots/:trustRootId` */ + systemUpdateTrustRootDelete: (params: { + path: Api.SystemUpdateTrustRootDeletePathParams + req: Request + cookies: Record + }) => Promisable /** `GET /v1/system/users` */ siloUserList: (params: { query: Api.SiloUserListQueryParams @@ -2908,6 +2942,14 @@ export function makeHandlers(handlers: MSWHandlers): HttpHandler[] { '/v1/system/networking/bgp-status', handler(handlers['networkingBgpStatus'], null, null) ), + http.get( + '/v1/system/networking/inbound-icmp', + handler(handlers['networkingInboundIcmpView'], null, null) + ), + http.put( + '/v1/system/networking/inbound-icmp', + handler(handlers['networkingInboundIcmpUpdate'], null, schema.ServiceIcmpConfig) + ), http.get( '/v1/system/networking/loopback-address', handler( @@ -2969,14 +3011,6 @@ export function makeHandlers(handlers: MSWHandlers): HttpHandler[] { '/v1/system/policy', handler(handlers['systemPolicyUpdate'], null, schema.FleetRolePolicy) ), - http.get( - '/v1/system/roles', - handler(handlers['roleList'], schema.RoleListParams, null) - ), - http.get( - '/v1/system/roles/:roleName', - handler(handlers['roleView'], schema.RoleViewParams, null) - ), http.get( '/v1/system/silo-quotas', handler(handlers['systemQuotasList'], schema.SystemQuotasListParams, null) @@ -3034,6 +3068,22 @@ export function makeHandlers(handlers: MSWHandlers): HttpHandler[] { null ) ), + http.put( + '/v1/system/update/repository', + handler( + handlers['systemUpdatePutRepository'], + schema.SystemUpdatePutRepositoryParams, + null + ) + ), + http.get( + '/v1/system/update/repository/:systemVersion', + handler( + handlers['systemUpdateGetRepository'], + schema.SystemUpdateGetRepositoryParams, + null + ) + ), http.get( '/v1/system/update/target-release', handler(handlers['targetReleaseView'], null, null) @@ -3042,6 +3092,34 @@ export function makeHandlers(handlers: MSWHandlers): HttpHandler[] { '/v1/system/update/target-release', handler(handlers['targetReleaseUpdate'], null, schema.SetTargetReleaseParams) ), + http.get( + '/v1/system/update/trust-roots', + handler( + handlers['systemUpdateTrustRootList'], + schema.SystemUpdateTrustRootListParams, + null + ) + ), + http.post( + '/v1/system/update/trust-roots', + handler(handlers['systemUpdateTrustRootCreate'], null, null) + ), + http.get( + '/v1/system/update/trust-roots/:trustRootId', + handler( + handlers['systemUpdateTrustRootView'], + schema.SystemUpdateTrustRootViewParams, + null + ) + ), + http.delete( + '/v1/system/update/trust-roots/:trustRootId', + handler( + handlers['systemUpdateTrustRootDelete'], + schema.SystemUpdateTrustRootDeleteParams, + null + ) + ), http.get( '/v1/system/users', handler(handlers['siloUserList'], schema.SiloUserListParams, null) diff --git a/app/api/__generated__/validate.ts b/app/api/__generated__/validate.ts index 3c54af9f8..d94dedad7 100644 --- a/app/api/__generated__/validate.ts +++ b/app/api/__generated__/validate.ts @@ -602,6 +602,14 @@ export const AntiAffinityGroupUpdate = z.preprocess( }) ) +/** + * An identifier for an artifact. + */ +export const ArtifactId = z.preprocess( + processResponseBody, + z.object({ kind: z.string(), name: z.string(), version: z.string() }) +) + /** * Authorization scope for a timeseries. * @@ -805,7 +813,7 @@ export const BgpPeer = z.preprocess( enforceFirstAs: SafeBoolean, holdTime: z.number().min(0).max(4294967295), idleHoldTime: z.number().min(0).max(4294967295), - interfaceName: z.string(), + interfaceName: Name, keepalive: z.number().min(0).max(4294967295), localPref: z.number().min(0).max(4294967295).nullable().optional(), md5AuthKey: z.string().nullable().optional(), @@ -1711,7 +1719,11 @@ export const Error = z.preprocess( export const ExternalIp = z.preprocess( processResponseBody, z.union([ - z.object({ ip: z.string().ip(), kind: z.enum(['ephemeral']) }), + z.object({ + ip: z.string().ip(), + ipPoolId: z.string().uuid(), + kind: z.enum(['ephemeral']), + }), z.object({ description: z.string(), id: z.string().uuid(), @@ -1944,6 +1956,20 @@ export const Hostname = z.preprocess( .regex(/^([a-zA-Z0-9]+[a-zA-Z0-9\-]*(? { + if (protocol.type === 'tcp' || protocol.type === 'udp') { + return {protocol.type.toUpperCase()} + } + + if (protocol.value === null) { + // All ICMP types + return ICMP + } + + return ( +
+ ICMP + + + type {protocol.value.icmpType} + {protocol.value.code && ( + <> + + code {protocol.value.code} + + )} + + +
+ ) +} diff --git a/app/forms/firewall-rules-common.tsx b/app/forms/firewall-rules-common.tsx index 0e1698b2a..65a5e397f 100644 --- a/app/forms/firewall-rules-common.tsx +++ b/app/forms/firewall-rules-common.tsx @@ -14,7 +14,11 @@ import { type ApiError, type Instance, type Vpc, + type VpcFirewallIcmpFilter, + type VpcFirewallRuleAction, + type VpcFirewallRuleDirection, type VpcFirewallRuleHostFilter, + type VpcFirewallRuleProtocol, type VpcFirewallRuleTarget, type VpcSubnet, } from '~/api' @@ -28,6 +32,11 @@ import { NumberField } from '~/components/form/fields/NumberField' import { RadioField } from '~/components/form/fields/RadioField' import { TextField, TextFieldInner } from '~/components/form/fields/TextField' import { useVpcSelector } from '~/hooks/use-params' +import { + ProtocolCell, + ProtocolCodeCell, + ProtocolTypeCell, +} from '~/table/cells/ProtocolCell' import { Badge } from '~/ui/lib/Badge' import { toComboboxItems } from '~/ui/lib/Combobox' import { FormDivider } from '~/ui/lib/Divider' @@ -40,7 +49,8 @@ import { KEYS } from '~/ui/util/keys' import { ALL_ISH } from '~/util/consts' import { validateIp, validateIpNet } from '~/util/ip' import { links } from '~/util/links' -import { capitalize } from '~/util/str' +import { getProtocolDisplayName, getProtocolKey, ICMP_TYPES } from '~/util/protocol' +import { capitalize, normalizeDashes } from '~/util/str' import { type FirewallRuleValues } from './firewall-rules-util' @@ -160,13 +170,7 @@ const TargetAndHostFilterSubform = ({ name="type" label={`${capitalize(sectionType)} type`} control={subformControl} - items={[ - { value: 'vpc', label: 'VPC' }, - { value: 'subnet', label: 'VPC subnet' }, - { value: 'instance', label: 'Instance' }, - { value: 'ip', label: 'IP' }, - { value: 'ip_net', label: 'IP subnet' }, - ]} + items={targetHostTypeItems} onChange={onTypeChange} hideOptionalTag /> @@ -220,12 +224,14 @@ const TargetAndHostFilterSubform = ({ className="mb-4" ariaLabel={nounTitle} items={field.value} - columns={[ - { header: 'Type', cell: (item) => {item.type} }, - { header: 'Value', cell: (item) => item.value }, - ]} - rowKey={({ type, value }) => `${type}|${value}`} - onRemoveItem={({ type, value }) => { + columns={targetAndHostTableColumns} + rowKey={({ type, value }: VpcFirewallRuleTarget | VpcFirewallRuleHostFilter) => + `${type}|${value}` + } + onRemoveItem={({ + type, + value, + }: VpcFirewallRuleTarget | VpcFirewallRuleHostFilter) => { field.onChange(field.value.filter((i) => !(i.value === value && i.type === type))) }} /> @@ -251,17 +257,267 @@ const availableItems = ( ) } -type ProtocolFieldProps = { - control: Control - protocol: 'TCP' | 'UDP' | 'ICMP' +// Protocol selection form values for the subform +type ProtocolFormValues = { + protocolType: VpcFirewallRuleProtocol['type'] | '' + icmpType?: string // ComboboxField with allowArbitraryValues can return strings + icmpCode?: string +} + +const targetHostTypeItems: Array<{ + value: VpcFirewallRuleHostFilter['type'] + label: string +}> = [ + { value: 'vpc', label: 'VPC' }, + { value: 'subnet', label: 'VPC subnet' }, + { value: 'instance', label: 'Instance' }, + { value: 'ip', label: 'IP' }, + { value: 'ip_net', label: 'IP subnet' }, +] + +const actionItems: Array<{ value: VpcFirewallRuleAction; label: string }> = [ + { value: 'allow', label: 'Allow' }, + { value: 'deny', label: 'Deny' }, +] + +const directionItems: Array<{ value: VpcFirewallRuleDirection; label: string }> = [ + { value: 'inbound', label: 'Inbound' }, + { value: 'outbound', label: 'Outbound' }, +] + +const protocolTypeItems: Array<{ value: VpcFirewallRuleProtocol['type']; label: string }> = + [ + { value: 'tcp', label: 'TCP' }, + { value: 'udp', label: 'UDP' }, + { value: 'icmp', label: 'ICMP' }, + ] + +const icmpTypeItems = [ + { value: '', label: 'All types', selectedLabel: 'All types' }, + ...Object.entries(ICMP_TYPES).map(([type, name]) => ({ + value: type, + label: `${type} - ${name}`, + selectedLabel: `${type}`, + })), +] + +const targetAndHostTableColumns = [ + { + header: 'Type', + cell: (item: VpcFirewallRuleTarget | VpcFirewallRuleHostFilter) => ( + {item.type} + ), + }, + { + header: 'Value', + cell: (item: VpcFirewallRuleTarget | VpcFirewallRuleHostFilter) => item.value, + }, +] + +const portTableColumns = [{ header: 'Port ranges', cell: (p: string) => p }] + +const protocolTableColumns = [ + { + header: 'Protocol', + cell: (protocol: VpcFirewallRuleProtocol) => , + }, + { + header: 'Type', + cell: (protocol: VpcFirewallRuleProtocol) => , + }, + { + header: 'Code', + cell: (protocol: VpcFirewallRuleProtocol) => , + }, +] + +const isDuplicateProtocol = ( + newProtocol: VpcFirewallRuleProtocol, + existingProtocols: VpcFirewallRuleProtocol[] +) => { + if (newProtocol.type === 'tcp' || newProtocol.type === 'udp') { + return existingProtocols.some((p) => p.type === newProtocol.type) + } + + if (newProtocol.type === 'icmp') { + if (newProtocol.value === null) { + return existingProtocols.some((p) => p.type === 'icmp' && p.value === null) + } + return existingProtocols.some( + (p) => + p.type === 'icmp' && + p.value?.icmpType === newProtocol.value?.icmpType && + p.value?.code === newProtocol.value?.code + ) + } + + return false +} + +type ParseResult = { success: true; data: T } | { success: false; message: string } + +const parseIcmpType = (value: string | undefined): ParseResult => { + if (value === undefined || value === '') return { success: true, data: undefined } + const parsed = parseInt(value, 10) + if (isNaN(parsed) || parsed < 0 || parsed > 255) { + return { success: false, message: `ICMP type must be a number between 0 and 255` } + } + return { success: true, data: parsed } +} + +const icmpCodeValidation = (value: string | undefined) => { + if (!value || value.trim() === '') return undefined // allow empty + + const trimmedValue = value.trim() + + // Check if it's a single number + if (/^\d+$/.test(trimmedValue)) { + const num = parseInt(trimmedValue, 10) + if (num < 0 || num > 255) { + return 'ICMP code must be between 0 and 255' + } + return undefined + } + + // Check if it's a range (e.g., "0-255", "1-10") + if (/^\d+-\d+$/.test(trimmedValue)) { + const [startStr, endStr] = trimmedValue.split('-') + const start = parseInt(startStr, 10) + const end = parseInt(endStr, 10) + + if (start < 0 || start > 255) { + return 'ICMP code range start must be between 0 and 255' + } + if (end < 0 || end > 255) { + return 'ICMP code range end must be between 0 and 255' + } + if (start > end) { + return 'ICMP code range start must be less than or equal to range end' + } + + return undefined + } + + return 'ICMP code must be a number or numeric range' +} + +const ProtocolFilters = ({ control }: { control: Control }) => { + const protocols = useController({ name: 'protocols', control }).field + const protocolForm = useForm({ + defaultValues: { protocolType: '' }, + }) + + const selectedProtocolType = protocolForm.watch('protocolType') + const selectedIcmpType = protocolForm.watch('icmpType') + + const addProtocolIfUnique = (newProtocol: VpcFirewallRuleProtocol) => { + if (!isDuplicateProtocol(newProtocol, protocols.value)) { + protocols.onChange([...protocols.value, newProtocol]) + } + } + + const submitProtocol = protocolForm.handleSubmit((values) => { + if (values.protocolType === 'tcp' || values.protocolType === 'udp') { + addProtocolIfUnique({ type: values.protocolType }) + } else if (values.protocolType === 'icmp') { + // this parse should never fail because we've already validated, but doing + // it this way keeps the just-in-case early return logic consistent + const parseResult = parseIcmpType(values.icmpType) + if (!parseResult.success) return + + const icmpType = parseResult.data + if (icmpType === undefined) { + // All ICMP types + addProtocolIfUnique({ type: 'icmp', value: null }) + } else { + // Specific ICMP type + const icmpValue: VpcFirewallIcmpFilter = { icmpType } + if (values.icmpCode) { + icmpValue.code = values.icmpCode + } + addProtocolIfUnique({ type: 'icmp', value: icmpValue }) + } + } + protocolForm.reset() + }) + + const removeProtocol = (protocolToRemove: VpcFirewallRuleProtocol) => { + const newProtocols = protocols.value.filter((protocol) => protocol !== protocolToRemove) + protocols.onChange(newProtocols) + } + + return ( + <> +
+
+ + + {selectedProtocolType === 'icmp' && ( + <> + protocolForm.setValue('icmpType', value)} + items={icmpTypeItems} + validate={(value) => { + const result = parseIcmpType(value) + if (!result.success) return result.message + }} + /> + + {selectedIcmpType !== undefined && selectedIcmpType !== '' && ( + + Enter a code (0) or range (e.g. 1–3). Leave blank for all + traffic of type {selectedIcmpType}. + + } + placeholder="" + validate={icmpCodeValidation} + transform={normalizeDashes} + /> + )} + + )} +
+ + protocolForm.reset()} + onSubmit={submitProtocol} + /> +
+ + {protocols.value.length > 0 && ( + `Remove ${getProtocolDisplayName(protocol)}`} + /> + )} + + ) } -const ProtocolField = ({ control, protocol }: ProtocolFieldProps) => ( -
- - {protocol} - -
-) type CommonFieldsProps = { control: Control @@ -334,15 +590,7 @@ export const CommonFields = ({ control, nameTaken, error }: CommonFieldsProps) = /> - + from the targets. } - items={[ - { value: 'inbound', label: 'Inbound' }, - { value: 'outbound', label: 'Outbound' }, - ]} + items={directionItems} /> p }]} + columns={portTableColumns} rowKey={(port) => port} emptyState={{ title: 'No ports', body: 'Add a port to see it here' }} onRemoveItem={(p) => ports.onChange(ports.value.filter((p1) => p1 !== p))} @@ -448,15 +693,7 @@ export const CommonFields = ({ control, nameTaken, error }: CommonFieldsProps) = /> )} -
- {/* todo: abstract this label and checkbox pattern */} - - Protocol filters - - - - -
+ diff --git a/app/forms/firewall-rules-edit.tsx b/app/forms/firewall-rules-edit.tsx index 59c9b22c2..b0b62a3fe 100644 --- a/app/forms/firewall-rules-edit.tsx +++ b/app/forms/firewall-rules-edit.tsx @@ -95,15 +95,14 @@ export default function EditFirewallRuleForm() { name: originalRule.name, description: originalRule.description, - priority: originalRule.priority, action: originalRule.action, direction: originalRule.direction, + priority: originalRule.priority, - protocols: originalRule.filters.protocols || [], - + targets: originalRule.targets, ports: originalRule.filters.ports || [], + protocols: originalRule.filters.protocols || [], hosts: originalRule.filters.hosts || [], - targets: originalRule.targets, } const form = useForm({ defaultValues }) diff --git a/app/pages/project/instances/NetworkingTab.tsx b/app/pages/project/instances/NetworkingTab.tsx index 2493a99db..f90b6e90c 100644 --- a/app/pages/project/instances/NetworkingTab.tsx +++ b/app/pages/project/instances/NetworkingTab.tsx @@ -39,6 +39,7 @@ import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' import { DescriptionCell } from '~/table/cells/DescriptionCell' import { EmptyCell, SkeletonCell } from '~/table/cells/EmptyCell' +import { IpPoolCell } from '~/table/cells/IpPoolCell' import { LinkCell } from '~/table/cells/LinkCell' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' import { Columns } from '~/table/columns/common' @@ -179,6 +180,10 @@ const staticIpCols = [ ), cell: (info) => {info.getValue()}, }), + ipColHelper.accessor('ipPoolId', { + header: 'IP pool', + cell: (info) => , + }), ipColHelper.accessor('name', { cell: (info) => info.row.original.kind === 'ephemeral' ? : info.getValue(), diff --git a/app/pages/project/vpcs/VpcFirewallRulesTab.tsx b/app/pages/project/vpcs/VpcFirewallRulesTab.tsx index 87b21de38..c496d4b54 100644 --- a/app/pages/project/vpcs/VpcFirewallRulesTab.tsx +++ b/app/pages/project/vpcs/VpcFirewallRulesTab.tsx @@ -19,6 +19,7 @@ import { } from '@oxide/api' import { ListPlusCell } from '~/components/ListPlusCell' +import { ProtocolBadge } from '~/components/ProtocolBadge' import { getVpcSelector, useVpcSelector } from '~/hooks/use-params' import { confirmDelete } from '~/stores/confirm-delete' import { EnabledCell } from '~/table/cells/EnabledCell' @@ -27,12 +28,12 @@ import { TypeValueCell } from '~/table/cells/TypeValueCell' import { getActionsCol } from '~/table/columns/action-col' import { Columns } from '~/table/columns/common' import { Table } from '~/table/Table' -import { Badge } from '~/ui/lib/Badge' import { CreateLink } from '~/ui/lib/CreateButton' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { TableEmptyBox } from '~/ui/lib/Table' import { pb } from '~/util/path-builder' import type * as PP from '~/util/path-params' +import { getProtocolKey } from '~/util/protocol' import { titleCase } from '~/util/str' const colHelper = createColumnHelper() @@ -77,7 +78,9 @@ const staticColumns = [ ...(hosts || []).map((tv, i) => ( )), - ...(protocols || []).map((p, i) => {p}), + ...(protocols || []).map((p) => ( + + )), ...(ports || []).map((p, i) => ( )), diff --git a/app/table/cells/IpPoolCell.tsx b/app/table/cells/IpPoolCell.tsx index f15a03fd8..03cbce193 100644 --- a/app/table/cells/IpPoolCell.tsx +++ b/app/table/cells/IpPoolCell.tsx @@ -5,14 +5,20 @@ * * Copyright Oxide Computer Company */ -import { useApiQuery } from '~/api' +import { useApiQueryErrorsAllowed } from '~/api' import { Tooltip } from '~/ui/lib/Tooltip' -import { EmptyCell } from './EmptyCell' +import { EmptyCell, SkeletonCell } from './EmptyCell' export const IpPoolCell = ({ ipPoolId }: { ipPoolId: string }) => { - const pool = useApiQuery('projectIpPoolView', { path: { pool: ipPoolId } }).data - if (!pool) return + const { data: result } = useApiQueryErrorsAllowed('projectIpPoolView', { + path: { pool: ipPoolId }, + }) + if (!result) return + // this should essentially never happen, but it's probably better than blowing + // up the whole page if the pool is not found + if (result.type === 'error') return + const pool = result.data return ( {pool.name} diff --git a/app/table/cells/ProtocolCell.tsx b/app/table/cells/ProtocolCell.tsx new file mode 100644 index 000000000..77ab93745 --- /dev/null +++ b/app/table/cells/ProtocolCell.tsx @@ -0,0 +1,53 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import type { VpcFirewallRuleProtocol } from '~/api' +import { Badge } from '~/ui/lib/Badge' +import { Tooltip } from '~/ui/lib/Tooltip' + +import { EmptyCell } from './EmptyCell' + +export const ProtocolCell = ({ protocol }: { protocol: VpcFirewallRuleProtocol }) => ( + {protocol.type.toUpperCase()} +) + +/** Generate tooltip content for empty protocol cells in the mini table */ +const protocolEmptyCellTooltipContent = (protocol: VpcFirewallRuleProtocol): string => { + if (protocol.type === 'tcp') return 'This firewall rule will match all TCP traffic' + if (protocol.type === 'udp') return 'This firewall rule will match all UDP traffic' + // in this case, the user could be looking at the type column or the code column, but both get the same tooltip + if (protocol.value === null) { + return 'This firewall rule will match all ICMP traffic' + } + // in this case, there's an icmpType but no code, which means the user is looking at the code column + return `This firewall rule will match all ICMP traffic of type ${protocol.value.icmpType}` +} + +export const ProtocolEmptyCell = ({ protocol }: { protocol: VpcFirewallRuleProtocol }) => ( + +
+ +
+
+) + +export const ProtocolTypeCell = ({ protocol }: { protocol: VpcFirewallRuleProtocol }) => + // icmpType could be zero, so we check for `not undefined` + protocol.type === 'icmp' && protocol.value?.icmpType !== undefined ? ( + protocol.value.icmpType + ) : ( + + ) + +export const ProtocolCodeCell = ({ protocol }: { protocol: VpcFirewallRuleProtocol }) => + // code could be zero, so we check for `not undefined` + protocol.type === 'icmp' && protocol.value?.code !== undefined ? ( + protocol.value.code + ) : ( + + ) diff --git a/app/util/protocol.ts b/app/util/protocol.ts new file mode 100644 index 000000000..a41ec3e72 --- /dev/null +++ b/app/util/protocol.ts @@ -0,0 +1,57 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import type { VpcFirewallRuleProtocol } from '~/api' + +export const ICMP_TYPES: Record = { + 0: 'Echo Reply', + 3: 'Destination Unreachable', + 5: 'Redirect Message', + 8: 'Echo Request', + 9: 'Router Advertisement', + 10: 'Router Solicitation', + 11: 'Time Exceeded', + 12: 'Parameter Problem', + 13: 'Timestamp Request', + 14: 'Timestamp Reply', +} + +/** + * Get the human-readable name for an ICMP type + */ +export const getIcmpTypeName = (type: number): string | undefined => ICMP_TYPES[type] + +/** + * Get a display name for a protocol, including ICMP types and codes + */ +export const getProtocolDisplayName = (protocol: VpcFirewallRuleProtocol): string => { + if (protocol.type === 'icmp') { + if (protocol.value === null) { + return 'ICMP (All types)' + } else { + const typeName = + ICMP_TYPES[protocol.value.icmpType] || `Type ${protocol.value.icmpType}` + const codePart = protocol.value.code ? ` | Code ${protocol.value.code}` : '' + return `ICMP ${protocol.value.icmpType} - ${typeName}${codePart}` + } + } + return protocol.type.toUpperCase() +} + +/** + * Generate a key for a protocol that can be used in React lists. + * Relies on callsite logic to ensure uniqueness. + */ +export const getProtocolKey = (protocol: VpcFirewallRuleProtocol): string => { + if (protocol.type === 'tcp' || protocol.type === 'udp') { + return protocol.type + } + return protocol.value === null + ? 'icmp|all' + : `icmp|${protocol.value.icmpType}|${protocol.value.code || 'all'}` +} diff --git a/app/util/str.ts b/app/util/str.ts index a6c323a42..0eea97c4c 100644 --- a/app/util/str.ts +++ b/app/util/str.ts @@ -85,3 +85,7 @@ export function addDashes(dashAfterIdxs: number[], code: string) { } return result } + +/** Convert en- or em-dashes to regular dashes for user-inputted numeric ranges */ +export const normalizeDashes = (value: string): string => + value.replace(/[—–-]/g, '-').replace(/-+/g, '-') diff --git a/mock-api/external-ip.ts b/mock-api/external-ip.ts index a90895b15..f444521e2 100644 --- a/mock-api/external-ip.ts +++ b/mock-api/external-ip.ts @@ -8,6 +8,7 @@ import type { ExternalIp } from '@oxide/api' import { instances } from './instance' +import { ipPool1 } from './ip-pool' import type { Json } from './json-type' /** @@ -34,6 +35,7 @@ export const ephemeralIps: DbExternalIp[] = [ instance_id: instances[0].id, external_ip: { ip: '123.4.56.0', + ip_pool_id: ipPool1.id, kind: 'ephemeral', }, }, @@ -42,6 +44,7 @@ export const ephemeralIps: DbExternalIp[] = [ instance_id: instances[2].id, external_ip: { ip: '123.4.56.1', + ip_pool_id: ipPool1.id, kind: 'ephemeral', }, }, @@ -49,6 +52,7 @@ export const ephemeralIps: DbExternalIp[] = [ instance_id: instances[2].id, external_ip: { ip: '123.4.56.2', + ip_pool_id: ipPool1.id, kind: 'ephemeral', }, }, @@ -56,6 +60,7 @@ export const ephemeralIps: DbExternalIp[] = [ instance_id: instances[2].id, external_ip: { ip: '123.4.56.3', + ip_pool_id: ipPool1.id, kind: 'ephemeral', }, }, diff --git a/mock-api/msw/db.ts b/mock-api/msw/db.ts index cecc37e66..8bdcbedc6 100644 --- a/mock-api/msw/db.ts +++ b/mock-api/msw/db.ts @@ -18,7 +18,7 @@ import type * as Sel from '~/api/selectors' import { commaSeries } from '~/util/str' import type { Json } from '../json-type' -import { siloSettings } from '../silo' +import { defaultSilo, siloSettings } from '../silo' import { internalError } from './util' export const notFoundErr = (msg: string) => { @@ -55,10 +55,16 @@ function ensureNoParentSelectors( } } -export const getIpFromPool = (poolName: string | undefined | null) => { - const pool = lookup.ipPool({ pool: poolName || undefined }) +/** + * If pool name or ID is given, look it up. Otherwise use silo default pool, + * (and error if the silo doesn't have one). + */ +export const resolveIpPool = (pool: string | undefined | null) => + pool ? lookup.ipPool({ pool }) : lookup.siloDefaultIpPool({ silo: defaultSilo.id }) + +export const getIpFromPool = (pool: Json) => { const ipPoolRange = db.ipPoolRanges.find((range) => range.ip_pool_id === pool.id) - if (!ipPoolRange) throw notFoundErr(`IP range for pool '${poolName}'`) + if (!ipPoolRange) throw notFoundErr(`IP range for pool '${pool.name}'`) // right now, we're just using the first address in the range, but we'll // want to filter the list of available IPs for the first unused address diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index c868aa41e..0399ed049 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -41,6 +41,7 @@ import { lookup, lookupById, notFoundErr, + resolveIpPool, utilizationForSilo, } from './db' import { @@ -477,7 +478,8 @@ export const handlers = makeHandlers({ // if there are no ranges in the pool or if the pool doesn't exist, // which aren't quite as good as checking that there are actually IPs // available, but they are good things to check - getIpFromPool(ip.pool) + const pool = resolveIpPool(ip.pool) + getIpFromPool(pool) } }) @@ -563,11 +565,14 @@ export const handlers = makeHandlers({ // we've already validated that the IP isn't attached floatingIp.instance_id = instanceId } else if (ip.type === 'ephemeral') { - const firstAvailableAddress = getIpFromPool(ip.pool) + const pool = resolveIpPool(ip.pool) + const firstAvailableAddress = getIpFromPool(pool) + db.ephemeralIps.push({ instance_id: instanceId, external_ip: { ip: firstAvailableAddress, + ip_pool_id: pool.id, kind: 'ephemeral', }, }) @@ -736,12 +741,10 @@ export const handlers = makeHandlers({ }, instanceEphemeralIpAttach({ path, query: projectParams, body }) { const instance = lookup.instance({ ...path, ...projectParams }) - const { pool } = body - const firstAvailableAddress = getIpFromPool(pool) - const externalIp = { - ip: firstAvailableAddress, - kind: 'ephemeral' as const, - } + const pool = resolveIpPool(body.pool) + const ip = getIpFromPool(pool) + + const externalIp = { ip, ip_pool_id: pool.id, kind: 'ephemeral' as const } db.ephemeralIps.push({ instance_id: instance.id, external_ip: externalIp, @@ -1858,6 +1861,8 @@ export const handlers = makeHandlers({ networkingBgpImportedRoutesIpv4: NotImplemented, networkingBgpMessageHistory: NotImplemented, networkingBgpStatus: NotImplemented, + networkingInboundIcmpUpdate: NotImplemented, + networkingInboundIcmpView: NotImplemented, networkingLoopbackAddressCreate: NotImplemented, networkingLoopbackAddressDelete: NotImplemented, networkingLoopbackAddressList: NotImplemented, @@ -1878,8 +1883,6 @@ export const handlers = makeHandlers({ probeList: NotImplemented, probeView: NotImplemented, rackView: NotImplemented, - roleList: NotImplemented, - roleView: NotImplemented, siloPolicyUpdate: NotImplemented, siloPolicyView: NotImplemented, siloUserList: NotImplemented, @@ -1900,6 +1903,12 @@ export const handlers = makeHandlers({ systemPolicyUpdate: NotImplemented, systemQuotasList: NotImplemented, systemTimeseriesSchemaList: NotImplemented, + systemUpdateGetRepository: NotImplemented, + systemUpdatePutRepository: NotImplemented, + systemUpdateTrustRootCreate: NotImplemented, + systemUpdateTrustRootDelete: NotImplemented, + systemUpdateTrustRootList: NotImplemented, + systemUpdateTrustRootView: NotImplemented, targetReleaseUpdate: NotImplemented, targetReleaseView: NotImplemented, userBuiltinList: NotImplemented, diff --git a/mock-api/vpc.ts b/mock-api/vpc.ts index e3614f9ac..0b23cd861 100644 --- a/mock-api/vpc.ts +++ b/mock-api/vpc.ts @@ -202,7 +202,7 @@ export function defaultFirewallRules(vpcId: string): Json { description: 'allow inbound TCP connections on port 22 from anywhere', filters: { ports: ['22'], - protocols: ['TCP'], + protocols: [{ type: 'tcp' }], }, action: 'allow', priority: 65534, @@ -217,7 +217,7 @@ export function defaultFirewallRules(vpcId: string): Json { targets: [{ type: 'vpc', value: 'default' }], description: 'allow inbound ICMP traffic from anywhere', filters: { - protocols: ['ICMP'], + protocols: [{ type: 'icmp', value: null }], }, action: 'allow', priority: 65534, @@ -242,7 +242,7 @@ export const firewallRules: Json = [ description: 'we just want to test with lots of filters', filters: { ports: ['3389', '45-89'], - protocols: ['TCP'], + protocols: [{ type: 'tcp' }, { type: 'icmp', value: { icmp_type: 5, code: '1-3' } }], hosts: [ { type: 'instance', value: 'hello-friend' }, { type: 'subnet', value: 'my-subnet' }, diff --git a/test/e2e/firewall-rules.e2e.ts b/test/e2e/firewall-rules.e2e.ts index e62761ca4..fbe3e4a5c 100644 --- a/test/e2e/firewall-rules.e2e.ts +++ b/test/e2e/firewall-rules.e2e.ts @@ -81,8 +81,9 @@ test('can create firewall rule', async ({ page }) => { // don't need to click because we're already validating onChange await expect(dupePort).toBeVisible() - // check the UDP box - await page.locator('text=UDP').click() + // select UDP from protocol dropdown + await selectOption(page, 'Protocol filters', 'UDP') + await page.getByRole('button', { name: 'Add protocol' }).click() // submit the form await page.getByRole('button', { name: 'Add rule' }).click() @@ -127,15 +128,15 @@ test('firewall rule targets and filters overflow', async ({ page }) => { ).toBeVisible() await expect( - page.getByRole('cell', { name: 'instance hello-friend +5', exact: true }) + page.getByRole('cell', { name: 'instance hello-friend +6', exact: true }) ).toBeVisible() // scroll table sideways past the filters cell await page.getByText('Enabled').first().scrollIntoViewIfNeeded() - await page.getByText('+5').hover() + await page.getByText('+6').hover() const tooltip = page.getByRole('tooltip', { - name: 'Other filters subnet my-subnet ip 148.38.89.5 TCP Port 3389 Port 45-89', + name: 'Other filters subnet my-subnet ip 148.38.89.5 TCP ICMP type 5 code 1-3 Port 3389 Port 45-89', exact: true, }) await expect(tooltip).toBeVisible() @@ -323,7 +324,7 @@ test('firewall rule form host validation', async ({ page }) => { await expect(ipError).toBeVisible() // test clear button - await page.getByRole('button', { name: 'Clear' }).nth(2).click() + await page.getByRole('button', { name: 'Clear' }).nth(3).click() await expect(ipField).toHaveValue('') // Change back to VPC, enter valid value @@ -438,10 +439,19 @@ test('can update firewall rule', async ({ page }) => { // priority is populated await expect(page.getByRole('textbox', { name: 'Priority' })).toHaveValue('65534') - // protocol is populated - await expect(page.locator('label >> text=ICMP')).toBeChecked() - await expect(page.locator('label >> text=TCP')).not.toBeChecked() - await expect(page.locator('label >> text=UDP')).not.toBeChecked() + // protocol is populated in the table + const protocolTable = page.getByRole('table', { name: 'Protocol filters' }) + await expect(protocolTable.getByText('ICMP')).toBeVisible() + + // remove the existing ICMP protocol filter + await protocolTable.getByRole('button', { name: 'remove' }).click() + + // add a new ICMP protocol filter with type 3 and code 0 + await selectOption(page, 'Protocol filters', 'ICMP') + await page.getByRole('combobox', { name: 'ICMP Type' }).fill('3') + await page.getByRole('combobox', { name: 'ICMP Type' }).press('Enter') + await page.getByRole('textbox', { name: 'ICMP Code' }).fill('0') + await page.getByRole('button', { name: 'Add protocol' }).click() // targets default vpc // screen.getByRole('cell', { name: 'vpc' }) @@ -472,9 +482,15 @@ test('can update firewall rule', async ({ page }) => { await expect(rows).toHaveCount(3) - // new target shows up in target cell + // new host filter shows up in filters cell, along with the new ICMP protocol await expect(page.locator('text=subnetedit-filter-subnetICMP')).toBeVisible() + // scroll table sideways past the filters cell to see the full content + await page.getByText('Enabled').first().scrollIntoViewIfNeeded() + + // Look for the new ICMP type 3 code 0 in the filters cell using ProtocolBadge format + await expect(page.getByText('TYPE 3CODE 0')).toBeVisible() + // other 3 rules are still there const rest = defaultRules.filter((r) => r !== 'allow-icmp') for (const name of rest) { @@ -497,9 +513,11 @@ test('create from existing rule', async ({ page }) => { 'allow-icmp-copy' ) - await expect(modal.getByRole('checkbox', { name: 'TCP' })).not.toBeChecked() - await expect(modal.getByRole('checkbox', { name: 'UDP' })).not.toBeChecked() - await expect(modal.getByRole('checkbox', { name: 'ICMP' })).toBeChecked() + // protocol is populated in the table + const protocolTable = modal.getByRole('table', { name: 'Protocol filters' }) + await expect(protocolTable.getByText('ICMP')).toBeVisible() + await expect(protocolTable.getByText('TCP')).toBeHidden() + await expect(protocolTable.getByText('UDP')).toBeHidden() // no port filters const portFilters = modal.getByRole('table', { name: 'Port filters' }) @@ -522,9 +540,10 @@ test('create from existing rule', async ({ page }) => { await expect(portFilters.getByRole('cell', { name: '22', exact: true })).toBeVisible() - await expect(modal.getByRole('checkbox', { name: 'TCP' })).toBeChecked() - await expect(modal.getByRole('checkbox', { name: 'UDP' })).not.toBeChecked() - await expect(modal.getByRole('checkbox', { name: 'ICMP' })).not.toBeChecked() + // protocol is populated in the table + await expect(protocolTable.getByText('TCP')).toBeVisible() + await expect(protocolTable.getByText('UDP')).toBeHidden() + await expect(protocolTable.getByText('ICMP')).toBeHidden() await expect(targets.getByRole('row', { name: 'vpc default' })).toBeVisible() }) @@ -633,6 +652,19 @@ test('arbitrary values combobox', async ({ page }) => { // same options show up after blur (there was a bug around this) await expectOptions(page, ['db1', 'db2', 'Custom: d']) + + // make sure typing in ICMP filter input actually updates the underlying value, + // triggering a validation error for bad input. without onInputChange binding + // the input value to the form value, this does not trigger an error because + // the form thinks the input is empyt. + await selectOption(page, 'Protocol filters', 'ICMP') + await page.getByRole('combobox', { name: 'ICMP type' }).pressSequentially('abc') + const error = page + .getByRole('dialog') + .getByText('ICMP type must be a number between 0 and 255') + await expect(error).toBeHidden() + await page.getByRole('button', { name: 'Add protocol filter' }).click() + await expect(error).toBeVisible() }) test("esc in combobox doesn't close form", async ({ page }) => {