Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| efaa2e6826 | |||
| 48114da772 | |||
| 70cfd4bc96 | |||
| 12b50f4868 | |||
| 6e0bb98844 | |||
| d4726950da | |||
| eeadeef664 | |||
| 9f1ea32183 | |||
| 67660858eb | |||
| bb5ca85a88 |
@@ -58,6 +58,7 @@ cd packages/api && bun run scripts/service-matrix.ts
|
||||
| Group | Service | AWS | Azure | GCP |
|
||||
|---|---|---|---|---|
|
||||
| Compute | Compute | Yes (list, inspect, create, delete) | Yes (list, inspect, create, delete) | No |
|
||||
| Compute | EC2 Auto Scaling / Auto Scaling | Yes (list, create, delete, inspect) | No | No |
|
||||
| Compute | EKS / AKS / GKE | Yes (list, inspect) | Yes (list, inspect) | Yes (list, create, inspect, delete) |
|
||||
| Compute | Serverless | Yes (list, create, update, inspect, delete) | Runtime gap | Yes (list, create, inspect, delete) |
|
||||
| Compute | Containers / Cloud Run | No | No | Yes (list, create, delete, inspect) |
|
||||
@@ -81,6 +82,7 @@ cd packages/api && bun run scripts/service-matrix.ts
|
||||
| Integration | API Gateway | Yes (list, create, delete, inspect) | No | No |
|
||||
| Integration | SNS / Notifications | Yes (list, create, inspect, delete) | No | No |
|
||||
| Integration | EventBridge / Events | Yes (list, create, delete, inspect) | No | No |
|
||||
| Integration | EventBridge Pipes / Pipes | Yes (list, create, delete, inspect) | No | No |
|
||||
| Integration | SES Mailbox / Email | Yes (list, inspect) | No | No |
|
||||
| Integration | Cloud Scheduler | No | No | Yes (list, create, delete, inspect) |
|
||||
| Integration | Step Functions / Workflows | Yes (list, create, update, delete, inspect) | No | No |
|
||||
@@ -94,6 +96,7 @@ cd packages/api && bun run scripts/service-matrix.ts
|
||||
| Security | Secrets Manager / Key Vault / Secret Manager | Yes (list, create, inspect, delete) | Yes (list, create, delete, inspect) | Yes (list, create, inspect, delete) |
|
||||
| Security | KMS / Key Management | Yes (list, create, delete, inspect) | No | No |
|
||||
| Security | Parameter Store | Yes (list, create, delete, inspect) | No | No |
|
||||
| Security | Certificate Manager / Certificates | Yes (list, create, delete, inspect) | No | No |
|
||||
| Observability | CloudWatch / Monitoring | Yes (list, create, delete, inspect) | No | No |
|
||||
| Observability | CloudWatch Logs / Logs | Yes (list, create, delete, inspect) | No | No |
|
||||
|
||||
@@ -252,14 +255,18 @@ Current gaps:
|
||||
<details>
|
||||
<summary><strong>API Gateway</strong></summary>
|
||||
|
||||
AWS only, through the generic apigateway service category.
|
||||
AWS only, through the generic apigateway service category. REST APIs (v1) and HTTP and WebSocket APIs (v2) are listed together, with a Type column.
|
||||
|
||||
- List and inspect REST APIs.
|
||||
- Create and delete REST APIs.
|
||||
- REST APIs: create, delete and inspect; add Lambda proxy routes on a resource tree, enable CORS, test a method with TestInvokeMethod, and send a request to a deployed stage.
|
||||
- HTTP APIs: quick-create with a Lambda (a catch-all route on an auto-deployed `$default` stage); add routes such as `GET /notes/{id}` to a Lambda or an HTTP backend; add stages with or without automatic deployment and deploy them; add JWT authorizers, filled in from a Cognito user pool and app client or from any OpenID Connect issuer, and protect routes with them; configure CORS; send a real request to a stage through its routes and authorizers.
|
||||
- WebSocket APIs: create with a route selection expression and a stage; add `$connect`, `$disconnect`, `$default` and action routes to Lambda, optionally sending the function's response back; connect to a stage, send a message and read the replies.
|
||||
|
||||
Verified against TheCloud's runtime: quick create, Lambda and HTTP routes, a Cognito JWT authorizer (401 without a token, the token's claims reach the Lambda with one), CORS preflight, a stage deployed on demand, and a WebSocket round trip.
|
||||
|
||||
Current gaps:
|
||||
|
||||
- Resources, methods, deployments, and stages are not yet exposed.
|
||||
- WebSocket APIs: Lambda (REQUEST) authorizers, `@connections` messages pushed from a backend, and route request and response models are not exposed.
|
||||
- Custom domain names, API mappings, VPC links, usage plans and API keys are not exposed.
|
||||
- No Azure or GCP API Gateway adapter yet.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -10,8 +10,11 @@
|
||||
"test": "FLOCI_UI_ADMIN_USER=admin FLOCI_UI_ADMIN_PASSWORD_HASH='$argon2id$v=19$m=65536,t=2,p=1$I5n/tqYyZO0S+4z++KcAlpAJVu442Ivi7KxPl2CXoO4$mRSyN9R5ACFosgnfnd6S5ePoKrz2XBfVf4DTfG7T6LQ' FLOCI_UI_SESSION_SECRET=01234567890123456789012345678901 FLOCI_UI_SECURE_COOKIES=false bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-acm": "^3.1146.0",
|
||||
"@aws-sdk/client-api-gateway": "^3.1076.0",
|
||||
"@aws-sdk/client-apigatewayv2": "^3.1076.0",
|
||||
"@aws-sdk/client-appconfig": "^3.1076.0",
|
||||
"@aws-sdk/client-auto-scaling": "^3.1146.0",
|
||||
"@aws-sdk/client-cloudformation": "^3.1111.0",
|
||||
"@aws-sdk/client-cloudfront": "^3.1146.0",
|
||||
"@aws-sdk/client-cloudwatch": "^3.1076.0",
|
||||
@@ -35,6 +38,7 @@
|
||||
"@aws-sdk/client-kms": "^3.1096.0",
|
||||
"@aws-sdk/client-lambda": "^3.1076.0",
|
||||
"@aws-sdk/client-lightsail": "^3.1076.0",
|
||||
"@aws-sdk/client-pipes": "^3.1146.0",
|
||||
"@aws-sdk/client-rds": "^3.1076.0",
|
||||
"@aws-sdk/client-rds-data": "^3.1076.0",
|
||||
"@aws-sdk/client-route-53": "^3.1146.0",
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
type APIGatewayClient,
|
||||
} from '@aws-sdk/client-api-gateway'
|
||||
import {AddPermissionCommand, GetFunctionCommand, RemovePermissionCommand, type LambdaClient} from '@aws-sdk/client-lambda'
|
||||
import {GetApisCommand, type ApiGatewayV2Client} from '@aws-sdk/client-apigatewayv2'
|
||||
import {AwsApiGatewayAdapter} from './AwsApiGatewayAdapter'
|
||||
|
||||
function fakeClient(handler: (command: unknown) => unknown): APIGatewayClient {
|
||||
@@ -32,6 +33,18 @@ function fakeLambda(handler: (command: unknown) => unknown): LambdaClient {
|
||||
} as unknown as LambdaClient
|
||||
}
|
||||
|
||||
/** An API Gateway v2 runtime with no HTTP or WebSocket APIs, for tests about REST APIs. */
|
||||
const noHttpApis = {
|
||||
send: async (command: unknown) => {
|
||||
if (command instanceof GetApisCommand) return {Items: []}
|
||||
throw Object.assign(new Error('NotFoundException'), {$metadata: {httpStatusCode: 404}})
|
||||
},
|
||||
} as unknown as ApiGatewayV2Client
|
||||
|
||||
function restOnlyAdapter(client: APIGatewayClient) {
|
||||
return new AwsApiGatewayAdapter(client, fakeLambda(() => ({})), '000000000000', 'us-east-1', noHttpApis)
|
||||
}
|
||||
|
||||
const orders = {
|
||||
id: 'a1b2c3',
|
||||
name: 'orders-api',
|
||||
@@ -44,7 +57,7 @@ const orders = {
|
||||
|
||||
describe('AwsApiGatewayAdapter', () => {
|
||||
test('lists every page and maps REST APIs to normalized resources', async () => {
|
||||
const adapter = new AwsApiGatewayAdapter(fakeClient((command) => {
|
||||
const adapter = restOnlyAdapter(fakeClient((command) => {
|
||||
if (!(command instanceof GetRestApisCommand)) throw new Error('Unexpected command')
|
||||
if (!command.input.position) return {items: [orders], position: 'next'}
|
||||
return {items: [{...orders, id: 'd4e5f6', name: 'accounts-api'}]}
|
||||
@@ -70,7 +83,7 @@ describe('AwsApiGatewayAdapter', () => {
|
||||
})
|
||||
|
||||
test('filters APIs by search term', async () => {
|
||||
const adapter = new AwsApiGatewayAdapter(fakeClient(() => ({
|
||||
const adapter = restOnlyAdapter(fakeClient(() => ({
|
||||
items: [orders, {...orders, id: 'd4e5f6', name: 'accounts-api'}],
|
||||
})))
|
||||
|
||||
@@ -92,7 +105,7 @@ describe('AwsApiGatewayAdapter', () => {
|
||||
})
|
||||
|
||||
test('returns null when API Gateway reports a missing REST API', async () => {
|
||||
const adapter = new AwsApiGatewayAdapter(fakeClient(() => {
|
||||
const adapter = restOnlyAdapter(fakeClient(() => {
|
||||
throw Object.assign(new Error('NotFoundException'), {$metadata: {httpStatusCode: 404}})
|
||||
}))
|
||||
|
||||
@@ -123,7 +136,7 @@ describe('AwsApiGatewayAdapter', () => {
|
||||
})
|
||||
|
||||
test('deletes the requested REST API', async () => {
|
||||
const adapter = new AwsApiGatewayAdapter(fakeClient((command) => {
|
||||
const adapter = restOnlyAdapter(fakeClient((command) => {
|
||||
expect(command).toBeInstanceOf(DeleteRestApiCommand)
|
||||
expect((command as DeleteRestApiCommand).input.restApiId).toBe('a1b2c3')
|
||||
return {}
|
||||
|
||||
@@ -22,10 +22,13 @@ import {
|
||||
type RestApi,
|
||||
} from '@aws-sdk/client-api-gateway'
|
||||
import {AddPermissionCommand, GetFunctionCommand, RemovePermissionCommand, type LambdaClient} from '@aws-sdk/client-lambda'
|
||||
import {apiGateway as defaultApiGateway, defaultAccountId, lambda as defaultLambda} from '../aws'
|
||||
import type {ApiGatewayV2Client} from '@aws-sdk/client-apigatewayv2'
|
||||
import {apiGateway as defaultApiGateway, apiGatewayV2 as defaultApiGatewayV2, defaultAccountId, lambda as defaultLambda} from '../aws'
|
||||
import {awsApiGatewaySchema} from '../cloud-spi/apigatewaySchema'
|
||||
import {ValidationError} from '../cloud-spi/errors'
|
||||
import {awsEndpoint} from '../service/runtimeProbe'
|
||||
import {AwsHttpApiOperations} from './AwsHttpApiOperations'
|
||||
import type {HttpApiAuthorizerInput, HttpApiAuthorizerOverview, HttpApiCors, HttpApiOverview, HttpApiRouteInput, HttpApiRouteOverview, HttpApiStageInput, HttpApiStageOverview, HttpApiWebSocketTestInput, HttpApiWebSocketTestResult} from '../cloud-spi/types'
|
||||
import type {ApiGatewayCorsInput, ApiGatewayCorsResult, ApiGatewayDeleteRouteInput, ApiGatewayLambdaRouteInput, ApiGatewayLambdaRouteResult, ApiGatewayMethodTestInput, ApiGatewayMethodTestResult, ApiGatewayRouteOverview, ApiGatewayRoutesResult, ApiGatewayTestRouteInput, ApiGatewayTestRouteResult, CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types'
|
||||
|
||||
export class AwsApiGatewayAdapter implements CloudServiceAdapter {
|
||||
@@ -37,7 +40,13 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter {
|
||||
private readonly lambda: LambdaClient = defaultLambda,
|
||||
private readonly accountId = defaultAccountId(),
|
||||
private readonly region = process.env.AWS_REGION ?? 'us-east-1',
|
||||
) {}
|
||||
apiGatewayV2: ApiGatewayV2Client = defaultApiGatewayV2,
|
||||
) {
|
||||
this.httpApis = new AwsHttpApiOperations(apiGatewayV2, lambda, accountId, region)
|
||||
}
|
||||
|
||||
/** HTTP and WebSocket APIs (API Gateway v2), listed and managed alongside REST APIs. */
|
||||
private readonly httpApis: AwsHttpApiOperations
|
||||
|
||||
schema(): ServiceSchema {
|
||||
return awsApiGatewaySchema()
|
||||
@@ -53,7 +62,17 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter {
|
||||
position = res.position
|
||||
} while (position)
|
||||
|
||||
return filterBySearch(apis.map(toResource), query.search)
|
||||
return filterBySearch([...apis.map(toResource), ...await this.listHttpApis()], query.search)
|
||||
}
|
||||
|
||||
/** A runtime without API Gateway v2 still lists its REST APIs. */
|
||||
private async listHttpApis(): Promise<CloudResource[]> {
|
||||
try {
|
||||
return await this.httpApis.list()
|
||||
} catch (error) {
|
||||
if (hasHttpStatus(error, 404) || hasHttpStatus(error, 501)) return []
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async get(id: string): Promise<CloudResource | null> {
|
||||
@@ -61,12 +80,15 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter {
|
||||
const res = await this.apiGateway.send(new GetRestApiCommand({restApiId: id}))
|
||||
return toResource(res)
|
||||
} catch (error) {
|
||||
if (hasHttpStatus(error, 404)) return null
|
||||
throw error
|
||||
if (!hasHttpStatus(error, 404)) throw error
|
||||
}
|
||||
return this.httpApis.get(id)
|
||||
}
|
||||
|
||||
async create(input: CreateResourceInput): Promise<CloudResource> {
|
||||
const protocol = stringValue(input.values.protocol) || 'REST'
|
||||
if (protocol === 'HTTP' || protocol === 'WEBSOCKET') return this.httpApis.create(input.values, protocol)
|
||||
if (protocol !== 'REST') throw new ValidationError('protocol must be REST, HTTP or WEBSOCKET')
|
||||
const name = stringValue(input.values.name)
|
||||
const description = stringValue(input.values.description)
|
||||
if (!name) throw new ValidationError('name is required')
|
||||
@@ -79,9 +101,54 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter {
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
if (await this.httpApis.get(id)) return this.httpApis.delete(id)
|
||||
await this.apiGateway.send(new DeleteRestApiCommand({restApiId: id}))
|
||||
}
|
||||
|
||||
describeHttpApi(apiId: string): Promise<HttpApiOverview> {
|
||||
return this.httpApis.describe(apiId)
|
||||
}
|
||||
|
||||
createHttpApiRoute(apiId: string, input: HttpApiRouteInput): Promise<HttpApiRouteOverview> {
|
||||
return this.httpApis.createRoute(apiId, input)
|
||||
}
|
||||
|
||||
deleteHttpApiRoute(apiId: string, routeId: string): Promise<void> {
|
||||
return this.httpApis.deleteRoute(apiId, routeId)
|
||||
}
|
||||
|
||||
setHttpApiRouteAuthorizer(apiId: string, routeId: string, authorizerId: string | null): Promise<HttpApiRouteOverview> {
|
||||
return this.httpApis.setRouteAuthorizer(apiId, routeId, authorizerId)
|
||||
}
|
||||
|
||||
createHttpApiStage(apiId: string, input: HttpApiStageInput): Promise<HttpApiStageOverview> {
|
||||
return this.httpApis.createStage(apiId, input)
|
||||
}
|
||||
|
||||
deleteHttpApiStage(apiId: string, stageName: string): Promise<void> {
|
||||
return this.httpApis.deleteStage(apiId, stageName)
|
||||
}
|
||||
|
||||
deployHttpApi(apiId: string, stageName: string): Promise<HttpApiStageOverview> {
|
||||
return this.httpApis.deploy(apiId, stageName)
|
||||
}
|
||||
|
||||
createHttpApiAuthorizer(apiId: string, input: HttpApiAuthorizerInput): Promise<HttpApiAuthorizerOverview> {
|
||||
return this.httpApis.createAuthorizer(apiId, input)
|
||||
}
|
||||
|
||||
deleteHttpApiAuthorizer(apiId: string, authorizerId: string): Promise<void> {
|
||||
return this.httpApis.deleteAuthorizer(apiId, authorizerId)
|
||||
}
|
||||
|
||||
updateHttpApiCors(apiId: string, cors: HttpApiCors | null): Promise<HttpApiCors | null> {
|
||||
return this.httpApis.updateCors(apiId, cors)
|
||||
}
|
||||
|
||||
testWebSocketApi(apiId: string, input: HttpApiWebSocketTestInput): Promise<HttpApiWebSocketTestResult> {
|
||||
return this.httpApis.testWebSocket(apiId, input)
|
||||
}
|
||||
|
||||
async createApiGatewayLambdaRoute(apiId: string, input: ApiGatewayLambdaRouteInput): Promise<ApiGatewayLambdaRouteResult> {
|
||||
const method = input.method.toUpperCase()
|
||||
const stage = stageName(input.stage)
|
||||
@@ -470,7 +537,8 @@ function childPath(parentPath: string, pathPart: string): string {
|
||||
|
||||
function stageName(stage: string): string {
|
||||
const value = stringValue(stage)
|
||||
if (!/^[A-Za-z0-9_-]+$/.test(value)) {
|
||||
// $default is an HTTP API's catch-all stage; the deployed-request test serves both kinds of API.
|
||||
if (!/^(?:\$default|[A-Za-z0-9_-]+)$/.test(value)) {
|
||||
throw new ValidationError('stage must contain only letters, numbers, underscores, or hyphens')
|
||||
}
|
||||
return value
|
||||
@@ -518,6 +586,7 @@ function toResource(api: RestApi): CloudResource {
|
||||
metadata: {
|
||||
provider: 'aws',
|
||||
apigatewayService: 'rest',
|
||||
protocol: 'REST',
|
||||
description: api.description,
|
||||
version: api.version,
|
||||
endpointTypes: api.endpointConfiguration?.types,
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import {describe, expect, test} from 'bun:test'
|
||||
import {
|
||||
type AutoScalingClient,
|
||||
CreateAutoScalingGroupCommand,
|
||||
DeleteAutoScalingGroupCommand,
|
||||
DescribeAutoScalingGroupsCommand,
|
||||
DescribeScalingActivitiesCommand,
|
||||
TerminateInstanceInAutoScalingGroupCommand,
|
||||
UpdateAutoScalingGroupCommand,
|
||||
} from '@aws-sdk/client-auto-scaling'
|
||||
import {DescribeSubnetsCommand, type EC2Client} from '@aws-sdk/client-ec2'
|
||||
import {AwsAutoScalingAdapter} from './AwsAutoScalingAdapter'
|
||||
import {NotFoundError, ValidationError} from '../cloud-spi/errors'
|
||||
|
||||
type SendResult = Record<string, unknown>
|
||||
|
||||
function stub(handler: (command: object) => SendResult | Promise<SendResult>) {
|
||||
const sent: object[] = []
|
||||
const client = {
|
||||
async send(command: object) {
|
||||
sent.push(command)
|
||||
return handler(command)
|
||||
},
|
||||
}
|
||||
return {client, sent}
|
||||
}
|
||||
|
||||
const group = {
|
||||
AutoScalingGroupName: 'web-asg',
|
||||
AutoScalingGroupARN: 'arn:aws:autoscaling:us-east-1:000000000000:autoScalingGroup:web-asg',
|
||||
LaunchTemplate: {LaunchTemplateName: 'web-lt', Version: '$Latest'},
|
||||
MinSize: 1, MaxSize: 3, DesiredCapacity: 2,
|
||||
VPCZoneIdentifier: 'subnet-a,subnet-b',
|
||||
HealthCheckType: 'EC2',
|
||||
Instances: [
|
||||
{InstanceId: 'i-b8736c8b4a8c129a3', AvailabilityZone: 'us-east-1a', LifecycleState: 'InService', HealthStatus: 'Healthy', InstanceType: 't3.micro'},
|
||||
{InstanceId: 'i-a3155c876867976b1', AvailabilityZone: 'us-east-1b', LifecycleState: 'Pending', HealthStatus: 'Healthy', InstanceType: 't3.micro'},
|
||||
],
|
||||
CreatedTime: new Date('2026-10-25T16:49:54Z'),
|
||||
}
|
||||
const activity = {ActivityId: 'a1', Description: 'Launching a new EC2 instance: i-b8736c8b4a8c129a3', Cause: 'An instance was started in response to a desired capacity change.', StatusCode: 'Successful', StartTime: new Date('2026-10-25T16:50:09Z')}
|
||||
|
||||
function adapterWith(handler: (command: object) => SendResult | undefined) {
|
||||
const autoscaling = stub((command) => handler(command) ?? (
|
||||
command instanceof DescribeAutoScalingGroupsCommand ? {AutoScalingGroups: [group]}
|
||||
: command instanceof DescribeScalingActivitiesCommand ? {Activities: [activity]}
|
||||
: {}))
|
||||
const ec2 = stub((command) => command instanceof DescribeSubnetsCommand ? {Subnets: [{SubnetId: 'subnet-default-b'}, {SubnetId: 'subnet-default-a'}]} : {})
|
||||
return {
|
||||
sent: autoscaling.sent,
|
||||
ec2Sent: ec2.sent,
|
||||
adapter: new AwsAutoScalingAdapter(autoscaling.client as unknown as AutoScalingClient, ec2.client as unknown as EC2Client),
|
||||
}
|
||||
}
|
||||
|
||||
describe('AwsAutoScalingAdapter', () => {
|
||||
test('lists groups across pages with their capacity and template', async () => {
|
||||
const {adapter} = adapterWith((command) => {
|
||||
if (!(command instanceof DescribeAutoScalingGroupsCommand)) return undefined
|
||||
return command.input.NextToken
|
||||
? {AutoScalingGroups: [{...group, AutoScalingGroupName: 'jobs', LaunchTemplate: {LaunchTemplateName: 'jobs-lt'}, Instances: []}]}
|
||||
: {AutoScalingGroups: [group], NextToken: 'n'}
|
||||
})
|
||||
|
||||
const groups = await adapter.list()
|
||||
expect(groups.map((g) => [g.name, g.metadata.capacity, g.metadata.desiredCapacity, g.metadata.launchTemplateName])).toEqual([
|
||||
['web-asg', '1 in service', 2, 'web-lt'],
|
||||
['jobs', '0 in service', 2, 'jobs-lt'],
|
||||
])
|
||||
expect((await adapter.list({search: 'jobs-lt'})).map((g) => g.name)).toEqual(['jobs'])
|
||||
})
|
||||
|
||||
test('describes the instances and recent scaling activity', async () => {
|
||||
const {adapter} = adapterWith(() => undefined)
|
||||
const detail = await adapter.get('web-asg')
|
||||
expect(detail?.metadata.instances).toEqual([
|
||||
{id: 'i-b8736c8b4a8c129a3', availabilityZone: 'us-east-1a', lifecycleState: 'InService', health: 'Healthy', instanceType: 't3.micro', protectedFromScaleIn: false},
|
||||
{id: 'i-a3155c876867976b1', availabilityZone: 'us-east-1b', lifecycleState: 'Pending', health: 'Healthy', instanceType: 't3.micro', protectedFromScaleIn: false},
|
||||
])
|
||||
expect(detail?.metadata.activities).toEqual([{id: 'a1', description: activity.Description, cause: activity.Cause, status: 'Successful', statusMessage: null, startTime: '2026-10-25T16:50:09.000Z'}])
|
||||
})
|
||||
|
||||
test('keeps the group readable when its activities cannot be read', async () => {
|
||||
const {adapter} = adapterWith((command) => {
|
||||
if (command instanceof DescribeScalingActivitiesCommand) throw new Error('throttled')
|
||||
return undefined
|
||||
})
|
||||
expect((await adapter.get('web-asg'))?.metadata.activitiesUnavailable).toBe(true)
|
||||
})
|
||||
|
||||
test('a missing group is null', async () => {
|
||||
const {adapter} = adapterWith((command) => command instanceof DescribeAutoScalingGroupsCommand ? {AutoScalingGroups: []} : undefined)
|
||||
expect(await adapter.get('nope')).toBeNull()
|
||||
})
|
||||
|
||||
test('creates a group in the default subnets when none are given', async () => {
|
||||
const {adapter, sent} = adapterWith(() => undefined)
|
||||
|
||||
await adapter.create({values: {name: 'web-asg', launchTemplate: 'web-lt', minSize: '1', desiredCapacity: '2', maxSize: '3'}})
|
||||
expect((sent[0] as CreateAutoScalingGroupCommand).input).toEqual({
|
||||
AutoScalingGroupName: 'web-asg',
|
||||
LaunchTemplate: {LaunchTemplateName: 'web-lt', Version: '$Latest'},
|
||||
MinSize: 1, MaxSize: 3, DesiredCapacity: 2,
|
||||
VPCZoneIdentifier: 'subnet-default-a,subnet-default-b',
|
||||
HealthCheckType: 'EC2',
|
||||
})
|
||||
})
|
||||
|
||||
test('creates from a template ID with given subnets, target groups and ELB health checks', async () => {
|
||||
const {adapter, sent, ec2Sent} = adapterWith(() => undefined)
|
||||
const targetGroup = 'arn:aws:elasticloadbalancing:us-east-1:000000000000:targetgroup/web/abc'
|
||||
|
||||
await adapter.create({values: {name: 'web-asg', launchTemplate: 'lt-ba034c5aa8346f602', launchTemplateVersion: '2', minSize: '0', maxSize: '2', subnets: 'subnet-a, subnet-b', targetGroupArns: targetGroup, healthCheckType: 'ELB'}})
|
||||
expect((sent[0] as CreateAutoScalingGroupCommand).input).toMatchObject({
|
||||
LaunchTemplate: {LaunchTemplateId: 'lt-ba034c5aa8346f602', Version: '2'},
|
||||
DesiredCapacity: 0,
|
||||
VPCZoneIdentifier: 'subnet-a,subnet-b',
|
||||
TargetGroupARNs: [targetGroup],
|
||||
HealthCheckType: 'ELB',
|
||||
HealthCheckGracePeriod: 300,
|
||||
})
|
||||
expect(ec2Sent).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('refuses capacities AWS would reject, before calling it', async () => {
|
||||
const {adapter, sent} = adapterWith(() => undefined)
|
||||
const base = {name: 'g', launchTemplate: 'web-lt'}
|
||||
await expect(adapter.create({values: {...base, minSize: '3', maxSize: '1'}})).rejects.toThrow('minSize cannot be larger than maxSize')
|
||||
await expect(adapter.create({values: {...base, minSize: '1', desiredCapacity: '5', maxSize: '3'}})).rejects.toThrow('desiredCapacity must be between')
|
||||
await expect(adapter.create({values: {...base, minSize: '0', maxSize: '500'}})).rejects.toBeInstanceOf(ValidationError)
|
||||
await expect(adapter.create({values: {...base, minSize: '', maxSize: '1'}})).rejects.toBeInstanceOf(ValidationError)
|
||||
await expect(adapter.create({values: {...base, minSize: '1', maxSize: '1', launchTemplateVersion: 'latest'}})).rejects.toBeInstanceOf(ValidationError)
|
||||
await expect(adapter.create({values: {...base, name: 'a:b', minSize: '1', maxSize: '1'}})).rejects.toBeInstanceOf(ValidationError)
|
||||
expect(sent).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('changes capacity, keeping the values not given', async () => {
|
||||
const {adapter, sent} = adapterWith(() => undefined)
|
||||
|
||||
await adapter.setAutoScalingCapacity('web-asg', {desiredCapacity: 3})
|
||||
expect((sent.find((command) => command instanceof UpdateAutoScalingGroupCommand) as UpdateAutoScalingGroupCommand).input).toEqual({AutoScalingGroupName: 'web-asg', MinSize: 1, DesiredCapacity: 3, MaxSize: 3})
|
||||
await expect(adapter.setAutoScalingCapacity('web-asg', {desiredCapacity: 4})).rejects.toThrow('desiredCapacity must be between')
|
||||
})
|
||||
|
||||
test('terminates an instance of the group, replacing it or shrinking the group', async () => {
|
||||
const {adapter, sent} = adapterWith(() => undefined)
|
||||
|
||||
await adapter.terminateAutoScalingInstance('web-asg', 'i-b8736c8b4a8c129a3', true)
|
||||
await adapter.terminateAutoScalingInstance('web-asg', 'i-b8736c8b4a8c129a3', false)
|
||||
const terminations = sent.filter((command): command is TerminateInstanceInAutoScalingGroupCommand => command instanceof TerminateInstanceInAutoScalingGroupCommand)
|
||||
expect(terminations.map((command) => command.input.ShouldDecrementDesiredCapacity)).toEqual([false, true])
|
||||
await expect(adapter.terminateAutoScalingInstance('web-asg', 'i-0000000000000000', true)).rejects.toBeInstanceOf(NotFoundError)
|
||||
})
|
||||
|
||||
test('deletes a group together with its instances', async () => {
|
||||
const {adapter, sent} = adapterWith(() => undefined)
|
||||
await adapter.delete('web-asg')
|
||||
expect((sent[0] as DeleteAutoScalingGroupCommand).input).toEqual({AutoScalingGroupName: 'web-asg', ForceDelete: true})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,227 @@
|
||||
import {
|
||||
type Activity,
|
||||
type AutoScalingClient,
|
||||
type AutoScalingGroup,
|
||||
CreateAutoScalingGroupCommand,
|
||||
DeleteAutoScalingGroupCommand,
|
||||
DescribeAutoScalingGroupsCommand,
|
||||
DescribeScalingActivitiesCommand,
|
||||
TerminateInstanceInAutoScalingGroupCommand,
|
||||
UpdateAutoScalingGroupCommand,
|
||||
} from '@aws-sdk/client-auto-scaling'
|
||||
import {DescribeSubnetsCommand, type EC2Client} from '@aws-sdk/client-ec2'
|
||||
import {AUTO_SCALING_GROUP_NAME_PATTERN, AUTO_SCALING_MAX_INSTANCES, awsAutoScalingSchema} from '../cloud-spi/autoScalingSchema'
|
||||
import {NotFoundError, RuntimeError, ValidationError} from '../cloud-spi/errors'
|
||||
import type {AutoScalingCapacityInput, CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types'
|
||||
|
||||
/** The most recent activities a group's detail shows. */
|
||||
const ACTIVITY_LIMIT = 20
|
||||
|
||||
/**
|
||||
* Talks to EC2 Auto Scaling, verified against Floci core (community, `latest`).
|
||||
*
|
||||
* Notes from probing the runtime:
|
||||
* - Groups really scale: a reconciler runs every 10 seconds and launches or
|
||||
* terminates EC2 instances (containers) until the group matches its desired
|
||||
* capacity, registering them with the group's target groups, and records each
|
||||
* launch and termination as a scaling activity.
|
||||
* - Scaling policies are stored but never run: the runtime's alarms drive only
|
||||
* Application Auto Scaling (ECS services), so this adapter does not offer them.
|
||||
*
|
||||
* Groups are keyed by name, which is unique per region.
|
||||
*/
|
||||
export class AwsAutoScalingAdapter implements CloudServiceAdapter {
|
||||
readonly cloud = 'aws' as const
|
||||
readonly service = 'autoscaling' as const
|
||||
|
||||
constructor(private readonly autoscaling: AutoScalingClient, private readonly ec2: EC2Client) {}
|
||||
|
||||
schema(): ServiceSchema {
|
||||
return awsAutoScalingSchema()
|
||||
}
|
||||
|
||||
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
|
||||
const groups: AutoScalingGroup[] = []
|
||||
let next: string | undefined
|
||||
do {
|
||||
const res = await this.autoscaling.send(new DescribeAutoScalingGroupsCommand({MaxRecords: 100, ...(next ? {NextToken: next} : {})}))
|
||||
groups.push(...(res.AutoScalingGroups ?? []))
|
||||
next = res.NextToken
|
||||
} while (next)
|
||||
|
||||
const search = query.search?.trim().toLowerCase()
|
||||
return groups.map((group) => toResource(group)).filter((group) => !search
|
||||
|| group.name.toLowerCase().includes(search)
|
||||
|| String(group.metadata.launchTemplateName ?? '').toLowerCase().includes(search))
|
||||
}
|
||||
|
||||
async get(id: string): Promise<CloudResource | null> {
|
||||
const group = await this.describe(id)
|
||||
if (!group) return null
|
||||
return toResource(group, await this.activities(id))
|
||||
}
|
||||
|
||||
async create(input: CreateResourceInput): Promise<CloudResource> {
|
||||
const values = input.values
|
||||
const name = text(values.name)
|
||||
if (!new RegExp(AUTO_SCALING_GROUP_NAME_PATTERN).test(name)) throw new ValidationError('name must be 1 to 255 characters, without a colon')
|
||||
|
||||
const template = text(values.launchTemplate)
|
||||
if (!template) throw new ValidationError('launchTemplate is required')
|
||||
const version = text(values.launchTemplateVersion) || '$Latest'
|
||||
if (!/^(\d+|\$Latest|\$Default)$/.test(version)) throw new ValidationError('launchTemplateVersion must be a version number, $Latest or $Default')
|
||||
|
||||
const minSize = count(values.minSize, 'minSize')
|
||||
const maxSize = count(values.maxSize, 'maxSize')
|
||||
const desiredCapacity = text(values.desiredCapacity) ? count(values.desiredCapacity, 'desiredCapacity') : minSize
|
||||
checkOrder({minSize, desiredCapacity, maxSize})
|
||||
|
||||
const subnets = text(values.subnets).split(',').map((subnet) => subnet.trim()).filter(Boolean)
|
||||
if (subnets.some((subnet) => !/^subnet-[\w-]+$/.test(subnet))) throw new ValidationError('subnets must be subnet IDs separated by commas')
|
||||
const targetGroupArns = text(values.targetGroupArns).split(/\s+/).filter(Boolean)
|
||||
if (targetGroupArns.some((arn) => !/^arn:aws[a-z-]*:elasticloadbalancing:.+:targetgroup\/.+$/.test(arn))) {
|
||||
throw new ValidationError('targetGroupArns must be target group ARNs, one per line')
|
||||
}
|
||||
const healthCheckType = values.healthCheckType === 'ELB' ? 'ELB' : 'EC2'
|
||||
|
||||
await this.autoscaling.send(new CreateAutoScalingGroupCommand({
|
||||
AutoScalingGroupName: name,
|
||||
LaunchTemplate: {...(template.startsWith('lt-') ? {LaunchTemplateId: template} : {LaunchTemplateName: template}), Version: version},
|
||||
MinSize: minSize,
|
||||
MaxSize: maxSize,
|
||||
DesiredCapacity: desiredCapacity,
|
||||
VPCZoneIdentifier: (subnets.length > 0 ? subnets : await this.defaultSubnets()).join(','),
|
||||
...(targetGroupArns.length > 0 ? {TargetGroupARNs: targetGroupArns} : {}),
|
||||
HealthCheckType: healthCheckType,
|
||||
// AWS's default grace period when a load balancer decides health.
|
||||
...(healthCheckType === 'ELB' ? {HealthCheckGracePeriod: 300} : {}),
|
||||
}))
|
||||
|
||||
const created = await this.get(name)
|
||||
if (!created) throw new RuntimeError(`EC2 Auto Scaling did not return the new group ${name}`)
|
||||
return created
|
||||
}
|
||||
|
||||
/** ForceDelete terminates the group's instances too, which is what deleting a group in the console means. */
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.autoscaling.send(new DeleteAutoScalingGroupCommand({AutoScalingGroupName: id, ForceDelete: true}))
|
||||
}
|
||||
|
||||
async setAutoScalingCapacity(id: string, input: AutoScalingCapacityInput): Promise<CloudResource> {
|
||||
const group = await this.describe(id)
|
||||
if (!group) throw new NotFoundError(`Auto Scaling group ${id} was not found`)
|
||||
|
||||
const next = {
|
||||
minSize: input.minSize === undefined ? group.MinSize ?? 0 : count(input.minSize, 'minSize'),
|
||||
desiredCapacity: input.desiredCapacity === undefined ? group.DesiredCapacity ?? 0 : count(input.desiredCapacity, 'desiredCapacity'),
|
||||
maxSize: input.maxSize === undefined ? group.MaxSize ?? 0 : count(input.maxSize, 'maxSize'),
|
||||
}
|
||||
checkOrder(next)
|
||||
|
||||
await this.autoscaling.send(new UpdateAutoScalingGroupCommand({
|
||||
AutoScalingGroupName: id,
|
||||
MinSize: next.minSize,
|
||||
DesiredCapacity: next.desiredCapacity,
|
||||
MaxSize: next.maxSize,
|
||||
}))
|
||||
const updated = await this.get(id)
|
||||
if (!updated) throw new NotFoundError(`Auto Scaling group ${id} was not found`)
|
||||
return updated
|
||||
}
|
||||
|
||||
async terminateAutoScalingInstance(id: string, instanceId: string, replace: boolean): Promise<void> {
|
||||
if (!/^i-[0-9a-f]+$/.test(instanceId)) throw new ValidationError('instanceId must be an EC2 instance ID such as i-0abc1234')
|
||||
const group = await this.describe(id)
|
||||
if (!group?.Instances?.some((instance) => instance.InstanceId === instanceId)) {
|
||||
throw new NotFoundError(`Instance ${instanceId} is not in Auto Scaling group ${id}`)
|
||||
}
|
||||
await this.autoscaling.send(new TerminateInstanceInAutoScalingGroupCommand({InstanceId: instanceId, ShouldDecrementDesiredCapacity: !replace}))
|
||||
}
|
||||
|
||||
private async describe(name: string): Promise<AutoScalingGroup | null> {
|
||||
const res = await this.autoscaling.send(new DescribeAutoScalingGroupsCommand({AutoScalingGroupNames: [name]}))
|
||||
return res.AutoScalingGroups?.find((group) => group.AutoScalingGroupName === name) ?? null
|
||||
}
|
||||
|
||||
/** Activities are enrichment: a failure leaves the group readable rather than failing the request. */
|
||||
private async activities(name: string): Promise<Activity[] | 'unavailable'> {
|
||||
try {
|
||||
const res = await this.autoscaling.send(new DescribeScalingActivitiesCommand({AutoScalingGroupName: name, MaxRecords: ACTIVITY_LIMIT}))
|
||||
return res.Activities ?? []
|
||||
} catch {
|
||||
return 'unavailable'
|
||||
}
|
||||
}
|
||||
|
||||
/** AWS needs subnets or zones; like its console, fall back to the default VPC's subnets. */
|
||||
private async defaultSubnets(): Promise<string[]> {
|
||||
const res = await this.ec2.send(new DescribeSubnetsCommand({Filters: [{Name: 'default-for-az', Values: ['true']}]}))
|
||||
const subnets = (res.Subnets ?? []).map((subnet) => subnet.SubnetId).filter((id): id is string => Boolean(id)).sort()
|
||||
if (subnets.length === 0) throw new ValidationError('There is no default VPC to launch into; enter subnet IDs')
|
||||
return subnets
|
||||
}
|
||||
}
|
||||
|
||||
function toResource(group: AutoScalingGroup, activities?: Activity[] | 'unavailable'): CloudResource {
|
||||
const name = group.AutoScalingGroupName ?? ''
|
||||
const instances = group.Instances ?? []
|
||||
const template = group.LaunchTemplate ?? group.MixedInstancesPolicy?.LaunchTemplate?.LaunchTemplateSpecification
|
||||
return {
|
||||
id: name,
|
||||
name,
|
||||
cloud: 'aws',
|
||||
service: 'autoscaling',
|
||||
type: 'autoScalingGroup',
|
||||
region: group.AutoScalingGroupARN?.split(':')[3] || null,
|
||||
createdAt: group.CreatedTime ? group.CreatedTime.toISOString() : null,
|
||||
status: group.Status ?? null,
|
||||
metadata: {
|
||||
arn: group.AutoScalingGroupARN,
|
||||
minSize: group.MinSize ?? 0,
|
||||
maxSize: group.MaxSize ?? 0,
|
||||
desiredCapacity: group.DesiredCapacity ?? 0,
|
||||
capacity: `${instances.filter((instance) => instance.LifecycleState === 'InService').length} in service`,
|
||||
launchTemplateName: template?.LaunchTemplateName ?? template?.LaunchTemplateId ?? group.LaunchConfigurationName ?? null,
|
||||
launchTemplateVersion: template?.Version ?? null,
|
||||
subnets: (group.VPCZoneIdentifier ?? '').split(',').filter(Boolean),
|
||||
targetGroupArns: group.TargetGroupARNs ?? [],
|
||||
healthCheckType: group.HealthCheckType ?? null,
|
||||
suspendedProcesses: (group.SuspendedProcesses ?? []).map((process) => process.ProcessName).filter(Boolean),
|
||||
instances: instances.map((instance) => ({
|
||||
id: instance.InstanceId ?? '',
|
||||
availabilityZone: instance.AvailabilityZone ?? null,
|
||||
lifecycleState: instance.LifecycleState ?? null,
|
||||
health: instance.HealthStatus ?? null,
|
||||
instanceType: instance.InstanceType ?? null,
|
||||
protectedFromScaleIn: instance.ProtectedFromScaleIn ?? false,
|
||||
})),
|
||||
...(activities === 'unavailable' ? {activitiesUnavailable: true} : activities ? {
|
||||
activities: activities.map((activity) => ({
|
||||
id: activity.ActivityId ?? '',
|
||||
description: activity.Description ?? '',
|
||||
cause: activity.Cause ?? '',
|
||||
status: activity.StatusCode ?? null,
|
||||
statusMessage: activity.StatusMessage ?? null,
|
||||
startTime: activity.StartTime ? activity.StartTime.toISOString() : null,
|
||||
})),
|
||||
} : {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function checkOrder({minSize, desiredCapacity, maxSize}: Required<AutoScalingCapacityInput>): void {
|
||||
if (minSize > maxSize) throw new ValidationError('minSize cannot be larger than maxSize')
|
||||
if (desiredCapacity < minSize || desiredCapacity > maxSize) throw new ValidationError('desiredCapacity must be between minSize and maxSize')
|
||||
}
|
||||
|
||||
function count(value: unknown, field: string): number {
|
||||
const raw = typeof value === 'number' ? value : Number(text(value))
|
||||
if (!Number.isInteger(raw) || raw < 0 || raw > AUTO_SCALING_MAX_INSTANCES || (typeof value === 'string' && !text(value))) {
|
||||
throw new ValidationError(`${field} must be a whole number from 0 to ${AUTO_SCALING_MAX_INSTANCES}`)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
function text(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import {describe, expect, test} from 'bun:test'
|
||||
import {
|
||||
type ACMClient,
|
||||
DeleteCertificateCommand,
|
||||
DescribeCertificateCommand,
|
||||
ExportCertificateCommand,
|
||||
GetCertificateCommand,
|
||||
ImportCertificateCommand,
|
||||
ListCertificatesCommand,
|
||||
RequestCertificateCommand,
|
||||
RevokeCertificateCommand,
|
||||
} from '@aws-sdk/client-acm'
|
||||
import {AwsCertificatesAdapter} from './AwsCertificatesAdapter'
|
||||
import {ValidationError} from '../cloud-spi/errors'
|
||||
|
||||
type SendResult = Record<string, unknown>
|
||||
|
||||
function stubAcm(handler: (command: object) => SendResult | Promise<SendResult>) {
|
||||
const sent: object[] = []
|
||||
const client = {
|
||||
async send(command: object) {
|
||||
sent.push(command)
|
||||
return handler(command)
|
||||
},
|
||||
} as unknown as ACMClient
|
||||
return {client, sent}
|
||||
}
|
||||
|
||||
function awsError(name: string, status = 400): Error {
|
||||
return Object.assign(new Error(name), {name, $metadata: {httpStatusCode: status}})
|
||||
}
|
||||
|
||||
const ID = '5ed9dc37-d9fb-456e-911e-e87f091480ba'
|
||||
const ARN = `arn:aws:acm:us-east-1:000000000000:certificate/${ID}`
|
||||
const detail = {
|
||||
CertificateArn: ARN,
|
||||
DomainName: 'notes.thecloud.localhost',
|
||||
SubjectAlternativeNames: ['notes.thecloud.localhost', 'api.thecloud.localhost'],
|
||||
DomainValidationOptions: [{
|
||||
DomainName: 'notes.thecloud.localhost',
|
||||
ValidationStatus: 'SUCCESS',
|
||||
ValidationMethod: 'DNS',
|
||||
ResourceRecord: {Name: '_a1.notes.thecloud.localhost.', Type: 'CNAME', Value: '_e8.acm-validations.aws.'},
|
||||
}],
|
||||
Status: 'ISSUED',
|
||||
Type: 'AMAZON_ISSUED',
|
||||
KeyAlgorithm: 'RSA-2048',
|
||||
Issuer: 'CN=Floci Local CA',
|
||||
CreatedAt: new Date('2026-10-25T16:28:12Z'),
|
||||
NotAfter: new Date('2027-10-25T16:28:12Z'),
|
||||
InUseBy: ['arn:aws:elasticloadbalancing:us-east-1:000000000000:loadbalancer/app/web/1'],
|
||||
Options: {Export: 'ENABLED', CertificateTransparencyLoggingPreference: 'ENABLED'},
|
||||
}
|
||||
|
||||
function adapterWith(handler: (command: object) => SendResult | Promise<SendResult>) {
|
||||
const stub = stubAcm(handler)
|
||||
return {...stub, adapter: new AwsCertificatesAdapter(stub.client, '000000000000', 'us-east-1')}
|
||||
}
|
||||
|
||||
describe('AwsCertificatesAdapter', () => {
|
||||
test('lists every key type across pages and searches the alternative names too', async () => {
|
||||
const {adapter, sent} = adapterWith((command) => {
|
||||
if (!(command instanceof ListCertificatesCommand)) return {}
|
||||
return command.input.NextToken
|
||||
? {CertificateSummaryList: [{CertificateArn: ARN.replace(ID, '11111111-2222-3333-4444-555555555555'), DomainName: 'shop.test', Type: 'IMPORTED', Status: 'ISSUED', InUse: false}]}
|
||||
: {CertificateSummaryList: [{CertificateArn: ARN, DomainName: 'notes.thecloud.localhost', SubjectAlternativeNameSummaries: ['notes.thecloud.localhost', 'api.thecloud.localhost'], Type: 'AMAZON_ISSUED', Status: 'ISSUED', KeyAlgorithm: 'EC-prime256v1', InUse: true}], NextToken: 'page-2'}
|
||||
})
|
||||
|
||||
const all = await adapter.list()
|
||||
expect(all.map((certificate) => [certificate.id, certificate.name, certificate.metadata.certificateType, certificate.metadata.inUse])).toEqual([
|
||||
[ID, 'notes.thecloud.localhost', 'Amazon issued', 'Yes'],
|
||||
['11111111-2222-3333-4444-555555555555', 'shop.test', 'Imported', 'No'],
|
||||
])
|
||||
// Without Includes, AWS leaves EC certificates out of the list.
|
||||
expect((sent[0] as ListCertificatesCommand).input.Includes?.keyTypes).toContain('EC_prime256v1')
|
||||
expect((await adapter.list({search: 'api.thecloud'})).map((certificate) => certificate.id)).toEqual([ID])
|
||||
})
|
||||
|
||||
test('describes a certificate with its users and the DNS records validation asks for', async () => {
|
||||
const {adapter, sent} = adapterWith(() => ({Certificate: detail}))
|
||||
|
||||
const certificate = await adapter.get(ID)
|
||||
expect((sent[0] as DescribeCertificateCommand).input.CertificateArn).toBe(ARN)
|
||||
expect(certificate?.metadata).toMatchObject({
|
||||
inUse: 'Yes',
|
||||
inUseBy: detail.InUseBy,
|
||||
exportable: true,
|
||||
issuer: 'CN=Floci Local CA',
|
||||
notAfter: '2027-10-25T16:28:12.000Z',
|
||||
domainValidations: [{domainName: 'notes.thecloud.localhost', status: 'SUCCESS', method: 'DNS', record: {name: '_a1.notes.thecloud.localhost.', type: 'CNAME', value: '_e8.acm-validations.aws.'}}],
|
||||
})
|
||||
})
|
||||
|
||||
test('a missing certificate is null, and an id that is not a UUID never reaches ACM', async () => {
|
||||
const {adapter, sent} = adapterWith(() => { throw awsError('ResourceNotFoundException') })
|
||||
expect(await adapter.get(ID)).toBeNull()
|
||||
await expect(adapter.get('../other')).rejects.toBeInstanceOf(ValidationError)
|
||||
expect(sent).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('requests a certificate for the domain and its extra names', async () => {
|
||||
const {adapter, sent} = adapterWith((command) => command instanceof RequestCertificateCommand ? {CertificateArn: ARN} : {Certificate: detail})
|
||||
|
||||
const created = await adapter.create({values: {source: 'request', domainName: 'Notes.TheCloud.localhost', subjectAlternativeNames: 'api.thecloud.localhost\nnotes.thecloud.localhost\n*.thecloud.localhost', keyAlgorithm: 'EC_prime256v1', export: 'ENABLED'}})
|
||||
expect(created.id).toBe(ID)
|
||||
expect((sent[0] as RequestCertificateCommand).input).toEqual({
|
||||
DomainName: 'notes.thecloud.localhost',
|
||||
SubjectAlternativeNames: ['notes.thecloud.localhost', 'api.thecloud.localhost', '*.thecloud.localhost'],
|
||||
ValidationMethod: 'DNS',
|
||||
KeyAlgorithm: 'EC_prime256v1',
|
||||
Options: {Export: 'ENABLED'},
|
||||
})
|
||||
})
|
||||
|
||||
test('refuses a request ACM would reject before calling it', async () => {
|
||||
const {adapter, sent} = adapterWith(() => ({}))
|
||||
await expect(adapter.create({values: {source: 'request', domainName: 'not a domain'}})).rejects.toBeInstanceOf(ValidationError)
|
||||
await expect(adapter.create({values: {source: 'request', domainName: 'a.test', subjectAlternativeNames: 'b..test'}})).rejects.toThrow('b..test is not a domain name')
|
||||
await expect(adapter.create({values: {source: 'request', domainName: 'a.test', keyAlgorithm: 'RSA_8192'}})).rejects.toBeInstanceOf(ValidationError)
|
||||
await expect(adapter.create({values: {source: 'copy', domainName: 'a.test'}})).rejects.toBeInstanceOf(ValidationError)
|
||||
expect(sent).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('imports PEM text as bytes, with the chain when given', async () => {
|
||||
const {adapter, sent} = adapterWith((command) => command instanceof ImportCertificateCommand ? {CertificateArn: ARN} : {Certificate: {...detail, Type: 'IMPORTED'}})
|
||||
const certificate = '-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----'
|
||||
const privateKey = '-----BEGIN RSA PRIVATE KEY-----\nMIIE\n-----END RSA PRIVATE KEY-----'
|
||||
|
||||
const created = await adapter.create({values: {source: 'import', certificate, privateKey, certificateChain: certificate}})
|
||||
expect(created.metadata.certificateType).toBe('Imported')
|
||||
const input = (sent[0] as ImportCertificateCommand).input
|
||||
expect(new TextDecoder().decode(input.Certificate)).toBe(certificate)
|
||||
expect(new TextDecoder().decode(input.PrivateKey)).toBe(privateKey)
|
||||
expect(input.CertificateChain).toBeDefined()
|
||||
|
||||
await expect(adapter.create({values: {source: 'import', certificate, privateKey: 'secret'}})).rejects.toThrow('privateKey must be PEM text')
|
||||
})
|
||||
|
||||
test('downloads, exports and revokes by the rebuilt ARN', async () => {
|
||||
const {adapter, sent} = adapterWith((command) => {
|
||||
if (command instanceof GetCertificateCommand) return {Certificate: 'CERT', CertificateChain: 'CHAIN'}
|
||||
if (command instanceof ExportCertificateCommand) return {Certificate: 'CERT', CertificateChain: 'CHAIN', PrivateKey: 'ENCRYPTED'}
|
||||
if (command instanceof RevokeCertificateCommand) return {CertificateArn: ARN}
|
||||
return {Certificate: {...detail, Status: 'REVOKED', RevocationReason: 'SUPERSEDED'}}
|
||||
})
|
||||
|
||||
expect(await adapter.getCertificatePem(ID)).toEqual({certificate: 'CERT', certificateChain: 'CHAIN'})
|
||||
expect(await adapter.exportCertificate(ID, 'open-sesame')).toEqual({certificate: 'CERT', certificateChain: 'CHAIN', privateKey: 'ENCRYPTED'})
|
||||
expect(new TextDecoder().decode((sent[1] as ExportCertificateCommand).input.Passphrase)).toBe('open-sesame')
|
||||
const revoked = await adapter.revokeCertificate(ID, 'SUPERSEDED')
|
||||
expect(revoked.status).toBe('REVOKED')
|
||||
expect((sent[2] as RevokeCertificateCommand).input).toEqual({CertificateArn: ARN, RevocationReason: 'SUPERSEDED'})
|
||||
|
||||
await expect(adapter.exportCertificate(ID, 'abc')).rejects.toBeInstanceOf(ValidationError)
|
||||
await expect(adapter.exportCertificate(ID, 'has$dollar')).rejects.toBeInstanceOf(ValidationError)
|
||||
await expect(adapter.revokeCertificate(ID, 'BORED' as never)).rejects.toBeInstanceOf(ValidationError)
|
||||
})
|
||||
|
||||
test('deletes by ARN and lets ACM refuse a certificate that is in use', async () => {
|
||||
const {adapter, sent} = adapterWith(() => { throw awsError('ResourceInUseException') })
|
||||
await expect(adapter.delete(ID)).rejects.toThrow('ResourceInUseException')
|
||||
expect((sent[0] as DeleteCertificateCommand).input.CertificateArn).toBe(ARN)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,290 @@
|
||||
import {
|
||||
type ACMClient,
|
||||
type CertificateDetail,
|
||||
type CertificateSummary,
|
||||
DeleteCertificateCommand,
|
||||
DescribeCertificateCommand,
|
||||
ExportCertificateCommand,
|
||||
GetCertificateCommand,
|
||||
ImportCertificateCommand,
|
||||
type KeyAlgorithm,
|
||||
ListCertificatesCommand,
|
||||
RequestCertificateCommand,
|
||||
RevokeCertificateCommand,
|
||||
} from '@aws-sdk/client-acm'
|
||||
import {
|
||||
awsCertificatesSchema,
|
||||
CERTIFICATE_DOMAIN_PATTERN,
|
||||
CERTIFICATE_KEY_ALGORITHMS,
|
||||
CERTIFICATE_REVOCATION_REASONS,
|
||||
CERTIFICATE_VALIDATION_METHODS,
|
||||
} from '../cloud-spi/certificatesSchema'
|
||||
import {RuntimeError, ValidationError} from '../cloud-spi/errors'
|
||||
import type {
|
||||
CertificatePem,
|
||||
CertificateRevocationReason,
|
||||
CloudResource,
|
||||
CloudServiceAdapter,
|
||||
CreateResourceInput,
|
||||
ExportedCertificate,
|
||||
ResourceQuery,
|
||||
ServiceSchema,
|
||||
} from '../cloud-spi/types'
|
||||
|
||||
/**
|
||||
* ListCertificates returns only RSA certificates unless asked for other key
|
||||
* types, so a list without this would silently leave out every EC certificate.
|
||||
*/
|
||||
const ALL_KEY_TYPES: KeyAlgorithm[] = ['RSA_1024', 'RSA_2048', 'RSA_3072', 'RSA_4096', 'EC_prime256v1', 'EC_secp384r1', 'EC_secp521r1']
|
||||
/** ACM's limit on names per certificate, the domain name included. */
|
||||
const MAX_NAMES = 100
|
||||
const CERTIFICATE_TYPE_LABEL: Record<string, string> = {AMAZON_ISSUED: 'Amazon issued', IMPORTED: 'Imported', PRIVATE: 'Private'}
|
||||
|
||||
/**
|
||||
* Talks to AWS Certificate Manager, verified against Floci core (community, `latest`).
|
||||
*
|
||||
* Notes from probing the runtime:
|
||||
* - Requested certificates are signed by the runtime's own local CA and issued at
|
||||
* once (its validation wait defaults to 0), but DescribeCertificate still lists
|
||||
* the CNAME records DNS validation would ask for, as AWS does.
|
||||
* - DeleteCertificate refuses with ResourceInUseException while a load balancer,
|
||||
* distribution or API domain uses the certificate, as on AWS.
|
||||
*
|
||||
* Resources are keyed by the certificate's UUID, the last part of its ARN, because
|
||||
* an ARN's slash cannot travel in a URL path segment; the ARN is rebuilt from the
|
||||
* account and region this adapter serves.
|
||||
*/
|
||||
export class AwsCertificatesAdapter implements CloudServiceAdapter {
|
||||
readonly cloud = 'aws' as const
|
||||
readonly service = 'certificates' as const
|
||||
|
||||
constructor(
|
||||
private readonly acm: ACMClient,
|
||||
private readonly accountId: string,
|
||||
private readonly region = process.env.AWS_REGION || 'us-east-1',
|
||||
) {}
|
||||
|
||||
schema(): ServiceSchema {
|
||||
return awsCertificatesSchema()
|
||||
}
|
||||
|
||||
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
|
||||
const summaries: CertificateSummary[] = []
|
||||
let next: string | undefined
|
||||
do {
|
||||
const res = await this.acm.send(new ListCertificatesCommand({Includes: {keyTypes: ALL_KEY_TYPES}, MaxItems: 1000, ...(next ? {NextToken: next} : {})}))
|
||||
summaries.push(...(res.CertificateSummaryList ?? []))
|
||||
next = res.NextToken
|
||||
} while (next)
|
||||
|
||||
const search = query.search?.trim().toLowerCase()
|
||||
return summaries.map((summary) => this.fromSummary(summary)).filter((certificate) => !search
|
||||
|| certificate.name.toLowerCase().includes(search)
|
||||
|| certificate.id.includes(search)
|
||||
|| (certificate.metadata.subjectAlternativeNames as string[]).some((name) => name.toLowerCase().includes(search)))
|
||||
}
|
||||
|
||||
async get(id: string): Promise<CloudResource | null> {
|
||||
try {
|
||||
const res = await this.acm.send(new DescribeCertificateCommand({CertificateArn: this.arn(id)}))
|
||||
return res.Certificate ? this.fromDetail(res.Certificate) : null
|
||||
} catch (error) {
|
||||
if (errorName(error) === 'ResourceNotFoundException') return null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async create(input: CreateResourceInput): Promise<CloudResource> {
|
||||
const source = input.values.source ?? 'request'
|
||||
const arn = source === 'import' ? await this.importCertificate(input.values)
|
||||
: source === 'request' ? await this.requestCertificate(input.values)
|
||||
: null
|
||||
if (arn === null) throw new ValidationError('source must be request or import')
|
||||
|
||||
const created = await this.get(idFromArn(arn))
|
||||
if (!created) throw new RuntimeError(`ACM did not return the new certificate ${arn}`)
|
||||
return created
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.acm.send(new DeleteCertificateCommand({CertificateArn: this.arn(id)}))
|
||||
}
|
||||
|
||||
async getCertificatePem(id: string): Promise<CertificatePem> {
|
||||
const res = await this.acm.send(new GetCertificateCommand({CertificateArn: this.arn(id)}))
|
||||
if (!res.Certificate) throw new RuntimeError('ACM did not return the certificate')
|
||||
return {certificate: res.Certificate, certificateChain: res.CertificateChain ?? null}
|
||||
}
|
||||
|
||||
/**
|
||||
* AWS exports only private certificates and public ones requested as
|
||||
* exportable; the private key comes back encrypted with the passphrase.
|
||||
*/
|
||||
async exportCertificate(id: string, passphrase: string): Promise<ExportedCertificate> {
|
||||
if (typeof passphrase !== 'string' || passphrase.length < 4 || passphrase.length > 128) {
|
||||
throw new ValidationError('passphrase must be 4 to 128 characters')
|
||||
}
|
||||
if (/[#$%]/.test(passphrase)) throw new ValidationError('passphrase must not contain #, $ or %')
|
||||
|
||||
const res = await this.acm.send(new ExportCertificateCommand({CertificateArn: this.arn(id), Passphrase: new TextEncoder().encode(passphrase)}))
|
||||
if (!res.Certificate || !res.PrivateKey) throw new RuntimeError('ACM did not return the exported certificate')
|
||||
return {certificate: res.Certificate, certificateChain: res.CertificateChain ?? null, privateKey: res.PrivateKey}
|
||||
}
|
||||
|
||||
async revokeCertificate(id: string, reason: CertificateRevocationReason): Promise<CloudResource> {
|
||||
if (!(CERTIFICATE_REVOCATION_REASONS as readonly string[]).includes(reason)) {
|
||||
throw new ValidationError(`reason must be one of ${CERTIFICATE_REVOCATION_REASONS.join(', ')}`)
|
||||
}
|
||||
await this.acm.send(new RevokeCertificateCommand({CertificateArn: this.arn(id), RevocationReason: reason}))
|
||||
const revoked = await this.get(id)
|
||||
if (!revoked) throw new RuntimeError(`ACM did not return the revoked certificate ${id}`)
|
||||
return revoked
|
||||
}
|
||||
|
||||
private async requestCertificate(values: Record<string, unknown>): Promise<string> {
|
||||
const domainName = text(values.domainName).toLowerCase()
|
||||
if (!domainName) throw new ValidationError('domainName is required')
|
||||
const pattern = new RegExp(CERTIFICATE_DOMAIN_PATTERN)
|
||||
if (!pattern.test(domainName)) throw new ValidationError('domainName must be a domain name such as example.com or *.example.com')
|
||||
|
||||
const alternatives = [...new Set(text(values.subjectAlternativeNames).split(/[\s,]+/).map((name) => name.toLowerCase()).filter((name) => name && name !== domainName))]
|
||||
const invalid = alternatives.find((name) => !pattern.test(name))
|
||||
if (invalid) throw new ValidationError(`${invalid} is not a domain name`)
|
||||
if (alternatives.length + 1 > MAX_NAMES) throw new ValidationError(`A certificate covers at most ${MAX_NAMES} names`)
|
||||
|
||||
const validationMethod = oneOf(values.validationMethod, CERTIFICATE_VALIDATION_METHODS, 'validationMethod')
|
||||
const keyAlgorithm = oneOf(values.keyAlgorithm, CERTIFICATE_KEY_ALGORITHMS, 'keyAlgorithm')
|
||||
const exportOption = oneOf(values.export, ['DISABLED', 'ENABLED'] as const, 'export')
|
||||
|
||||
const res = await this.acm.send(new RequestCertificateCommand({
|
||||
DomainName: domainName,
|
||||
...(alternatives.length > 0 ? {SubjectAlternativeNames: [domainName, ...alternatives]} : {}),
|
||||
ValidationMethod: validationMethod,
|
||||
KeyAlgorithm: keyAlgorithm,
|
||||
Options: {Export: exportOption},
|
||||
}))
|
||||
if (!res.CertificateArn) throw new RuntimeError('ACM did not return the requested certificate')
|
||||
return res.CertificateArn
|
||||
}
|
||||
|
||||
private async importCertificate(values: Record<string, unknown>): Promise<string> {
|
||||
const certificate = pem(values.certificate, 'certificate', 'CERTIFICATE')
|
||||
const privateKey = pem(values.privateKey, 'privateKey', 'PRIVATE KEY')
|
||||
const chain = text(values.certificateChain)
|
||||
if (chain) pem(chain, 'certificateChain', 'CERTIFICATE')
|
||||
|
||||
const encode = (value: string) => new TextEncoder().encode(value)
|
||||
const res = await this.acm.send(new ImportCertificateCommand({
|
||||
Certificate: encode(certificate),
|
||||
PrivateKey: encode(privateKey),
|
||||
...(chain ? {CertificateChain: encode(chain)} : {}),
|
||||
}))
|
||||
if (!res.CertificateArn) throw new RuntimeError('ACM did not return the imported certificate')
|
||||
return res.CertificateArn
|
||||
}
|
||||
|
||||
private arn(id: string): string {
|
||||
if (!/^[0-9a-f-]{36}$/i.test(id)) throw new ValidationError('certificate id must be the UUID at the end of its ARN')
|
||||
return `arn:aws:acm:${this.region}:${this.accountId}:certificate/${id}`
|
||||
}
|
||||
|
||||
private fromSummary(summary: CertificateSummary): CloudResource {
|
||||
const arn = summary.CertificateArn ?? ''
|
||||
return {
|
||||
id: idFromArn(arn),
|
||||
name: summary.DomainName ?? '',
|
||||
cloud: 'aws',
|
||||
service: 'certificates',
|
||||
type: 'certificate',
|
||||
region: arn.split(':')[3] || this.region,
|
||||
createdAt: iso(summary.CreatedAt ?? summary.ImportedAt),
|
||||
status: summary.Status ?? null,
|
||||
metadata: {
|
||||
arn,
|
||||
certificateType: CERTIFICATE_TYPE_LABEL[summary.Type ?? ''] ?? summary.Type ?? null,
|
||||
inUse: summary.InUse ? 'Yes' : 'No',
|
||||
keyAlgorithm: summary.KeyAlgorithm ?? null,
|
||||
notAfter: iso(summary.NotAfter),
|
||||
subjectAlternativeNames: summary.SubjectAlternativeNameSummaries ?? [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
private fromDetail(detail: CertificateDetail): CloudResource {
|
||||
const arn = detail.CertificateArn ?? ''
|
||||
const inUseBy = detail.InUseBy ?? []
|
||||
return {
|
||||
id: idFromArn(arn),
|
||||
name: detail.DomainName ?? '',
|
||||
cloud: 'aws',
|
||||
service: 'certificates',
|
||||
type: 'certificate',
|
||||
region: arn.split(':')[3] || this.region,
|
||||
createdAt: iso(detail.CreatedAt ?? detail.ImportedAt),
|
||||
status: detail.Status ?? null,
|
||||
metadata: {
|
||||
arn,
|
||||
certificateType: CERTIFICATE_TYPE_LABEL[detail.Type ?? ''] ?? detail.Type ?? null,
|
||||
inUse: inUseBy.length > 0 ? 'Yes' : 'No',
|
||||
inUseBy,
|
||||
keyAlgorithm: detail.KeyAlgorithm ?? null,
|
||||
signatureAlgorithm: detail.SignatureAlgorithm ?? null,
|
||||
notBefore: iso(detail.NotBefore),
|
||||
notAfter: iso(detail.NotAfter),
|
||||
issuedAt: iso(detail.IssuedAt),
|
||||
importedAt: iso(detail.ImportedAt),
|
||||
revokedAt: iso(detail.RevokedAt),
|
||||
revocationReason: detail.RevocationReason ?? null,
|
||||
failureReason: detail.FailureReason ?? null,
|
||||
subject: detail.Subject ?? null,
|
||||
issuer: detail.Issuer ?? null,
|
||||
serial: detail.Serial ?? null,
|
||||
subjectAlternativeNames: detail.SubjectAlternativeNames ?? [],
|
||||
exportable: detail.Options?.Export === 'ENABLED',
|
||||
transparencyLogging: detail.Options?.CertificateTransparencyLoggingPreference ?? null,
|
||||
renewalEligibility: detail.RenewalEligibility ?? null,
|
||||
domainValidations: (detail.DomainValidationOptions ?? []).map((validation) => ({
|
||||
domainName: validation.DomainName ?? '',
|
||||
status: validation.ValidationStatus ?? null,
|
||||
method: validation.ValidationMethod ?? null,
|
||||
record: validation.ResourceRecord
|
||||
? {name: validation.ResourceRecord.Name ?? '', type: validation.ResourceRecord.Type ?? 'CNAME', value: validation.ResourceRecord.Value ?? ''}
|
||||
: null,
|
||||
})),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** `arn:aws:acm:us-east-1:000000000000:certificate/<uuid>` — the UUID is what the console keys on. */
|
||||
function idFromArn(arn: string): string {
|
||||
return arn.slice(arn.lastIndexOf('/') + 1)
|
||||
}
|
||||
|
||||
function iso(value: Date | undefined): string | null {
|
||||
return value ? value.toISOString() : null
|
||||
}
|
||||
|
||||
function text(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
function pem(value: unknown, field: string, label: string): string {
|
||||
const body = text(value)
|
||||
if (!body) throw new ValidationError(`${field} is required`)
|
||||
if (!new RegExp(`-----BEGIN [A-Z ]*${label}-----`).test(body)) {
|
||||
throw new ValidationError(`${field} must be PEM text starting with -----BEGIN ${label}-----`)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
function oneOf<T extends readonly string[]>(value: unknown, allowed: T, field: string): T[number] {
|
||||
const raw = text(value)
|
||||
if (!raw) return allowed[0] as T[number]
|
||||
if (!allowed.includes(raw)) throw new ValidationError(`${field} must be one of ${allowed.join(', ')}`)
|
||||
return raw as T[number]
|
||||
}
|
||||
|
||||
function errorName(error: unknown): string | undefined {
|
||||
return typeof error === 'object' && error !== null ? (error as {name?: string}).name : undefined
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
import {afterEach, describe, expect, test} from 'bun:test'
|
||||
import {
|
||||
CreateApiCommand,
|
||||
CreateAuthorizerCommand,
|
||||
CreateDeploymentCommand,
|
||||
CreateIntegrationCommand,
|
||||
CreateRouteCommand,
|
||||
CreateRouteResponseCommand,
|
||||
CreateStageCommand,
|
||||
DeleteApiCommand,
|
||||
DeleteCorsConfigurationCommand,
|
||||
DeleteIntegrationCommand,
|
||||
DeleteRouteCommand,
|
||||
GetApiCommand,
|
||||
GetApisCommand,
|
||||
GetAuthorizersCommand,
|
||||
GetIntegrationsCommand,
|
||||
GetRoutesCommand,
|
||||
GetStageCommand,
|
||||
GetStagesCommand,
|
||||
UpdateApiCommand,
|
||||
UpdateRouteCommand,
|
||||
type ApiGatewayV2Client,
|
||||
} from '@aws-sdk/client-apigatewayv2'
|
||||
import {AddPermissionCommand, GetFunctionCommand, RemovePermissionCommand, type LambdaClient} from '@aws-sdk/client-lambda'
|
||||
import {AwsHttpApiOperations, httpRouteKey, webSocketRouteKey} from './AwsHttpApiOperations'
|
||||
import {ValidationError} from '../cloud-spi/errors'
|
||||
|
||||
const CREATED = new Date('2026-10-25T10:00:00Z')
|
||||
const httpApi = {ApiId: 'h1', Name: 'notes', ProtocolType: 'HTTP', ApiEndpoint: 'https://h1.execute-api.us-east-1.amazonaws.com', CreatedDate: CREATED}
|
||||
const webSocketApi = {ApiId: 'w1', Name: 'chat', ProtocolType: 'WEBSOCKET', RouteSelectionExpression: '$request.body.action', CreatedDate: CREATED}
|
||||
const functionArn = 'arn:aws:lambda:us-east-1:000000000000:function:notes'
|
||||
|
||||
interface Runtime {
|
||||
apis?: Record<string, unknown>
|
||||
routes?: unknown[]
|
||||
integrations?: unknown[]
|
||||
stages?: unknown[]
|
||||
authorizers?: unknown[]
|
||||
reply?: (command: object) => unknown
|
||||
}
|
||||
|
||||
function stubRuntime(runtime: Runtime = {}) {
|
||||
const sent: object[] = []
|
||||
const lambdaSent: object[] = []
|
||||
const apis = runtime.apis ?? {h1: httpApi, w1: webSocketApi}
|
||||
const client = {
|
||||
async send(command: object) {
|
||||
sent.push(command)
|
||||
const custom = runtime.reply?.(command)
|
||||
if (custom !== undefined) return custom
|
||||
if (command instanceof GetApisCommand) return {Items: Object.values(apis)}
|
||||
if (command instanceof GetApiCommand) {
|
||||
const api = apis[command.input.ApiId!]
|
||||
if (!api) throw Object.assign(new Error('NotFoundException'), {$metadata: {httpStatusCode: 404}})
|
||||
return api
|
||||
}
|
||||
if (command instanceof GetRoutesCommand) return {Items: runtime.routes ?? []}
|
||||
if (command instanceof GetIntegrationsCommand) return {Items: runtime.integrations ?? []}
|
||||
if (command instanceof GetStagesCommand) return {Items: runtime.stages ?? []}
|
||||
if (command instanceof GetAuthorizersCommand) return {Items: runtime.authorizers ?? []}
|
||||
if (command instanceof CreateIntegrationCommand) return {IntegrationId: 'new-integration', ...command.input}
|
||||
if (command instanceof CreateRouteCommand) return {RouteId: 'new-route', ...command.input}
|
||||
if (command instanceof CreateApiCommand) return {ApiId: 'new-api', CreatedDate: CREATED, ...command.input}
|
||||
if (command instanceof CreateStageCommand) return {...command.input}
|
||||
return {}
|
||||
},
|
||||
} as unknown as ApiGatewayV2Client
|
||||
const lambda = {
|
||||
async send(command: object) {
|
||||
lambdaSent.push(command)
|
||||
if (command instanceof GetFunctionCommand) return {Configuration: {FunctionArn: functionArn}}
|
||||
return {}
|
||||
},
|
||||
} as unknown as LambdaClient
|
||||
const discoveries: string[] = []
|
||||
const fetchJson = async (url: string) => {
|
||||
discoveries.push(url)
|
||||
return {issuer: 'http://floci:4566/us-east-1_pool1'}
|
||||
}
|
||||
const operations = new AwsHttpApiOperations(client, lambda, '000000000000', 'us-east-1', fetchJson)
|
||||
const of = <T extends object>(type: new (...args: never[]) => T): T[] => sent.filter((command) => command instanceof type) as T[]
|
||||
const lambdaOf = <T extends object>(type: new (...args: never[]) => T): T[] => lambdaSent.filter((command) => command instanceof type) as T[]
|
||||
return {operations, sent, of, lambdaOf, discoveries}
|
||||
}
|
||||
|
||||
describe('AwsHttpApiOperations APIs', () => {
|
||||
test('lists HTTP and WebSocket APIs with their protocol', async () => {
|
||||
const {operations} = stubRuntime()
|
||||
|
||||
const apis = await operations.list()
|
||||
expect(apis.map((api) => [api.id, api.type, api.metadata.protocol, api.metadata.apigatewayService])).toEqual([
|
||||
['h1', 'http-api', 'HTTP', 'http'],
|
||||
['w1', 'websocket-api', 'WEBSOCKET', 'websocket'],
|
||||
])
|
||||
expect(await operations.get('rest-id')).toBeNull()
|
||||
})
|
||||
|
||||
test('quick-creates an HTTP API that sends every request to a Lambda', async () => {
|
||||
const {operations, of, lambdaOf} = stubRuntime()
|
||||
|
||||
await operations.create({name: 'notes', functionName: 'notes'}, 'HTTP')
|
||||
|
||||
expect(of(CreateApiCommand)[0].input).toMatchObject({Name: 'notes', ProtocolType: 'HTTP', Target: functionArn})
|
||||
expect(of(CreateStageCommand)).toHaveLength(0)
|
||||
expect(lambdaOf(AddPermissionCommand)[0].input).toMatchObject({
|
||||
FunctionName: 'notes', Principal: 'apigateway.amazonaws.com', SourceArn: 'arn:aws:execute-api:us-east-1:000000000000:new-api/*',
|
||||
})
|
||||
})
|
||||
|
||||
test('gives an HTTP API without a target an auto-deployed $default stage', async () => {
|
||||
const {operations, of} = stubRuntime()
|
||||
|
||||
await operations.create({name: 'empty'}, 'HTTP')
|
||||
expect(of(CreateStageCommand)[0].input).toEqual({ApiId: 'new-api', StageName: '$default', AutoDeploy: true})
|
||||
})
|
||||
|
||||
test('creates a WebSocket API with its route selection expression and a stage', async () => {
|
||||
const {operations, of} = stubRuntime()
|
||||
|
||||
await operations.create({name: 'chat', stageName: 'prod'}, 'WEBSOCKET')
|
||||
expect(of(CreateApiCommand)[0].input).toMatchObject({ProtocolType: 'WEBSOCKET', RouteSelectionExpression: '$request.body.action'})
|
||||
expect(of(CreateStageCommand)[0].input).toMatchObject({StageName: 'prod', AutoDeploy: true})
|
||||
await expect(operations.create({name: 'chat', routeSelectionExpression: 'action'}, 'WEBSOCKET')).rejects.toThrow(/routeSelectionExpression/)
|
||||
})
|
||||
|
||||
test('deletes an API and the Lambda permissions it granted', async () => {
|
||||
const {operations, of, lambdaOf} = stubRuntime({integrations: [{IntegrationId: 'i1', IntegrationType: 'AWS_PROXY', IntegrationUri: functionArn}]})
|
||||
|
||||
await operations.delete('h1')
|
||||
expect(of(DeleteApiCommand)[0].input).toEqual({ApiId: 'h1'})
|
||||
expect(lambdaOf(RemovePermissionCommand)[0].input).toEqual({FunctionName: 'notes', StatementId: 'apigatewayv2-h1'})
|
||||
})
|
||||
})
|
||||
|
||||
describe('AwsHttpApiOperations routes', () => {
|
||||
test('describes routes with their integration, stages, authorizers and CORS', async () => {
|
||||
const {operations} = stubRuntime({
|
||||
apis: {h1: {...httpApi, CorsConfiguration: {AllowOrigins: ['*'], AllowMethods: ['GET'], MaxAge: 300}}},
|
||||
routes: [
|
||||
{RouteId: 'r2', RouteKey: 'POST /notes', Target: 'integrations/i1', AuthorizationType: 'JWT', AuthorizerId: 'a1'},
|
||||
{RouteId: 'r1', RouteKey: 'GET /notes', Target: 'integrations/i1', AuthorizationType: 'NONE', AuthorizerId: 'a1'},
|
||||
],
|
||||
integrations: [{IntegrationId: 'i1', IntegrationType: 'AWS_PROXY', IntegrationUri: functionArn, PayloadFormatVersion: '2.0'}],
|
||||
stages: [{StageName: '$default', AutoDeploy: true}],
|
||||
authorizers: [{AuthorizerId: 'a1', Name: 'cognito', AuthorizerType: 'JWT', IdentitySource: ['$request.header.Authorization'], JwtConfiguration: {Issuer: 'http://floci:4566/us-east-1_pool1', Audience: ['client1']}}],
|
||||
})
|
||||
|
||||
const overview = await operations.describe('h1')
|
||||
expect(overview.routes.map((route) => [route.routeKey, route.integration?.functionName, route.authorizerId])).toEqual([
|
||||
['GET /notes', 'notes', null],
|
||||
['POST /notes', 'notes', 'a1'],
|
||||
])
|
||||
expect(overview.stages).toEqual([{stageName: '$default', autoDeploy: true, deploymentId: null, lastUpdatedAt: null, invokePath: '/execute-api/h1/$default'}])
|
||||
expect(overview.authorizers[0]).toMatchObject({name: 'cognito', issuer: 'http://floci:4566/us-east-1_pool1', audience: ['client1']})
|
||||
expect(overview.cors).toMatchObject({allowOrigins: ['*'], allowMethods: ['GET'], maxAgeSeconds: 300})
|
||||
})
|
||||
|
||||
test('adds a Lambda route, reusing the integration the function already has', async () => {
|
||||
const {operations, of, lambdaOf} = stubRuntime({
|
||||
integrations: [{IntegrationId: 'i1', IntegrationType: 'AWS_PROXY', IntegrationUri: functionArn}],
|
||||
})
|
||||
|
||||
const route = await operations.createRoute('h1', {routeKey: 'get /notes/{id}', functionName: 'notes', authorizerId: 'a1'})
|
||||
expect(of(CreateIntegrationCommand)).toHaveLength(0)
|
||||
expect(of(CreateRouteCommand)[0].input).toMatchObject({RouteKey: 'GET /notes/{id}', Target: 'integrations/i1', AuthorizationType: 'JWT', AuthorizerId: 'a1'})
|
||||
expect(lambdaOf(AddPermissionCommand)).toHaveLength(1)
|
||||
expect(route.integration?.functionName).toBe('notes')
|
||||
})
|
||||
|
||||
test('adds an HTTP proxy route with a payload 1.0 integration', async () => {
|
||||
const {operations, of} = stubRuntime()
|
||||
|
||||
await operations.createRoute('h1', {routeKey: 'ANY /legacy/{proxy+}', httpUrl: 'https://example.com/{proxy}'})
|
||||
expect(of(CreateIntegrationCommand)[0].input).toMatchObject({IntegrationType: 'HTTP_PROXY', IntegrationUri: 'https://example.com/{proxy}', IntegrationMethod: 'ANY', PayloadFormatVersion: '1.0'})
|
||||
})
|
||||
|
||||
test('a two-way WebSocket route gets a route response and an invocation-URI integration', async () => {
|
||||
const {operations, of} = stubRuntime()
|
||||
|
||||
await operations.createRoute('w1', {routeKey: 'sendMessage', functionName: 'notes', returnsResponse: true})
|
||||
expect(of(CreateIntegrationCommand)[0].input.IntegrationUri).toBe(`arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/${functionArn}/invocations`)
|
||||
expect(of(CreateRouteCommand)[0].input.RouteResponseSelectionExpression).toBe('$default')
|
||||
expect(of(CreateRouteResponseCommand)[0].input).toEqual({ApiId: 'w1', RouteId: 'new-route', RouteResponseKey: '$default'})
|
||||
})
|
||||
|
||||
test('checks a route before creating anything', async () => {
|
||||
const {operations, sent} = stubRuntime({routes: [{RouteId: 'r1', RouteKey: 'GET /notes'}]})
|
||||
const before = sent.length
|
||||
|
||||
await expect(operations.createRoute('h1', {routeKey: 'GET /notes', functionName: 'notes'})).rejects.toThrow(/already has/)
|
||||
await expect(operations.createRoute('h1', {routeKey: 'GET /notes/x', functionName: 'notes', httpUrl: 'https://example.com'})).rejects.toThrow(/either/)
|
||||
await expect(operations.createRoute('h1', {routeKey: 'FETCH /notes', functionName: 'notes'})).rejects.toThrow(ValidationError)
|
||||
await expect(operations.createRoute('w1', {routeKey: 'send', httpUrl: 'https://example.com'})).rejects.toThrow(/Lambda functions only/)
|
||||
await expect(operations.createRoute('w1', {routeKey: 'send', functionName: 'notes', authorizerId: 'a1'})).rejects.toThrow(/Lambda authorizers/)
|
||||
expect(sent.slice(before).some((command) => command instanceof CreateRouteCommand || command instanceof CreateIntegrationCommand)).toBe(false)
|
||||
})
|
||||
|
||||
test('deleting a route removes the integration and permission only it used', async () => {
|
||||
const integrations = [
|
||||
{IntegrationId: 'i1', IntegrationType: 'AWS_PROXY', IntegrationUri: functionArn},
|
||||
{IntegrationId: 'i2', IntegrationType: 'HTTP_PROXY', IntegrationUri: 'https://example.com'},
|
||||
]
|
||||
const shared = stubRuntime({routes: [{RouteId: 'r1', Target: 'integrations/i1'}, {RouteId: 'r2', Target: 'integrations/i1'}], integrations})
|
||||
await shared.operations.deleteRoute('h1', 'r1')
|
||||
expect(shared.of(DeleteRouteCommand)).toHaveLength(1)
|
||||
expect(shared.of(DeleteIntegrationCommand)).toHaveLength(0)
|
||||
|
||||
const last = stubRuntime({routes: [{RouteId: 'r1', Target: 'integrations/i1'}], integrations})
|
||||
await last.operations.deleteRoute('h1', 'r1')
|
||||
expect(last.of(DeleteIntegrationCommand)[0].input).toEqual({ApiId: 'h1', IntegrationId: 'i1'})
|
||||
expect(last.lambdaOf(RemovePermissionCommand)[0].input.FunctionName).toBe('notes')
|
||||
})
|
||||
|
||||
test('protects a route with an authorizer and removes it again', async () => {
|
||||
const {operations, of} = stubRuntime({reply: (command) => command instanceof UpdateRouteCommand ? {RouteKey: 'GET /me', ...command.input} : undefined})
|
||||
|
||||
expect((await operations.setRouteAuthorizer('h1', 'r1', 'a1')).authorizerId).toBe('a1')
|
||||
expect((await operations.setRouteAuthorizer('h1', 'r1', null)).authorizerId).toBeNull()
|
||||
expect(of(UpdateRouteCommand).map((command) => command.input)).toEqual([
|
||||
{ApiId: 'h1', RouteId: 'r1', AuthorizationType: 'JWT', AuthorizerId: 'a1'},
|
||||
{ApiId: 'h1', RouteId: 'r1', AuthorizationType: 'NONE', AuthorizerId: undefined},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('AwsHttpApiOperations stages, authorizers and CORS', () => {
|
||||
test('a stage without auto-deploy is deployed once when created', async () => {
|
||||
const {operations, of} = stubRuntime({reply: (command) => command instanceof GetStageCommand ? {StageName: 'prod', DeploymentId: 'd1'} : undefined})
|
||||
|
||||
const stage = await operations.createStage('h1', {stageName: 'prod', autoDeploy: false})
|
||||
expect(of(CreateDeploymentCommand)[0].input).toMatchObject({ApiId: 'h1', StageName: 'prod'})
|
||||
expect(stage).toMatchObject({stageName: 'prod', deploymentId: 'd1', invokePath: '/execute-api/h1/prod'})
|
||||
await expect(operations.createStage('h1', {stageName: 'pro d', autoDeploy: true})).rejects.toThrow(/stage/)
|
||||
})
|
||||
|
||||
test('fills a JWT authorizer from a Cognito pool discovery document', async () => {
|
||||
const {operations, of, discoveries} = stubRuntime({reply: (command) => command instanceof CreateAuthorizerCommand ? {AuthorizerId: 'a1', ...command.input} : undefined})
|
||||
|
||||
const authorizer = await operations.createAuthorizer('h1', {name: 'cognito', userPoolId: 'us-east-1_pool1', userPoolClientId: 'client1'})
|
||||
expect(discoveries[0]).toEndWith('/us-east-1_pool1/.well-known/openid-configuration')
|
||||
expect(of(CreateAuthorizerCommand)[0].input).toEqual({
|
||||
ApiId: 'h1', Name: 'cognito', AuthorizerType: 'JWT', IdentitySource: ['$request.header.Authorization'],
|
||||
JwtConfiguration: {Issuer: 'http://floci:4566/us-east-1_pool1', Audience: ['client1']},
|
||||
})
|
||||
expect(authorizer.issuer).toBe('http://floci:4566/us-east-1_pool1')
|
||||
await expect(operations.createAuthorizer('h1', {name: 'cognito', userPoolId: 'us-east-1_pool1'})).rejects.toThrow(/app client/)
|
||||
await expect(operations.createAuthorizer('h1', {name: 'other', issuer: 'https://issuer.example.com', audience: []})).rejects.toThrow(/audience/)
|
||||
await expect(operations.createAuthorizer('w1', {name: 'x', issuer: 'https://issuer.example.com', audience: ['a']})).rejects.toThrow(/HTTP APIs/)
|
||||
})
|
||||
|
||||
test('will not delete an authorizer that still protects a route', async () => {
|
||||
const {operations} = stubRuntime({routes: [{RouteId: 'r1', RouteKey: 'GET /me', AuthorizationType: 'JWT', AuthorizerId: 'a1'}]})
|
||||
|
||||
await expect(operations.deleteAuthorizer('h1', 'a1')).rejects.toThrow('Remove the authorizer from GET /me first')
|
||||
})
|
||||
|
||||
test('sets and removes the CORS configuration', async () => {
|
||||
const {operations, of} = stubRuntime({reply: (command) => command instanceof UpdateApiCommand ? {...httpApi, CorsConfiguration: command.input.CorsConfiguration} : undefined})
|
||||
const cors = {allowOrigins: ['https://app.example.com'], allowMethods: ['get', 'POST'], allowHeaders: ['content-type'], exposeHeaders: [], maxAgeSeconds: 600, allowCredentials: true}
|
||||
|
||||
expect(await operations.updateCors('h1', cors)).toEqual({...cors, allowMethods: ['GET', 'POST']})
|
||||
expect(await operations.updateCors('h1', null)).toBeNull()
|
||||
expect(of(DeleteCorsConfigurationCommand)).toHaveLength(1)
|
||||
await expect(operations.updateCors('h1', {...cors, allowOrigins: ['*']})).rejects.toThrow(/Credentials/)
|
||||
await expect(operations.updateCors('h1', {...cors, allowOrigins: ['app.example.com']})).rejects.toThrow(/origin/)
|
||||
await expect(operations.updateCors('w1', cors)).rejects.toThrow(/HTTP APIs only/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AwsHttpApiOperations WebSocket test', () => {
|
||||
const endpoint = process.env.FLOCI_ENDPOINT
|
||||
afterEach(() => {
|
||||
if (endpoint === undefined) delete process.env.FLOCI_ENDPOINT
|
||||
else process.env.FLOCI_ENDPOINT = endpoint
|
||||
})
|
||||
|
||||
test('connects to the stage, sends the message and collects the replies', async () => {
|
||||
const paths: string[] = []
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(request, upgrade) {
|
||||
paths.push(new URL(request.url).pathname + new URL(request.url).search)
|
||||
return upgrade.upgrade(request) ? undefined : new Response('no', {status: 400})
|
||||
},
|
||||
websocket: {message(socket, message) { socket.send(`echo ${message}`) }},
|
||||
})
|
||||
process.env.FLOCI_ENDPOINT = `http://localhost:${server.port}`
|
||||
try {
|
||||
const {operations} = stubRuntime()
|
||||
const result = await operations.testWebSocket('w1', {stage: 'dev', message: '{"action":"ping"}', query: '?token=abc'})
|
||||
expect(result).toMatchObject({connected: true, messages: ['echo {"action":"ping"}'], error: null})
|
||||
expect(paths).toEqual(['/ws/w1/dev?token=abc'])
|
||||
} finally {
|
||||
server.stop(true)
|
||||
}
|
||||
})
|
||||
|
||||
test('reports a refused connection', async () => {
|
||||
process.env.FLOCI_ENDPOINT = 'http://127.0.0.1:1'
|
||||
const {operations} = stubRuntime()
|
||||
|
||||
const result = await operations.testWebSocket('w1', {stage: 'dev', message: 'hi'}, 2000)
|
||||
expect(result.connected).toBe(false)
|
||||
expect(result.error).toBeTruthy()
|
||||
await expect(operations.testWebSocket('h1', {stage: 'dev', message: 'hi'})).rejects.toThrow(/not a WebSocket API/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('route keys', () => {
|
||||
test('HTTP route keys are $default or a method and a path', () => {
|
||||
expect(httpRouteKey('$default')).toBe('$default')
|
||||
expect(httpRouteKey(' any /files/{proxy+} ')).toBe('ANY /files/{proxy+}')
|
||||
expect(httpRouteKey('GET /')).toBe('GET /')
|
||||
for (const bad of ['GET', '/notes', 'GET notes', 'GET /{proxy+}/more', 'GET /a b', 'GET /notes/']) {
|
||||
expect(() => httpRouteKey(bad), bad).toThrow(ValidationError)
|
||||
}
|
||||
})
|
||||
|
||||
test('WebSocket route keys are the system routes or an action name', () => {
|
||||
expect(webSocketRouteKey('$connect')).toBe('$connect')
|
||||
expect(webSocketRouteKey('sendMessage')).toBe('sendMessage')
|
||||
expect(() => webSocketRouteKey('$other')).toThrow(ValidationError)
|
||||
expect(() => webSocketRouteKey('send message')).toThrow(ValidationError)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,592 @@
|
||||
import {
|
||||
CreateApiCommand,
|
||||
CreateAuthorizerCommand,
|
||||
CreateDeploymentCommand,
|
||||
CreateIntegrationCommand,
|
||||
CreateRouteCommand,
|
||||
CreateRouteResponseCommand,
|
||||
CreateStageCommand,
|
||||
DeleteApiCommand,
|
||||
DeleteAuthorizerCommand,
|
||||
DeleteCorsConfigurationCommand,
|
||||
DeleteIntegrationCommand,
|
||||
DeleteRouteCommand,
|
||||
DeleteStageCommand,
|
||||
GetApiCommand,
|
||||
GetApisCommand,
|
||||
GetAuthorizersCommand,
|
||||
GetIntegrationsCommand,
|
||||
GetRoutesCommand,
|
||||
GetStageCommand,
|
||||
GetStagesCommand,
|
||||
UpdateApiCommand,
|
||||
UpdateRouteCommand,
|
||||
type Api,
|
||||
type ApiGatewayV2Client,
|
||||
type Authorizer,
|
||||
type Cors,
|
||||
type Integration,
|
||||
type Route,
|
||||
type Stage,
|
||||
} from '@aws-sdk/client-apigatewayv2'
|
||||
import {AddPermissionCommand, GetFunctionCommand, RemovePermissionCommand, type LambdaClient} from '@aws-sdk/client-lambda'
|
||||
import {NotFoundError, ValidationError} from '../cloud-spi/errors'
|
||||
import {awsEndpoint} from '../service/runtimeProbe'
|
||||
import type {
|
||||
CloudResource,
|
||||
HttpApiAuthorizerInput,
|
||||
HttpApiAuthorizerOverview,
|
||||
HttpApiCors,
|
||||
HttpApiIntegrationOverview,
|
||||
HttpApiOverview,
|
||||
HttpApiProtocol,
|
||||
HttpApiRouteInput,
|
||||
HttpApiRouteOverview,
|
||||
HttpApiStageInput,
|
||||
HttpApiStageOverview,
|
||||
HttpApiWebSocketTestInput,
|
||||
HttpApiWebSocketTestResult,
|
||||
} from '../cloud-spi/types'
|
||||
|
||||
export const HTTP_ROUTE_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS', 'ANY']
|
||||
export const CORS_ALLOWED_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS', '*']
|
||||
export const DEFAULT_ROUTE_SELECTION_EXPRESSION = '$request.body.action'
|
||||
const WEBSOCKET_SYSTEM_ROUTES = ['$connect', '$disconnect', '$default']
|
||||
|
||||
/**
|
||||
* API Gateway v2: HTTP and WebSocket APIs. A separate AWS API from REST APIs, with its own
|
||||
* routes, integrations, stages and JWT authorizers, served by the same API Gateway page.
|
||||
*/
|
||||
export class AwsHttpApiOperations {
|
||||
constructor(
|
||||
private readonly client: ApiGatewayV2Client,
|
||||
private readonly lambda: LambdaClient,
|
||||
private readonly accountId: string,
|
||||
private readonly region: string,
|
||||
private readonly fetchJson: (url: string) => Promise<unknown> = defaultFetchJson,
|
||||
) {}
|
||||
|
||||
async list(): Promise<CloudResource[]> {
|
||||
const apis: Api[] = []
|
||||
let next: string | undefined
|
||||
do {
|
||||
const page = await this.client.send(new GetApisCommand({NextToken: next}))
|
||||
apis.push(...(page.Items ?? []))
|
||||
next = page.NextToken
|
||||
} while (next)
|
||||
return apis.map(toResource)
|
||||
}
|
||||
|
||||
/** The API, or null when no HTTP or WebSocket API has this id (it may be a REST API). */
|
||||
async get(apiId: string): Promise<CloudResource | null> {
|
||||
const api = await this.findApi(apiId)
|
||||
return api ? toResource(api) : null
|
||||
}
|
||||
|
||||
async create(values: Record<string, unknown>, protocol: HttpApiProtocol): Promise<CloudResource> {
|
||||
const name = stringValue(values.name)
|
||||
if (!name) throw new ValidationError('name is required')
|
||||
const description = stringValue(values.description) || undefined
|
||||
|
||||
if (protocol === 'WEBSOCKET') {
|
||||
const routeSelectionExpression = stringValue(values.routeSelectionExpression) || DEFAULT_ROUTE_SELECTION_EXPRESSION
|
||||
if (!/^\$request\.body\.[A-Za-z0-9_.]+$/.test(routeSelectionExpression)) {
|
||||
throw new ValidationError('routeSelectionExpression must name a field of the message body, for example $request.body.action')
|
||||
}
|
||||
const api = await this.client.send(new CreateApiCommand({Name: name, Description: description, ProtocolType: 'WEBSOCKET', RouteSelectionExpression: routeSelectionExpression}))
|
||||
const stageName = stageNameValue(stringValue(values.stageName) || 'dev')
|
||||
await this.client.send(new CreateStageCommand({ApiId: api.ApiId, StageName: stageName, AutoDeploy: true}))
|
||||
return toResource(api)
|
||||
}
|
||||
|
||||
// Quick create: a Lambda target gives the API a catch-all route and an auto-deployed $default stage.
|
||||
const functionName = stringValue(values.functionName)
|
||||
if (functionName) {
|
||||
const functionArn = await this.functionArn(functionName)
|
||||
const api = await this.client.send(new CreateApiCommand({Name: name, Description: description, ProtocolType: 'HTTP', Target: functionArn}))
|
||||
await this.allowInvoke(api.ApiId!, functionName)
|
||||
return toResource(api)
|
||||
}
|
||||
const api = await this.client.send(new CreateApiCommand({Name: name, Description: description, ProtocolType: 'HTTP'}))
|
||||
await this.client.send(new CreateStageCommand({ApiId: api.ApiId, StageName: '$default', AutoDeploy: true}))
|
||||
return toResource(api)
|
||||
}
|
||||
|
||||
async delete(apiId: string): Promise<void> {
|
||||
const {integrations} = await this.describeParts(apiId)
|
||||
await this.client.send(new DeleteApiCommand({ApiId: apiId}))
|
||||
for (const functionName of new Set(integrations.map(integrationFunctionName).filter((name): name is string => Boolean(name)))) {
|
||||
await this.removeInvokePermission(apiId, functionName)
|
||||
}
|
||||
}
|
||||
|
||||
async describe(apiId: string): Promise<HttpApiOverview> {
|
||||
const api = await this.requireApi(apiId)
|
||||
const {routes, integrations, stages, authorizers} = await this.describeParts(apiId)
|
||||
const protocol = protocolOf(api)
|
||||
const integrationsById = new Map(integrations.map((integration) => [integration.IntegrationId, integration]))
|
||||
return {
|
||||
apiId,
|
||||
protocol,
|
||||
apiEndpoint: api.ApiEndpoint ?? null,
|
||||
routeSelectionExpression: protocol === 'WEBSOCKET' ? api.RouteSelectionExpression ?? null : null,
|
||||
routes: routes.map((route) => toRouteOverview(route, integrationsById)).sort(compareRoutes),
|
||||
stages: stages.map((stage) => toStageOverview(apiId, protocol, stage)).sort((left, right) => left.stageName.localeCompare(right.stageName)),
|
||||
authorizers: authorizers.map(toAuthorizerOverview).sort((left, right) => left.name.localeCompare(right.name)),
|
||||
cors: protocol === 'HTTP' ? fromCors(api.CorsConfiguration) : null,
|
||||
}
|
||||
}
|
||||
|
||||
async createRoute(apiId: string, input: HttpApiRouteInput): Promise<HttpApiRouteOverview> {
|
||||
const api = await this.requireApi(apiId)
|
||||
const protocol = protocolOf(api)
|
||||
const routeKey = protocol === 'HTTP' ? httpRouteKey(input.routeKey) : webSocketRouteKey(input.routeKey)
|
||||
const functionName = stringValue(input.functionName)
|
||||
const httpUrl = stringValue(input.httpUrl)
|
||||
if (Boolean(functionName) === Boolean(httpUrl)) throw new ValidationError('Give the route either a Lambda function or an HTTP URL')
|
||||
if (httpUrl && protocol === 'WEBSOCKET') throw new ValidationError('TheCloud connects WebSocket routes to Lambda functions only')
|
||||
if (httpUrl && !/^https?:\/\/[^\s/]+/.test(httpUrl)) throw new ValidationError('httpUrl must be an http:// or https:// URL')
|
||||
|
||||
const {routes, integrations} = await this.describeParts(apiId)
|
||||
if (routes.some((route) => route.RouteKey === routeKey)) throw new ValidationError(`The API already has a ${routeKey} route`)
|
||||
const authorizerId = stringValue(input.authorizerId)
|
||||
if (authorizerId && protocol === 'WEBSOCKET') throw new ValidationError('WebSocket APIs use Lambda authorizers, which TheCloud does not set up yet')
|
||||
|
||||
let integration: Partial<Integration> | undefined
|
||||
if (functionName) {
|
||||
const functionArn = await this.functionArn(functionName)
|
||||
const uri = protocol === 'HTTP' ? functionArn : lambdaInvocationUri(this.region, functionArn)
|
||||
integration = integrations.find((candidate) => candidate.IntegrationType === 'AWS_PROXY' && candidate.IntegrationUri === uri)
|
||||
?? await this.client.send(new CreateIntegrationCommand({
|
||||
ApiId: apiId,
|
||||
IntegrationType: 'AWS_PROXY',
|
||||
IntegrationUri: uri,
|
||||
IntegrationMethod: 'POST',
|
||||
PayloadFormatVersion: protocol === 'HTTP' ? '2.0' : undefined,
|
||||
}))
|
||||
await this.allowInvoke(apiId, functionName)
|
||||
} else {
|
||||
const method = routeKey === '$default' ? 'ANY' : routeKey.split(' ')[0]
|
||||
integration = integrations.find((candidate) => candidate.IntegrationType === 'HTTP_PROXY' && candidate.IntegrationUri === httpUrl && candidate.IntegrationMethod === method)
|
||||
?? await this.client.send(new CreateIntegrationCommand({
|
||||
ApiId: apiId,
|
||||
IntegrationType: 'HTTP_PROXY',
|
||||
IntegrationUri: httpUrl,
|
||||
IntegrationMethod: method,
|
||||
PayloadFormatVersion: '1.0',
|
||||
}))
|
||||
}
|
||||
|
||||
const route = await this.client.send(new CreateRouteCommand({
|
||||
ApiId: apiId,
|
||||
RouteKey: routeKey,
|
||||
Target: `integrations/${integration.IntegrationId}`,
|
||||
AuthorizationType: authorizerId ? 'JWT' : 'NONE',
|
||||
AuthorizerId: authorizerId || undefined,
|
||||
RouteResponseSelectionExpression: protocol === 'WEBSOCKET' && input.returnsResponse ? '$default' : undefined,
|
||||
}))
|
||||
// A WebSocket route sends its integration's response back only once it has a route response.
|
||||
if (protocol === 'WEBSOCKET' && input.returnsResponse) {
|
||||
await this.client.send(new CreateRouteResponseCommand({ApiId: apiId, RouteId: route.RouteId, RouteResponseKey: '$default'}))
|
||||
}
|
||||
return toRouteOverview(route, new Map([[integration.IntegrationId, integration]]))
|
||||
}
|
||||
|
||||
async deleteRoute(apiId: string, routeId: string): Promise<void> {
|
||||
const {routes, integrations} = await this.describeParts(apiId)
|
||||
const route = routes.find((candidate) => candidate.RouteId === routeId)
|
||||
if (!route) throw new NotFoundError(`Route ${routeId} not found`)
|
||||
await this.client.send(new DeleteRouteCommand({ApiId: apiId, RouteId: routeId}))
|
||||
|
||||
// Clean up what only this route used: its integration, then the Lambda's permission for this API.
|
||||
const integrationId = targetIntegrationId(route.Target)
|
||||
const stillUsed = routes.some((candidate) => candidate.RouteId !== routeId && targetIntegrationId(candidate.Target) === integrationId)
|
||||
const integration = integrations.find((candidate) => candidate.IntegrationId === integrationId)
|
||||
if (!integration || stillUsed) return
|
||||
await this.client.send(new DeleteIntegrationCommand({ApiId: apiId, IntegrationId: integrationId}))
|
||||
const functionName = integrationFunctionName(integration)
|
||||
const functionStillUsed = integrations.some((candidate) => candidate.IntegrationId !== integrationId && integrationFunctionName(candidate) === functionName)
|
||||
if (functionName && !functionStillUsed) await this.removeInvokePermission(apiId, functionName)
|
||||
}
|
||||
|
||||
async setRouteAuthorizer(apiId: string, routeId: string, authorizerId: string | null): Promise<HttpApiRouteOverview> {
|
||||
const api = await this.requireApi(apiId)
|
||||
if (protocolOf(api) === 'WEBSOCKET' && authorizerId) throw new ValidationError('WebSocket APIs use Lambda authorizers, which TheCloud does not set up yet')
|
||||
const route = await this.client.send(new UpdateRouteCommand({
|
||||
ApiId: apiId,
|
||||
RouteId: routeId,
|
||||
AuthorizationType: authorizerId ? 'JWT' : 'NONE',
|
||||
AuthorizerId: authorizerId ?? undefined,
|
||||
}))
|
||||
const {integrations} = await this.describeParts(apiId)
|
||||
return toRouteOverview(route, new Map(integrations.map((integration) => [integration.IntegrationId, integration])))
|
||||
}
|
||||
|
||||
async createStage(apiId: string, input: HttpApiStageInput): Promise<HttpApiStageOverview> {
|
||||
const api = await this.requireApi(apiId)
|
||||
const stage = await this.client.send(new CreateStageCommand({ApiId: apiId, StageName: stageNameValue(input.stageName), AutoDeploy: Boolean(input.autoDeploy)}))
|
||||
// A stage without auto-deploy serves nothing until its first deployment.
|
||||
if (!input.autoDeploy) return this.deploy(apiId, stage.StageName!)
|
||||
return toStageOverview(apiId, protocolOf(api), stage)
|
||||
}
|
||||
|
||||
async deleteStage(apiId: string, stageName: string): Promise<void> {
|
||||
await this.client.send(new DeleteStageCommand({ApiId: apiId, StageName: stageNameValue(stageName)}))
|
||||
}
|
||||
|
||||
async deploy(apiId: string, stageName: string): Promise<HttpApiStageOverview> {
|
||||
const api = await this.requireApi(apiId)
|
||||
const name = stageNameValue(stageName)
|
||||
await this.client.send(new CreateDeploymentCommand({ApiId: apiId, StageName: name, Description: 'Deployed from TheCloud'}))
|
||||
const stage = await this.client.send(new GetStageCommand({ApiId: apiId, StageName: name}))
|
||||
return toStageOverview(apiId, protocolOf(api), stage)
|
||||
}
|
||||
|
||||
async createAuthorizer(apiId: string, input: HttpApiAuthorizerInput): Promise<HttpApiAuthorizerOverview> {
|
||||
const api = await this.requireApi(apiId)
|
||||
if (protocolOf(api) === 'WEBSOCKET') throw new ValidationError('JWT authorizers are for HTTP APIs; WebSocket APIs use Lambda authorizers')
|
||||
const name = stringValue(input.name)
|
||||
if (!/^[A-Za-z0-9_-]{1,128}$/.test(name)) throw new ValidationError('name must be 1-128 letters, numbers, hyphens or underscores')
|
||||
|
||||
let issuer = stringValue(input.issuer)
|
||||
let audience = (input.audience ?? []).map(stringValue).filter(Boolean)
|
||||
const userPoolId = stringValue(input.userPoolId)
|
||||
if (userPoolId) {
|
||||
const clientId = stringValue(input.userPoolClientId)
|
||||
if (!clientId) throw new ValidationError('Choose the app client whose tokens the authorizer accepts')
|
||||
issuer = await this.userPoolIssuer(userPoolId)
|
||||
audience = [clientId]
|
||||
}
|
||||
if (!/^https?:\/\/[^\s]+$/.test(issuer)) throw new ValidationError('issuer must be the token issuer URL, for example https://cognito-idp.us-east-1.amazonaws.com/us-east-1_abc')
|
||||
if (audience.length === 0) throw new ValidationError('audience needs at least one value, such as an app client id')
|
||||
|
||||
const authorizer = await this.client.send(new CreateAuthorizerCommand({
|
||||
ApiId: apiId,
|
||||
Name: name,
|
||||
AuthorizerType: 'JWT',
|
||||
IdentitySource: ['$request.header.Authorization'],
|
||||
JwtConfiguration: {Issuer: issuer, Audience: audience},
|
||||
}))
|
||||
return toAuthorizerOverview(authorizer)
|
||||
}
|
||||
|
||||
async deleteAuthorizer(apiId: string, authorizerId: string): Promise<void> {
|
||||
const {routes} = await this.describeParts(apiId)
|
||||
const users = routes.filter((route) => route.AuthorizationType !== 'NONE' && route.AuthorizerId === authorizerId).map((route) => route.RouteKey)
|
||||
if (users.length > 0) throw new ValidationError(`Remove the authorizer from ${users.join(', ')} first`)
|
||||
await this.client.send(new DeleteAuthorizerCommand({ApiId: apiId, AuthorizerId: authorizerId}))
|
||||
}
|
||||
|
||||
async updateCors(apiId: string, cors: HttpApiCors | null): Promise<HttpApiCors | null> {
|
||||
const api = await this.requireApi(apiId)
|
||||
if (protocolOf(api) !== 'HTTP') throw new ValidationError('CORS applies to HTTP APIs only')
|
||||
if (!cors) {
|
||||
await this.client.send(new DeleteCorsConfigurationCommand({ApiId: apiId}))
|
||||
return null
|
||||
}
|
||||
const configuration = toCors(cors)
|
||||
const updated = await this.client.send(new UpdateApiCommand({ApiId: apiId, CorsConfiguration: configuration}))
|
||||
return fromCors(updated.CorsConfiguration)
|
||||
}
|
||||
|
||||
/** Opens a real connection to the deployed stage, sends one message and collects the replies. */
|
||||
async testWebSocket(apiId: string, input: HttpApiWebSocketTestInput, timeoutMs = 4000): Promise<HttpApiWebSocketTestResult> {
|
||||
const api = await this.requireApi(apiId)
|
||||
if (protocolOf(api) !== 'WEBSOCKET') throw new ValidationError('This is not a WebSocket API')
|
||||
const stage = stageNameValue(input.stage)
|
||||
const query = stringValue(input.query).replace(/^\?/, '')
|
||||
const url = `${awsEndpoint().replace(/\/$/, '').replace(/^http/, 'ws')}/ws/${encodeURIComponent(apiId)}/${encodeURIComponent(stage)}${query ? `?${query}` : ''}`
|
||||
return exchangeWebSocketMessage(url, input.message, timeoutMs)
|
||||
}
|
||||
|
||||
private async describeParts(apiId: string) {
|
||||
const [routes, integrations, stages, authorizers] = await Promise.all([
|
||||
this.pages((NextToken) => this.client.send(new GetRoutesCommand({ApiId: apiId, NextToken}))),
|
||||
this.pages((NextToken) => this.client.send(new GetIntegrationsCommand({ApiId: apiId, NextToken}))),
|
||||
this.pages((NextToken) => this.client.send(new GetStagesCommand({ApiId: apiId, NextToken}))),
|
||||
this.pages((NextToken) => this.client.send(new GetAuthorizersCommand({ApiId: apiId, NextToken}))),
|
||||
])
|
||||
return {routes: routes as Route[], integrations: integrations as Integration[], stages: stages as Stage[], authorizers: authorizers as Authorizer[]}
|
||||
}
|
||||
|
||||
private async pages<T>(fetchPage: (next?: string) => Promise<{Items?: T[]; NextToken?: string}>): Promise<T[]> {
|
||||
const items: T[] = []
|
||||
let next: string | undefined
|
||||
do {
|
||||
const page = await fetchPage(next)
|
||||
items.push(...(page.Items ?? []))
|
||||
next = page.NextToken
|
||||
} while (next)
|
||||
return items
|
||||
}
|
||||
|
||||
private async findApi(apiId: string): Promise<Partial<Api> | null> {
|
||||
try {
|
||||
return await this.client.send(new GetApiCommand({ApiId: apiId}))
|
||||
} catch (error) {
|
||||
if (hasHttpStatus(error, 404)) return null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async requireApi(apiId: string): Promise<Partial<Api>> {
|
||||
const api = await this.findApi(apiId)
|
||||
if (!api) throw new NotFoundError(`HTTP or WebSocket API ${apiId} not found`)
|
||||
return api
|
||||
}
|
||||
|
||||
private async functionArn(functionName: string): Promise<string> {
|
||||
const result = await this.lambda.send(new GetFunctionCommand({FunctionName: functionName}))
|
||||
const arn = result.Configuration?.FunctionArn
|
||||
if (!arn) throw new ValidationError(`Lambda function ${functionName} did not return an ARN`)
|
||||
return arn
|
||||
}
|
||||
|
||||
/** API Gateway may invoke the function for any route and stage of this API, as the AWS console grants it. */
|
||||
private async allowInvoke(apiId: string, functionName: string) {
|
||||
try {
|
||||
await this.lambda.send(new AddPermissionCommand({
|
||||
FunctionName: functionName,
|
||||
StatementId: invokeStatementId(apiId),
|
||||
Action: 'lambda:InvokeFunction',
|
||||
Principal: 'apigateway.amazonaws.com',
|
||||
SourceArn: `arn:aws:execute-api:${this.region}:${this.accountId}:${apiId}/*`,
|
||||
}))
|
||||
} catch (error) {
|
||||
if (!hasHttpStatus(error, 409)) throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async removeInvokePermission(apiId: string, functionName: string) {
|
||||
try {
|
||||
await this.lambda.send(new RemovePermissionCommand({FunctionName: functionName, StatementId: invokeStatementId(apiId)}))
|
||||
} catch (error) {
|
||||
if (!hasHttpStatus(error, 404)) throw error
|
||||
}
|
||||
}
|
||||
|
||||
/** The pool's token issuer, read from its OpenID Connect discovery document as any relying party would. */
|
||||
private async userPoolIssuer(userPoolId: string): Promise<string> {
|
||||
if (!/^[\w-]+_[0-9A-Za-z]+$/.test(userPoolId)) throw new ValidationError('userPoolId is not a Cognito user pool id')
|
||||
const discovery = await this.fetchJson(`${awsEndpoint().replace(/\/$/, '')}/${userPoolId}/.well-known/openid-configuration`)
|
||||
const issuer = (discovery as {issuer?: unknown} | null)?.issuer
|
||||
if (typeof issuer !== 'string' || !issuer) throw new NotFoundError(`User pool ${userPoolId} has no OpenID Connect discovery document`)
|
||||
return issuer
|
||||
}
|
||||
}
|
||||
|
||||
async function defaultFetchJson(url: string): Promise<unknown> {
|
||||
const response = await globalThis.fetch(url)
|
||||
if (!response.ok) throw new NotFoundError(`${url} answered ${response.status}`)
|
||||
return response.json()
|
||||
}
|
||||
|
||||
function exchangeWebSocketMessage(url: string, message: string, timeoutMs: number): Promise<HttpApiWebSocketTestResult> {
|
||||
const startedAt = performance.now()
|
||||
return new Promise((resolve) => {
|
||||
const messages: string[] = []
|
||||
let connected = false
|
||||
let settled = false
|
||||
let socket: WebSocket
|
||||
const finish = (closeCode: number | null, error: string | null) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
if (socket && socket.readyState === WebSocket.OPEN) socket.close(1000)
|
||||
resolve({connected, messages, closeCode, error, durationMs: Math.round(performance.now() - startedAt)})
|
||||
}
|
||||
// After the first reply, wait briefly for any more before hanging up.
|
||||
let quiet: ReturnType<typeof setTimeout> | undefined
|
||||
const timer = setTimeout(() => finish(null, connected ? null : 'Timed out connecting'), timeoutMs)
|
||||
try {
|
||||
socket = new WebSocket(url)
|
||||
} catch (error) {
|
||||
finish(null, error instanceof Error ? error.message : 'Could not connect')
|
||||
return
|
||||
}
|
||||
socket.onopen = () => {
|
||||
connected = true
|
||||
if (message) socket.send(message)
|
||||
else finish(null, null)
|
||||
}
|
||||
socket.onmessage = (event) => {
|
||||
messages.push(typeof event.data === 'string' ? event.data : String(event.data))
|
||||
clearTimeout(quiet)
|
||||
quiet = setTimeout(() => finish(null, null), 500)
|
||||
}
|
||||
socket.onerror = () => finish(null, connected ? 'The connection failed' : 'The stage refused the connection')
|
||||
socket.onclose = (event) => finish(event.code, connected ? null : `The stage refused the connection (${event.code})`)
|
||||
})
|
||||
}
|
||||
|
||||
function toResource(api: Partial<Api>): CloudResource {
|
||||
const protocol = protocolOf(api)
|
||||
return {
|
||||
id: api.ApiId ?? '',
|
||||
name: api.Name ?? '',
|
||||
cloud: 'aws',
|
||||
service: 'apigateway',
|
||||
type: protocol === 'HTTP' ? 'http-api' : 'websocket-api',
|
||||
region: null,
|
||||
createdAt: api.CreatedDate?.toISOString() ?? null,
|
||||
metadata: {
|
||||
provider: 'aws',
|
||||
apigatewayService: protocol === 'HTTP' ? 'http' : 'websocket',
|
||||
protocol,
|
||||
description: api.Description,
|
||||
apiEndpoint: api.ApiEndpoint,
|
||||
routeSelectionExpression: protocol === 'WEBSOCKET' ? api.RouteSelectionExpression : undefined,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function protocolOf(api: Partial<Api>): HttpApiProtocol {
|
||||
return api.ProtocolType === 'WEBSOCKET' ? 'WEBSOCKET' : 'HTTP'
|
||||
}
|
||||
|
||||
function toRouteOverview(route: Partial<Route>, integrations: Map<string | undefined, Partial<Integration>>): HttpApiRouteOverview {
|
||||
const integration = integrations.get(targetIntegrationId(route.Target))
|
||||
return {
|
||||
routeId: route.RouteId ?? '',
|
||||
routeKey: route.RouteKey ?? '',
|
||||
integration: integration ? toIntegrationOverview(integration) : null,
|
||||
authorizationType: route.AuthorizationType ?? 'NONE',
|
||||
// Real API Gateway keeps a route's last AuthorizerId after its authorization goes back to NONE.
|
||||
authorizerId: route.AuthorizationType && route.AuthorizationType !== 'NONE' ? route.AuthorizerId || null : null,
|
||||
authorizationScopes: route.AuthorizationScopes ?? [],
|
||||
returnsResponse: Boolean(route.RouteResponseSelectionExpression),
|
||||
}
|
||||
}
|
||||
|
||||
function toIntegrationOverview(integration: Partial<Integration>): HttpApiIntegrationOverview {
|
||||
return {
|
||||
integrationId: integration.IntegrationId ?? '',
|
||||
type: integration.IntegrationType ?? '',
|
||||
uri: integration.IntegrationUri ?? null,
|
||||
functionName: integrationFunctionName(integration),
|
||||
payloadFormatVersion: integration.PayloadFormatVersion ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
function toStageOverview(apiId: string, protocol: HttpApiProtocol, stage: Partial<Stage>): HttpApiStageOverview {
|
||||
const stageName = stage.StageName ?? ''
|
||||
return {
|
||||
stageName,
|
||||
autoDeploy: Boolean(stage.AutoDeploy),
|
||||
deploymentId: stage.DeploymentId ?? null,
|
||||
lastUpdatedAt: stage.LastUpdatedDate?.toISOString() ?? null,
|
||||
invokePath: `${protocol === 'WEBSOCKET' ? '/ws' : '/execute-api'}/${apiId}/${stageName}`,
|
||||
}
|
||||
}
|
||||
|
||||
function toAuthorizerOverview(authorizer: Partial<Authorizer>): HttpApiAuthorizerOverview {
|
||||
return {
|
||||
authorizerId: authorizer.AuthorizerId ?? '',
|
||||
name: authorizer.Name ?? '',
|
||||
type: authorizer.AuthorizerType ?? '',
|
||||
identitySource: authorizer.IdentitySource ?? [],
|
||||
issuer: authorizer.JwtConfiguration?.Issuer ?? null,
|
||||
audience: authorizer.JwtConfiguration?.Audience ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
function fromCors(cors: Cors | undefined): HttpApiCors | null {
|
||||
if (!cors) return null
|
||||
return {
|
||||
allowOrigins: cors.AllowOrigins ?? [],
|
||||
allowMethods: cors.AllowMethods ?? [],
|
||||
allowHeaders: cors.AllowHeaders ?? [],
|
||||
exposeHeaders: cors.ExposeHeaders ?? [],
|
||||
maxAgeSeconds: cors.MaxAge ?? null,
|
||||
allowCredentials: Boolean(cors.AllowCredentials),
|
||||
}
|
||||
}
|
||||
|
||||
function toCors(cors: HttpApiCors): Cors {
|
||||
const clean = (values: string[]) => [...new Set((values ?? []).map(stringValue).filter(Boolean))]
|
||||
const allowOrigins = clean(cors.allowOrigins)
|
||||
const allowMethods = clean(cors.allowMethods).map((method) => method.toUpperCase())
|
||||
if (allowOrigins.length === 0) throw new ValidationError('allowOrigins needs at least one origin, or *')
|
||||
if (allowOrigins.some((origin) => origin !== '*' && !/^https?:\/\/[^\s/]+$/.test(origin))) {
|
||||
throw new ValidationError('Each allowed origin must be * or a scheme and host, such as https://app.example.com')
|
||||
}
|
||||
if (allowMethods.some((method) => !CORS_ALLOWED_METHODS.includes(method))) throw new ValidationError(`allowMethods may contain ${CORS_ALLOWED_METHODS.join(', ')}`)
|
||||
if (cors.allowCredentials && allowOrigins.includes('*')) throw new ValidationError('Credentials cannot be allowed for every origin (*); list the origins instead')
|
||||
const maxAge = cors.maxAgeSeconds
|
||||
if (maxAge !== null && maxAge !== undefined && (!Number.isInteger(maxAge) || maxAge < 0 || maxAge > 86_400)) {
|
||||
throw new ValidationError('maxAgeSeconds must be a whole number from 0 to 86400')
|
||||
}
|
||||
return {
|
||||
AllowOrigins: allowOrigins,
|
||||
AllowMethods: allowMethods,
|
||||
AllowHeaders: clean(cors.allowHeaders),
|
||||
ExposeHeaders: clean(cors.exposeHeaders),
|
||||
MaxAge: maxAge ?? undefined,
|
||||
AllowCredentials: Boolean(cors.allowCredentials),
|
||||
}
|
||||
}
|
||||
|
||||
/** `$default`, or a method and a path such as `GET /orders/{id}` or `ANY /files/{proxy+}`. */
|
||||
export function httpRouteKey(value: string): string {
|
||||
const key = stringValue(value).replace(/\s+/g, ' ')
|
||||
if (key === '$default') return key
|
||||
const [method, path, ...rest] = key.split(' ')
|
||||
if (rest.length > 0 || !HTTP_ROUTE_METHODS.includes(method?.toUpperCase() ?? '') || !path) {
|
||||
throw new ValidationError('routeKey must be $default or a method and a path, for example GET /orders/{id}')
|
||||
}
|
||||
const segments = path === '/' ? [] : path.split('/').slice(1)
|
||||
if (!path.startsWith('/') || segments.some((segment, index) => !/^(?:[A-Za-z0-9._~-]+|\{[A-Za-z0-9_]+\}|\{[A-Za-z0-9_]+\+\})$/.test(segment)
|
||||
|| (segment.endsWith('+}') && index !== segments.length - 1))) {
|
||||
throw new ValidationError('The path must start with / and use segments such as orders, {id}, or a final {proxy+}')
|
||||
}
|
||||
return `${method.toUpperCase()} ${path}`
|
||||
}
|
||||
|
||||
/** `$connect`, `$disconnect`, `$default`, or a value of the route selection expression such as `sendMessage`. */
|
||||
export function webSocketRouteKey(value: string): string {
|
||||
const key = stringValue(value)
|
||||
if (WEBSOCKET_SYSTEM_ROUTES.includes(key)) return key
|
||||
if (!/^[A-Za-z0-9_.:-]{1,128}$/.test(key)) {
|
||||
throw new ValidationError('routeKey must be $connect, $disconnect, $default, or a message action such as sendMessage')
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
function stageNameValue(value: string): string {
|
||||
const name = stringValue(value)
|
||||
if (!/^(?:\$default|[A-Za-z0-9_-]{1,128})$/.test(name)) {
|
||||
throw new ValidationError('stage must be $default or letters, numbers, hyphens and underscores')
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
function compareRoutes(left: HttpApiRouteOverview, right: HttpApiRouteOverview): number {
|
||||
const path = (route: HttpApiRouteOverview) => route.routeKey.split(' ')[1] ?? route.routeKey
|
||||
return path(left).localeCompare(path(right)) || left.routeKey.localeCompare(right.routeKey)
|
||||
}
|
||||
|
||||
function lambdaInvocationUri(region: string, functionArn: string): string {
|
||||
return `arn:aws:apigateway:${region}:lambda:path/2015-03-31/functions/${functionArn}/invocations`
|
||||
}
|
||||
|
||||
function integrationFunctionName(integration: Partial<Integration>): string | null {
|
||||
if (integration.IntegrationType !== 'AWS_PROXY') return null
|
||||
return integration.IntegrationUri?.match(/arn:aws:lambda:[^:]+:\d+:function:([^/:]+)/)?.[1] ?? null
|
||||
}
|
||||
|
||||
function targetIntegrationId(target: string | undefined): string | undefined {
|
||||
return target?.startsWith('integrations/') ? target.slice('integrations/'.length) : undefined
|
||||
}
|
||||
|
||||
function invokeStatementId(apiId: string): string {
|
||||
return `apigatewayv2-${apiId}`
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
function hasHttpStatus(error: unknown, status: number): boolean {
|
||||
if (typeof error !== 'object' || error === null) return false
|
||||
return (error as {$metadata?: {httpStatusCode?: number}}).$metadata?.httpStatusCode === status
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import {describe, expect, test} from 'bun:test'
|
||||
import {
|
||||
CreatePipeCommand,
|
||||
DeletePipeCommand,
|
||||
DescribePipeCommand,
|
||||
ListPipesCommand,
|
||||
type PipesClient,
|
||||
StartPipeCommand,
|
||||
StopPipeCommand,
|
||||
} from '@aws-sdk/client-pipes'
|
||||
import {AwsPipesAdapter, describeArn} from './AwsPipesAdapter'
|
||||
import {ValidationError} from '../cloud-spi/errors'
|
||||
|
||||
type SendResult = Record<string, unknown>
|
||||
|
||||
function stubPipes(handler: (command: object) => SendResult | Promise<SendResult>) {
|
||||
const sent: object[] = []
|
||||
const client = {
|
||||
async send(command: object) {
|
||||
sent.push(command)
|
||||
return handler(command)
|
||||
},
|
||||
} as unknown as PipesClient
|
||||
return {client, sent, adapter: new AwsPipesAdapter(client)}
|
||||
}
|
||||
|
||||
const SRC = 'arn:aws:sqs:us-east-1:000000000000:pipe-src'
|
||||
const DST = 'arn:aws:lambda:us-east-1:000000000000:function:worker'
|
||||
const ROLE = 'arn:aws:iam::000000000000:role/pipe-role'
|
||||
const described = {
|
||||
Name: 'orders-pipe', Arn: 'arn:aws:pipes:us-east-1:000000000000:pipe/orders-pipe',
|
||||
DesiredState: 'RUNNING', CurrentState: 'RUNNING', Source: SRC, Target: DST, RoleArn: ROLE,
|
||||
SourceParameters: {FilterCriteria: {Filters: [{Pattern: '{"body":{"kind":["order"]}}'}]}, SqsQueueParameters: {BatchSize: 5}},
|
||||
TargetParameters: {InputTemplate: '{"id": <$.messageId>}'},
|
||||
CreationTime: new Date('2026-10-25T16:42:45Z'),
|
||||
}
|
||||
|
||||
describe('AwsPipesAdapter', () => {
|
||||
test('lists pipes across pages with readable source and target names', async () => {
|
||||
const {adapter} = stubPipes((command) => {
|
||||
if (!(command instanceof ListPipesCommand)) return {}
|
||||
return command.input.NextToken
|
||||
? {Pipes: [{Name: 'clicks', CurrentState: 'STOPPED', Source: 'arn:aws:kinesis:us-east-1:000000000000:stream/clicks', Target: 'arn:aws:events:us-east-1:000000000000:event-bus/default'}]}
|
||||
: {Pipes: [{Name: 'orders-pipe', CurrentState: 'RUNNING', Source: SRC, Target: DST}], NextToken: 'n'}
|
||||
})
|
||||
|
||||
const pipes = await adapter.list()
|
||||
expect(pipes.map((pipe) => [pipe.name, pipe.status, pipe.metadata.sourceName, pipe.metadata.targetName])).toEqual([
|
||||
['orders-pipe', 'RUNNING', 'pipe-src (SQS queue)', 'worker (Lambda function)'],
|
||||
['clicks', 'STOPPED', 'clicks (Kinesis stream)', 'default (EventBridge event bus)'],
|
||||
])
|
||||
expect((await adapter.list({search: 'kinesis'})).map((pipe) => pipe.name)).toEqual(['clicks'])
|
||||
})
|
||||
|
||||
test('describes the filter, batch size, template and role', async () => {
|
||||
const {adapter} = stubPipes(() => described)
|
||||
expect((await adapter.get('orders-pipe'))?.metadata).toMatchObject({
|
||||
filterPatterns: ['{"body":{"kind":["order"]}}'],
|
||||
batchSize: 5,
|
||||
inputTemplate: '{"id": <$.messageId>}',
|
||||
roleArn: ROLE,
|
||||
})
|
||||
})
|
||||
|
||||
test('a missing pipe is null', async () => {
|
||||
const {adapter} = stubPipes(() => { throw Object.assign(new Error('gone'), {name: 'NotFoundException'}) })
|
||||
expect(await adapter.get('nope')).toBeNull()
|
||||
})
|
||||
|
||||
test('creates an SQS pipe with its filter, batch size and template', async () => {
|
||||
const {adapter, sent} = stubPipes((command) => command instanceof DescribePipeCommand ? described : {})
|
||||
|
||||
await adapter.create({values: {name: 'orders-pipe', source: SRC, target: DST, roleArn: ROLE, batchSize: '5', filterPattern: '{ "body": { "kind": ["order"] } }', inputTemplate: '{"id": <$.messageId>}'}})
|
||||
expect((sent[0] as CreatePipeCommand).input).toEqual({
|
||||
Name: 'orders-pipe',
|
||||
Source: SRC,
|
||||
SourceParameters: {FilterCriteria: {Filters: [{Pattern: '{"body":{"kind":["order"]}}'}]}, SqsQueueParameters: {BatchSize: 5}},
|
||||
Target: DST,
|
||||
TargetParameters: {InputTemplate: '{"id": <$.messageId>}'},
|
||||
RoleArn: ROLE,
|
||||
DesiredState: 'RUNNING',
|
||||
})
|
||||
})
|
||||
|
||||
test('gives stream and MSK sources the starting position they require', async () => {
|
||||
const {adapter, sent} = stubPipes((command) => command instanceof DescribePipeCommand ? described : {})
|
||||
|
||||
await adapter.create({values: {name: 'clicks', source: 'arn:aws:kinesis:us-east-1:000000000000:stream/clicks', target: DST, roleArn: ROLE, startingPosition: 'TRIM_HORIZON', desiredState: 'STOPPED'}})
|
||||
expect((sent[0] as CreatePipeCommand).input).toMatchObject({SourceParameters: {KinesisStreamParameters: {StartingPosition: 'TRIM_HORIZON'}}, DesiredState: 'STOPPED'})
|
||||
|
||||
await adapter.create({values: {name: 'k', source: 'arn:aws:kafka:us-east-1:000000000000:cluster/events/abc', target: DST, roleArn: ROLE, topicName: 'orders'}})
|
||||
expect((sent[2] as CreatePipeCommand).input.SourceParameters).toEqual({ManagedStreamingKafkaParameters: {TopicName: 'orders', StartingPosition: 'LATEST'}})
|
||||
})
|
||||
|
||||
test('refuses pipes the runtime could not run, before calling it', async () => {
|
||||
const {adapter, sent} = stubPipes(() => ({}))
|
||||
const base = {name: 'p', source: SRC, target: DST, roleArn: ROLE}
|
||||
await expect(adapter.create({values: {...base, target: 'arn:aws:s3:::bucket'}})).rejects.toThrow('target must be the ARN of a')
|
||||
await expect(adapter.create({values: {...base, source: 'arn:aws:sns:us-east-1:000000000000:topic'}})).rejects.toBeInstanceOf(ValidationError)
|
||||
await expect(adapter.create({values: {...base, enrichment: 'arn:aws:sqs:us-east-1:000000000000:q'}})).rejects.toBeInstanceOf(ValidationError)
|
||||
await expect(adapter.create({values: {...base, filterPattern: '{kind: order}'}})).rejects.toThrow('filterPattern must be JSON')
|
||||
await expect(adapter.create({values: {...base, batchSize: '20000'}})).rejects.toBeInstanceOf(ValidationError)
|
||||
await expect(adapter.create({values: {...base, source: 'arn:aws:kafka:us-east-1:000000000000:cluster/c/1'}})).rejects.toThrow('topicName is required')
|
||||
await expect(adapter.create({values: {...base, roleArn: 'pipe-role'}})).rejects.toBeInstanceOf(ValidationError)
|
||||
expect(sent).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('starts, stops and deletes by name', async () => {
|
||||
const {adapter, sent} = stubPipes(() => ({}))
|
||||
await adapter.stop('orders-pipe')
|
||||
await adapter.start('orders-pipe')
|
||||
await adapter.delete('orders-pipe')
|
||||
expect(sent.map((command) => command.constructor)).toEqual([StopPipeCommand, StartPipeCommand, DeletePipeCommand])
|
||||
expect(sent.every((command) => (command as StopPipeCommand).input.Name === 'orders-pipe')).toBe(true)
|
||||
})
|
||||
|
||||
test('names every kind of source and target ARN', () => {
|
||||
expect(describeArn('arn:aws:dynamodb:us-east-1:000000000000:table/orders/stream/2026-10-25T00:00:00.000')).toBe('orders (DynamoDB stream)')
|
||||
expect(describeArn('arn:aws:states:us-east-1:000000000000:stateMachine:flow')).toBe('flow (Step Functions state machine)')
|
||||
expect(describeArn('arn:aws:sns:us-east-1:000000000000:alerts')).toBe('alerts (SNS topic)')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,237 @@
|
||||
import {
|
||||
CreatePipeCommand,
|
||||
DeletePipeCommand,
|
||||
DescribePipeCommand,
|
||||
type DescribePipeCommandOutput,
|
||||
ListPipesCommand,
|
||||
type Pipe,
|
||||
type PipeSourceParameters,
|
||||
type PipesClient,
|
||||
StartPipeCommand,
|
||||
StopPipeCommand,
|
||||
} from '@aws-sdk/client-pipes'
|
||||
import {RuntimeError, ValidationError} from '../cloud-spi/errors'
|
||||
import {
|
||||
awsPipesSchema,
|
||||
PIPE_ENRICHMENT_SERVICES,
|
||||
PIPE_NAME_PATTERN,
|
||||
PIPE_SOURCE_SERVICES,
|
||||
PIPE_STARTING_POSITIONS,
|
||||
PIPE_TARGET_SERVICES,
|
||||
} from '../cloud-spi/pipesSchema'
|
||||
import type {CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types'
|
||||
|
||||
const MAX_BATCH_SIZE = 10_000
|
||||
|
||||
/**
|
||||
* Talks to EventBridge Pipes, verified against Floci core (community, `latest`).
|
||||
*
|
||||
* Notes from probing the runtime:
|
||||
* - Pipes really run: the runtime polls SQS, Kinesis, DynamoDB streams and MSK,
|
||||
* applies the filter, enrichment and input template, and calls the target.
|
||||
* - Its poller delivers only to Lambda, SQS, SNS, EventBridge buses and Step
|
||||
* Functions, so create refuses other targets instead of making a pipe that
|
||||
* would never deliver.
|
||||
* - ListPipes returns no parameters, so the table shows only what it carries;
|
||||
* the filter, template and role come from DescribePipe.
|
||||
*/
|
||||
export class AwsPipesAdapter implements CloudServiceAdapter {
|
||||
readonly cloud = 'aws' as const
|
||||
readonly service = 'pipes' as const
|
||||
|
||||
constructor(private readonly pipes: PipesClient) {}
|
||||
|
||||
schema(): ServiceSchema {
|
||||
return awsPipesSchema()
|
||||
}
|
||||
|
||||
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
|
||||
const found: Pipe[] = []
|
||||
let next: string | undefined
|
||||
do {
|
||||
const res = await this.pipes.send(new ListPipesCommand({Limit: 100, ...(next ? {NextToken: next} : {})}))
|
||||
found.push(...(res.Pipes ?? []))
|
||||
next = res.NextToken
|
||||
} while (next)
|
||||
|
||||
const search = query.search?.trim().toLowerCase()
|
||||
return found.map((pipe) => toResource(pipe)).filter((pipe) => !search
|
||||
|| pipe.name.toLowerCase().includes(search)
|
||||
|| String(pipe.metadata.source).toLowerCase().includes(search)
|
||||
|| String(pipe.metadata.target).toLowerCase().includes(search))
|
||||
}
|
||||
|
||||
async get(id: string): Promise<CloudResource | null> {
|
||||
try {
|
||||
return toResource(await this.pipes.send(new DescribePipeCommand({Name: id})))
|
||||
} catch (error) {
|
||||
if (errorName(error) === 'NotFoundException') return null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async create(input: CreateResourceInput): Promise<CloudResource> {
|
||||
const values = input.values
|
||||
const name = text(values.name)
|
||||
if (!new RegExp(PIPE_NAME_PATTERN).test(name)) throw new ValidationError('name must be 1 to 64 letters, numbers, dots, dashes or underscores')
|
||||
|
||||
const source = text(values.source)
|
||||
const sourceService = supported(source, PIPE_SOURCE_SERVICES, 'source')
|
||||
const target = text(values.target)
|
||||
supported(target, PIPE_TARGET_SERVICES, 'target')
|
||||
const enrichment = text(values.enrichment)
|
||||
if (enrichment) supported(enrichment, PIPE_ENRICHMENT_SERVICES, 'enrichment')
|
||||
const roleArn = text(values.roleArn)
|
||||
if (!/^arn:aws[a-z-]*:iam::\d{12}:role\/.+$/.test(roleArn)) throw new ValidationError('roleArn must be an IAM role ARN')
|
||||
|
||||
const inputTemplate = text(values.inputTemplate)
|
||||
const description = text(values.description)
|
||||
const desiredState = values.desiredState === 'STOPPED' ? 'STOPPED' : 'RUNNING'
|
||||
|
||||
await this.pipes.send(new CreatePipeCommand({
|
||||
Name: name,
|
||||
Source: source,
|
||||
SourceParameters: sourceParameters(sourceService, values),
|
||||
Target: target,
|
||||
...(inputTemplate ? {TargetParameters: {InputTemplate: inputTemplate}} : {}),
|
||||
...(enrichment ? {Enrichment: enrichment} : {}),
|
||||
RoleArn: roleArn,
|
||||
DesiredState: desiredState,
|
||||
...(description ? {Description: description} : {}),
|
||||
}))
|
||||
|
||||
const created = await this.get(name)
|
||||
if (!created) throw new RuntimeError(`EventBridge Pipes did not return the new pipe ${name}`)
|
||||
return created
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.pipes.send(new DeletePipeCommand({Name: id}))
|
||||
}
|
||||
|
||||
async start(id: string): Promise<void> {
|
||||
await this.pipes.send(new StartPipeCommand({Name: id}))
|
||||
}
|
||||
|
||||
async stop(id: string): Promise<void> {
|
||||
await this.pipes.send(new StopPipeCommand({Name: id}))
|
||||
}
|
||||
}
|
||||
|
||||
/** Each source kind carries its own parameter block, as CreatePipe expects. */
|
||||
function sourceParameters(service: keyof typeof PIPE_SOURCE_SERVICES, values: Record<string, unknown>): PipeSourceParameters {
|
||||
const batchSize = optionalBatchSize(values.batchSize)
|
||||
const filterPattern = text(values.filterPattern)
|
||||
const parameters: PipeSourceParameters = filterPattern ? {FilterCriteria: {Filters: [{Pattern: jsonObject(filterPattern, 'filterPattern')}]}} : {}
|
||||
const batch = batchSize ? {BatchSize: batchSize} : {}
|
||||
|
||||
if (service === 'sqs') return {...parameters, SqsQueueParameters: batch}
|
||||
|
||||
const startingPosition = text(values.startingPosition) || 'LATEST'
|
||||
if (!(PIPE_STARTING_POSITIONS as readonly string[]).includes(startingPosition)) {
|
||||
throw new ValidationError(`startingPosition must be one of ${PIPE_STARTING_POSITIONS.join(', ')}`)
|
||||
}
|
||||
const position = startingPosition as (typeof PIPE_STARTING_POSITIONS)[number]
|
||||
if (service === 'kinesis') return {...parameters, KinesisStreamParameters: {StartingPosition: position, ...batch}}
|
||||
if (service === 'dynamodb') return {...parameters, DynamoDBStreamParameters: {StartingPosition: position, ...batch}}
|
||||
|
||||
const topicName = text(values.topicName)
|
||||
if (!topicName) throw new ValidationError('topicName is required for an MSK source')
|
||||
return {...parameters, ManagedStreamingKafkaParameters: {TopicName: topicName, StartingPosition: position, ...batch}}
|
||||
}
|
||||
|
||||
function toResource(pipe: Pipe | DescribePipeCommandOutput): CloudResource {
|
||||
const name = pipe.Name ?? ''
|
||||
const detail = 'SourceParameters' in pipe ? pipe : undefined
|
||||
const sourceParameters = detail?.SourceParameters
|
||||
const batchSize = sourceParameters?.SqsQueueParameters?.BatchSize
|
||||
?? sourceParameters?.KinesisStreamParameters?.BatchSize
|
||||
?? sourceParameters?.DynamoDBStreamParameters?.BatchSize
|
||||
?? sourceParameters?.ManagedStreamingKafkaParameters?.BatchSize
|
||||
return {
|
||||
id: name,
|
||||
name,
|
||||
cloud: 'aws',
|
||||
service: 'pipes',
|
||||
type: 'pipe',
|
||||
region: pipe.Arn?.split(':')[3] || null,
|
||||
createdAt: pipe.CreationTime ? pipe.CreationTime.toISOString() : null,
|
||||
status: pipe.CurrentState ?? null,
|
||||
metadata: {
|
||||
arn: pipe.Arn,
|
||||
desiredState: pipe.DesiredState ?? null,
|
||||
currentState: pipe.CurrentState ?? null,
|
||||
stateReason: pipe.StateReason ?? null,
|
||||
source: pipe.Source ?? null,
|
||||
sourceName: describeArn(pipe.Source),
|
||||
target: pipe.Target ?? null,
|
||||
targetName: describeArn(pipe.Target),
|
||||
enrichment: pipe.Enrichment ?? null,
|
||||
lastModified: pipe.LastModifiedTime ? pipe.LastModifiedTime.toISOString() : null,
|
||||
...(detail ? {
|
||||
description: detail.Description ?? null,
|
||||
roleArn: detail.RoleArn ?? null,
|
||||
filterPatterns: (sourceParameters?.FilterCriteria?.Filters ?? []).map((filter) => filter.Pattern ?? '').filter(Boolean),
|
||||
batchSize: batchSize ?? null,
|
||||
startingPosition: sourceParameters?.KinesisStreamParameters?.StartingPosition
|
||||
?? sourceParameters?.DynamoDBStreamParameters?.StartingPosition
|
||||
?? sourceParameters?.ManagedStreamingKafkaParameters?.StartingPosition
|
||||
?? null,
|
||||
topicName: sourceParameters?.ManagedStreamingKafkaParameters?.TopicName ?? null,
|
||||
inputTemplate: detail.TargetParameters?.InputTemplate ?? null,
|
||||
enrichmentInputTemplate: detail.EnrichmentParameters?.InputTemplate ?? null,
|
||||
} : {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A readable name for an ARN: `queue (SQS queue)`. The resource part differs by
|
||||
* service — `function:name`, `stream/name`, `table/name/stream/…`, `cluster/name/uuid`.
|
||||
*/
|
||||
export function describeArn(arn: string | undefined): string | null {
|
||||
if (!arn) return null
|
||||
const parts = arn.split(':')
|
||||
const service = parts[2] ?? ''
|
||||
const resource = parts.slice(5).join(':')
|
||||
const name = service === 'lambda' || service === 'states' ? resource.split(':')[1] ?? resource
|
||||
: service === 'dynamodb' || service === 'kafka' || service === 'kinesis' || service === 'events' ? resource.split('/')[1] ?? resource
|
||||
: resource
|
||||
const kind = (PIPE_SOURCE_SERVICES as Record<string, string>)[service] ?? (PIPE_TARGET_SERVICES as Record<string, string>)[service]
|
||||
return kind ? `${name} (${kind})` : name
|
||||
}
|
||||
|
||||
function supported<T extends Record<string, string>>(arn: string, services: T, field: string): keyof T {
|
||||
const service = arn.split(':')[2] ?? ''
|
||||
if (!arn.startsWith('arn:') || !(service in services)) {
|
||||
throw new ValidationError(`${field} must be the ARN of a ${Object.values(services).join(', ')}`)
|
||||
}
|
||||
return service as keyof T
|
||||
}
|
||||
|
||||
function optionalBatchSize(value: unknown): number | undefined {
|
||||
const raw = text(value)
|
||||
if (!raw) return undefined
|
||||
const size = Number(raw)
|
||||
if (!Number.isInteger(size) || size < 1 || size > MAX_BATCH_SIZE) throw new ValidationError(`batchSize must be a whole number from 1 to ${MAX_BATCH_SIZE}`)
|
||||
return size
|
||||
}
|
||||
|
||||
function jsonObject(value: string, field: string): string {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(value)
|
||||
} catch {
|
||||
throw new ValidationError(`${field} must be JSON, such as {"body": {"kind": ["order"]}}`)
|
||||
}
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) throw new ValidationError(`${field} must be a JSON object`)
|
||||
return JSON.stringify(parsed)
|
||||
}
|
||||
|
||||
function text(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
function errorName(error: unknown): string | undefined {
|
||||
return typeof error === 'object' && error !== null ? (error as {name?: string}).name : undefined
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { SQSClient } from "@aws-sdk/client-sqs";
|
||||
import { IAMClient } from "@aws-sdk/client-iam";
|
||||
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
||||
import { APIGatewayClient } from "@aws-sdk/client-api-gateway";
|
||||
import { ApiGatewayV2Client } from "@aws-sdk/client-apigatewayv2";
|
||||
import { CloudFormationClient } from "@aws-sdk/client-cloudformation";
|
||||
import { AppConfigClient } from "@aws-sdk/client-appconfig";
|
||||
import { SNSClient } from "@aws-sdk/client-sns";
|
||||
@@ -33,6 +34,9 @@ import { LightsailClient } from "@aws-sdk/client-lightsail";
|
||||
import { CognitoIdentityProviderClient } from "@aws-sdk/client-cognito-identity-provider";
|
||||
import { Route53Client } from "@aws-sdk/client-route-53";
|
||||
import { CloudFrontClient } from "@aws-sdk/client-cloudfront";
|
||||
import { AutoScalingClient } from "@aws-sdk/client-auto-scaling";
|
||||
import { ACMClient } from "@aws-sdk/client-acm";
|
||||
import { PipesClient } from "@aws-sdk/client-pipes";
|
||||
|
||||
const endpoint = process.env.FLOCI_ENDPOINT;
|
||||
const region = process.env.AWS_REGION || "us-east-1";
|
||||
@@ -79,6 +83,7 @@ export type AwsClients = {
|
||||
iam: IAMClient;
|
||||
dynamodb: DynamoDBClient;
|
||||
apiGateway: APIGatewayClient;
|
||||
apiGatewayV2: ApiGatewayV2Client;
|
||||
cloudformation: CloudFormationClient;
|
||||
appConfig: AppConfigClient;
|
||||
sns: SNSClient;
|
||||
@@ -97,6 +102,9 @@ export type AwsClients = {
|
||||
cognito: CognitoIdentityProviderClient;
|
||||
route53: Route53Client;
|
||||
cloudfront: CloudFrontClient;
|
||||
autoscaling: AutoScalingClient;
|
||||
acm: ACMClient;
|
||||
pipes: PipesClient;
|
||||
};
|
||||
|
||||
export type AwsClientName = keyof AwsClients;
|
||||
@@ -127,6 +135,7 @@ function buildClients(accountId: string): AwsClients {
|
||||
iam: new IAMClient(base),
|
||||
dynamodb: new DynamoDBClient(base),
|
||||
apiGateway: new APIGatewayClient(base),
|
||||
apiGatewayV2: new ApiGatewayV2Client(base),
|
||||
cloudformation: new CloudFormationClient(base),
|
||||
appConfig: new AppConfigClient(base),
|
||||
sns: new SNSClient(base),
|
||||
@@ -145,6 +154,9 @@ function buildClients(accountId: string): AwsClients {
|
||||
cognito: new CognitoIdentityProviderClient(base),
|
||||
route53: new Route53Client(base),
|
||||
cloudfront: new CloudFrontClient(base),
|
||||
autoscaling: new AutoScalingClient(base),
|
||||
acm: new ACMClient(base),
|
||||
pipes: new PipesClient(base),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -181,6 +193,7 @@ export const sqs = awsClients.sqs;
|
||||
export const iam = awsClients.iam;
|
||||
export const dynamodb = awsClients.dynamodb;
|
||||
export const apiGateway = awsClients.apiGateway;
|
||||
export const apiGatewayV2 = awsClients.apiGatewayV2;
|
||||
export const cloudformation = awsClients.cloudformation;
|
||||
export const ecr = awsClients.ecr;
|
||||
export const kinesis = awsClients.kinesis;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type {CapabilitySchema, ResourceActionName, ServiceSchema} from './types'
|
||||
import type {CapabilitySchema, HttpApiActionName, ResourceActionName, ServiceSchema} from './types'
|
||||
|
||||
const resourceActions: CapabilitySchema<ResourceActionName>[] = [
|
||||
{name: 'list', label: 'List APIs', enabled: true, status: 'available', runtimeRequired: true},
|
||||
@@ -7,12 +7,42 @@ const resourceActions: CapabilitySchema<ResourceActionName>[] = [
|
||||
{name: 'inspect', label: 'Inspect API', enabled: true, status: 'available', runtimeRequired: false},
|
||||
]
|
||||
|
||||
const httpApiActions: CapabilitySchema<HttpApiActionName>[] = [
|
||||
{name: 'describe', label: 'Show routes, stages and authorizers', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'createRoute', label: 'Add a route', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'deleteRoute', label: 'Delete a route', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'setRouteAuthorizer', label: 'Protect a route with an authorizer', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'createStage', label: 'Add a stage', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'deleteStage', label: 'Delete a stage', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'deploy', label: 'Deploy to a stage', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'createAuthorizer', label: 'Add a JWT authorizer', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'deleteAuthorizer', label: 'Delete an authorizer', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'updateCors', label: 'Configure CORS', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'sendRequest', label: 'Send a request to a stage', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'testWebSocket', label: 'Connect and send a WebSocket message', enabled: true, status: 'available', runtimeRequired: true},
|
||||
]
|
||||
|
||||
const httpOnly = {field: 'protocol', values: ['HTTP']}
|
||||
const webSocketOnly = {field: 'protocol', values: ['WEBSOCKET']}
|
||||
|
||||
export function awsApiGatewaySchema(): ServiceSchema {
|
||||
return {
|
||||
cloud: 'aws',
|
||||
service: 'apigateway',
|
||||
displayName: 'AWS API Gateway',
|
||||
fields: [
|
||||
{
|
||||
name: 'protocol',
|
||||
label: 'API type',
|
||||
type: 'select',
|
||||
required: true,
|
||||
defaultValue: 'REST',
|
||||
options: [
|
||||
{label: 'REST API (resources, methods and stages)', value: 'REST'},
|
||||
{label: 'HTTP API (routes to Lambda or HTTP backends, JWT authorizers)', value: 'HTTP'},
|
||||
{label: 'WebSocket API (two-way messages routed to Lambda)', value: 'WEBSOCKET'},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
label: 'API Name',
|
||||
@@ -30,20 +60,47 @@ export function awsApiGatewaySchema(): ServiceSchema {
|
||||
label: 'Description',
|
||||
type: 'text',
|
||||
required: false,
|
||||
description: 'Optional description for the REST API.',
|
||||
description: 'Optional description for the API.',
|
||||
validation: {
|
||||
maxLength: 1024,
|
||||
message: 'Description must be 1024 characters or fewer.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'functionName',
|
||||
label: 'Lambda function',
|
||||
type: 'text',
|
||||
required: false,
|
||||
visibleWhen: httpOnly,
|
||||
description: 'Optional. Sends every request to this function through a catch-all route, on a $default stage that deploys automatically. Leave empty to add routes yourself.',
|
||||
},
|
||||
{
|
||||
name: 'routeSelectionExpression',
|
||||
label: 'Route selection expression',
|
||||
type: 'text',
|
||||
required: false,
|
||||
defaultValue: '$request.body.action',
|
||||
visibleWhen: webSocketOnly,
|
||||
description: 'Which field of each message picks its route. With $request.body.action, {"action": "sendMessage"} goes to the sendMessage route.',
|
||||
},
|
||||
{
|
||||
name: 'stageName',
|
||||
label: 'Stage',
|
||||
type: 'text',
|
||||
required: false,
|
||||
defaultValue: 'dev',
|
||||
visibleWhen: webSocketOnly,
|
||||
description: 'Created with automatic deployment, so route changes go live right away.',
|
||||
validation: {pattern: '^[A-Za-z0-9_-]{1,128}$', message: 'Use letters, numbers, hyphens and underscores'},
|
||||
},
|
||||
],
|
||||
actions: ['list', 'create', 'delete', 'inspect'],
|
||||
capabilities: {resourceActions},
|
||||
capabilities: {resourceActions, httpApiActions},
|
||||
filters: [{name: 'search', label: 'Search', type: 'text', required: false}],
|
||||
columns: [
|
||||
{name: 'name', label: 'API Name'},
|
||||
{name: 'type', label: 'Type'},
|
||||
{name: 'createdAt', label: 'Created At'},
|
||||
{name: 'protocol', label: 'Type', path: 'metadata.protocol', format: 'badge'},
|
||||
{name: 'createdAt', label: 'Created At', format: 'relative'},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types'
|
||||
|
||||
/** CreateAutoScalingGroup's name rule: 1 to 255 characters, no colon. */
|
||||
export const AUTO_SCALING_GROUP_NAME_PATTERN = '^[^:]{1,255}$'
|
||||
/** A capacity the console accepts; AWS's own ceiling is far above what a laptop can run. */
|
||||
export const AUTO_SCALING_MAX_INSTANCES = 20
|
||||
|
||||
const groupColumns: TableColumnSchema[] = [
|
||||
{name: 'name', label: 'Name'},
|
||||
{name: 'capacity', label: 'Instances', path: 'metadata.capacity'},
|
||||
{name: 'desired', label: 'Desired', path: 'metadata.desiredCapacity'},
|
||||
{name: 'min', label: 'Min', path: 'metadata.minSize'},
|
||||
{name: 'max', label: 'Max', path: 'metadata.maxSize'},
|
||||
{name: 'launchTemplate', label: 'Launch template', path: 'metadata.launchTemplateName', emptyText: '—'},
|
||||
{name: 'createdAt', label: 'Created', format: 'datetime'},
|
||||
]
|
||||
|
||||
const groupFilters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}]
|
||||
|
||||
const capacity = {pattern: '^\\d{1,2}$', message: `Use a whole number from 0 to ${AUTO_SCALING_MAX_INSTANCES}`}
|
||||
|
||||
export function awsAutoScalingSchema(): ServiceSchema {
|
||||
return {
|
||||
cloud: 'aws',
|
||||
service: 'autoscaling',
|
||||
displayName: 'EC2 Auto Scaling groups',
|
||||
fields: [
|
||||
{
|
||||
name: 'name',
|
||||
label: 'Name',
|
||||
type: 'text',
|
||||
required: true,
|
||||
validation: {pattern: AUTO_SCALING_GROUP_NAME_PATTERN, message: 'Use up to 255 characters, without a colon'},
|
||||
},
|
||||
{
|
||||
name: 'launchTemplate',
|
||||
label: 'Launch template',
|
||||
type: 'text',
|
||||
required: true,
|
||||
description: 'The name or ID (lt-…) of an EC2 launch template. New instances use its image and instance type.',
|
||||
},
|
||||
{
|
||||
name: 'launchTemplateVersion',
|
||||
label: 'Template version',
|
||||
type: 'text',
|
||||
required: false,
|
||||
defaultValue: '$Latest',
|
||||
description: 'A version number, $Latest or $Default.',
|
||||
validation: {pattern: '^(\\d+|\\$Latest|\\$Default)$', message: 'Use a version number, $Latest or $Default'},
|
||||
},
|
||||
{name: 'minSize', label: 'Minimum', type: 'text', required: true, defaultValue: '1', validation: capacity},
|
||||
{name: 'desiredCapacity', label: 'Desired', type: 'text', required: false, defaultValue: '1', validation: capacity, description: 'How many instances to run now. Between the minimum and the maximum.'},
|
||||
{name: 'maxSize', label: 'Maximum', type: 'text', required: true, defaultValue: '2', validation: capacity},
|
||||
{
|
||||
name: 'subnets',
|
||||
label: 'Subnets',
|
||||
type: 'text',
|
||||
required: false,
|
||||
span: true,
|
||||
description: 'Subnet IDs to launch into, separated by commas. Leave empty for the default subnets.',
|
||||
validation: {pattern: '^\\s*subnet-[\\w-]+(\\s*,\\s*subnet-[\\w-]+)*\\s*$', message: 'Use subnet IDs separated by commas'},
|
||||
},
|
||||
{
|
||||
name: 'targetGroupArns',
|
||||
label: 'Target groups',
|
||||
type: 'textarea',
|
||||
required: false,
|
||||
span: true,
|
||||
description: 'Optional load balancer target group ARNs, one per line. Instances are registered with them as they launch.',
|
||||
},
|
||||
{
|
||||
name: 'healthCheckType',
|
||||
label: 'Health check',
|
||||
type: 'select',
|
||||
required: false,
|
||||
defaultValue: 'EC2',
|
||||
options: [
|
||||
{label: 'EC2 instance status', value: 'EC2'},
|
||||
{label: 'Load balancer health checks', value: 'ELB'},
|
||||
],
|
||||
},
|
||||
],
|
||||
actions: ['list', 'create', 'delete', 'inspect'],
|
||||
capabilities: {
|
||||
resourceActions: [
|
||||
{name: 'list', label: 'List Auto Scaling groups', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'create', label: 'Create Auto Scaling group', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'delete', label: 'Delete group and its instances', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'inspect', label: 'Inspect Auto Scaling group', enabled: true, status: 'available', runtimeRequired: true},
|
||||
],
|
||||
autoScalingActions: [
|
||||
{name: 'setCapacity', label: 'Change minimum, desired and maximum', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'terminateInstance', label: 'Terminate an instance', enabled: true, status: 'available', runtimeRequired: true},
|
||||
],
|
||||
},
|
||||
filters: groupFilters,
|
||||
columns: groupColumns,
|
||||
}
|
||||
}
|
||||
|
||||
export function autoScalingSchemaFor(cloud: CloudProvider): ServiceSchema | null {
|
||||
return cloud === 'aws' ? awsAutoScalingSchema() : null
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types'
|
||||
|
||||
/** A domain name, optionally a wildcard (`*.example.com`), as RequestCertificate accepts it. */
|
||||
export const CERTIFICATE_DOMAIN_PATTERN = '^(\\*\\.)?(?=.{1,253}$)([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$'
|
||||
|
||||
/** The key algorithms RequestCertificate accepts for a public certificate. */
|
||||
export const CERTIFICATE_KEY_ALGORITHMS = ['RSA_2048', 'EC_prime256v1', 'EC_secp384r1'] as const
|
||||
export const CERTIFICATE_VALIDATION_METHODS = ['DNS', 'EMAIL'] as const
|
||||
/** RevokeCertificate's reasons, as the API names them. */
|
||||
export const CERTIFICATE_REVOCATION_REASONS = [
|
||||
'UNSPECIFIED',
|
||||
'KEY_COMPROMISE',
|
||||
'CA_COMPROMISE',
|
||||
'AFFILIATION_CHANGED',
|
||||
'SUPERSEDED',
|
||||
'CESSATION_OF_OPERATION',
|
||||
'PRIVILEGE_WITHDRAWN',
|
||||
] as const
|
||||
|
||||
const certificateColumns: TableColumnSchema[] = [
|
||||
{name: 'name', label: 'Domain name'},
|
||||
{name: 'status', label: 'Status', format: 'badge'},
|
||||
{name: 'certificateType', label: 'Type', path: 'metadata.certificateType'},
|
||||
{name: 'inUse', label: 'In use', path: 'metadata.inUse'},
|
||||
{name: 'keyAlgorithm', label: 'Key algorithm', path: 'metadata.keyAlgorithm'},
|
||||
{name: 'notAfter', label: 'Expires', path: 'metadata.notAfter', format: 'datetime', emptyText: '—'},
|
||||
{name: 'id', label: 'Certificate ID', format: 'code'},
|
||||
]
|
||||
|
||||
const certificateFilters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}]
|
||||
|
||||
const requested = {field: 'source', values: ['request']}
|
||||
const imported = {field: 'source', values: ['import']}
|
||||
|
||||
export function awsCertificatesSchema(): ServiceSchema {
|
||||
return {
|
||||
cloud: 'aws',
|
||||
service: 'certificates',
|
||||
displayName: 'AWS Certificate Manager',
|
||||
fields: [
|
||||
{
|
||||
name: 'source',
|
||||
label: 'Certificate type',
|
||||
type: 'select',
|
||||
required: true,
|
||||
defaultValue: 'request',
|
||||
options: [
|
||||
{label: 'Request a public certificate', value: 'request'},
|
||||
{label: 'Import a certificate', value: 'import'},
|
||||
],
|
||||
span: true,
|
||||
},
|
||||
{
|
||||
name: 'domainName',
|
||||
label: 'Domain name',
|
||||
type: 'text',
|
||||
required: false,
|
||||
requiredWhen: {field: 'source', equals: 'request'},
|
||||
visibleWhen: requested,
|
||||
description: 'For example notes.thecloud.localhost, or *.thecloud.localhost for every subdomain. TheCloud signs it with its own local certificate authority.',
|
||||
validation: {pattern: CERTIFICATE_DOMAIN_PATTERN, message: 'Use a domain name such as example.com or *.example.com'},
|
||||
},
|
||||
{
|
||||
name: 'subjectAlternativeNames',
|
||||
label: 'Additional names',
|
||||
type: 'textarea',
|
||||
required: false,
|
||||
visibleWhen: requested,
|
||||
description: 'Other names the certificate covers, one per line.',
|
||||
},
|
||||
{
|
||||
name: 'validationMethod',
|
||||
label: 'Validation method',
|
||||
type: 'select',
|
||||
required: false,
|
||||
defaultValue: 'DNS',
|
||||
visibleWhen: requested,
|
||||
description: 'How ACM proves you own the domain. TheCloud issues the certificate without checking, and still shows the DNS records AWS would ask for.',
|
||||
options: CERTIFICATE_VALIDATION_METHODS.map((value) => ({label: value, value})),
|
||||
},
|
||||
{
|
||||
name: 'keyAlgorithm',
|
||||
label: 'Key algorithm',
|
||||
type: 'select',
|
||||
required: false,
|
||||
defaultValue: 'RSA_2048',
|
||||
visibleWhen: requested,
|
||||
options: CERTIFICATE_KEY_ALGORITHMS.map((value) => ({label: value.replace('_', ' '), value})),
|
||||
},
|
||||
{
|
||||
name: 'export',
|
||||
label: 'Exportable',
|
||||
type: 'select',
|
||||
required: false,
|
||||
defaultValue: 'DISABLED',
|
||||
visibleWhen: requested,
|
||||
description: 'An exportable certificate can be downloaded with its private key, to use on servers outside AWS. This is chosen once and cannot be changed later.',
|
||||
options: [
|
||||
{label: 'No', value: 'DISABLED'},
|
||||
{label: 'Yes, allow export', value: 'ENABLED'},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'certificate',
|
||||
label: 'Certificate body (PEM)',
|
||||
type: 'textarea',
|
||||
required: false,
|
||||
requiredWhen: {field: 'source', equals: 'import'},
|
||||
visibleWhen: imported,
|
||||
span: true,
|
||||
description: 'Starts with -----BEGIN CERTIFICATE-----.',
|
||||
},
|
||||
{
|
||||
name: 'privateKey',
|
||||
label: 'Private key (PEM)',
|
||||
type: 'textarea',
|
||||
required: false,
|
||||
requiredWhen: {field: 'source', equals: 'import'},
|
||||
visibleWhen: imported,
|
||||
span: true,
|
||||
description: 'The unencrypted key that matches the certificate.',
|
||||
},
|
||||
{
|
||||
name: 'certificateChain',
|
||||
label: 'Certificate chain (PEM)',
|
||||
type: 'textarea',
|
||||
required: false,
|
||||
visibleWhen: imported,
|
||||
span: true,
|
||||
description: 'The intermediate certificates, if the certificate is not self-signed.',
|
||||
},
|
||||
],
|
||||
actions: ['list', 'create', 'delete', 'inspect'],
|
||||
capabilities: {
|
||||
resourceActions: [
|
||||
{name: 'list', label: 'List certificates', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'create', label: 'Request or import a certificate', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'delete', label: 'Delete certificate', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'inspect', label: 'Inspect certificate', enabled: true, status: 'available', runtimeRequired: true},
|
||||
],
|
||||
certificateActions: [
|
||||
{name: 'download', label: 'Download the certificate and chain', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'export', label: 'Export with the private key', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'revoke', label: 'Revoke certificate', enabled: true, status: 'available', runtimeRequired: true},
|
||||
],
|
||||
},
|
||||
filters: certificateFilters,
|
||||
columns: certificateColumns,
|
||||
}
|
||||
}
|
||||
|
||||
export function certificatesSchemaFor(cloud: CloudProvider): ServiceSchema | null {
|
||||
return cloud === 'aws' ? awsCertificatesSchema() : null
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types'
|
||||
|
||||
/** CreatePipe's name rule. */
|
||||
export const PIPE_NAME_PATTERN = '^[\\.\\-_A-Za-z0-9]{1,64}$'
|
||||
export const PIPE_STARTING_POSITIONS = ['TRIM_HORIZON', 'LATEST'] as const
|
||||
|
||||
/**
|
||||
* The sources and targets the runtime's pipe poller actually moves events
|
||||
* between, keyed by the ARN's service field. Anything else is refused on create
|
||||
* rather than accepted into a pipe that would never deliver.
|
||||
*/
|
||||
export const PIPE_SOURCE_SERVICES = {sqs: 'SQS queue', kinesis: 'Kinesis stream', dynamodb: 'DynamoDB stream', kafka: 'MSK topic'} as const
|
||||
export const PIPE_TARGET_SERVICES = {lambda: 'Lambda function', sqs: 'SQS queue', sns: 'SNS topic', events: 'EventBridge event bus', states: 'Step Functions state machine'} as const
|
||||
export const PIPE_ENRICHMENT_SERVICES = {lambda: 'Lambda function', states: 'Step Functions state machine'} as const
|
||||
|
||||
const ARN = '^arn:aws[a-z-]*:[a-z0-9-]+:[a-z0-9-]*:\\d{12}:.+$'
|
||||
|
||||
const pipeColumns: TableColumnSchema[] = [
|
||||
{name: 'name', label: 'Name'},
|
||||
{name: 'status', label: 'State', format: 'badge'},
|
||||
{name: 'source', label: 'Source', path: 'metadata.sourceName'},
|
||||
{name: 'target', label: 'Target', path: 'metadata.targetName'},
|
||||
{name: 'createdAt', label: 'Created', format: 'datetime'},
|
||||
]
|
||||
|
||||
const pipeFilters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}]
|
||||
|
||||
export function awsPipesSchema(): ServiceSchema {
|
||||
return {
|
||||
cloud: 'aws',
|
||||
service: 'pipes',
|
||||
displayName: 'EventBridge Pipes',
|
||||
fields: [
|
||||
{
|
||||
name: 'name',
|
||||
label: 'Name',
|
||||
type: 'text',
|
||||
required: true,
|
||||
validation: {pattern: PIPE_NAME_PATTERN, message: 'Use up to 64 letters, numbers, dots, dashes and underscores'},
|
||||
},
|
||||
{name: 'description', label: 'Description', type: 'text', required: false, validation: {maxLength: 512}},
|
||||
{
|
||||
name: 'source',
|
||||
label: 'Source ARN',
|
||||
type: 'text',
|
||||
required: true,
|
||||
span: true,
|
||||
description: 'Where events come from: an SQS queue, a Kinesis stream, a DynamoDB stream or an MSK cluster.',
|
||||
validation: {pattern: ARN, message: 'Use the ARN of a queue or stream'},
|
||||
},
|
||||
{
|
||||
name: 'startingPosition',
|
||||
label: 'Starting position',
|
||||
type: 'select',
|
||||
required: false,
|
||||
defaultValue: 'LATEST',
|
||||
description: 'For streams and MSK: read new records only, or everything still in the stream.',
|
||||
options: [
|
||||
{label: 'New records only (LATEST)', value: 'LATEST'},
|
||||
{label: 'Oldest record (TRIM_HORIZON)', value: 'TRIM_HORIZON'},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'topicName',
|
||||
label: 'MSK topic',
|
||||
type: 'text',
|
||||
required: false,
|
||||
description: 'Required when the source is an MSK cluster.',
|
||||
},
|
||||
{
|
||||
name: 'batchSize',
|
||||
label: 'Batch size',
|
||||
type: 'text',
|
||||
required: false,
|
||||
description: 'Records sent to the target at once. 1 to 10,000; leave empty for the default of 10.',
|
||||
validation: {pattern: '^[1-9][0-9]{0,4}$', message: 'Use a whole number from 1 to 10000'},
|
||||
},
|
||||
{
|
||||
name: 'filterPattern',
|
||||
label: 'Filter pattern',
|
||||
type: 'textarea',
|
||||
required: false,
|
||||
span: true,
|
||||
description: 'Optional EventBridge event pattern; only matching records go on. For SQS, match the parsed message under body, for example {"body": {"kind": ["order"]}}.',
|
||||
},
|
||||
{
|
||||
name: 'enrichment',
|
||||
label: 'Enrichment ARN',
|
||||
type: 'text',
|
||||
required: false,
|
||||
span: true,
|
||||
description: 'Optional Lambda function or Step Functions state machine that transforms each batch before the target gets it.',
|
||||
validation: {pattern: ARN, message: 'Use the ARN of a Lambda function or state machine'},
|
||||
},
|
||||
{
|
||||
name: 'target',
|
||||
label: 'Target ARN',
|
||||
type: 'text',
|
||||
required: true,
|
||||
span: true,
|
||||
description: 'Where events go: a Lambda function, SQS queue, SNS topic, EventBridge event bus or Step Functions state machine.',
|
||||
validation: {pattern: ARN, message: 'Use the ARN of the target'},
|
||||
},
|
||||
{
|
||||
name: 'inputTemplate',
|
||||
label: 'Target input template',
|
||||
type: 'textarea',
|
||||
required: false,
|
||||
span: true,
|
||||
description: 'Optional. Reshape what the target receives, with <$.path> placeholders, for example {"id": <$.messageId>}.',
|
||||
},
|
||||
{
|
||||
name: 'roleArn',
|
||||
label: 'Execution role ARN',
|
||||
type: 'text',
|
||||
required: true,
|
||||
span: true,
|
||||
description: 'The IAM role the pipe uses to read the source and call the target, as AWS requires. Create one in IAM first.',
|
||||
validation: {pattern: '^arn:aws[a-z-]*:iam::\\d{12}:role/.+$', message: 'Use an IAM role ARN such as arn:aws:iam::000000000000:role/pipe-role'},
|
||||
},
|
||||
{
|
||||
name: 'desiredState',
|
||||
label: 'State after creating',
|
||||
type: 'select',
|
||||
required: false,
|
||||
defaultValue: 'RUNNING',
|
||||
options: [
|
||||
{label: 'Running', value: 'RUNNING'},
|
||||
{label: 'Stopped', value: 'STOPPED'},
|
||||
],
|
||||
},
|
||||
],
|
||||
actions: ['list', 'create', 'delete', 'inspect'],
|
||||
capabilities: {
|
||||
resourceActions: [
|
||||
{name: 'list', label: 'List pipes', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'create', label: 'Create pipe', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'delete', label: 'Delete pipe', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'inspect', label: 'Inspect pipe', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'start', label: 'Start pipe', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'stop', label: 'Stop pipe', enabled: true, status: 'available', runtimeRequired: true},
|
||||
],
|
||||
},
|
||||
filters: pipeFilters,
|
||||
columns: pipeColumns,
|
||||
}
|
||||
}
|
||||
|
||||
export function pipesSchemaFor(cloud: CloudProvider): ServiceSchema | null {
|
||||
return cloud === 'aws' ? awsPipesSchema() : null
|
||||
}
|
||||
@@ -74,6 +74,13 @@ export const SERVICE_CATALOG = {
|
||||
order: 20,
|
||||
},
|
||||
serverless: {displayName: 'Serverless', iconKey: 'serverless', group: 'Compute', order: 30},
|
||||
autoscaling: {
|
||||
displayName: 'Auto Scaling',
|
||||
displayNameByCloud: {aws: 'EC2 Auto Scaling'},
|
||||
iconKey: 'autoscaling',
|
||||
group: 'Compute',
|
||||
order: 15,
|
||||
},
|
||||
containers: {
|
||||
displayName: 'Containers',
|
||||
displayNameByCloud: {gcp: 'Cloud Run'},
|
||||
@@ -239,6 +246,13 @@ export const SERVICE_CATALOG = {
|
||||
// AWS retains its dedicated page while Azure and GCP use Cloud Explorer.
|
||||
routeByCloud: {aws: '/secretsmanager'},
|
||||
},
|
||||
certificates: {
|
||||
displayName: 'Certificates',
|
||||
displayNameByCloud: {aws: 'Certificate Manager'},
|
||||
iconKey: 'certificates',
|
||||
group: 'Security',
|
||||
order: 35,
|
||||
},
|
||||
cdn: {
|
||||
displayName: 'CDN',
|
||||
displayNameByCloud: {aws: 'CloudFront'},
|
||||
@@ -274,6 +288,13 @@ export const SERVICE_CATALOG = {
|
||||
group: 'Provisioning',
|
||||
order: 20,
|
||||
},
|
||||
pipes: {
|
||||
displayName: 'Pipes',
|
||||
displayNameByCloud: {aws: 'EventBridge Pipes'},
|
||||
iconKey: 'pipes',
|
||||
group: 'Integration',
|
||||
order: 25,
|
||||
},
|
||||
scheduler: {
|
||||
displayName: 'Cloud Scheduler',
|
||||
displayNameByCloud: {gcp: 'Cloud Scheduler'},
|
||||
|
||||
@@ -44,6 +44,11 @@ export interface CloudServiceDescriptor {
|
||||
iconKey: string
|
||||
group: ServiceGroup
|
||||
order: number
|
||||
/**
|
||||
* Whether the generic resource list serves it. False for a service still on its
|
||||
* own pre-SPI page (MSK, MSK Connect): available, but not countable.
|
||||
*/
|
||||
listable: boolean
|
||||
}
|
||||
|
||||
export type RuntimeReachability = 'reachable' | 'unavailable' | 'coming_soon'
|
||||
@@ -181,7 +186,22 @@ export type UserPoolActionName =
|
||||
| 'describeClient'
|
||||
| 'updateClientOAuth'
|
||||
export type DistributionActionName = 'setEnabled' | 'createInvalidation'
|
||||
export type AutoScalingActionName = 'setCapacity' | 'terminateInstance'
|
||||
export type CertificateActionName = 'download' | 'export' | 'revoke'
|
||||
export type MonitoringActionName = 'listMetrics' | 'getMetricData' | 'alarmHistory' | 'setAlarmState' | 'setAlarmActions'
|
||||
export type HttpApiActionName =
|
||||
| 'describe'
|
||||
| 'createRoute'
|
||||
| 'deleteRoute'
|
||||
| 'setRouteAuthorizer'
|
||||
| 'createStage'
|
||||
| 'deleteStage'
|
||||
| 'deploy'
|
||||
| 'createAuthorizer'
|
||||
| 'deleteAuthorizer'
|
||||
| 'updateCors'
|
||||
| 'sendRequest'
|
||||
| 'testWebSocket'
|
||||
export type CapabilityStatus = 'available' | 'blocked' | 'partial' | 'coming_soon'
|
||||
|
||||
export interface CapabilitySchema<TAction extends string> {
|
||||
@@ -227,7 +247,11 @@ export interface ServiceSchema {
|
||||
kubernetesActions?: CapabilitySchema<KubernetesActionName>[]
|
||||
userPoolActions?: CapabilitySchema<UserPoolActionName>[]
|
||||
distributionActions?: CapabilitySchema<DistributionActionName>[]
|
||||
autoScalingActions?: CapabilitySchema<AutoScalingActionName>[]
|
||||
certificateActions?: CapabilitySchema<CertificateActionName>[]
|
||||
monitoringActions?: CapabilitySchema<MonitoringActionName>[]
|
||||
/** API Gateway v2 (HTTP and WebSocket APIs): what the routes, stages and security panel can do. */
|
||||
httpApiActions?: CapabilitySchema<HttpApiActionName>[]
|
||||
// Child levels advertise separately from the resource level because a
|
||||
// store can be readable at the leaf and writable at the collection —
|
||||
// CloudWatch Logs creates streams but cannot delete an event.
|
||||
@@ -495,6 +519,33 @@ export interface AlarmHistoryItem {
|
||||
summary: string
|
||||
}
|
||||
|
||||
/** A certificate and its chain as PEM text; never the private key. */
|
||||
export interface CertificatePem {
|
||||
certificate: string
|
||||
certificateChain: string | null
|
||||
}
|
||||
|
||||
/** An exported certificate: the private key is PEM, encrypted with the caller's passphrase. */
|
||||
export interface ExportedCertificate extends CertificatePem {
|
||||
privateKey: string
|
||||
}
|
||||
|
||||
export type CertificateRevocationReason =
|
||||
| 'UNSPECIFIED'
|
||||
| 'KEY_COMPROMISE'
|
||||
| 'CA_COMPROMISE'
|
||||
| 'AFFILIATION_CHANGED'
|
||||
| 'SUPERSEDED'
|
||||
| 'CESSATION_OF_OPERATION'
|
||||
| 'PRIVILEGE_WITHDRAWN'
|
||||
|
||||
/** Any of the three may be left out to keep its current value. */
|
||||
export interface AutoScalingCapacityInput {
|
||||
minSize?: number
|
||||
desiredCapacity?: number
|
||||
maxSize?: number
|
||||
}
|
||||
|
||||
export interface DistributionInvalidation {
|
||||
id: string
|
||||
status: string
|
||||
@@ -769,6 +820,107 @@ export interface ApiGatewayCorsResult {
|
||||
proxyMethods: string[]
|
||||
}
|
||||
|
||||
export type HttpApiProtocol = 'HTTP' | 'WEBSOCKET'
|
||||
|
||||
export interface HttpApiIntegrationOverview {
|
||||
integrationId: string
|
||||
type: string
|
||||
uri: string | null
|
||||
/** The Lambda function a proxy integration invokes, when it is one. */
|
||||
functionName: string | null
|
||||
payloadFormatVersion: string | null
|
||||
}
|
||||
|
||||
export interface HttpApiRouteOverview {
|
||||
routeId: string
|
||||
/** `GET /orders/{id}`, `$default`, or a WebSocket key such as `$connect` or `sendMessage`. */
|
||||
routeKey: string
|
||||
integration: HttpApiIntegrationOverview | null
|
||||
authorizationType: string
|
||||
authorizerId: string | null
|
||||
authorizationScopes: string[]
|
||||
/** WebSocket only: whether the integration's response is sent back to the client. */
|
||||
returnsResponse: boolean
|
||||
}
|
||||
|
||||
export interface HttpApiStageOverview {
|
||||
stageName: string
|
||||
autoDeploy: boolean
|
||||
deploymentId: string | null
|
||||
lastUpdatedAt: string | null
|
||||
/** Where the stage is served on the runtime endpoint: /execute-api/{api}/{stage} or, for WebSocket, /ws/{api}/{stage}. */
|
||||
invokePath: string
|
||||
}
|
||||
|
||||
export interface HttpApiAuthorizerOverview {
|
||||
authorizerId: string
|
||||
name: string
|
||||
type: string
|
||||
identitySource: string[]
|
||||
issuer: string | null
|
||||
audience: string[]
|
||||
}
|
||||
|
||||
export interface HttpApiCors {
|
||||
allowOrigins: string[]
|
||||
allowMethods: string[]
|
||||
allowHeaders: string[]
|
||||
exposeHeaders: string[]
|
||||
maxAgeSeconds: number | null
|
||||
allowCredentials: boolean
|
||||
}
|
||||
|
||||
export interface HttpApiOverview {
|
||||
apiId: string
|
||||
protocol: HttpApiProtocol
|
||||
apiEndpoint: string | null
|
||||
routeSelectionExpression: string | null
|
||||
routes: HttpApiRouteOverview[]
|
||||
stages: HttpApiStageOverview[]
|
||||
authorizers: HttpApiAuthorizerOverview[]
|
||||
cors: HttpApiCors | null
|
||||
}
|
||||
|
||||
export interface HttpApiRouteInput {
|
||||
routeKey: string
|
||||
/** A Lambda function name; exactly one of functionName and httpUrl. */
|
||||
functionName?: string
|
||||
/** An HTTP backend the route proxies to; HTTP APIs only. */
|
||||
httpUrl?: string
|
||||
authorizerId?: string
|
||||
/** WebSocket only: send the integration's response back to the client. */
|
||||
returnsResponse?: boolean
|
||||
}
|
||||
|
||||
export interface HttpApiStageInput {
|
||||
stageName: string
|
||||
autoDeploy: boolean
|
||||
}
|
||||
|
||||
export interface HttpApiAuthorizerInput {
|
||||
name: string
|
||||
/** Fill the issuer and audience from a Cognito user pool and one of its app clients. */
|
||||
userPoolId?: string
|
||||
userPoolClientId?: string
|
||||
issuer?: string
|
||||
audience?: string[]
|
||||
}
|
||||
|
||||
export interface HttpApiWebSocketTestInput {
|
||||
stage: string
|
||||
message: string
|
||||
/** Sent as query string parameters on the connection request, for a $connect authorizer. */
|
||||
query?: string
|
||||
}
|
||||
|
||||
export interface HttpApiWebSocketTestResult {
|
||||
connected: boolean
|
||||
messages: string[]
|
||||
closeCode: number | null
|
||||
error: string | null
|
||||
durationMs: number
|
||||
}
|
||||
|
||||
export interface S3LambdaTriggerInput {
|
||||
functionName: string
|
||||
events: string[]
|
||||
@@ -963,6 +1115,17 @@ export interface CloudServiceAdapter {
|
||||
listApiGatewayRoutes?(apiId: string): Promise<ApiGatewayRoutesResult>
|
||||
deleteApiGatewayRoute?(apiId: string, input: ApiGatewayDeleteRouteInput): Promise<void>
|
||||
enableApiGatewayCors?(apiId: string, input: ApiGatewayCorsInput): Promise<ApiGatewayCorsResult>
|
||||
describeHttpApi?(apiId: string): Promise<HttpApiOverview>
|
||||
createHttpApiRoute?(apiId: string, input: HttpApiRouteInput): Promise<HttpApiRouteOverview>
|
||||
deleteHttpApiRoute?(apiId: string, routeId: string): Promise<void>
|
||||
setHttpApiRouteAuthorizer?(apiId: string, routeId: string, authorizerId: string | null): Promise<HttpApiRouteOverview>
|
||||
createHttpApiStage?(apiId: string, input: HttpApiStageInput): Promise<HttpApiStageOverview>
|
||||
deleteHttpApiStage?(apiId: string, stageName: string): Promise<void>
|
||||
deployHttpApi?(apiId: string, stageName: string): Promise<HttpApiStageOverview>
|
||||
createHttpApiAuthorizer?(apiId: string, input: HttpApiAuthorizerInput): Promise<HttpApiAuthorizerOverview>
|
||||
deleteHttpApiAuthorizer?(apiId: string, authorizerId: string): Promise<void>
|
||||
updateHttpApiCors?(apiId: string, cors: HttpApiCors | null): Promise<HttpApiCors | null>
|
||||
testWebSocketApi?(apiId: string, input: HttpApiWebSocketTestInput): Promise<HttpApiWebSocketTestResult>
|
||||
createS3LambdaTrigger?(bucketName: string, input: S3LambdaTriggerInput): Promise<S3LambdaTriggerOverview>
|
||||
listS3LambdaTriggers?(bucketName: string): Promise<S3LambdaTriggerOverview[]>
|
||||
deleteS3LambdaTrigger?(bucketName: string, triggerId: string): Promise<void>
|
||||
@@ -1050,6 +1213,12 @@ export interface CloudServiceAdapter {
|
||||
setAlarmState?(alarmName: string, state: AlarmStateValue, reason: string): Promise<void>
|
||||
setAlarmActionsEnabled?(alarmName: string, enabled: boolean): Promise<void>
|
||||
createDistributionInvalidation?(distributionId: string, paths: string[]): Promise<DistributionInvalidation>
|
||||
setAutoScalingCapacity?(groupName: string, input: AutoScalingCapacityInput): Promise<CloudResource>
|
||||
/** With `replace`, the group launches a new instance in its place; otherwise desired capacity drops by one. */
|
||||
terminateAutoScalingInstance?(groupName: string, instanceId: string, replace: boolean): Promise<void>
|
||||
getCertificatePem?(certificateId: string): Promise<CertificatePem>
|
||||
exportCertificate?(certificateId: string, passphrase: string): Promise<ExportedCertificate>
|
||||
revokeCertificate?(certificateId: string, reason: CertificateRevocationReason): Promise<CloudResource>
|
||||
updateUserPoolClientOAuth?(poolId: string, clientId: string, input: UpdateUserPoolClientOAuthInput): Promise<UserPoolClientSettings>
|
||||
/** Clears the provider's locally captured email inbox, if it exposes one. */
|
||||
clearEmailInbox?(): Promise<void>
|
||||
|
||||
@@ -2,7 +2,7 @@ import {describe, expect, test} from 'bun:test'
|
||||
import {createCloudAdapterRegistry} from './cloudProxy'
|
||||
import {checkChildCapabilities} from './cloud-spi/childCapabilities'
|
||||
import {isServiceType} from './cloud-spi/serviceCatalog'
|
||||
import type {CloudProvider, CloudServiceAdapter, CloudServiceType, DatabaseActionName, DistributionActionName, KubernetesActionName, MonitoringActionName, ResourceActionName, UserPoolActionName} from './cloud-spi/types'
|
||||
import type {AutoScalingActionName, CloudProvider, CloudServiceAdapter, CloudServiceType, CertificateActionName, DatabaseActionName, DistributionActionName, HttpApiActionName, KubernetesActionName, MonitoringActionName, ResourceActionName, UserPoolActionName} from './cloud-spi/types'
|
||||
|
||||
/**
|
||||
* Guards the schema-to-implementation contract for every registered adapter.
|
||||
@@ -55,11 +55,37 @@ const METHOD_FOR_MONITORING_ACTION: Record<MonitoringActionName, keyof CloudServ
|
||||
setAlarmActions: 'setAlarmActionsEnabled',
|
||||
}
|
||||
|
||||
const METHOD_FOR_HTTP_API_ACTION: Record<HttpApiActionName, keyof CloudServiceAdapter> = {
|
||||
describe: 'describeHttpApi',
|
||||
createRoute: 'createHttpApiRoute',
|
||||
deleteRoute: 'deleteHttpApiRoute',
|
||||
setRouteAuthorizer: 'setHttpApiRouteAuthorizer',
|
||||
createStage: 'createHttpApiStage',
|
||||
deleteStage: 'deleteHttpApiStage',
|
||||
deploy: 'deployHttpApi',
|
||||
createAuthorizer: 'createHttpApiAuthorizer',
|
||||
deleteAuthorizer: 'deleteHttpApiAuthorizer',
|
||||
updateCors: 'updateHttpApiCors',
|
||||
sendRequest: 'testApiGatewayRoute',
|
||||
testWebSocket: 'testWebSocketApi',
|
||||
}
|
||||
|
||||
const METHOD_FOR_DISTRIBUTION_ACTION: Record<DistributionActionName, keyof CloudServiceAdapter> = {
|
||||
setEnabled: 'setDistributionEnabled',
|
||||
createInvalidation: 'createDistributionInvalidation',
|
||||
}
|
||||
|
||||
const METHOD_FOR_CERTIFICATE_ACTION: Record<CertificateActionName, keyof CloudServiceAdapter> = {
|
||||
download: 'getCertificatePem',
|
||||
export: 'exportCertificate',
|
||||
revoke: 'revokeCertificate',
|
||||
}
|
||||
|
||||
const METHOD_FOR_AUTO_SCALING_ACTION: Record<AutoScalingActionName, keyof CloudServiceAdapter> = {
|
||||
setCapacity: 'setAutoScalingCapacity',
|
||||
terminateInstance: 'terminateAutoScalingInstance',
|
||||
}
|
||||
|
||||
const METHOD_FOR_USER_POOL_ACTION: Record<UserPoolActionName, keyof CloudServiceAdapter> = {
|
||||
updateUser: 'updateUserPoolUser',
|
||||
setUserGroups: 'setUserPoolUserGroups',
|
||||
@@ -164,6 +190,33 @@ describe('registered adapters honour their schema', () => {
|
||||
).toBe('function')
|
||||
}
|
||||
|
||||
const httpApiClaims = (adapter.schema().capabilities?.httpApiActions ?? []).filter((capability) => capability.status === 'available')
|
||||
for (const capability of httpApiClaims) {
|
||||
const method = METHOD_FOR_HTTP_API_ACTION[capability.name]
|
||||
expect(
|
||||
typeof adapter[method],
|
||||
`${label} advertises ${capability.name} as available but has no ${String(method)}()`,
|
||||
).toBe('function')
|
||||
}
|
||||
|
||||
const certificateClaims = (adapter.schema().capabilities?.certificateActions ?? []).filter((capability) => capability.status === 'available')
|
||||
for (const capability of certificateClaims) {
|
||||
const method = METHOD_FOR_CERTIFICATE_ACTION[capability.name]
|
||||
expect(
|
||||
typeof adapter[method],
|
||||
`${label} advertises ${capability.name} as available but has no ${String(method)}()`,
|
||||
).toBe('function')
|
||||
}
|
||||
|
||||
const autoScalingClaims = (adapter.schema().capabilities?.autoScalingActions ?? []).filter((capability) => capability.status === 'available')
|
||||
for (const capability of autoScalingClaims) {
|
||||
const method = METHOD_FOR_AUTO_SCALING_ACTION[capability.name]
|
||||
expect(
|
||||
typeof adapter[method],
|
||||
`${label} advertises ${capability.name} as available but has no ${String(method)}()`,
|
||||
).toBe('function')
|
||||
}
|
||||
|
||||
const distributionClaims = (adapter.schema().capabilities?.distributionActions ?? []).filter((capability) => capability.status === 'available')
|
||||
for (const capability of distributionClaims) {
|
||||
const method = METHOD_FOR_DISTRIBUTION_ACTION[capability.name]
|
||||
@@ -186,7 +239,10 @@ describe('registered adapters honour their schema', () => {
|
||||
...(adapter.schema().capabilities?.kubernetesActions ?? []),
|
||||
...(adapter.schema().capabilities?.userPoolActions ?? []),
|
||||
...(adapter.schema().capabilities?.distributionActions ?? []),
|
||||
...(adapter.schema().capabilities?.autoScalingActions ?? []),
|
||||
...(adapter.schema().capabilities?.certificateActions ?? []),
|
||||
...(adapter.schema().capabilities?.monitoringActions ?? []),
|
||||
...(adapter.schema().capabilities?.httpApiActions ?? []),
|
||||
...(adapter.schema().capabilities?.collectionActions ?? []),
|
||||
...(adapter.schema().capabilities?.itemActions ?? []),
|
||||
]
|
||||
|
||||
@@ -50,6 +50,9 @@ import {AwsLightsailAdapter} from './adapter-aws/AwsLightsailAdapter'
|
||||
import {AwsUserPoolsAdapter} from './adapter-aws/AwsUserPoolsAdapter'
|
||||
import {AwsDnsAdapter} from './adapter-aws/AwsDnsAdapter'
|
||||
import {AwsCdnAdapter} from './adapter-aws/AwsCdnAdapter'
|
||||
import {AwsAutoScalingAdapter} from './adapter-aws/AwsAutoScalingAdapter'
|
||||
import {AwsCertificatesAdapter} from './adapter-aws/AwsCertificatesAdapter'
|
||||
import {AwsPipesAdapter} from './adapter-aws/AwsPipesAdapter'
|
||||
import {AwsMonitoringAdapter} from './adapter-aws/AwsMonitoringAdapter'
|
||||
import {awsClientsForAccount, resolveAccountId} from './aws'
|
||||
import {createEc2Service} from './services/ec2'
|
||||
@@ -85,7 +88,7 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap
|
||||
new AwsTargetGroupAdapter(clients.elbv2, clients.ec2),
|
||||
new AwsEventBridgeAdapter(clients.eventbridge, clients.lambda),
|
||||
new AwsIamAdapter(clients.iam),
|
||||
new AwsApiGatewayAdapter(clients.apiGateway, clients.lambda, resolveAccountId(accountId)),
|
||||
new AwsApiGatewayAdapter(clients.apiGateway, clients.lambda, resolveAccountId(accountId), undefined, clients.apiGatewayV2),
|
||||
new AwsCloudFormationAdapter(clients.cloudformation),
|
||||
new AwsSecretsAdapter(clients.secretsManager),
|
||||
new AwsSesAdapter(),
|
||||
@@ -103,6 +106,9 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap
|
||||
new AwsUserPoolsAdapter(clients.cognito),
|
||||
new AwsDnsAdapter(clients.route53),
|
||||
new AwsCdnAdapter(clients.cloudfront, clients.s3),
|
||||
new AwsAutoScalingAdapter(clients.autoscaling, clients.ec2),
|
||||
new AwsCertificatesAdapter(clients.acm, resolveAccountId(accountId)),
|
||||
new AwsPipesAdapter(clients.pipes),
|
||||
new AwsMonitoringAdapter(clients.cloudwatch),
|
||||
new AzureStorageAdapter(),
|
||||
new AzureServiceBusAdapter(),
|
||||
|
||||
@@ -18,6 +18,8 @@ import {awsStorageSchema, azureStorageSchema, gcpStorageSchema} from '../cloud-s
|
||||
import {awsSesEmailSchema} from '../cloud-spi/emailSchema'
|
||||
import {awsUserPoolsSchema} from '../cloud-spi/userPoolsSchema'
|
||||
import {awsCdnSchema} from '../cloud-spi/cdnSchema'
|
||||
import {awsAutoScalingSchema} from '../cloud-spi/autoScalingSchema'
|
||||
import {awsCertificatesSchema} from '../cloud-spi/certificatesSchema'
|
||||
import {awsMonitoringSchema} from '../cloud-spi/monitoringSchema'
|
||||
import {awsAppConfigSchema} from '../cloud-spi/appConfigSchema'
|
||||
import {awsEventBridgeSchema} from '../cloud-spi/eventbridgeSchema'
|
||||
@@ -823,6 +825,56 @@ describe('cloud schema routes', () => {
|
||||
expect(calls).toEqual([['enabled', 'E123', false], ['invalidate', 'E123', ['/*']]])
|
||||
})
|
||||
|
||||
test('downloads, exports and revokes a certificate through the ACM adapter', async () => {
|
||||
const calls: unknown[] = []
|
||||
const app = appWithRoutes([mockAdapter('aws', {
|
||||
service: 'certificates',
|
||||
schema: awsCertificatesSchema,
|
||||
getCertificatePem: async (id) => {
|
||||
calls.push(['pem', id])
|
||||
return {certificate: 'CERT', certificateChain: null}
|
||||
},
|
||||
exportCertificate: async (id, passphrase) => {
|
||||
calls.push(['export', id, passphrase])
|
||||
return {certificate: 'CERT', certificateChain: null, privateKey: 'KEY'}
|
||||
},
|
||||
revokeCertificate: async (id, reason) => {
|
||||
calls.push(['revoke', id, reason])
|
||||
return {id, name: 'a.test', cloud: 'aws', service: 'certificates', type: 'certificate', region: null, createdAt: null, status: 'REVOKED', metadata: {}}
|
||||
},
|
||||
})])
|
||||
const json = (body: unknown) => ({method: 'POST', headers: {'content-type': 'application/json'}, body: JSON.stringify(body)})
|
||||
const base = '/api/clouds/aws/services/certificates/resources/c1'
|
||||
|
||||
expect(await (await app.request(`${base}/pem`)).json()).toEqual({certificate: 'CERT', certificateChain: null})
|
||||
expect((await (await app.request(`${base}/export`, json({passphrase: 'secret'}))).json()).privateKey).toBe('KEY')
|
||||
const revoked = await app.request(`${base}/revoke`, json({}))
|
||||
expect((await revoked.json()).status).toBe('REVOKED')
|
||||
expect(calls).toEqual([['pem', 'c1'], ['export', 'c1', 'secret'], ['revoke', 'c1', 'UNSPECIFIED']])
|
||||
})
|
||||
|
||||
test('changes capacity and terminates an instance through the Auto Scaling adapter', async () => {
|
||||
const calls: unknown[] = []
|
||||
const app = appWithRoutes([mockAdapter('aws', {
|
||||
service: 'autoscaling',
|
||||
schema: awsAutoScalingSchema,
|
||||
setAutoScalingCapacity: async (id, input) => {
|
||||
calls.push(['capacity', id, input])
|
||||
return {id, name: id, cloud: 'aws', service: 'autoscaling', type: 'autoScalingGroup', region: null, createdAt: null, metadata: {desiredCapacity: 3}}
|
||||
},
|
||||
terminateAutoScalingInstance: async (id, instanceId, replace) => {
|
||||
calls.push(['terminate', id, instanceId, replace])
|
||||
},
|
||||
})])
|
||||
const json = (method: string, body: unknown) => ({method, headers: {'content-type': 'application/json'}, body: JSON.stringify(body)})
|
||||
const base = '/api/clouds/aws/services/autoscaling/resources/web-asg'
|
||||
|
||||
const updated = await app.request(`${base}/capacity`, json('PUT', {desiredCapacity: 3}))
|
||||
expect((await updated.json()).metadata.desiredCapacity).toBe(3)
|
||||
expect((await app.request(`${base}/instances/i-abc/terminate`, json('POST', {replace: true}))).status).toBe(200)
|
||||
expect(calls).toEqual([['capacity', 'web-asg', {desiredCapacity: 3}], ['terminate', 'web-asg', 'i-abc', true]])
|
||||
})
|
||||
|
||||
test('clears the SES mailbox through the email adapter', async () => {
|
||||
let cleared = false
|
||||
const app = appWithRoutes([mockAdapter('aws', {
|
||||
@@ -1403,6 +1455,18 @@ describe('service descriptors', () => {
|
||||
expect(secretsFor(bare)).toMatchObject({availability: 'coming_soon'})
|
||||
expect(secretsFor(gcp)).toMatchObject({availability: 'available', route: 'secrets'})
|
||||
})
|
||||
|
||||
test('marks a service listable only when an adapter serves the generic list', async () => {
|
||||
// MSK is available through its own pre-SPI page; Console Home must not count it
|
||||
// through /resources, which has no adapter behind it and answers 501.
|
||||
const body = await (await appWithRoutes([mockAdapter('aws')]).request('/api/clouds/aws/services')).json() as Array<{service: string; availability: string; listable: boolean}>
|
||||
const of = (service: string) => body.find((descriptor) => descriptor.service === service)
|
||||
|
||||
expect(of('storage')).toMatchObject({availability: 'available', listable: true})
|
||||
expect(of('msk')).toMatchObject({availability: 'available', listable: false})
|
||||
expect(of('msk-connect')).toMatchObject({availability: 'available', listable: false})
|
||||
expect(of('serverless')).toMatchObject({availability: 'coming_soon', listable: false})
|
||||
})
|
||||
})
|
||||
|
||||
describe('AppConfig nested routes', () => {
|
||||
|
||||
@@ -2,6 +2,8 @@ import {Hono} from 'hono'
|
||||
import type {Context} from 'hono'
|
||||
import type {
|
||||
AlarmStateValue,
|
||||
AutoScalingCapacityInput,
|
||||
CertificateRevocationReason,
|
||||
CloudProvider,
|
||||
CloudServiceType,
|
||||
CreateDatabaseSnapshotInput,
|
||||
@@ -525,6 +527,51 @@ export function createCloudRoutes(injectedService?: CloudProxyService) {
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/:cloud/services/certificates/resources/:id/pem', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||
|
||||
return withRuntime(c, async () => c.json(await svc(c).getCertificatePem(cloud, c.req.param('id'))))
|
||||
})
|
||||
|
||||
app.post('/:cloud/services/certificates/resources/:id/export', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||
|
||||
return withRuntime(c, async () => {
|
||||
const body = await c.req.json<{passphrase?: string}>()
|
||||
return c.json(await svc(c).exportCertificate(cloud, c.req.param('id'), body.passphrase as string))
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/:cloud/services/certificates/resources/:id/revoke', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||
|
||||
return withRuntime(c, async () => {
|
||||
const body = await c.req.json<{reason?: CertificateRevocationReason}>()
|
||||
return c.json(await svc(c).revokeCertificate(cloud, c.req.param('id'), body.reason ?? 'UNSPECIFIED'))
|
||||
})
|
||||
})
|
||||
|
||||
app.put('/:cloud/services/autoscaling/resources/:id/capacity', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||
|
||||
return withRuntime(c, async () => c.json(await svc(c).setAutoScalingCapacity(cloud, c.req.param('id'), await c.req.json<AutoScalingCapacityInput>())))
|
||||
})
|
||||
|
||||
app.post('/:cloud/services/autoscaling/resources/:id/instances/:instanceId/terminate', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||
|
||||
return withRuntime(c, async () => {
|
||||
const body = await c.req.json<{replace?: boolean}>()
|
||||
await svc(c).terminateAutoScalingInstance(cloud, c.req.param('id'), c.req.param('instanceId'), body.replace === true)
|
||||
return c.json({ok: true})
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/:cloud/services/k8s/resources/:id/nodegroups', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||
@@ -803,6 +850,131 @@ export function createCloudRoutes(injectedService?: CloudProxyService) {
|
||||
})
|
||||
})
|
||||
|
||||
// HTTP and WebSocket APIs (API Gateway v2) live on the same page as REST APIs.
|
||||
app.get('/:cloud/services/apigateway/resources/:id/http-api', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
|
||||
return withRuntime(c, async () => c.json(await svc(c).describeHttpApi(cloud, c.req.param('id'))))
|
||||
})
|
||||
|
||||
app.post('/:cloud/services/apigateway/resources/:id/http-api/routes', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
|
||||
return withRuntime(c, async () => {
|
||||
const input = await c.req.json<Record<string, unknown>>()
|
||||
const route = await svc(c).createHttpApiRoute(cloud, c.req.param('id'), {
|
||||
routeKey: typeof input.routeKey === 'string' ? input.routeKey : '',
|
||||
functionName: typeof input.functionName === 'string' ? input.functionName : undefined,
|
||||
httpUrl: typeof input.httpUrl === 'string' ? input.httpUrl : undefined,
|
||||
authorizerId: typeof input.authorizerId === 'string' ? input.authorizerId : undefined,
|
||||
returnsResponse: input.returnsResponse === true,
|
||||
})
|
||||
return c.json(route, 201)
|
||||
})
|
||||
})
|
||||
|
||||
app.delete('/:cloud/services/apigateway/resources/:id/http-api/routes/:routeId', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
|
||||
return withRuntime(c, async () => {
|
||||
await svc(c).deleteHttpApiRoute(cloud, c.req.param('id'), c.req.param('routeId'))
|
||||
return c.json({ok: true})
|
||||
})
|
||||
})
|
||||
|
||||
app.put('/:cloud/services/apigateway/resources/:id/http-api/routes/:routeId/authorizer', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
|
||||
return withRuntime(c, async () => {
|
||||
const input = await c.req.json<{authorizerId?: unknown}>()
|
||||
const authorizerId = typeof input.authorizerId === 'string' && input.authorizerId ? input.authorizerId : null
|
||||
return c.json(await svc(c).setHttpApiRouteAuthorizer(cloud, c.req.param('id'), c.req.param('routeId'), authorizerId))
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/:cloud/services/apigateway/resources/:id/http-api/stages', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
|
||||
return withRuntime(c, async () => {
|
||||
const input = await c.req.json<Record<string, unknown>>()
|
||||
const stage = await svc(c).createHttpApiStage(cloud, c.req.param('id'), {
|
||||
stageName: typeof input.stageName === 'string' ? input.stageName : '',
|
||||
autoDeploy: input.autoDeploy === true,
|
||||
})
|
||||
return c.json(stage, 201)
|
||||
})
|
||||
})
|
||||
|
||||
app.delete('/:cloud/services/apigateway/resources/:id/http-api/stages/:stageName', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
|
||||
return withRuntime(c, async () => {
|
||||
await svc(c).deleteHttpApiStage(cloud, c.req.param('id'), c.req.param('stageName'))
|
||||
return c.json({ok: true})
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/:cloud/services/apigateway/resources/:id/http-api/stages/:stageName/deployments', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
|
||||
return withRuntime(c, async () => c.json(await svc(c).deployHttpApi(cloud, c.req.param('id'), c.req.param('stageName')), 201))
|
||||
})
|
||||
|
||||
app.post('/:cloud/services/apigateway/resources/:id/http-api/authorizers', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
|
||||
return withRuntime(c, async () => {
|
||||
const input = await c.req.json<Record<string, unknown>>()
|
||||
const authorizer = await svc(c).createHttpApiAuthorizer(cloud, c.req.param('id'), {
|
||||
name: typeof input.name === 'string' ? input.name : '',
|
||||
userPoolId: typeof input.userPoolId === 'string' ? input.userPoolId : undefined,
|
||||
userPoolClientId: typeof input.userPoolClientId === 'string' ? input.userPoolClientId : undefined,
|
||||
issuer: typeof input.issuer === 'string' ? input.issuer : undefined,
|
||||
audience: Array.isArray(input.audience) ? input.audience.filter((value): value is string => typeof value === 'string') : undefined,
|
||||
})
|
||||
return c.json(authorizer, 201)
|
||||
})
|
||||
})
|
||||
|
||||
app.delete('/:cloud/services/apigateway/resources/:id/http-api/authorizers/:authorizerId', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
|
||||
return withRuntime(c, async () => {
|
||||
await svc(c).deleteHttpApiAuthorizer(cloud, c.req.param('id'), c.req.param('authorizerId'))
|
||||
return c.json({ok: true})
|
||||
})
|
||||
})
|
||||
|
||||
app.put('/:cloud/services/apigateway/resources/:id/http-api/cors', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
|
||||
return withRuntime(c, async () => {
|
||||
const input = await c.req.json<{cors?: Record<string, unknown> | null}>()
|
||||
const cors = input.cors ? {
|
||||
allowOrigins: stringList(input.cors.allowOrigins),
|
||||
allowMethods: stringList(input.cors.allowMethods),
|
||||
allowHeaders: stringList(input.cors.allowHeaders),
|
||||
exposeHeaders: stringList(input.cors.exposeHeaders),
|
||||
maxAgeSeconds: typeof input.cors.maxAgeSeconds === 'number' ? input.cors.maxAgeSeconds : null,
|
||||
allowCredentials: input.cors.allowCredentials === true,
|
||||
} : null
|
||||
return c.json({cors: await svc(c).updateHttpApiCors(cloud, c.req.param('id'), cors)})
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/:cloud/services/apigateway/resources/:id/http-api/websocket-test', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
|
||||
return withRuntime(c, async () => {
|
||||
const input = await c.req.json<Record<string, unknown>>()
|
||||
return c.json(await svc(c).testWebSocketApi(cloud, c.req.param('id'), {
|
||||
stage: typeof input.stage === 'string' ? input.stage : '',
|
||||
message: typeof input.message === 'string' ? input.message : '',
|
||||
query: typeof input.query === 'string' ? input.query : undefined,
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/:cloud/services/apigateway/resources/:id/routes/test', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
|
||||
@@ -1662,6 +1834,10 @@ function isStringRecord(value: unknown): value is Record<string, string> {
|
||||
&& Object.values(value).every((item) => typeof item === 'string')
|
||||
}
|
||||
|
||||
function stringList(value: unknown): string[] {
|
||||
return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []
|
||||
}
|
||||
|
||||
function stringHeaders(value: unknown): Record<string, string> {
|
||||
if (value === undefined) return {}
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
|
||||
@@ -30,6 +30,16 @@ import type {
|
||||
ApiGatewayDeleteRouteInput,
|
||||
ApiGatewayCorsInput,
|
||||
ApiGatewayCorsResult,
|
||||
HttpApiAuthorizerInput,
|
||||
HttpApiAuthorizerOverview,
|
||||
HttpApiCors,
|
||||
HttpApiOverview,
|
||||
HttpApiRouteInput,
|
||||
HttpApiRouteOverview,
|
||||
HttpApiStageInput,
|
||||
HttpApiStageOverview,
|
||||
HttpApiWebSocketTestInput,
|
||||
HttpApiWebSocketTestResult,
|
||||
S3LambdaTriggerInput,
|
||||
S3LambdaTriggerOverview,
|
||||
S3BucketWebsiteConfiguration,
|
||||
@@ -83,6 +93,10 @@ import type {
|
||||
UserPoolSignInTestResult,
|
||||
UserPoolClientSettings,
|
||||
DistributionInvalidation,
|
||||
AutoScalingCapacityInput,
|
||||
CertificatePem,
|
||||
CertificateRevocationReason,
|
||||
ExportedCertificate,
|
||||
AlarmHistoryItem,
|
||||
AlarmStateValue,
|
||||
MetricSeries,
|
||||
@@ -167,6 +181,7 @@ export class CloudProxyService {
|
||||
iconKey: entry.iconKey,
|
||||
group: entry.group,
|
||||
order: entry.order,
|
||||
listable: Boolean(adapter),
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -330,6 +345,56 @@ export class CloudProxyService {
|
||||
return adapter.enableApiGatewayCors(apiId, input)
|
||||
}
|
||||
|
||||
async describeHttpApi(cloud: CloudProvider, apiId: string): Promise<HttpApiOverview> {
|
||||
return this.httpApiAdapter(cloud, 'describeHttpApi').describeHttpApi!(apiId)
|
||||
}
|
||||
|
||||
async createHttpApiRoute(cloud: CloudProvider, apiId: string, input: HttpApiRouteInput): Promise<HttpApiRouteOverview> {
|
||||
return this.httpApiAdapter(cloud, 'createHttpApiRoute').createHttpApiRoute!(apiId, input)
|
||||
}
|
||||
|
||||
async deleteHttpApiRoute(cloud: CloudProvider, apiId: string, routeId: string): Promise<void> {
|
||||
await this.httpApiAdapter(cloud, 'deleteHttpApiRoute').deleteHttpApiRoute!(apiId, routeId)
|
||||
}
|
||||
|
||||
async setHttpApiRouteAuthorizer(cloud: CloudProvider, apiId: string, routeId: string, authorizerId: string | null): Promise<HttpApiRouteOverview> {
|
||||
return this.httpApiAdapter(cloud, 'setHttpApiRouteAuthorizer').setHttpApiRouteAuthorizer!(apiId, routeId, authorizerId)
|
||||
}
|
||||
|
||||
async createHttpApiStage(cloud: CloudProvider, apiId: string, input: HttpApiStageInput): Promise<HttpApiStageOverview> {
|
||||
return this.httpApiAdapter(cloud, 'createHttpApiStage').createHttpApiStage!(apiId, input)
|
||||
}
|
||||
|
||||
async deleteHttpApiStage(cloud: CloudProvider, apiId: string, stageName: string): Promise<void> {
|
||||
await this.httpApiAdapter(cloud, 'deleteHttpApiStage').deleteHttpApiStage!(apiId, stageName)
|
||||
}
|
||||
|
||||
async deployHttpApi(cloud: CloudProvider, apiId: string, stageName: string): Promise<HttpApiStageOverview> {
|
||||
return this.httpApiAdapter(cloud, 'deployHttpApi').deployHttpApi!(apiId, stageName)
|
||||
}
|
||||
|
||||
async createHttpApiAuthorizer(cloud: CloudProvider, apiId: string, input: HttpApiAuthorizerInput): Promise<HttpApiAuthorizerOverview> {
|
||||
return this.httpApiAdapter(cloud, 'createHttpApiAuthorizer').createHttpApiAuthorizer!(apiId, input)
|
||||
}
|
||||
|
||||
async deleteHttpApiAuthorizer(cloud: CloudProvider, apiId: string, authorizerId: string): Promise<void> {
|
||||
await this.httpApiAdapter(cloud, 'deleteHttpApiAuthorizer').deleteHttpApiAuthorizer!(apiId, authorizerId)
|
||||
}
|
||||
|
||||
async updateHttpApiCors(cloud: CloudProvider, apiId: string, cors: HttpApiCors | null): Promise<HttpApiCors | null> {
|
||||
return this.httpApiAdapter(cloud, 'updateHttpApiCors').updateHttpApiCors!(apiId, cors)
|
||||
}
|
||||
|
||||
async testWebSocketApi(cloud: CloudProvider, apiId: string, input: HttpApiWebSocketTestInput): Promise<HttpApiWebSocketTestResult> {
|
||||
return this.httpApiAdapter(cloud, 'testWebSocketApi').testWebSocketApi!(apiId, input)
|
||||
}
|
||||
|
||||
private httpApiAdapter(cloud: CloudProvider, method: keyof CloudServiceAdapter): CloudServiceAdapter {
|
||||
const adapter = this.requireAdapter(cloud, 'apigateway')
|
||||
if (typeof adapter[method] !== 'function') throw new NotSupportedError(`HTTP and WebSocket APIs are not supported for ${cloud}`)
|
||||
return adapter
|
||||
}
|
||||
|
||||
async createS3LambdaTrigger(cloud: CloudProvider, bucketName: string, input: S3LambdaTriggerInput): Promise<S3LambdaTriggerOverview> {
|
||||
const adapter = this.requireAdapter(cloud, 'storage')
|
||||
if (!adapter.createS3LambdaTrigger) throw new NotSupportedError(`S3 Lambda triggers are not supported for ${cloud}`)
|
||||
@@ -881,6 +946,36 @@ export class CloudProxyService {
|
||||
return adapter.createDistributionInvalidation(distributionId, paths)
|
||||
}
|
||||
|
||||
async getCertificatePem(cloud: CloudProvider, certificateId: string): Promise<CertificatePem> {
|
||||
const adapter = this.requireAdapter(cloud, 'certificates')
|
||||
if (!adapter.getCertificatePem) throw new NotSupportedError(`Downloading certificates is not supported for ${cloud}/certificates`)
|
||||
return adapter.getCertificatePem(certificateId)
|
||||
}
|
||||
|
||||
async exportCertificate(cloud: CloudProvider, certificateId: string, passphrase: string): Promise<ExportedCertificate> {
|
||||
const adapter = this.requireAdapter(cloud, 'certificates')
|
||||
if (!adapter.exportCertificate) throw new NotSupportedError(`Exporting certificates is not supported for ${cloud}/certificates`)
|
||||
return adapter.exportCertificate(certificateId, passphrase)
|
||||
}
|
||||
|
||||
async revokeCertificate(cloud: CloudProvider, certificateId: string, reason: CertificateRevocationReason): Promise<CloudResource> {
|
||||
const adapter = this.requireAdapter(cloud, 'certificates')
|
||||
if (!adapter.revokeCertificate) throw new NotSupportedError(`Revoking certificates is not supported for ${cloud}/certificates`)
|
||||
return adapter.revokeCertificate(certificateId, reason)
|
||||
}
|
||||
|
||||
async setAutoScalingCapacity(cloud: CloudProvider, groupName: string, input: AutoScalingCapacityInput): Promise<CloudResource> {
|
||||
const adapter = this.requireAdapter(cloud, 'autoscaling')
|
||||
if (!adapter.setAutoScalingCapacity) throw new NotSupportedError(`Changing capacity is not supported for ${cloud}/autoscaling`)
|
||||
return adapter.setAutoScalingCapacity(groupName, input)
|
||||
}
|
||||
|
||||
async terminateAutoScalingInstance(cloud: CloudProvider, groupName: string, instanceId: string, replace: boolean): Promise<void> {
|
||||
const adapter = this.requireAdapter(cloud, 'autoscaling')
|
||||
if (!adapter.terminateAutoScalingInstance) throw new NotSupportedError(`Terminating instances is not supported for ${cloud}/autoscaling`)
|
||||
return adapter.terminateAutoScalingInstance(groupName, instanceId, replace)
|
||||
}
|
||||
|
||||
async describeUserPoolClient(cloud: CloudProvider, poolId: string, clientId: string): Promise<UserPoolClientSettings> {
|
||||
const adapter = this.requireAdapter(cloud, 'userpools')
|
||||
if (!adapter.describeUserPoolClient) throw new NotSupportedError(`App client settings are not supported for ${cloud}/userpools`)
|
||||
|
||||
@@ -6,7 +6,7 @@ const STARTED_AT = new Date('2026-10-23T08:00:00.000Z')
|
||||
const NOW = new Date('2026-10-23T10:30:15.000Z')
|
||||
|
||||
function descriptor(service: CloudServiceDescriptor['service'], displayName: string, availability: CloudServiceDescriptor['availability'] = 'available'): CloudServiceDescriptor {
|
||||
return {cloud: 'aws', service, displayName, availability, route: `/cloud-explorer/aws/${service}`, iconKey: service, group: 'Compute', order: 1}
|
||||
return {cloud: 'aws', service, displayName, availability, route: `/cloud-explorer/aws/${service}`, iconKey: service, group: 'Compute', order: 1, listable: availability === 'available'}
|
||||
}
|
||||
|
||||
function proxy(status: Partial<CloudStatus> = {}, descriptors: CloudServiceDescriptor[] = []) {
|
||||
|
||||
@@ -130,6 +130,19 @@ export const apiEndpointKeys = {
|
||||
messages: {send: "clouds.services.messaging.messages.send", receive: "clouds.services.messaging.messages.receive", delete: "clouds.services.messaging.messages.delete", purge: "clouds.services.messaging.messages.purge"},
|
||||
moveTasks: "clouds.services.messaging.move-tasks",
|
||||
},
|
||||
httpApi: {
|
||||
describe: "clouds.services.apigateway.http-api.describe",
|
||||
createRoute: "clouds.services.apigateway.http-api.routes.create",
|
||||
deleteRoute: "clouds.services.apigateway.http-api.routes.delete",
|
||||
setRouteAuthorizer: "clouds.services.apigateway.http-api.routes.authorizer",
|
||||
createStage: "clouds.services.apigateway.http-api.stages.create",
|
||||
deleteStage: "clouds.services.apigateway.http-api.stages.delete",
|
||||
deploy: "clouds.services.apigateway.http-api.stages.deploy",
|
||||
createAuthorizer: "clouds.services.apigateway.http-api.authorizers.create",
|
||||
deleteAuthorizer: "clouds.services.apigateway.http-api.authorizers.delete",
|
||||
cors: "clouds.services.apigateway.http-api.cors",
|
||||
webSocketTest: "clouds.services.apigateway.http-api.websocket-test",
|
||||
},
|
||||
monitoring: {
|
||||
metrics: "clouds.services.monitoring.metrics",
|
||||
metricData: "clouds.services.monitoring.metric-data",
|
||||
@@ -137,6 +150,15 @@ export const apiEndpointKeys = {
|
||||
state: "clouds.services.monitoring.state",
|
||||
actions: "clouds.services.monitoring.actions",
|
||||
},
|
||||
certificates: {
|
||||
pem: "clouds.services.certificates.pem",
|
||||
export: "clouds.services.certificates.export",
|
||||
revoke: "clouds.services.certificates.revoke",
|
||||
},
|
||||
autoscaling: {
|
||||
capacity: "clouds.services.autoscaling.capacity",
|
||||
terminate: "clouds.services.autoscaling.instances.terminate",
|
||||
},
|
||||
cdn: {
|
||||
enabled: "clouds.services.cdn.enabled",
|
||||
invalidations: "clouds.services.cdn.invalidations",
|
||||
@@ -897,11 +919,57 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
method: "POST",
|
||||
},
|
||||
],
|
||||
[apiEndpointKeys.clouds.httpApi.describe, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api", method: "GET"}],
|
||||
[apiEndpointKeys.clouds.httpApi.createRoute, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/routes", method: "POST"}],
|
||||
[apiEndpointKeys.clouds.httpApi.deleteRoute, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/routes/:routeId", method: "DELETE"}],
|
||||
[apiEndpointKeys.clouds.httpApi.setRouteAuthorizer, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/routes/:routeId/authorizer", method: "PUT"}],
|
||||
[apiEndpointKeys.clouds.httpApi.createStage, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/stages", method: "POST"}],
|
||||
[apiEndpointKeys.clouds.httpApi.deleteStage, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/stages/:stageName", method: "DELETE"}],
|
||||
[apiEndpointKeys.clouds.httpApi.deploy, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/stages/:stageName/deployments", method: "POST"}],
|
||||
[apiEndpointKeys.clouds.httpApi.createAuthorizer, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/authorizers", method: "POST"}],
|
||||
[apiEndpointKeys.clouds.httpApi.deleteAuthorizer, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/authorizers/:authorizerId", method: "DELETE"}],
|
||||
[apiEndpointKeys.clouds.httpApi.cors, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/cors", method: "PUT"}],
|
||||
[apiEndpointKeys.clouds.httpApi.webSocketTest, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/websocket-test", method: "POST"}],
|
||||
[apiEndpointKeys.clouds.monitoring.metrics, {path: "/clouds/:cloud/services/monitoring/metrics", method: "GET"}],
|
||||
[apiEndpointKeys.clouds.monitoring.metricData, {path: "/clouds/:cloud/services/monitoring/metric-data", method: "POST"}],
|
||||
[apiEndpointKeys.clouds.monitoring.history, {path: "/clouds/:cloud/services/monitoring/resources/:id/history", method: "GET"}],
|
||||
[apiEndpointKeys.clouds.monitoring.state, {path: "/clouds/:cloud/services/monitoring/resources/:id/state", method: "PUT"}],
|
||||
[apiEndpointKeys.clouds.monitoring.actions, {path: "/clouds/:cloud/services/monitoring/resources/:id/actions", method: "PUT"}],
|
||||
[
|
||||
apiEndpointKeys.clouds.certificates.pem,
|
||||
{
|
||||
path: "/clouds/:cloud/services/certificates/resources/:id/pem",
|
||||
method: "GET",
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.certificates.export,
|
||||
{
|
||||
path: "/clouds/:cloud/services/certificates/resources/:id/export",
|
||||
method: "POST",
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.certificates.revoke,
|
||||
{
|
||||
path: "/clouds/:cloud/services/certificates/resources/:id/revoke",
|
||||
method: "POST",
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.autoscaling.capacity,
|
||||
{
|
||||
path: "/clouds/:cloud/services/autoscaling/resources/:id/capacity",
|
||||
method: "PUT",
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.autoscaling.terminate,
|
||||
{
|
||||
path: "/clouds/:cloud/services/autoscaling/resources/:id/instances/:instanceId/terminate",
|
||||
method: "POST",
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.cdn.enabled,
|
||||
{
|
||||
|
||||
@@ -33,6 +33,10 @@ import type {
|
||||
UserPoolSignInTestResult,
|
||||
UserPoolClientSettings,
|
||||
DistributionInvalidation,
|
||||
AutoScalingCapacityInput,
|
||||
CertificatePem,
|
||||
CertificateRevocationReason,
|
||||
ExportedCertificate,
|
||||
AlarmHistoryItem,
|
||||
AlarmStateValue,
|
||||
MetricSeries,
|
||||
@@ -1546,6 +1550,203 @@ export async function setAlarmActionsEnabled(
|
||||
);
|
||||
}
|
||||
|
||||
export type HttpApiProtocol = "HTTP" | "WEBSOCKET";
|
||||
|
||||
export interface HttpApiIntegrationOverview {
|
||||
integrationId: string;
|
||||
type: string;
|
||||
uri: string | null;
|
||||
functionName: string | null;
|
||||
payloadFormatVersion: string | null;
|
||||
}
|
||||
|
||||
export interface HttpApiRouteOverview {
|
||||
routeId: string;
|
||||
routeKey: string;
|
||||
integration: HttpApiIntegrationOverview | null;
|
||||
authorizationType: string;
|
||||
authorizerId: string | null;
|
||||
authorizationScopes: string[];
|
||||
returnsResponse: boolean;
|
||||
}
|
||||
|
||||
export interface HttpApiStageOverview {
|
||||
stageName: string;
|
||||
autoDeploy: boolean;
|
||||
deploymentId: string | null;
|
||||
lastUpdatedAt: string | null;
|
||||
invokePath: string;
|
||||
}
|
||||
|
||||
export interface HttpApiAuthorizerOverview {
|
||||
authorizerId: string;
|
||||
name: string;
|
||||
type: string;
|
||||
identitySource: string[];
|
||||
issuer: string | null;
|
||||
audience: string[];
|
||||
}
|
||||
|
||||
export interface HttpApiCors {
|
||||
allowOrigins: string[];
|
||||
allowMethods: string[];
|
||||
allowHeaders: string[];
|
||||
exposeHeaders: string[];
|
||||
maxAgeSeconds: number | null;
|
||||
allowCredentials: boolean;
|
||||
}
|
||||
|
||||
export interface HttpApiOverview {
|
||||
apiId: string;
|
||||
protocol: HttpApiProtocol;
|
||||
apiEndpoint: string | null;
|
||||
routeSelectionExpression: string | null;
|
||||
routes: HttpApiRouteOverview[];
|
||||
stages: HttpApiStageOverview[];
|
||||
authorizers: HttpApiAuthorizerOverview[];
|
||||
cors: HttpApiCors | null;
|
||||
}
|
||||
|
||||
export interface HttpApiRouteInput {
|
||||
routeKey: string;
|
||||
functionName?: string;
|
||||
httpUrl?: string;
|
||||
authorizerId?: string;
|
||||
returnsResponse?: boolean;
|
||||
}
|
||||
|
||||
export interface HttpApiAuthorizerInput {
|
||||
name: string;
|
||||
userPoolId?: string;
|
||||
userPoolClientId?: string;
|
||||
issuer?: string;
|
||||
audience?: string[];
|
||||
}
|
||||
|
||||
export interface HttpApiWebSocketTestResult {
|
||||
connected: boolean;
|
||||
messages: string[];
|
||||
closeCode: number | null;
|
||||
error: string | null;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export async function describeHttpApi(cloud: CloudProvider, apiId: string, signal?: AbortSignal): Promise<HttpApiOverview> {
|
||||
const res = await apiClient.call<HttpApiOverview>(apiEndpointKeys.clouds.httpApi.describe, requestOptions(cloud, "apigateway", { signal }), { cloud, id: apiId });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function createHttpApiRoute(cloud: CloudProvider, apiId: string, input: HttpApiRouteInput): Promise<HttpApiRouteOverview> {
|
||||
const res = await apiClient.call<HttpApiRouteOverview, HttpApiRouteInput>(apiEndpointKeys.clouds.httpApi.createRoute, requestOptions(cloud, "apigateway", { body: input }), { cloud, id: apiId });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function deleteHttpApiRoute(cloud: CloudProvider, apiId: string, routeId: string): Promise<void> {
|
||||
await apiClient.call<void>(apiEndpointKeys.clouds.httpApi.deleteRoute, requestOptions(cloud, "apigateway"), { cloud, id: apiId, routeId });
|
||||
}
|
||||
|
||||
export async function setHttpApiRouteAuthorizer(cloud: CloudProvider, apiId: string, routeId: string, authorizerId: string | null): Promise<HttpApiRouteOverview> {
|
||||
const res = await apiClient.call<HttpApiRouteOverview, { authorizerId: string | null }>(apiEndpointKeys.clouds.httpApi.setRouteAuthorizer, requestOptions(cloud, "apigateway", { body: { authorizerId } }), { cloud, id: apiId, routeId });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function createHttpApiStage(cloud: CloudProvider, apiId: string, input: { stageName: string; autoDeploy: boolean }): Promise<HttpApiStageOverview> {
|
||||
const res = await apiClient.call<HttpApiStageOverview, typeof input>(apiEndpointKeys.clouds.httpApi.createStage, requestOptions(cloud, "apigateway", { body: input }), { cloud, id: apiId });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function deleteHttpApiStage(cloud: CloudProvider, apiId: string, stageName: string): Promise<void> {
|
||||
await apiClient.call<void>(apiEndpointKeys.clouds.httpApi.deleteStage, requestOptions(cloud, "apigateway"), { cloud, id: apiId, stageName });
|
||||
}
|
||||
|
||||
export async function deployHttpApi(cloud: CloudProvider, apiId: string, stageName: string): Promise<HttpApiStageOverview> {
|
||||
const res = await apiClient.call<HttpApiStageOverview>(apiEndpointKeys.clouds.httpApi.deploy, requestOptions(cloud, "apigateway"), { cloud, id: apiId, stageName });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function createHttpApiAuthorizer(cloud: CloudProvider, apiId: string, input: HttpApiAuthorizerInput): Promise<HttpApiAuthorizerOverview> {
|
||||
const res = await apiClient.call<HttpApiAuthorizerOverview, HttpApiAuthorizerInput>(apiEndpointKeys.clouds.httpApi.createAuthorizer, requestOptions(cloud, "apigateway", { body: input }), { cloud, id: apiId });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function deleteHttpApiAuthorizer(cloud: CloudProvider, apiId: string, authorizerId: string): Promise<void> {
|
||||
await apiClient.call<void>(apiEndpointKeys.clouds.httpApi.deleteAuthorizer, requestOptions(cloud, "apigateway"), { cloud, id: apiId, authorizerId });
|
||||
}
|
||||
|
||||
export async function updateHttpApiCors(cloud: CloudProvider, apiId: string, cors: HttpApiCors | null): Promise<HttpApiCors | null> {
|
||||
const res = await apiClient.call<{ cors: HttpApiCors | null }, { cors: HttpApiCors | null }>(apiEndpointKeys.clouds.httpApi.cors, requestOptions(cloud, "apigateway", { body: { cors } }), { cloud, id: apiId });
|
||||
return res.data.cors;
|
||||
}
|
||||
|
||||
export async function testWebSocketApi(cloud: CloudProvider, apiId: string, input: { stage: string; message: string; query?: string }): Promise<HttpApiWebSocketTestResult> {
|
||||
const res = await apiClient.call<HttpApiWebSocketTestResult, typeof input>(apiEndpointKeys.clouds.httpApi.webSocketTest, requestOptions(cloud, "apigateway", { body: input }), { cloud, id: apiId });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getCertificatePem(
|
||||
cloud: CloudProvider,
|
||||
certificateId: string,
|
||||
): Promise<CertificatePem> {
|
||||
const res = await apiClient.call<CertificatePem>(
|
||||
apiEndpointKeys.clouds.certificates.pem,
|
||||
requestOptions(cloud, "certificates"),
|
||||
{ cloud, id: certificateId },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function exportCertificate(
|
||||
cloud: CloudProvider,
|
||||
certificateId: string,
|
||||
passphrase: string,
|
||||
): Promise<ExportedCertificate> {
|
||||
const res = await apiClient.call<ExportedCertificate, { passphrase: string }>(
|
||||
apiEndpointKeys.clouds.certificates.export,
|
||||
requestOptions(cloud, "certificates", { body: { passphrase } }),
|
||||
{ cloud, id: certificateId },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function revokeCertificate(
|
||||
cloud: CloudProvider,
|
||||
certificateId: string,
|
||||
reason: CertificateRevocationReason,
|
||||
): Promise<CloudResource> {
|
||||
const res = await apiClient.call<CloudResource, { reason: CertificateRevocationReason }>(
|
||||
apiEndpointKeys.clouds.certificates.revoke,
|
||||
requestOptions(cloud, "certificates", { body: { reason } }),
|
||||
{ cloud, id: certificateId },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function setAutoScalingCapacity(
|
||||
cloud: CloudProvider,
|
||||
groupName: string,
|
||||
input: AutoScalingCapacityInput,
|
||||
): Promise<CloudResource> {
|
||||
const res = await apiClient.call<CloudResource, AutoScalingCapacityInput>(
|
||||
apiEndpointKeys.clouds.autoscaling.capacity,
|
||||
requestOptions(cloud, "autoscaling", { body: input }),
|
||||
{ cloud, id: groupName },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function terminateAutoScalingInstance(
|
||||
cloud: CloudProvider,
|
||||
groupName: string,
|
||||
instanceId: string,
|
||||
replace: boolean,
|
||||
): Promise<void> {
|
||||
await apiClient.call<void, { replace: boolean }>(
|
||||
apiEndpointKeys.clouds.autoscaling.terminate,
|
||||
requestOptions(cloud, "autoscaling", { body: { replace } }),
|
||||
{ cloud, id: groupName, instanceId },
|
||||
);
|
||||
}
|
||||
|
||||
export async function setDistributionEnabled(
|
||||
cloud: CloudProvider,
|
||||
distributionId: string,
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {CloudResource} from '@/types/resource'
|
||||
import {CopyValueButton} from './CopyValueButton'
|
||||
import {LogsQueryPanel} from './LogsQueryPanel'
|
||||
import {ServiceIcon} from '@/components/serviceIcons'
|
||||
import {formatResponseBody, invokeEndpoint, parseHeaders} from '@/lib/apiGateway'
|
||||
|
||||
interface ApiGatewayRoutePanelProps {
|
||||
cloud: CloudProvider
|
||||
@@ -138,8 +139,8 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
|
||||
return (
|
||||
<section className="table-panel">
|
||||
<div className="empty compact">
|
||||
<h3>Select a REST API</h3>
|
||||
<p>Select an API Gateway REST API to connect a route to a Lambda function.</p>
|
||||
<h3>Select an API</h3>
|
||||
<p>Select an API to see its routes, or create one.</p>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
@@ -513,49 +514,6 @@ function MethodTestResponse({result, dismiss}: {result?: ApiGatewayMethodTestRes
|
||||
</div>
|
||||
}
|
||||
|
||||
function invokeEndpoint(runtimeEndpoint?: string): string | null {
|
||||
if (!runtimeEndpoint) return null
|
||||
try {
|
||||
const endpoint = new URL(runtimeEndpoint)
|
||||
if (isDockerBridgeAddress(endpoint.hostname) && typeof window !== 'undefined') {
|
||||
endpoint.hostname = window.location.hostname
|
||||
}
|
||||
return endpoint.toString().replace(/\/$/, '')
|
||||
} catch {
|
||||
return runtimeEndpoint.replace(/\/$/, '')
|
||||
}
|
||||
}
|
||||
|
||||
function isDockerBridgeAddress(hostname: string): boolean {
|
||||
const octets = hostname.split('.').map(Number)
|
||||
return octets.length === 4
|
||||
&& octets.every((octet) => Number.isInteger(octet) && octet >= 0 && octet <= 255)
|
||||
&& octets[0] === 172
|
||||
&& octets[1] >= 16
|
||||
&& octets[1] <= 31
|
||||
}
|
||||
|
||||
function parseHeaders(value: string): Record<string, string> {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(value || '{}')
|
||||
} catch {
|
||||
throw new Error('Request headers must be valid JSON.')
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed) || Object.values(parsed).some((header) => typeof header !== 'string')) {
|
||||
throw new Error('Request headers must be a JSON object with string values.')
|
||||
}
|
||||
return parsed as Record<string, string>
|
||||
}
|
||||
|
||||
function formatResponseBody(body: string): string {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(body), null, 2)
|
||||
} catch {
|
||||
return body || '(empty response body)'
|
||||
}
|
||||
}
|
||||
|
||||
function buildCurlCommand(apiId: string, stage: string, path: string, method: string, headersText: string, query: string, body: string): string {
|
||||
let headers: Record<string, string> = {}
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
||||
import {render, screen, within} from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import {beforeEach, expect, test, vi} from 'vitest'
|
||||
import {AutoScalingPanel} from './AutoScalingPanel'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
getCloudResource: vi.fn(),
|
||||
setAutoScalingCapacity: vi.fn(),
|
||||
terminateAutoScalingInstance: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/cloudProxyClient', () => api)
|
||||
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
|
||||
|
||||
function group(metadata: Record<string, unknown> = {}): CloudResource {
|
||||
return {
|
||||
id: 'web-asg', name: 'web-asg', cloud: 'aws', service: 'autoscaling', type: 'autoScalingGroup', region: 'us-east-1', createdAt: null, status: null,
|
||||
metadata: {
|
||||
minSize: 1, desiredCapacity: 2, maxSize: 3, capacity: '2 in service',
|
||||
instances: [
|
||||
{id: 'i-b8736c8b4a8c129a3', availabilityZone: 'us-east-1a', lifecycleState: 'InService', health: 'Healthy', instanceType: 't3.micro'},
|
||||
{id: 'i-a3155c876867976b1', availabilityZone: 'us-east-1b', lifecycleState: 'InService', health: 'Healthy', instanceType: 't3.micro'},
|
||||
],
|
||||
activities: [{id: 'a1', description: 'Launching a new EC2 instance: i-b8736c8b4a8c129a3', cause: 'An instance was started in response to a desired capacity change.', status: 'Successful', statusMessage: null, startTime: null}],
|
||||
...metadata,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function renderPanel(resource: CloudResource) {
|
||||
api.getCloudResource.mockResolvedValue(resource)
|
||||
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||
return render(<QueryClientProvider client={client}><AutoScalingPanel resource={resource} runtimeReachable/></QueryClientProvider>)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
api.setAutoScalingCapacity.mockImplementation(async (_cloud, _id, input) => group({desiredCapacity: input.desiredCapacity}))
|
||||
api.terminateAutoScalingInstance.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
test('shows the instances and what launched them', async () => {
|
||||
renderPanel(group())
|
||||
|
||||
expect(await screen.findByText('i-a3155c876867976b1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Launching a new EC2 instance: i-b8736c8b4a8c129a3')).toBeInTheDocument()
|
||||
expect(screen.getByText(/never runs them/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('changes the desired capacity', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPanel(group())
|
||||
|
||||
const form = await screen.findByRole('form', {name: 'Group capacity'})
|
||||
const update = within(form).getByRole('button', {name: 'Update capacity'})
|
||||
expect(update).toBeDisabled()
|
||||
const desired = within(form).getByLabelText('Desired')
|
||||
await user.clear(desired)
|
||||
await user.type(desired, '3')
|
||||
await user.click(update)
|
||||
|
||||
expect(api.setAutoScalingCapacity).toHaveBeenCalledWith('aws', 'web-asg', {minSize: 1, desiredCapacity: 3, maxSize: 3})
|
||||
expect(await screen.findByText(/Capacity set to 3/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('replaces an instance, or removes one while above the minimum', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
renderPanel(group())
|
||||
|
||||
await user.click(await screen.findByRole('button', {name: 'Replace i-b8736c8b4a8c129a3'}))
|
||||
expect(api.terminateAutoScalingInstance).toHaveBeenCalledWith('aws', 'web-asg', 'i-b8736c8b4a8c129a3', true)
|
||||
expect(await screen.findByText(/launches a replacement/)).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', {name: 'Remove i-a3155c876867976b1'}))
|
||||
expect(api.terminateAutoScalingInstance).toHaveBeenLastCalledWith('aws', 'web-asg', 'i-a3155c876867976b1', false)
|
||||
})
|
||||
|
||||
test('cannot remove an instance from a group at its minimum', async () => {
|
||||
renderPanel(group({minSize: 2}))
|
||||
expect(await screen.findByRole('button', {name: 'Remove i-b8736c8b4a8c129a3'})).toBeDisabled()
|
||||
expect(screen.getByRole('button', {name: 'Replace i-b8736c8b4a8c129a3'})).toBeEnabled()
|
||||
})
|
||||
@@ -0,0 +1,146 @@
|
||||
import {RefreshCw} from 'lucide-react'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
import {useEffect, useState} from 'react'
|
||||
import {getCloudResource, setAutoScalingCapacity, terminateAutoScalingInstance} from '@/api/cloudProxyClient'
|
||||
import {cloudQueryKeys} from '@/api/queries/cloudQueries'
|
||||
import {useAccountId} from '@/lib/accountStore'
|
||||
import {formatDateTime} from '@/lib/format'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
|
||||
type Props = {
|
||||
resource?: CloudResource
|
||||
runtimeReachable: boolean
|
||||
}
|
||||
|
||||
type Instance = {id: string; availabilityZone: string | null; lifecycleState: string | null; health: string | null; instanceType: string | null}
|
||||
type Activity = {id: string; description: string; cause: string; status: string | null; statusMessage: string | null; startTime: string | null}
|
||||
type Capacity = {minSize: string; desiredCapacity: string; maxSize: string}
|
||||
|
||||
/** The runtime reconciles every 10 seconds, so a group that is still moving is re-read this often. */
|
||||
const SETTLING_POLL_MS = 4000
|
||||
|
||||
export function AutoScalingPanel({resource, runtimeReachable}: Props) {
|
||||
const accountId = useAccountId()
|
||||
const queryClient = useQueryClient()
|
||||
const [capacity, setCapacity] = useState<Capacity>({minSize: '', desiredCapacity: '', maxSize: ''})
|
||||
const [notice, setNotice] = useState('')
|
||||
const groupKey = ['autoscaling-group', accountId, resource?.id]
|
||||
|
||||
const groupQuery = useQuery({
|
||||
queryKey: groupKey,
|
||||
queryFn: ({signal}) => getCloudResource('aws', 'autoscaling', resource?.id ?? '', signal),
|
||||
enabled: Boolean(resource) && runtimeReachable,
|
||||
refetchInterval: (query) => query.state.data && !settled(query.state.data) ? SETTLING_POLL_MS : false,
|
||||
})
|
||||
|
||||
const group = groupQuery.data ?? resource
|
||||
useEffect(() => {
|
||||
setNotice('')
|
||||
}, [resource?.id])
|
||||
// The form follows the group's saved capacity whenever it changes, here or elsewhere.
|
||||
const savedMin = String(group?.metadata.minSize ?? '')
|
||||
const savedDesired = String(group?.metadata.desiredCapacity ?? '')
|
||||
const savedMax = String(group?.metadata.maxSize ?? '')
|
||||
useEffect(() => {
|
||||
setCapacity({minSize: savedMin, desiredCapacity: savedDesired, maxSize: savedMax})
|
||||
}, [group?.id, savedMin, savedDesired, savedMax])
|
||||
|
||||
const refresh = () => Promise.all([
|
||||
queryClient.invalidateQueries({queryKey: groupKey}),
|
||||
queryClient.invalidateQueries({queryKey: cloudQueryKeys.resources('aws', 'autoscaling')}),
|
||||
])
|
||||
|
||||
const capacityMutation = useMutation({
|
||||
mutationFn: () => setAutoScalingCapacity('aws', resource?.id ?? '', {
|
||||
minSize: Number(capacity.minSize),
|
||||
desiredCapacity: Number(capacity.desiredCapacity),
|
||||
maxSize: Number(capacity.maxSize),
|
||||
}),
|
||||
onSuccess: (updated) => {
|
||||
setNotice(`Capacity set to ${String(updated.metadata.desiredCapacity)}. TheCloud launches or terminates instances within about 10 seconds.`)
|
||||
return refresh()
|
||||
},
|
||||
})
|
||||
const terminateMutation = useMutation({
|
||||
mutationFn: ({instanceId, replace}: {instanceId: string; replace: boolean}) => terminateAutoScalingInstance('aws', resource?.id ?? '', instanceId, replace),
|
||||
onSuccess: (_result, {instanceId, replace}) => {
|
||||
setNotice(replace ? `Terminating ${instanceId}. The group launches a replacement to stay at its desired capacity.` : `Terminating ${instanceId} and lowering the desired capacity by one.`)
|
||||
return refresh()
|
||||
},
|
||||
})
|
||||
|
||||
if (!resource || !group) {
|
||||
return <section className="table-panel"><div className="empty compact"><h3>Select an Auto Scaling group</h3><p>Choose a group to change its capacity, see its instances and follow its scaling activity.</p></div></section>
|
||||
}
|
||||
|
||||
const metadata = group.metadata
|
||||
const instances = Array.isArray(metadata.instances) ? metadata.instances as Instance[] : []
|
||||
const activities = Array.isArray(metadata.activities) ? metadata.activities as Activity[] : []
|
||||
const desired = Number(metadata.desiredCapacity ?? 0)
|
||||
const min = Number(metadata.minSize ?? 0)
|
||||
const pending = capacityMutation.isPending || terminateMutation.isPending
|
||||
const disabled = !runtimeReachable || pending
|
||||
const error = groupQuery.error ?? capacityMutation.error ?? terminateMutation.error
|
||||
const changed = capacity.minSize !== String(metadata.minSize) || capacity.desiredCapacity !== String(metadata.desiredCapacity) || capacity.maxSize !== String(metadata.maxSize)
|
||||
const field = (name: keyof Capacity, label: string) => <label className="dynamic-field"><span>{label}</span>
|
||||
<input className="input" type="number" min={0} max={20} step={1} value={capacity[name]} onChange={(event) => setCapacity({...capacity, [name]: event.target.value})} disabled={disabled} required/>
|
||||
</label>
|
||||
|
||||
return <section className="table-panel" style={{marginTop: 16}} aria-label={`Auto Scaling group ${group.name}`}>
|
||||
<div className="input-row resource-table-bar">
|
||||
<div><p className="eyebrow">Auto Scaling group</p><span className="muted">{String(metadata.capacity ?? '')} · desired {desired}{settled(group) ? '' : ' · scaling'}{group.status ? ` · ${group.status}` : ''}</span></div>
|
||||
<div className="cdn-actions">
|
||||
<button className="button" type="button" disabled={!runtimeReachable || groupQuery.isFetching} onClick={() => { void refresh() }}><RefreshCw size={14}/>{groupQuery.isFetching ? 'Loading' : 'Refresh'}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className="dynamic-form autoscaling-capacity" aria-label="Group capacity" onSubmit={(event) => { event.preventDefault(); setNotice(''); capacityMutation.mutate() }}>
|
||||
{field('minSize', 'Minimum')}
|
||||
{field('desiredCapacity', 'Desired')}
|
||||
{field('maxSize', 'Maximum')}
|
||||
<div><button className="button primary" type="submit" disabled={disabled || !changed}>{capacityMutation.isPending ? 'Updating' : 'Update capacity'}</button></div>
|
||||
</form>
|
||||
<p className="muted compact-text autoscaling-note">Scaling policies are not offered: TheCloud stores EC2 scaling policies but never runs them, so set the capacity here. Alarm-driven scaling works for ECS services through Application Auto Scaling.</p>
|
||||
|
||||
<div className="input-row resource-table-bar"><div><p className="eyebrow">Instances</p><span className="muted">Each is a real EC2 instance in TheCloud, launched from the group's template.</span></div></div>
|
||||
{instances.length === 0 ? <p className="muted compact-text autoscaling-note">No instances. {desired > 0 ? 'The group is launching them.' : 'Raise the desired capacity to launch some.'}</p>
|
||||
: <div className="cognito-table"><table className="table"><thead><tr><th>Instance</th><th>State</th><th>Health</th><th>Zone</th><th>Type</th><th aria-label="Actions"/></tr></thead><tbody>{instances.map((instance) => (
|
||||
<tr key={instance.id}>
|
||||
<td className="mono">{instance.id}</td>
|
||||
<td><code>{instance.lifecycleState ?? '—'}</code></td>
|
||||
<td>{instance.health ?? '—'}</td>
|
||||
<td>{instance.availabilityZone ?? '—'}</td>
|
||||
<td>{instance.instanceType ?? '—'}</td>
|
||||
<td className="autoscaling-instance-actions">
|
||||
<button className="button compact" type="button" disabled={disabled || instance.lifecycleState !== 'InService'} aria-label={`Replace ${instance.id}`} onClick={() => { setNotice(''); terminateMutation.mutate({instanceId: instance.id, replace: true}) }}>Replace</button>
|
||||
<button className="button compact danger" type="button" disabled={disabled || instance.lifecycleState !== 'InService' || desired - 1 < min} title={desired - 1 < min ? 'The group is at its minimum size' : undefined} aria-label={`Remove ${instance.id}`} onClick={() => {
|
||||
setNotice('')
|
||||
if (window.confirm(`Terminate ${instance.id} and lower the desired capacity to ${desired - 1}?`)) terminateMutation.mutate({instanceId: instance.id, replace: false})
|
||||
}}>Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}</tbody></table></div>}
|
||||
|
||||
{activities.length > 0 && <>
|
||||
<div className="input-row resource-table-bar"><div><p className="eyebrow">Activity</p><span className="muted">The most recent launches and terminations.</span></div></div>
|
||||
<div className="cognito-table"><table className="table"><thead><tr><th>Started</th><th>Status</th><th>Activity</th></tr></thead><tbody>{activities.map((activity) => (
|
||||
<tr key={activity.id}>
|
||||
<td>{formatDateTime(activity.startTime) ?? '—'}</td>
|
||||
<td><code>{activity.status ?? '—'}</code></td>
|
||||
<td>{activity.description}<br/><small className="muted">{activity.statusMessage || activity.cause}</small></td>
|
||||
</tr>
|
||||
))}</tbody></table></div>
|
||||
</>}
|
||||
{metadata.activitiesUnavailable === true && <p className="muted compact-text autoscaling-note">The scaling activity could not be read just now.</p>}
|
||||
|
||||
{notice && <p className="cognito-outcome cognito-outcome--ok" role="status">{notice}</p>}
|
||||
{error instanceof Error && <p className="error-text compact-text" role="alert">{error.message}</p>}
|
||||
</section>
|
||||
}
|
||||
|
||||
/** Settled once every desired instance is in service and none is still starting or stopping. */
|
||||
function settled(group: CloudResource): boolean {
|
||||
const instances = Array.isArray(group.metadata.instances) ? group.metadata.instances as Instance[] : []
|
||||
const inService = instances.filter((instance) => instance.lifecycleState === 'InService').length
|
||||
return inService === Number(group.metadata.desiredCapacity ?? 0) && instances.every((instance) => instance.lifecycleState === 'InService')
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
||||
import {render, screen, within} from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import {beforeEach, expect, test, vi} from 'vitest'
|
||||
import {CertificatePanel} from './CertificatePanel'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
getCloudResource: vi.fn(),
|
||||
getCertificatePem: vi.fn(),
|
||||
exportCertificate: vi.fn(),
|
||||
revokeCertificate: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/cloudProxyClient', () => api)
|
||||
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
|
||||
|
||||
const ID = '5ed9dc37-d9fb-456e-911e-e87f091480ba'
|
||||
function certificate(metadata: Record<string, unknown> = {}, status = 'ISSUED'): CloudResource {
|
||||
return {
|
||||
id: ID, name: 'notes.thecloud.localhost', cloud: 'aws', service: 'certificates', type: 'certificate',
|
||||
region: 'us-east-1', createdAt: null, status,
|
||||
metadata: {
|
||||
certificateType: 'Amazon issued', exportable: false, inUseBy: [],
|
||||
domainValidations: [{domainName: 'notes.thecloud.localhost', status: 'SUCCESS', method: 'DNS', record: {name: '_a1.notes.thecloud.localhost.', type: 'CNAME', value: '_e8.acm-validations.aws.'}}],
|
||||
...metadata,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function renderPanel(resource: CloudResource) {
|
||||
api.getCloudResource.mockResolvedValue(resource)
|
||||
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||
return render(<QueryClientProvider client={client}><CertificatePanel resource={resource} runtimeReachable/></QueryClientProvider>)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
api.getCertificatePem.mockResolvedValue({certificate: '-----BEGIN CERTIFICATE-----\nLEAF', certificateChain: '-----BEGIN CERTIFICATE-----\nCA'})
|
||||
api.exportCertificate.mockResolvedValue({certificate: 'LEAF', certificateChain: null, privateKey: '-----BEGIN ENCRYPTED PRIVATE KEY-----'})
|
||||
api.revokeCertificate.mockResolvedValue(certificate({exportable: true}, 'REVOKED'))
|
||||
})
|
||||
|
||||
test('shows the validation records and that nothing uses the certificate', async () => {
|
||||
renderPanel(certificate())
|
||||
|
||||
expect(await screen.findByText('_a1.notes.thecloud.localhost.')).toBeInTheDocument()
|
||||
expect(screen.getByText('_e8.acm-validations.aws.')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Nothing uses this certificate/)).toBeInTheDocument()
|
||||
// Export and revoke exist only for certificates requested as exportable, as on AWS.
|
||||
expect(screen.queryByRole('form', {name: 'Export certificate'})).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('form', {name: 'Revoke certificate'})).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('lists what uses the certificate, since ACM will not delete it then', async () => {
|
||||
renderPanel(certificate({inUseBy: ['arn:aws:elasticloadbalancing:us-east-1:000000000000:loadbalancer/app/web/1']}))
|
||||
|
||||
expect(await screen.findByText('arn:aws:elasticloadbalancing:us-east-1:000000000000:loadbalancer/app/web/1')).toBeInTheDocument()
|
||||
expect(screen.getByText(/will not delete the certificate/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('gets the certificate and chain as downloadable PEM files', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPanel(certificate())
|
||||
|
||||
await user.click(await screen.findByRole('button', {name: 'Get certificate'}))
|
||||
expect(await screen.findByRole('textbox', {name: 'Certificate'})).toHaveValue('-----BEGIN CERTIFICATE-----\nLEAF')
|
||||
expect(screen.getByRole('textbox', {name: 'Certificate chain'})).toHaveValue('-----BEGIN CERTIFICATE-----\nCA')
|
||||
expect(screen.getByRole('link', {name: /notes.thecloud.localhost.pem/})).toHaveAttribute('download', 'notes.thecloud.localhost.pem')
|
||||
})
|
||||
|
||||
test('exports an exportable certificate with the passphrase, then revokes it', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
renderPanel(certificate({exportable: true}))
|
||||
|
||||
const exportForm = await screen.findByRole('form', {name: 'Export certificate'})
|
||||
await user.type(within(exportForm).getByLabelText(/Passphrase/), 'open-sesame')
|
||||
await user.click(within(exportForm).getByRole('button', {name: 'Export'}))
|
||||
expect(api.exportCertificate).toHaveBeenCalledWith('aws', ID, 'open-sesame')
|
||||
expect(await screen.findByRole('textbox', {name: 'Private key (encrypted)'})).toHaveValue('-----BEGIN ENCRYPTED PRIVATE KEY-----')
|
||||
|
||||
const revokeForm = screen.getByRole('form', {name: 'Revoke certificate'})
|
||||
await user.selectOptions(within(revokeForm).getByLabelText('Reason'), 'SUPERSEDED')
|
||||
await user.click(within(revokeForm).getByRole('button', {name: 'Revoke'}))
|
||||
expect(api.revokeCertificate).toHaveBeenCalledWith('aws', ID, 'SUPERSEDED')
|
||||
expect(await screen.findByText(/Certificate revoked/)).toBeInTheDocument()
|
||||
})
|
||||
@@ -0,0 +1,166 @@
|
||||
import {Download, RefreshCw} from 'lucide-react'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
import {useEffect, useState} from 'react'
|
||||
import {exportCertificate, getCertificatePem, getCloudResource, revokeCertificate} from '@/api/cloudProxyClient'
|
||||
import {CopyValueButton} from '@/components/CopyValueButton'
|
||||
import {useAccountId} from '@/lib/accountStore'
|
||||
import {formatDateTime} from '@/lib/format'
|
||||
import type {CertificatePem, CertificateRevocationReason, CloudResource, ExportedCertificate} from '@/types/resource'
|
||||
|
||||
type Props = {
|
||||
resource?: CloudResource
|
||||
runtimeReachable: boolean
|
||||
}
|
||||
|
||||
type DomainValidation = {domainName: string; status: string | null; method: string | null; record: {name: string; type: string; value: string} | null}
|
||||
|
||||
const REVOCATION_REASONS: Array<{value: CertificateRevocationReason; label: string}> = [
|
||||
{value: 'UNSPECIFIED', label: 'Unspecified'},
|
||||
{value: 'KEY_COMPROMISE', label: 'Key compromise'},
|
||||
{value: 'SUPERSEDED', label: 'Superseded'},
|
||||
{value: 'AFFILIATION_CHANGED', label: 'Affiliation changed'},
|
||||
{value: 'CESSATION_OF_OPERATION', label: 'Cessation of operation'},
|
||||
{value: 'PRIVILEGE_WITHDRAWN', label: 'Privilege withdrawn'},
|
||||
{value: 'CA_COMPROMISE', label: 'CA compromise'},
|
||||
]
|
||||
|
||||
export function CertificatePanel({resource, runtimeReachable}: Props) {
|
||||
const accountId = useAccountId()
|
||||
const queryClient = useQueryClient()
|
||||
const [passphrase, setPassphrase] = useState('')
|
||||
const [reason, setReason] = useState<CertificateRevocationReason>('UNSPECIFIED')
|
||||
const [pem, setPem] = useState<CertificatePem | ExportedCertificate | null>(null)
|
||||
const [notice, setNotice] = useState('')
|
||||
const certificateKey = ['acm-certificate', accountId, resource?.id]
|
||||
|
||||
// The table row is a ListCertificates summary; validation records and users come from DescribeCertificate.
|
||||
const certificateQuery = useQuery({
|
||||
queryKey: certificateKey,
|
||||
queryFn: ({signal}) => getCloudResource('aws', 'certificates', resource?.id ?? '', signal),
|
||||
enabled: Boolean(resource) && runtimeReachable,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setPassphrase('')
|
||||
setReason('UNSPECIFIED')
|
||||
setPem(null)
|
||||
setNotice('')
|
||||
}, [resource?.id])
|
||||
|
||||
const refresh = () => Promise.all([
|
||||
queryClient.invalidateQueries({queryKey: certificateKey}),
|
||||
queryClient.invalidateQueries({queryKey: ['cloud-resources', 'aws', 'certificates']}),
|
||||
])
|
||||
|
||||
const pemMutation = useMutation({
|
||||
mutationFn: () => getCertificatePem('aws', resource?.id ?? ''),
|
||||
onSuccess: setPem,
|
||||
})
|
||||
const exportMutation = useMutation({
|
||||
mutationFn: () => exportCertificate('aws', resource?.id ?? '', passphrase),
|
||||
onSuccess: (exported) => {
|
||||
setPem(exported)
|
||||
setPassphrase('')
|
||||
setNotice('Exported. The private key is encrypted with your passphrase.')
|
||||
},
|
||||
})
|
||||
const revokeMutation = useMutation({
|
||||
mutationFn: () => revokeCertificate('aws', resource?.id ?? '', reason),
|
||||
onSuccess: () => {
|
||||
setNotice('Certificate revoked. It can no longer be renewed or exported.')
|
||||
return refresh()
|
||||
},
|
||||
})
|
||||
|
||||
if (!resource) {
|
||||
return <section className="table-panel"><div className="empty compact"><h3>Select a certificate</h3><p>Choose a certificate to see its validation records, where it is used, and to download or export it.</p></div></section>
|
||||
}
|
||||
|
||||
const certificate = certificateQuery.data ?? resource
|
||||
const metadata = certificate.metadata
|
||||
const validations = list<DomainValidation>(metadata.domainValidations)
|
||||
const inUseBy = list<string>(metadata.inUseBy)
|
||||
const exportable = metadata.exportable === true
|
||||
const issued = certificate.status === 'ISSUED'
|
||||
const pending = pemMutation.isPending || exportMutation.isPending || revokeMutation.isPending
|
||||
const disabled = !runtimeReachable || pending
|
||||
const error = certificateQuery.error ?? pemMutation.error ?? exportMutation.error ?? revokeMutation.error
|
||||
const fileStem = certificate.name.replace(/^\*\./, 'wildcard.').replace(/[^a-z0-9.-]/gi, '_')
|
||||
|
||||
return <section className="table-panel" style={{marginTop: 16}} aria-label={`Certificate ${certificate.name}`}>
|
||||
<div className="input-row resource-table-bar">
|
||||
<div><p className="eyebrow">Certificate</p><span className="muted">{certificate.status ?? 'Unknown'} · {String(metadata.certificateType ?? '')}{exportable ? ' · exportable' : ''}{metadata.notAfter ? ` · expires ${formatDateTime(String(metadata.notAfter))}` : ''}</span></div>
|
||||
<div className="cdn-actions">
|
||||
<button className="button" type="button" disabled={disabled} onClick={() => { setNotice(''); pemMutation.mutate() }}><Download size={14}/>{pemMutation.isPending ? 'Loading' : 'Get certificate'}</button>
|
||||
<button className="button" type="button" disabled={!runtimeReachable || certificateQuery.isFetching} onClick={() => { void refresh() }}><RefreshCw size={14}/>{certificateQuery.isFetching ? 'Loading' : 'Refresh'}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{validations.length > 0 && <>
|
||||
<div className="input-row resource-table-bar"><div><p className="eyebrow">Domains</p><span className="muted">On AWS, each name is validated by the CNAME record below before the certificate is issued. TheCloud issues it straight away.</span></div></div>
|
||||
<div className="cognito-table"><table className="table"><thead><tr><th>Domain</th><th>Validation</th><th>CNAME name</th><th>CNAME value</th></tr></thead><tbody>{validations.map((validation) => (
|
||||
<tr key={validation.domainName}>
|
||||
<td className="mono">{validation.domainName}</td>
|
||||
<td><code>{validation.status ?? '—'}</code>{validation.method ? ` ${validation.method}` : ''}</td>
|
||||
<td className="mono dns-values">{validation.record ? <span className="copy-value">{validation.record.name}<CopyValueButton value={validation.record.name} label="CNAME name"/></span> : '—'}</td>
|
||||
<td className="mono dns-values">{validation.record ? <span className="copy-value">{validation.record.value}<CopyValueButton value={validation.record.value} label="CNAME value"/></span> : '—'}</td>
|
||||
</tr>
|
||||
))}</tbody></table></div>
|
||||
</>}
|
||||
|
||||
<div className="input-row resource-table-bar"><div><p className="eyebrow">In use by</p><span className="muted">{inUseBy.length > 0 ? 'ACM will not delete the certificate until these stop using it.' : 'Nothing uses this certificate, so it can be deleted.'}</span></div></div>
|
||||
{inUseBy.length > 0 && <ul className="certificate-users">{inUseBy.map((arn) => <li key={arn} className="mono">{arn}</li>)}</ul>}
|
||||
|
||||
{exportable && issued && <>
|
||||
<div className="input-row resource-table-bar"><div><p className="eyebrow">Export</p><span className="muted">Download the certificate with its private key, encrypted with a passphrase, to use on a server outside AWS.</span></div></div>
|
||||
<form className="dynamic-form" aria-label="Export certificate" onSubmit={(event) => { event.preventDefault(); setNotice(''); exportMutation.mutate() }}>
|
||||
<label className="dynamic-field"><span>Passphrase</span>
|
||||
<input className="input" type="password" autoComplete="new-password" value={passphrase} onChange={(event) => setPassphrase(event.target.value)} disabled={disabled} minLength={4} maxLength={128} required/>
|
||||
<small className="muted">4 to 128 characters, without #, $ or %. You need it to decrypt the key.</small>
|
||||
</label>
|
||||
<div><button className="button primary" type="submit" disabled={disabled || passphrase.length < 4}>{exportMutation.isPending ? 'Exporting' : 'Export'}</button></div>
|
||||
</form>
|
||||
</>}
|
||||
|
||||
{exportable && certificate.status !== 'REVOKED' && <>
|
||||
<div className="input-row resource-table-bar"><div><p className="eyebrow">Revoke</p><span className="muted">Revoking cannot be undone. Use it if the private key leaked or the certificate is no longer needed.</span></div></div>
|
||||
<form className="dynamic-form" aria-label="Revoke certificate" onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
setNotice('')
|
||||
if (window.confirm(`Revoke the certificate for ${certificate.name}? This cannot be undone.`)) revokeMutation.mutate()
|
||||
}}>
|
||||
<label className="dynamic-field"><span>Reason</span>
|
||||
<select className="input" value={reason} onChange={(event) => setReason(event.target.value as CertificateRevocationReason)} disabled={disabled}>
|
||||
{REVOCATION_REASONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<div><button className="button danger" type="submit" disabled={disabled}>{revokeMutation.isPending ? 'Revoking' : 'Revoke'}</button></div>
|
||||
</form>
|
||||
</>}
|
||||
|
||||
{pem && <div className="certificate-pem">
|
||||
<PemBlock label="Certificate" value={pem.certificate} file={`${fileStem}.pem`}/>
|
||||
{pem.certificateChain && <PemBlock label="Certificate chain" value={pem.certificateChain} file={`${fileStem}-chain.pem`}/>}
|
||||
{'privateKey' in pem && <PemBlock label="Private key (encrypted)" value={pem.privateKey} file={`${fileStem}-key.pem`}/>}
|
||||
</div>}
|
||||
|
||||
{notice && <p className="cognito-outcome cognito-outcome--ok" role="status">{notice}</p>}
|
||||
{error instanceof Error && <p className="error-text compact-text" role="alert">{error.message}</p>}
|
||||
</section>
|
||||
}
|
||||
|
||||
function PemBlock({label, value, file}: {label: string; value: string; file: string}) {
|
||||
const href = `data:application/x-pem-file;charset=utf-8,${encodeURIComponent(value)}`
|
||||
return <div className="certificate-pem-block">
|
||||
<div className="certificate-pem-head">
|
||||
<strong>{label}</strong>
|
||||
<CopyValueButton value={value} label={label}/>
|
||||
<a className="button" href={href} download={file}><Download size={13}/> {file}</a>
|
||||
</div>
|
||||
<textarea className="textarea code-textarea small" rows={6} value={value} readOnly aria-label={label}/>
|
||||
</div>
|
||||
}
|
||||
|
||||
function list<T>(value: unknown): T[] {
|
||||
return Array.isArray(value) ? value as T[] : []
|
||||
}
|
||||
@@ -48,6 +48,7 @@ import { DatabaseLaunchProgress } from "@/components/DatabaseLaunchProgress";
|
||||
import { AppConfigPanel } from "@/components/AppConfigPanel";
|
||||
import { KmsCryptoPanel } from "@/components/KmsCryptoPanel";
|
||||
import { ApiGatewayRoutePanel } from "@/components/ApiGatewayRoutePanel";
|
||||
import { HttpApiPanel } from "@/components/HttpApiPanel";
|
||||
import { S3LambdaTriggerPanel } from "@/components/S3LambdaTriggerPanel";
|
||||
import { ScheduledLambdaTriggerPanel } from "@/components/ScheduledLambdaTriggerPanel";
|
||||
import { SqsLambdaTriggerPanel } from "@/components/SqsLambdaTriggerPanel";
|
||||
@@ -67,6 +68,9 @@ import { LoadBalancerListenerPanel } from "@/components/LoadBalancerListenerPane
|
||||
import { CognitoUsersPanel } from "@/components/CognitoUsersPanel";
|
||||
import { Route53RecordsPanel } from "@/components/Route53RecordsPanel";
|
||||
import { CloudFrontPanel } from "@/components/CloudFrontPanel";
|
||||
import { AutoScalingPanel } from "@/components/AutoScalingPanel";
|
||||
import { CertificatePanel } from "@/components/CertificatePanel";
|
||||
import { PipePanel } from "@/components/PipePanel";
|
||||
import { CloudWatchAlarmPanel } from "@/components/CloudWatchAlarmPanel";
|
||||
import { CloudWatchMetricsExplorer } from "@/components/CloudWatchMetricsExplorer";
|
||||
import { WorkflowExecutionDebugger } from "@/components/WorkflowExecutionDebugger";
|
||||
@@ -986,6 +990,15 @@ export function DynamicResourceView({
|
||||
{service === "dns" && cloud === "aws" && (
|
||||
<Route53RecordsPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
||||
)}
|
||||
{service === "certificates" && cloud === "aws" && (
|
||||
<CertificatePanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
||||
)}
|
||||
{service === "pipes" && cloud === "aws" && (
|
||||
<PipePanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
||||
)}
|
||||
{service === "autoscaling" && cloud === "aws" && (
|
||||
<AutoScalingPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
||||
)}
|
||||
{service === "cdn" && cloud === "aws" && (
|
||||
<CloudFrontPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
||||
)}
|
||||
@@ -1037,14 +1050,21 @@ export function DynamicResourceView({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{service === "apigateway" && (
|
||||
{service === "apigateway" && (activeSelected && ["http", "websocket"].includes(String(activeSelected.metadata.apigatewayService)) ? (
|
||||
<HttpApiPanel
|
||||
cloud={cloud}
|
||||
resource={activeSelected}
|
||||
runtimeReachable={canUseRuntime}
|
||||
runtimeEndpoint={cloudStatus?.endpoint ?? undefined}
|
||||
/>
|
||||
) : (
|
||||
<ApiGatewayRoutePanel
|
||||
cloud={cloud}
|
||||
resource={activeSelected}
|
||||
runtimeReachable={canUseRuntime}
|
||||
runtimeEndpoint={cloudStatus?.endpoint ?? undefined}
|
||||
/>
|
||||
)}
|
||||
))}
|
||||
{service === "events" && (
|
||||
<EventBridgeRulePanel
|
||||
cloud={cloud}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
||||
import {render, screen, within} from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import {beforeEach, expect, test, vi} from 'vitest'
|
||||
import {HttpApiPanel} from './HttpApiPanel'
|
||||
import type {HttpApiOverview} from '@/api/cloudProxyClient'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
describeHttpApi: vi.fn(),
|
||||
listCloudResources: vi.fn(),
|
||||
createHttpApiRoute: vi.fn(),
|
||||
deleteHttpApiRoute: vi.fn(),
|
||||
setHttpApiRouteAuthorizer: vi.fn(),
|
||||
createHttpApiStage: vi.fn(),
|
||||
deleteHttpApiStage: vi.fn(),
|
||||
deployHttpApi: vi.fn(),
|
||||
createHttpApiAuthorizer: vi.fn(),
|
||||
deleteHttpApiAuthorizer: vi.fn(),
|
||||
updateHttpApiCors: vi.fn(),
|
||||
testApiGatewayRoute: vi.fn(),
|
||||
testWebSocketApi: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/cloudProxyClient', () => api)
|
||||
|
||||
const httpResource: CloudResource = {
|
||||
id: 'h1', name: 'notes-api', cloud: 'aws', service: 'apigateway', type: 'http-api', region: null, createdAt: null,
|
||||
metadata: {apigatewayService: 'http', protocol: 'HTTP'},
|
||||
}
|
||||
|
||||
const httpOverview: HttpApiOverview = {
|
||||
apiId: 'h1', protocol: 'HTTP', apiEndpoint: null, routeSelectionExpression: null,
|
||||
routes: [
|
||||
{routeId: 'r1', routeKey: 'GET /notes/{id}', integration: {integrationId: 'i1', type: 'AWS_PROXY', uri: 'arn', functionName: 'notes', payloadFormatVersion: '2.0'}, authorizationType: 'NONE', authorizerId: null, authorizationScopes: [], returnsResponse: false},
|
||||
{routeId: 'r2', routeKey: 'GET /me', integration: {integrationId: 'i1', type: 'AWS_PROXY', uri: 'arn', functionName: 'notes', payloadFormatVersion: '2.0'}, authorizationType: 'JWT', authorizerId: 'a1', authorizationScopes: [], returnsResponse: false},
|
||||
],
|
||||
stages: [{stageName: '$default', autoDeploy: true, deploymentId: null, lastUpdatedAt: null, invokePath: '/execute-api/h1/$default'}],
|
||||
authorizers: [{authorizerId: 'a1', name: 'cognito', type: 'JWT', identitySource: ['$request.header.Authorization'], issuer: 'http://floci:4566/us-east-1_pool', audience: ['client1']}],
|
||||
cors: null,
|
||||
}
|
||||
|
||||
function renderPanel(resource: CloudResource = httpResource) {
|
||||
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||
return render(<QueryClientProvider client={client}><HttpApiPanel cloud="aws" resource={resource} runtimeReachable runtimeEndpoint="http://localhost:4566"/></QueryClientProvider>)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
api.describeHttpApi.mockResolvedValue(httpOverview)
|
||||
api.listCloudResources.mockImplementation(async (_cloud: string, service: string) => service === 'serverless'
|
||||
? [{id: 'notes', name: 'notes', type: 'lambda', cloud: 'aws', service: 'serverless', region: null, createdAt: null, metadata: {}}]
|
||||
: [{id: 'us-east-1_pool', name: 'notes-users', type: 'user-pool', cloud: 'aws', service: 'userpools', region: null, createdAt: null, metadata: {appClients: [{clientId: 'client1', clientName: 'web'}]}}])
|
||||
api.createHttpApiRoute.mockResolvedValue(httpOverview.routes[0])
|
||||
api.setHttpApiRouteAuthorizer.mockResolvedValue(httpOverview.routes[0])
|
||||
api.createHttpApiAuthorizer.mockResolvedValue(httpOverview.authorizers[0])
|
||||
api.updateHttpApiCors.mockResolvedValue({allowOrigins: ['*'], allowMethods: ['GET'], allowHeaders: [], exposeHeaders: [], maxAgeSeconds: null, allowCredentials: false})
|
||||
api.testApiGatewayRoute.mockResolvedValue({statusCode: 200, headers: {}, body: '{"ok":true}', durationMs: 12})
|
||||
})
|
||||
|
||||
test('shows the stage URL, routes with their targets and who may call them', async () => {
|
||||
renderPanel()
|
||||
|
||||
expect(await screen.findByText('http://localhost:4566/execute-api/h1/$default')).toBeInTheDocument()
|
||||
const routes = within(screen.getByRole('region', {name: 'Routes'}))
|
||||
expect(routes.getByText('GET /notes/{id}')).toBeInTheDocument()
|
||||
expect(routes.getAllByText('Lambda: notes')).toHaveLength(2)
|
||||
expect(routes.getByRole('combobox', {name: 'Access for GET /me'})).toHaveValue('a1')
|
||||
expect(routes.getByRole('combobox', {name: 'Access for GET /notes/{id}'})).toHaveValue('')
|
||||
})
|
||||
|
||||
test('adds a Lambda route and protects an existing one', async () => {
|
||||
renderPanel()
|
||||
const routes = within(await screen.findByRole('region', {name: 'Routes'}))
|
||||
|
||||
await userEvent.selectOptions(routes.getByRole('combobox', {name: 'Method'}), 'POST')
|
||||
await userEvent.clear(routes.getByRole('textbox', {name: 'Path'}))
|
||||
await userEvent.type(routes.getByRole('textbox', {name: 'Path'}), '/notes')
|
||||
await routes.findByRole('option', {name: 'notes'})
|
||||
await userEvent.selectOptions(routes.getByRole('combobox', {name: 'Lambda function'}), 'notes')
|
||||
await userEvent.click(routes.getByRole('button', {name: 'Add route'}))
|
||||
expect(api.createHttpApiRoute).toHaveBeenCalledWith('aws', 'h1', {routeKey: 'POST /notes', functionName: 'notes', httpUrl: undefined, authorizerId: undefined, returnsResponse: undefined})
|
||||
|
||||
await userEvent.selectOptions(routes.getByRole('combobox', {name: 'Access for GET /notes/{id}'}), 'a1')
|
||||
expect(api.setHttpApiRouteAuthorizer).toHaveBeenCalledWith('aws', 'h1', 'r1', 'a1')
|
||||
})
|
||||
|
||||
test('creates a JWT authorizer from a Cognito pool and app client', async () => {
|
||||
renderPanel()
|
||||
const security = within(await screen.findByRole('region', {name: 'Security'}))
|
||||
|
||||
await security.findByRole('option', {name: 'notes-users'})
|
||||
await userEvent.selectOptions(security.getByRole('combobox', {name: 'User pool'}), 'us-east-1_pool')
|
||||
await userEvent.selectOptions(security.getByRole('combobox', {name: 'App client'}), 'client1')
|
||||
await userEvent.click(security.getByRole('button', {name: 'Add authorizer'}))
|
||||
expect(api.createHttpApiAuthorizer).toHaveBeenCalledWith('aws', 'h1', {name: 'cognito', userPoolId: 'us-east-1_pool', userPoolClientId: 'client1'})
|
||||
})
|
||||
|
||||
test('sends a request to the stage and shows the response', async () => {
|
||||
renderPanel()
|
||||
const tester = within(await screen.findByRole('region', {name: 'Send a request'}))
|
||||
|
||||
expect(tester.getByRole('textbox', {name: 'Request path'})).toHaveValue('/notes/1')
|
||||
await userEvent.click(tester.getByRole('button', {name: 'Send request'}))
|
||||
expect(api.testApiGatewayRoute).toHaveBeenCalledWith('aws', 'h1', expect.objectContaining({stage: '$default', method: 'GET', path: '/notes/1'}))
|
||||
expect(await tester.findByText('200')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('a WebSocket API shows its wss URL and sends a message, without HTTP-only security', async () => {
|
||||
api.describeHttpApi.mockResolvedValue({
|
||||
apiId: 'w1', protocol: 'WEBSOCKET', apiEndpoint: null, routeSelectionExpression: '$request.body.action',
|
||||
routes: [{routeId: 'r1', routeKey: 'sendMessage', integration: null, authorizationType: 'NONE', authorizerId: null, authorizationScopes: [], returnsResponse: true}],
|
||||
stages: [{stageName: 'dev', autoDeploy: true, deploymentId: null, lastUpdatedAt: null, invokePath: '/ws/w1/dev'}],
|
||||
authorizers: [], cors: null,
|
||||
} satisfies HttpApiOverview)
|
||||
api.testWebSocketApi.mockResolvedValue({connected: true, messages: ['{"echo":"hello"}'], closeCode: null, error: null, durationMs: 40})
|
||||
renderPanel({...httpResource, id: 'w1', type: 'websocket-api', metadata: {apigatewayService: 'websocket', protocol: 'WEBSOCKET'}})
|
||||
|
||||
expect(await screen.findByText('ws://localhost:4566/ws/w1/dev')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('region', {name: 'Security'})).not.toBeInTheDocument()
|
||||
const tester = within(screen.getByRole('region', {name: 'Send a message'}))
|
||||
expect(tester.getByRole('textbox', {name: 'Message'})).toHaveValue(JSON.stringify({action: 'sendMessage', data: 'hello'}, null, 2))
|
||||
await userEvent.click(tester.getByRole('button', {name: 'Connect and send'}))
|
||||
expect(api.testWebSocketApi).toHaveBeenCalledWith('aws', 'w1', expect.objectContaining({stage: 'dev'}))
|
||||
expect(await tester.findByText('Connected')).toBeInTheDocument()
|
||||
})
|
||||
@@ -0,0 +1,610 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import {Loader2, Plus, RefreshCw, Rocket, Send, ShieldCheck, Trash2} from 'lucide-react'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
import {
|
||||
createHttpApiAuthorizer,
|
||||
createHttpApiRoute,
|
||||
createHttpApiStage,
|
||||
deleteHttpApiAuthorizer,
|
||||
deleteHttpApiRoute,
|
||||
deleteHttpApiStage,
|
||||
deployHttpApi,
|
||||
describeHttpApi,
|
||||
listCloudResources,
|
||||
setHttpApiRouteAuthorizer,
|
||||
testApiGatewayRoute,
|
||||
testWebSocketApi,
|
||||
updateHttpApiCors,
|
||||
} from '@/api/cloudProxyClient'
|
||||
import type {HttpApiAuthorizerOverview, HttpApiCors, HttpApiOverview, HttpApiRouteOverview, HttpApiStageOverview} from '@/api/cloudProxyClient'
|
||||
import type {CloudProvider} from '@/types/cloud'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
import {formatDateTime} from '@/lib/format'
|
||||
import {CopyValueButton} from './CopyValueButton'
|
||||
import {formatResponseBody, invokeEndpoint, parseHeaders} from '@/lib/apiGateway'
|
||||
|
||||
interface HttpApiPanelProps {
|
||||
cloud: CloudProvider
|
||||
resource: CloudResource
|
||||
runtimeReachable: boolean
|
||||
runtimeEndpoint?: string
|
||||
}
|
||||
|
||||
const ROUTE_METHODS = ['ANY', 'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']
|
||||
const TEST_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD']
|
||||
const CORS_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS', '*']
|
||||
const WEBSOCKET_SYSTEM_ROUTES = ['$connect', '$disconnect', '$default']
|
||||
|
||||
/** API Gateway v2: an HTTP or WebSocket API's routes, stages, security and a live tester. */
|
||||
export function HttpApiPanel({cloud, resource, runtimeReachable, runtimeEndpoint}: HttpApiPanelProps) {
|
||||
const queryKey = ['http-api', cloud, resource.id]
|
||||
const query = useQuery({
|
||||
queryKey,
|
||||
queryFn: ({signal}) => describeHttpApi(cloud, resource.id, signal),
|
||||
enabled: runtimeReachable,
|
||||
})
|
||||
const api = query.data
|
||||
const endpoint = invokeEndpoint(runtimeEndpoint)
|
||||
const isWebSocket = (api?.protocol ?? resource.metadata.protocol) === 'WEBSOCKET'
|
||||
|
||||
return <div className="http-api-workspace">
|
||||
<section className="table-panel" aria-label={`${isWebSocket ? 'WebSocket' : 'HTTP'} API ${resource.name}`}>
|
||||
<div className="input-row resource-table-bar">
|
||||
<div>
|
||||
<p className="eyebrow">{isWebSocket ? 'WebSocket API' : 'HTTP API'}</p>
|
||||
<span className="muted">{isWebSocket
|
||||
? <>Messages are routed by <code>{api?.routeSelectionExpression ?? String(resource.metadata.routeSelectionExpression ?? '')}</code>.</>
|
||||
: 'Routes send requests to Lambda functions or HTTP backends.'}</span>
|
||||
</div>
|
||||
<button className="button" type="button" disabled={!runtimeReachable || query.isFetching} onClick={() => void query.refetch()}><RefreshCw size={14}/>{query.isFetching ? 'Loading' : 'Refresh'}</button>
|
||||
</div>
|
||||
{query.isLoading && <p className="muted padded">Loading the API</p>}
|
||||
{query.error instanceof Error && <p className="error-text compact-text padded" role="alert">{query.error.message}</p>}
|
||||
{api && <StageUrls api={api} endpoint={endpoint}/>}
|
||||
</section>
|
||||
{api && <>
|
||||
<RoutesSection cloud={cloud} api={api} runtimeReachable={runtimeReachable} queryKey={queryKey}/>
|
||||
<StagesSection cloud={cloud} api={api} runtimeReachable={runtimeReachable} queryKey={queryKey}/>
|
||||
{api.protocol === 'HTTP' && <SecuritySection cloud={cloud} api={api} runtimeReachable={runtimeReachable} queryKey={queryKey}/>}
|
||||
{api.protocol === 'HTTP'
|
||||
? <HttpTester cloud={cloud} api={api} runtimeReachable={runtimeReachable}/>
|
||||
: <WebSocketTester cloud={cloud} api={api} runtimeReachable={runtimeReachable}/>}
|
||||
</>}
|
||||
</div>
|
||||
}
|
||||
|
||||
function StageUrls({api, endpoint}: {api: HttpApiOverview; endpoint: string | null}) {
|
||||
if (api.stages.length === 0) return <p className="muted padded">No stages yet. Add one below to serve the API.</p>
|
||||
return <div className="http-api-urls">
|
||||
{api.stages.map((stage) => {
|
||||
const url = stageUrl(api, stage, endpoint)
|
||||
return <div className="dynamic-field" key={stage.stageName}>
|
||||
<span>{stage.stageName} {stage.autoDeploy && <small className="muted">· deploys automatically</small>}</span>
|
||||
{url ? <div className="copy-value"><code>{url}</code><CopyValueButton value={url} label={`${stage.stageName} URL`}/></div> : <code>{stage.invokePath}</code>}
|
||||
</div>
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
|
||||
type SectionProps = {cloud: CloudProvider; api: HttpApiOverview; runtimeReachable: boolean; queryKey: unknown[]}
|
||||
|
||||
function RoutesSection({cloud, api, runtimeReachable, queryKey}: SectionProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const isWebSocket = api.protocol === 'WEBSOCKET'
|
||||
const [method, setMethod] = useState('GET')
|
||||
const [path, setPath] = useState('/hello')
|
||||
const [catchAll, setCatchAll] = useState(false)
|
||||
const [webSocketKey, setWebSocketKey] = useState('$default')
|
||||
const [targetKind, setTargetKind] = useState<'lambda' | 'http'>('lambda')
|
||||
const [functionName, setFunctionName] = useState('')
|
||||
const [httpUrl, setHttpUrl] = useState('')
|
||||
const [authorizerId, setAuthorizerId] = useState('')
|
||||
const [returnsResponse, setReturnsResponse] = useState(true)
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setConfirmDelete(null)
|
||||
setAuthorizerId('')
|
||||
}, [api.apiId])
|
||||
|
||||
const functionsQuery = useQuery({
|
||||
queryKey: ['cloud-resources', cloud, 'serverless'],
|
||||
queryFn: ({signal}) => listCloudResources(cloud, 'serverless', undefined, signal),
|
||||
enabled: runtimeReachable,
|
||||
})
|
||||
const functions = functionsQuery.data?.filter((item) => item.type === 'lambda') ?? []
|
||||
const refresh = () => queryClient.invalidateQueries({queryKey})
|
||||
|
||||
const routeKey = isWebSocket ? webSocketKey.trim() : catchAll ? '$default' : `${method} ${path.trim()}`
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => createHttpApiRoute(cloud, api.apiId, {
|
||||
routeKey,
|
||||
functionName: targetKind === 'lambda' ? functionName : undefined,
|
||||
httpUrl: targetKind === 'http' ? httpUrl.trim() : undefined,
|
||||
authorizerId: authorizerId || undefined,
|
||||
returnsResponse: isWebSocket ? returnsResponse : undefined,
|
||||
}),
|
||||
onSuccess: refresh,
|
||||
})
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (routeId: string) => deleteHttpApiRoute(cloud, api.apiId, routeId),
|
||||
onSuccess: () => {
|
||||
setConfirmDelete(null)
|
||||
return refresh()
|
||||
},
|
||||
})
|
||||
const authorizerMutation = useMutation({
|
||||
mutationFn: ({routeId, authorizer}: {routeId: string; authorizer: string | null}) => setHttpApiRouteAuthorizer(cloud, api.apiId, routeId, authorizer),
|
||||
onSuccess: refresh,
|
||||
})
|
||||
|
||||
const target = targetKind === 'lambda' ? functionName : httpUrl.trim()
|
||||
const canCreate = runtimeReachable && Boolean(routeKey && target) && !createMutation.isPending
|
||||
const authorizerName = (id: string | null) => api.authorizers.find((authorizer) => authorizer.authorizerId === id)?.name ?? id
|
||||
const error = createMutation.error ?? deleteMutation.error ?? authorizerMutation.error
|
||||
|
||||
return <section className="table-panel" aria-label="Routes">
|
||||
<div className="input-row resource-table-bar">
|
||||
<div>
|
||||
<p className="eyebrow">Routes</p>
|
||||
<span className="muted">{isWebSocket
|
||||
? '$connect and $disconnect run when a client connects and leaves; other messages go to the route their action names, or $default.'
|
||||
: 'The most specific route wins; $default catches every request no other route matches.'}</span>
|
||||
</div>
|
||||
</div>
|
||||
{api.routes.length === 0 && <p className="muted padded">No routes yet.</p>}
|
||||
{api.routes.length > 0 && <div className="cognito-table"><table className="table">
|
||||
<thead><tr><th>Route</th><th>Target</th>{!isWebSocket && <th>Access</th>}<th aria-label="Actions"/></tr></thead>
|
||||
<tbody>{api.routes.map((route) => <tr key={route.routeId}>
|
||||
<td><code>{route.routeKey}</code>{isWebSocket && route.returnsResponse && <small className="muted"> · replies</small>}</td>
|
||||
<td>{describeTarget(route)}</td>
|
||||
{!isWebSocket && <td>
|
||||
<select className="input http-api-access" aria-label={`Access for ${route.routeKey}`} value={route.authorizerId ?? ''} disabled={!runtimeReachable || authorizerMutation.isPending}
|
||||
onChange={(event) => authorizerMutation.mutate({routeId: route.routeId, authorizer: event.target.value || null})}>
|
||||
<option value="">Open to anyone</option>
|
||||
{api.authorizers.map((authorizer) => <option key={authorizer.authorizerId} value={authorizer.authorizerId}>JWT: {authorizer.name}</option>)}
|
||||
</select>
|
||||
{route.authorizerId && !api.authorizers.some((authorizer) => authorizer.authorizerId === route.authorizerId) && <small className="muted">Authorizer {authorizerName(route.authorizerId)}</small>}
|
||||
</td>}
|
||||
<td className="cognito-row-actions-cell">
|
||||
{confirmDelete === route.routeId
|
||||
? <span className="apigateway-route-confirm">
|
||||
<span>Delete {route.routeKey}?</span>
|
||||
<button className="button danger compact" type="button" disabled={deleteMutation.isPending} onClick={() => deleteMutation.mutate(route.routeId)}>Delete</button>
|
||||
<button className="button compact" type="button" disabled={deleteMutation.isPending} onClick={() => setConfirmDelete(null)}>Cancel</button>
|
||||
</span>
|
||||
: <button className="icon-btn" type="button" aria-label={`Delete ${route.routeKey}`} disabled={!runtimeReachable} onClick={() => setConfirmDelete(route.routeId)}><Trash2 size={13}/></button>}
|
||||
</td>
|
||||
</tr>)}</tbody>
|
||||
</table></div>}
|
||||
|
||||
<form className="dynamic-form" aria-label="Add a route" onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (canCreate) createMutation.mutate()
|
||||
}}>
|
||||
{isWebSocket ? <label className="dynamic-field">
|
||||
<span>Route key</span>
|
||||
<input className="input" list="websocket-route-keys" value={webSocketKey} onChange={(event) => setWebSocketKey(event.target.value)} placeholder="sendMessage"/>
|
||||
<datalist id="websocket-route-keys">{WEBSOCKET_SYSTEM_ROUTES.map((key) => <option key={key} value={key}/>)}</datalist>
|
||||
<small>$connect, $disconnect, $default, or an action such as sendMessage.</small>
|
||||
</label> : <>
|
||||
<label className="dynamic-field">
|
||||
<span>Method</span>
|
||||
<select className="input" value={method} disabled={catchAll} onChange={(event) => setMethod(event.target.value)}>
|
||||
{ROUTE_METHODS.map((value) => <option key={value} value={value}>{value}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="dynamic-field">
|
||||
<span>Path</span>
|
||||
<input className="input" aria-label="Path" value={path} disabled={catchAll} onChange={(event) => setPath(event.target.value)} placeholder="/notes/{id}"/>
|
||||
<small>Use {'{id}'} for a path parameter and a final {'{proxy+}'} for everything below.</small>
|
||||
</label>
|
||||
<label className="dynamic-field http-api-check-field">
|
||||
<input type="checkbox" checked={catchAll} onChange={(event) => setCatchAll(event.target.checked)}/>
|
||||
<span>Catch-all ($default)</span>
|
||||
</label>
|
||||
</>}
|
||||
{!isWebSocket && <label className="dynamic-field">
|
||||
<span>Target</span>
|
||||
<select className="input" value={targetKind} onChange={(event) => setTargetKind(event.target.value as 'lambda' | 'http')}>
|
||||
<option value="lambda">Lambda function</option>
|
||||
<option value="http">HTTP backend</option>
|
||||
</select>
|
||||
</label>}
|
||||
{targetKind === 'lambda' || isWebSocket
|
||||
? <label className="dynamic-field">
|
||||
<span>Lambda function</span>
|
||||
<select className="input" aria-label="Lambda function" value={functionName} onChange={(event) => setFunctionName(event.target.value)}>
|
||||
<option value="">Select a Lambda function</option>
|
||||
{functions.map((fn) => <option key={fn.id} value={fn.name}>{fn.name}</option>)}
|
||||
</select>
|
||||
<small>{functionsQuery.isLoading ? 'Loading functions' : functions.length === 0 ? 'Create a Lambda function first.' : isWebSocket ? 'Receives the message as a WebSocket event.' : 'Receives a version 2.0 proxy event.'}</small>
|
||||
</label>
|
||||
: <label className="dynamic-field">
|
||||
<span>Backend URL</span>
|
||||
<input className="input" value={httpUrl} onChange={(event) => setHttpUrl(event.target.value)} placeholder="https://backend.example.com/{proxy}"/>
|
||||
<small>Requests are proxied here as they are.</small>
|
||||
</label>}
|
||||
{!isWebSocket && api.authorizers.length > 0 && <label className="dynamic-field">
|
||||
<span>Access</span>
|
||||
<select className="input" aria-label="Access for the new route" value={authorizerId} onChange={(event) => setAuthorizerId(event.target.value)}>
|
||||
<option value="">Open to anyone</option>
|
||||
{api.authorizers.map((authorizer) => <option key={authorizer.authorizerId} value={authorizer.authorizerId}>JWT: {authorizer.name}</option>)}
|
||||
</select>
|
||||
</label>}
|
||||
{isWebSocket && <label className="dynamic-field http-api-check-field">
|
||||
<input type="checkbox" checked={returnsResponse} onChange={(event) => setReturnsResponse(event.target.checked)}/>
|
||||
<span>Send the function's response back to the client</span>
|
||||
</label>}
|
||||
<button className="button primary" type="submit" disabled={!canCreate}>
|
||||
{createMutation.isPending ? <Loader2 size={13}/> : <Plus size={13}/>}{createMutation.isPending ? 'Adding' : 'Add route'}
|
||||
</button>
|
||||
</form>
|
||||
{error instanceof Error && <p className="error-text compact-text padded" role="alert">{error.message}</p>}
|
||||
</section>
|
||||
}
|
||||
|
||||
function StagesSection({cloud, api, runtimeReachable, queryKey}: SectionProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const [stageName, setStageName] = useState('prod')
|
||||
const [autoDeploy, setAutoDeploy] = useState(true)
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null)
|
||||
const [notice, setNotice] = useState('')
|
||||
const refresh = () => queryClient.invalidateQueries({queryKey})
|
||||
|
||||
useEffect(() => {
|
||||
setConfirmDelete(null)
|
||||
setNotice('')
|
||||
}, [api.apiId])
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => createHttpApiStage(cloud, api.apiId, {stageName: stageName.trim(), autoDeploy}),
|
||||
onSuccess: (stage) => {
|
||||
setNotice(stage.autoDeploy ? `${stage.stageName} deploys every change automatically.` : `${stage.stageName} is deployed. Deploy it again after changing routes.`)
|
||||
return refresh()
|
||||
},
|
||||
})
|
||||
const deployMutation = useMutation({
|
||||
mutationFn: (name: string) => deployHttpApi(cloud, api.apiId, name),
|
||||
onSuccess: (stage) => {
|
||||
setNotice(`Deployed the current routes to ${stage.stageName}.`)
|
||||
return refresh()
|
||||
},
|
||||
})
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (name: string) => deleteHttpApiStage(cloud, api.apiId, name),
|
||||
onSuccess: () => {
|
||||
setConfirmDelete(null)
|
||||
return refresh()
|
||||
},
|
||||
})
|
||||
const error = createMutation.error ?? deployMutation.error ?? deleteMutation.error
|
||||
const busy = !runtimeReachable || createMutation.isPending || deployMutation.isPending || deleteMutation.isPending
|
||||
|
||||
return <section className="table-panel" aria-label="Stages">
|
||||
<div className="input-row resource-table-bar">
|
||||
<div>
|
||||
<p className="eyebrow">Stages</p>
|
||||
<span className="muted">A stage serves a deployment of the routes. With automatic deployment, every change is live right away.</span>
|
||||
</div>
|
||||
</div>
|
||||
{api.stages.length > 0 && <div className="cognito-table"><table className="table">
|
||||
<thead><tr><th>Stage</th><th>Deployment</th><th>Updated</th><th aria-label="Actions"/></tr></thead>
|
||||
<tbody>{api.stages.map((stage) => <tr key={stage.stageName}>
|
||||
<td><code>{stage.stageName}</code></td>
|
||||
<td>{stage.autoDeploy ? 'Automatic' : stage.deploymentId ? <code>{stage.deploymentId}</code> : 'Not deployed'}</td>
|
||||
<td>{formatDateTime(stage.lastUpdatedAt) ?? '—'}</td>
|
||||
<td className="cognito-row-actions-cell">
|
||||
{!stage.autoDeploy && <button className="button compact" type="button" disabled={busy} onClick={() => deployMutation.mutate(stage.stageName)}><Rocket size={13}/>Deploy</button>}
|
||||
{confirmDelete === stage.stageName
|
||||
? <span className="apigateway-route-confirm">
|
||||
<span>Delete {stage.stageName}?</span>
|
||||
<button className="button danger compact" type="button" disabled={busy} onClick={() => deleteMutation.mutate(stage.stageName)}>Delete</button>
|
||||
<button className="button compact" type="button" onClick={() => setConfirmDelete(null)}>Cancel</button>
|
||||
</span>
|
||||
: <button className="icon-btn" type="button" aria-label={`Delete stage ${stage.stageName}`} disabled={busy} onClick={() => setConfirmDelete(stage.stageName)}><Trash2 size={13}/></button>}
|
||||
</td>
|
||||
</tr>)}</tbody>
|
||||
</table></div>}
|
||||
<form className="dynamic-form" aria-label="Add a stage" onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
setNotice('')
|
||||
if (stageName.trim()) createMutation.mutate()
|
||||
}}>
|
||||
<label className="dynamic-field"><span>Stage name</span><input className="input" value={stageName} onChange={(event) => setStageName(event.target.value)} placeholder="prod"/></label>
|
||||
<label className="dynamic-field http-api-check-field">
|
||||
<input type="checkbox" checked={autoDeploy} onChange={(event) => setAutoDeploy(event.target.checked)}/>
|
||||
<span>Deploy changes automatically</span>
|
||||
</label>
|
||||
<button className="button primary" type="submit" disabled={busy || !stageName.trim()}><Plus size={13}/>{createMutation.isPending ? 'Adding' : 'Add stage'}</button>
|
||||
</form>
|
||||
{notice && <p className="cognito-outcome cognito-outcome--ok" role="status">{notice}</p>}
|
||||
{error instanceof Error && <p className="error-text compact-text padded" role="alert">{error.message}</p>}
|
||||
</section>
|
||||
}
|
||||
|
||||
function SecuritySection({cloud, api, runtimeReachable, queryKey}: SectionProps) {
|
||||
return <section className="table-panel" aria-label="Security">
|
||||
<div className="input-row resource-table-bar">
|
||||
<div>
|
||||
<p className="eyebrow"><ShieldCheck size={13}/> Security</p>
|
||||
<span className="muted">A JWT authorizer checks each request's bearer token; protect a route by choosing it under Access above.</span>
|
||||
</div>
|
||||
</div>
|
||||
<AuthorizersSection cloud={cloud} api={api} runtimeReachable={runtimeReachable} queryKey={queryKey}/>
|
||||
<CorsSection cloud={cloud} api={api} runtimeReachable={runtimeReachable} queryKey={queryKey}/>
|
||||
</section>
|
||||
}
|
||||
|
||||
function AuthorizersSection({cloud, api, runtimeReachable, queryKey}: SectionProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const [source, setSource] = useState<'cognito' | 'issuer'>('cognito')
|
||||
const [name, setName] = useState('cognito')
|
||||
const [userPoolId, setUserPoolId] = useState('')
|
||||
const [clientId, setClientId] = useState('')
|
||||
const [issuer, setIssuer] = useState('')
|
||||
const [audience, setAudience] = useState('')
|
||||
const refresh = () => queryClient.invalidateQueries({queryKey})
|
||||
|
||||
const poolsQuery = useQuery({
|
||||
queryKey: ['cloud-resources', cloud, 'userpools'],
|
||||
queryFn: ({signal}) => listCloudResources(cloud, 'userpools', undefined, signal),
|
||||
enabled: runtimeReachable,
|
||||
})
|
||||
const pools = poolsQuery.data ?? []
|
||||
const pool = pools.find((candidate) => candidate.id === userPoolId)
|
||||
const clients = Array.isArray(pool?.metadata.appClients) ? pool.metadata.appClients as Array<{clientId: string; clientName: string}> : []
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => createHttpApiAuthorizer(cloud, api.apiId, source === 'cognito'
|
||||
? {name: name.trim(), userPoolId, userPoolClientId: clientId}
|
||||
: {name: name.trim(), issuer: issuer.trim(), audience: audience.split(',').map((value) => value.trim()).filter(Boolean)}),
|
||||
onSuccess: refresh,
|
||||
})
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (authorizerId: string) => deleteHttpApiAuthorizer(cloud, api.apiId, authorizerId),
|
||||
onSuccess: refresh,
|
||||
})
|
||||
const ready = source === 'cognito' ? Boolean(userPoolId && clientId) : Boolean(issuer.trim() && audience.trim())
|
||||
const error = createMutation.error ?? deleteMutation.error
|
||||
|
||||
return <>
|
||||
{api.authorizers.length > 0 && <div className="cognito-table"><table className="table">
|
||||
<thead><tr><th>Authorizer</th><th>Issuer</th><th>Audience</th><th aria-label="Actions"/></tr></thead>
|
||||
<tbody>{api.authorizers.map((authorizer: HttpApiAuthorizerOverview) => <tr key={authorizer.authorizerId}>
|
||||
<td>{authorizer.name} <small className="muted">· {authorizer.type}</small></td>
|
||||
<td className="mono http-api-wrap">{authorizer.issuer ?? '—'}</td>
|
||||
<td className="mono http-api-wrap">{authorizer.audience.join(', ') || '—'}</td>
|
||||
<td className="cognito-row-actions-cell"><button className="icon-btn" type="button" aria-label={`Delete authorizer ${authorizer.name}`} disabled={!runtimeReachable || deleteMutation.isPending} onClick={() => deleteMutation.mutate(authorizer.authorizerId)}><Trash2 size={13}/></button></td>
|
||||
</tr>)}</tbody>
|
||||
</table></div>}
|
||||
<form className="dynamic-form" aria-label="Add a JWT authorizer" onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (ready && name.trim()) createMutation.mutate()
|
||||
}}>
|
||||
<label className="dynamic-field"><span>Name</span><input className="input" value={name} onChange={(event) => setName(event.target.value)}/></label>
|
||||
<label className="dynamic-field">
|
||||
<span>Tokens from</span>
|
||||
<select className="input" value={source} onChange={(event) => setSource(event.target.value as 'cognito' | 'issuer')}>
|
||||
<option value="cognito">A Cognito user pool</option>
|
||||
<option value="issuer">Another OpenID Connect issuer</option>
|
||||
</select>
|
||||
</label>
|
||||
{source === 'cognito' ? <>
|
||||
<label className="dynamic-field">
|
||||
<span>User pool</span>
|
||||
<select className="input" aria-label="User pool" value={userPoolId} onChange={(event) => { setUserPoolId(event.target.value); setClientId('') }}>
|
||||
<option value="">Select a user pool</option>
|
||||
{pools.map((candidate) => <option key={candidate.id} value={candidate.id}>{candidate.name}</option>)}
|
||||
</select>
|
||||
{poolsQuery.isSuccess && pools.length === 0 && <small>Create a Cognito user pool first.</small>}
|
||||
</label>
|
||||
<label className="dynamic-field">
|
||||
<span>App client</span>
|
||||
<select className="input" aria-label="App client" value={clientId} disabled={!pool} onChange={(event) => setClientId(event.target.value)}>
|
||||
<option value="">Select an app client</option>
|
||||
{clients.map((client) => <option key={client.clientId} value={client.clientId}>{client.clientName || client.clientId}</option>)}
|
||||
</select>
|
||||
<small>Only this client's tokens are accepted.</small>
|
||||
</label>
|
||||
</> : <>
|
||||
<label className="dynamic-field"><span>Issuer URL</span><input className="input" value={issuer} onChange={(event) => setIssuer(event.target.value)} placeholder="https://login.example.com/"/></label>
|
||||
<label className="dynamic-field"><span>Audience</span><input className="input" value={audience} onChange={(event) => setAudience(event.target.value)} placeholder="client-id, another-client-id"/></label>
|
||||
</>}
|
||||
<button className="button primary" type="submit" disabled={!runtimeReachable || !ready || !name.trim() || createMutation.isPending}><Plus size={13}/>{createMutation.isPending ? 'Adding' : 'Add authorizer'}</button>
|
||||
</form>
|
||||
{error instanceof Error && <p className="error-text compact-text padded" role="alert">{error.message}</p>}
|
||||
</>
|
||||
}
|
||||
|
||||
function CorsSection({cloud, api, runtimeReachable, queryKey}: SectionProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const [origins, setOrigins] = useState('')
|
||||
const [methods, setMethods] = useState<string[]>([])
|
||||
const [headers, setHeaders] = useState('')
|
||||
const [exposeHeaders, setExposeHeaders] = useState('')
|
||||
const [maxAge, setMaxAge] = useState('')
|
||||
const [credentials, setCredentials] = useState(false)
|
||||
const [notice, setNotice] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const cors = api.cors
|
||||
setOrigins(cors?.allowOrigins.join(', ') ?? '*')
|
||||
setMethods(cors?.allowMethods ?? ['GET', 'POST', 'OPTIONS'])
|
||||
setHeaders(cors?.allowHeaders.join(', ') ?? 'content-type, authorization')
|
||||
setExposeHeaders(cors?.exposeHeaders.join(', ') ?? '')
|
||||
setMaxAge(cors?.maxAgeSeconds === null || cors?.maxAgeSeconds === undefined ? '' : String(cors.maxAgeSeconds))
|
||||
setCredentials(cors?.allowCredentials ?? false)
|
||||
setNotice('')
|
||||
}, [api.apiId, api.cors])
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (cors: HttpApiCors | null) => updateHttpApiCors(cloud, api.apiId, cors),
|
||||
onSuccess: (cors) => {
|
||||
setNotice(cors ? 'CORS saved. API Gateway now answers preflight requests itself.' : 'CORS removed.')
|
||||
return queryClient.invalidateQueries({queryKey})
|
||||
},
|
||||
})
|
||||
const split = (value: string) => value.split(',').map((item) => item.trim()).filter(Boolean)
|
||||
|
||||
return <>
|
||||
<div className="input-row resource-table-bar"><div>
|
||||
<p className="eyebrow">CORS</p>
|
||||
<span className="muted">{api.cors ? 'On: browsers on the allowed origins can call this API.' : 'Off: browsers on other sites cannot call this API.'}</span>
|
||||
</div></div>
|
||||
<form className="dynamic-form" aria-label="CORS" onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
setNotice('')
|
||||
mutation.mutate({allowOrigins: split(origins), allowMethods: methods, allowHeaders: split(headers), exposeHeaders: split(exposeHeaders), maxAgeSeconds: maxAge.trim() ? Number(maxAge) : null, allowCredentials: credentials})
|
||||
}}>
|
||||
<label className="dynamic-field"><span>Allowed origins</span><input className="input" value={origins} onChange={(event) => setOrigins(event.target.value)} placeholder="https://app.example.com"/><small>Comma separated, or * for any site.</small></label>
|
||||
<div className="dynamic-field"><span>Allowed methods</span><div className="http-api-checks">{CORS_METHODS.map((value) => <label key={value}><input type="checkbox" checked={methods.includes(value)} onChange={() => setMethods((current) => current.includes(value) ? current.filter((item) => item !== value) : [...current, value])}/>{value}</label>)}</div></div>
|
||||
<label className="dynamic-field"><span>Allowed headers</span><input className="input" value={headers} onChange={(event) => setHeaders(event.target.value)}/></label>
|
||||
<label className="dynamic-field"><span>Exposed headers</span><input className="input" value={exposeHeaders} onChange={(event) => setExposeHeaders(event.target.value)} placeholder="x-request-id"/></label>
|
||||
<label className="dynamic-field"><span>Max age (seconds)</span><input className="input" inputMode="numeric" value={maxAge} onChange={(event) => setMaxAge(event.target.value)} placeholder="300"/></label>
|
||||
<label className="dynamic-field http-api-check-field"><input type="checkbox" checked={credentials} onChange={(event) => setCredentials(event.target.checked)}/><span>Allow credentials (cookies)</span></label>
|
||||
<div className="cloudwatch-form-actions">
|
||||
<button className="button primary" type="submit" disabled={!runtimeReachable || mutation.isPending}>{mutation.isPending ? 'Saving' : 'Save CORS'}</button>
|
||||
{api.cors && <button className="button" type="button" disabled={!runtimeReachable || mutation.isPending} onClick={() => { setNotice(''); mutation.mutate(null) }}>Remove CORS</button>}
|
||||
</div>
|
||||
</form>
|
||||
{notice && <p className="cognito-outcome cognito-outcome--ok" role="status">{notice}</p>}
|
||||
{mutation.error instanceof Error && <p className="error-text compact-text padded" role="alert">{mutation.error.message}</p>}
|
||||
</>
|
||||
}
|
||||
|
||||
function HttpTester({cloud, api, runtimeReachable}: {cloud: CloudProvider; api: HttpApiOverview; runtimeReachable: boolean}) {
|
||||
const [stage, setStage] = useState(api.stages[0]?.stageName ?? '$default')
|
||||
const [method, setMethod] = useState('GET')
|
||||
const [path, setPath] = useState(suggestedPath(api.routes))
|
||||
const [query, setQuery] = useState('')
|
||||
const [headers, setHeaders] = useState('{\n "content-type": "application/json"\n}')
|
||||
const [body, setBody] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
setStage(api.stages[0]?.stageName ?? '$default')
|
||||
setPath(suggestedPath(api.routes))
|
||||
// Only when another API is opened; keep what the user typed while this one refreshes.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [api.apiId])
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => testApiGatewayRoute(cloud, api.apiId, {path: path.trim(), method, stage, body, headers: parseHeaders(headers), query}),
|
||||
})
|
||||
const result = mutation.data
|
||||
|
||||
return <section className="table-panel apigateway-test-panel" aria-label="Send a request">
|
||||
<div className="input-row resource-table-bar"><div>
|
||||
<p className="eyebrow"><Send size={13}/> Send a request</p>
|
||||
<span className="muted">Sends a real request to the stage, through its routes and authorizers. For a protected route, add an <code>authorization</code> header with <code>Bearer</code> and a token.</span>
|
||||
</div></div>
|
||||
<div className="apigateway-test-workspace">
|
||||
<form className="apigateway-test-form" onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (!mutation.isPending) mutation.mutate()
|
||||
}}>
|
||||
<label className="dynamic-field"><span>Stage</span>
|
||||
<select className="input" value={stage} onChange={(event) => setStage(event.target.value)}>
|
||||
{api.stages.map((item) => <option key={item.stageName} value={item.stageName}>{item.stageName}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="dynamic-field"><span>Method</span>
|
||||
<select className="input" value={method} onChange={(event) => setMethod(event.target.value)}>{TEST_METHODS.map((value) => <option key={value} value={value}>{value}</option>)}</select>
|
||||
</label>
|
||||
<label className="dynamic-field"><span>Path</span><input className="input" aria-label="Request path" value={path} onChange={(event) => setPath(event.target.value)} placeholder="/notes/123"/></label>
|
||||
<label className="dynamic-field"><span>Query string</span><input className="input" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="page=1"/></label>
|
||||
<label className="dynamic-field"><span>Headers (JSON)</span><textarea className="textarea code-textarea small" value={headers} onChange={(event) => setHeaders(event.target.value)} spellCheck={false}/></label>
|
||||
{!['GET', 'HEAD'].includes(method) && <label className="dynamic-field"><span>Body</span><textarea className="textarea code-textarea" aria-label="Request body" value={body} onChange={(event) => setBody(event.target.value)} spellCheck={false}/></label>}
|
||||
<button className="button primary" type="submit" disabled={!runtimeReachable || api.stages.length === 0 || mutation.isPending}>
|
||||
{mutation.isPending ? <Loader2 size={13}/> : <Send size={13}/>}{mutation.isPending ? 'Sending' : 'Send request'}
|
||||
</button>
|
||||
{api.stages.length === 0 && <small className="muted">Add a stage first.</small>}
|
||||
{mutation.error instanceof Error && <p className="error-text compact-text" role="alert">{mutation.error.message}</p>}
|
||||
</form>
|
||||
<div className="apigateway-test-response">
|
||||
{result ? <div className="apigateway-test-result">
|
||||
<div className="apigateway-test-result-header">
|
||||
<strong>Response <span className={result.statusCode < 400 ? 'status-success' : 'status-error'}>{result.statusCode}</span></strong>
|
||||
<small>{result.durationMs} ms</small>
|
||||
</div>
|
||||
<details><summary>Response headers</summary><pre>{JSON.stringify(result.headers, null, 2)}</pre></details>
|
||||
<pre>{formatResponseBody(result.body)}</pre>
|
||||
</div> : <p className="muted compact-text">Send a request to see the response.</p>}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
function WebSocketTester({cloud, api, runtimeReachable}: {cloud: CloudProvider; api: HttpApiOverview; runtimeReachable: boolean}) {
|
||||
const actionField = api.routeSelectionExpression?.match(/^\$request\.body\.(.+)$/)?.[1] ?? 'action'
|
||||
const firstAction = api.routes.find((route) => !WEBSOCKET_SYSTEM_ROUTES.includes(route.routeKey))?.routeKey ?? 'sendMessage'
|
||||
const [stage, setStage] = useState(api.stages[0]?.stageName ?? '')
|
||||
const [message, setMessage] = useState(JSON.stringify({[actionField]: firstAction, data: 'hello'}, null, 2))
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
setStage(api.stages[0]?.stageName ?? '')
|
||||
// Only when another API is opened.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [api.apiId])
|
||||
|
||||
const mutation = useMutation({mutationFn: () => testWebSocketApi(cloud, api.apiId, {stage, message, query})})
|
||||
const result = mutation.data
|
||||
|
||||
return <section className="table-panel apigateway-test-panel" aria-label="Send a message">
|
||||
<div className="input-row resource-table-bar"><div>
|
||||
<p className="eyebrow"><Send size={13}/> Send a message</p>
|
||||
<span className="muted">Connects to the stage like a client would, sends one message, and shows what comes back. Replies need a route that sends the function's response back.</span>
|
||||
</div></div>
|
||||
<div className="apigateway-test-workspace">
|
||||
<form className="apigateway-test-form" onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (!mutation.isPending && stage) mutation.mutate()
|
||||
}}>
|
||||
<label className="dynamic-field"><span>Stage</span>
|
||||
<select className="input" value={stage} onChange={(event) => setStage(event.target.value)}>
|
||||
{api.stages.map((item) => <option key={item.stageName} value={item.stageName}>{item.stageName}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="dynamic-field"><span>Connection query string</span><input className="input" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="token=abc"/><small>Passed to $connect.</small></label>
|
||||
<label className="dynamic-field"><span>Message</span><textarea className="textarea code-textarea" aria-label="Message" value={message} onChange={(event) => setMessage(event.target.value)} spellCheck={false}/></label>
|
||||
<button className="button primary" type="submit" disabled={!runtimeReachable || !stage || mutation.isPending}>
|
||||
{mutation.isPending ? <Loader2 size={13}/> : <Send size={13}/>}{mutation.isPending ? 'Connecting' : 'Connect and send'}
|
||||
</button>
|
||||
{api.stages.length === 0 && <small className="muted">Add a stage first.</small>}
|
||||
{mutation.error instanceof Error && <p className="error-text compact-text" role="alert">{mutation.error.message}</p>}
|
||||
</form>
|
||||
<div className="apigateway-test-response">
|
||||
{result ? <div className="apigateway-test-result">
|
||||
<div className="apigateway-test-result-header">
|
||||
<strong>{result.connected ? <span className="status-success">Connected</span> : <span className="status-error">Not connected</span>}</strong>
|
||||
<small>{result.durationMs} ms</small>
|
||||
</div>
|
||||
{result.error && <p className="error-text compact-text">{result.error}</p>}
|
||||
{result.connected && result.messages.length === 0 && <p className="muted compact-text">No reply. Check the route sends the function's response back.</p>}
|
||||
{result.messages.map((reply, index) => <pre key={index}>{formatResponseBody(reply)}</pre>)}
|
||||
</div> : <p className="muted compact-text">Send a message to see the replies.</p>}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
function describeTarget(route: HttpApiRouteOverview): string {
|
||||
const integration = route.integration
|
||||
if (!integration) return 'No target'
|
||||
if (integration.functionName) return `Lambda: ${integration.functionName}`
|
||||
if (integration.type === 'HTTP_PROXY') return `HTTP: ${integration.uri ?? ''}`
|
||||
return integration.type
|
||||
}
|
||||
|
||||
function stageUrl(api: HttpApiOverview, stage: HttpApiStageOverview, endpoint: string | null): string | null {
|
||||
if (!endpoint) return null
|
||||
return `${api.protocol === 'WEBSOCKET' ? endpoint.replace(/^http/, 'ws') : endpoint}${stage.invokePath}`
|
||||
}
|
||||
|
||||
/** A path to try first: the first route's path with its parameters filled in. */
|
||||
function suggestedPath(routes: HttpApiRouteOverview[]): string {
|
||||
const path = routes.map((route) => route.routeKey.split(' ')[1]).find(Boolean)
|
||||
if (!path || path === '/') return '/hello'
|
||||
return path.replace(/\{[^}]+\+\}/g, 'anything').replace(/\{[^}]+\}/g, '1')
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
||||
import {render, screen, within} from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import {MemoryRouter} from 'react-router-dom'
|
||||
import {beforeEach, expect, test, vi} from 'vitest'
|
||||
import {PipePanel} from './PipePanel'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
getCloudResource: vi.fn(),
|
||||
startCloudResource: vi.fn(),
|
||||
stopCloudResource: vi.fn(),
|
||||
sendSqsMessage: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/cloudProxyClient', () => api)
|
||||
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
|
||||
|
||||
function pipe(status = 'RUNNING', metadata: Record<string, unknown> = {}): CloudResource {
|
||||
return {
|
||||
id: 'orders-pipe', name: 'orders-pipe', cloud: 'aws', service: 'pipes', type: 'pipe', region: 'us-east-1', createdAt: null, status,
|
||||
metadata: {
|
||||
desiredState: status, source: 'arn:aws:sqs:us-east-1:000000000000:pipe-src', sourceName: 'pipe-src (SQS queue)',
|
||||
target: 'arn:aws:lambda:us-east-1:000000000000:function:worker', targetName: 'worker (Lambda function)',
|
||||
filterPatterns: ['{"body":{"kind":["order"]}}'], batchSize: 5, inputTemplate: '{"id": <$.messageId>}',
|
||||
roleArn: 'arn:aws:iam::000000000000:role/pipe-role',
|
||||
...metadata,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function renderPanel(resource: CloudResource) {
|
||||
api.getCloudResource.mockResolvedValue(resource)
|
||||
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||
return render(<MemoryRouter><QueryClientProvider client={client}><PipePanel resource={resource} runtimeReachable/></QueryClientProvider></MemoryRouter>)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
api.startCloudResource.mockResolvedValue(undefined)
|
||||
api.stopCloudResource.mockResolvedValue(undefined)
|
||||
api.sendSqsMessage.mockResolvedValue({messageId: 'm-1'})
|
||||
})
|
||||
|
||||
test('shows the flow from source through the filter to the target, linked to each service', async () => {
|
||||
renderPanel(pipe())
|
||||
|
||||
const flow = await screen.findByRole('list', {name: 'Event flow'})
|
||||
expect(within(flow).getByRole('link', {name: 'pipe-src (SQS queue)'})).toHaveAttribute('href', '/cloud-explorer/aws/messaging')
|
||||
expect(within(flow).getByRole('link', {name: 'worker (Lambda function)'})).toHaveAttribute('href', '/cloud-explorer/aws/serverless')
|
||||
expect(within(flow).getByText('{"body":{"kind":["order"]}}')).toBeInTheDocument()
|
||||
expect(within(flow).getByText('batches of 5')).toBeInTheDocument()
|
||||
expect(within(flow).getByText('{"id": <$.messageId>}')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('stops a running pipe', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPanel(pipe())
|
||||
|
||||
await user.click(await screen.findByRole('button', {name: 'Stop'}))
|
||||
expect(api.stopCloudResource).toHaveBeenCalledWith('aws', 'pipes', 'orders-pipe')
|
||||
expect(await screen.findByText(/Pipe stopped/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('sends a test message to an SQS source queue', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPanel(pipe())
|
||||
|
||||
const form = await screen.findByRole('form', {name: 'Send test message'})
|
||||
await user.click(within(form).getByRole('button', {name: 'Send to pipe-src'}))
|
||||
expect(api.sendSqsMessage).toHaveBeenCalledWith('aws', 'pipe-src', {messageBody: '{"kind": "order", "id": 1}'})
|
||||
expect(await screen.findByText(/Sent message m-1/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('offers no test message for a stream source, and cannot toggle a pipe mid-change', async () => {
|
||||
renderPanel(pipe('STARTING', {source: 'arn:aws:kinesis:us-east-1:000000000000:stream/clicks', sourceName: 'clicks (Kinesis stream)', desiredState: 'RUNNING'}))
|
||||
|
||||
expect(await screen.findByRole('link', {name: 'clicks (Kinesis stream)'})).toBeInTheDocument()
|
||||
expect(screen.queryByRole('form', {name: 'Send test message'})).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', {name: 'Start'})).toBeDisabled()
|
||||
})
|
||||
@@ -0,0 +1,135 @@
|
||||
import {ArrowRight, RefreshCw} from 'lucide-react'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
import {type ReactNode, useEffect, useState} from 'react'
|
||||
import {Link} from 'react-router-dom'
|
||||
import {getCloudResource, sendSqsMessage, startCloudResource, stopCloudResource} from '@/api/cloudProxyClient'
|
||||
import {cloudQueryKeys} from '@/api/queries/cloudQueries'
|
||||
import {useAccountId} from '@/lib/accountStore'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
|
||||
type Props = {
|
||||
resource?: CloudResource
|
||||
runtimeReachable: boolean
|
||||
}
|
||||
|
||||
/** Where each ARN's service lives in TheCloud, so a step links to the resource behind it. */
|
||||
const CONSOLE_SERVICE: Record<string, string> = {
|
||||
sqs: 'messaging',
|
||||
lambda: 'serverless',
|
||||
sns: 'notifications',
|
||||
events: 'events',
|
||||
states: 'workflows',
|
||||
kinesis: 'kinesis',
|
||||
dynamodb: 'nosql',
|
||||
}
|
||||
|
||||
const SAMPLE_MESSAGE = '{"kind": "order", "id": 1}'
|
||||
|
||||
export function PipePanel({resource, runtimeReachable}: Props) {
|
||||
const accountId = useAccountId()
|
||||
const queryClient = useQueryClient()
|
||||
const [message, setMessage] = useState(SAMPLE_MESSAGE)
|
||||
const [notice, setNotice] = useState('')
|
||||
const pipeKey = ['pipe', accountId, resource?.id]
|
||||
|
||||
// The table row is a ListPipes entry; the filter, template and role come from DescribePipe.
|
||||
const pipeQuery = useQuery({
|
||||
queryKey: pipeKey,
|
||||
queryFn: ({signal}) => getCloudResource('aws', 'pipes', resource?.id ?? '', signal),
|
||||
enabled: Boolean(resource) && runtimeReachable,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setMessage(SAMPLE_MESSAGE)
|
||||
setNotice('')
|
||||
}, [resource?.id])
|
||||
|
||||
const refresh = () => Promise.all([
|
||||
queryClient.invalidateQueries({queryKey: pipeKey}),
|
||||
queryClient.invalidateQueries({queryKey: cloudQueryKeys.resources('aws', 'pipes')}),
|
||||
])
|
||||
|
||||
const stateMutation = useMutation({
|
||||
mutationFn: (running: boolean) => running ? startCloudResource('aws', 'pipes', resource?.id ?? '') : stopCloudResource('aws', 'pipes', resource?.id ?? ''),
|
||||
onSuccess: (_result, running) => {
|
||||
setNotice(running ? 'Pipe started. It reads the source again.' : 'Pipe stopped. Records wait in the source until it starts again.')
|
||||
return refresh()
|
||||
},
|
||||
})
|
||||
const sendMutation = useMutation({
|
||||
mutationFn: (queue: string) => sendSqsMessage('aws', queue, {messageBody: message}),
|
||||
onSuccess: (sent) => setNotice(`Sent message ${sent.messageId} to the source queue. The pipe picks it up within a few seconds.`),
|
||||
})
|
||||
|
||||
if (!resource) {
|
||||
return <section className="table-panel"><div className="empty compact"><h3>Select a pipe</h3><p>Choose a pipe to see where its events flow, start or stop it, or send it a test message.</p></div></section>
|
||||
}
|
||||
|
||||
const pipe = pipeQuery.data ?? resource
|
||||
const metadata = pipe.metadata
|
||||
const state = pipe.status ?? 'UNKNOWN'
|
||||
const running = state === 'RUNNING'
|
||||
const filters = Array.isArray(metadata.filterPatterns) ? metadata.filterPatterns as string[] : []
|
||||
const source = text(metadata.source)
|
||||
const sourceQueue = source.split(':')[2] === 'sqs' ? source.split(':')[5] : ''
|
||||
const pending = stateMutation.isPending || sendMutation.isPending
|
||||
const disabled = !runtimeReachable || pending
|
||||
const error = pipeQuery.error ?? stateMutation.error ?? sendMutation.error
|
||||
|
||||
return <section className="table-panel" style={{marginTop: 16}} aria-label={`Pipe ${pipe.name}`}>
|
||||
<div className="input-row resource-table-bar">
|
||||
<div><p className="eyebrow">Pipe</p><span className="muted">{state}{metadata.desiredState && metadata.desiredState !== state ? ` · going to ${String(metadata.desiredState)}` : ''}{metadata.description ? ` · ${String(metadata.description)}` : ''}</span></div>
|
||||
<div className="cdn-actions">
|
||||
<button className="button" type="button" disabled={disabled || !['RUNNING', 'STOPPED'].includes(state)} onClick={() => { setNotice(''); stateMutation.mutate(!running) }}>{running ? 'Stop' : 'Start'}</button>
|
||||
<button className="button" type="button" disabled={!runtimeReachable || pipeQuery.isFetching} onClick={() => { void refresh() }}><RefreshCw size={14}/>{pipeQuery.isFetching ? 'Loading' : 'Refresh'}</button>
|
||||
</div>
|
||||
</div>
|
||||
{typeof metadata.stateReason === 'string' && metadata.stateReason && <p className="error-text compact-text pipe-reason">{metadata.stateReason}</p>}
|
||||
|
||||
<ol className="pipe-flow" aria-label="Event flow">
|
||||
<PipeStep label="Source" arn={source} name={metadata.sourceName} detail={sourceDetail(metadata)}/>
|
||||
{filters.length > 0 && <li className="pipe-step"><span className="eyebrow">Filter</span>{filters.map((pattern) => <code key={pattern} className="pipe-code">{pattern}</code>)}<ArrowRight size={14} className="pipe-arrow" aria-hidden/></li>}
|
||||
{text(metadata.enrichment) && <PipeStep label="Enrichment" arn={text(metadata.enrichment)} name={null}/>}
|
||||
<PipeStep label="Target" arn={text(metadata.target)} name={metadata.targetName} detail={text(metadata.inputTemplate) ? <>input template <code className="pipe-code">{text(metadata.inputTemplate)}</code></> : undefined}/>
|
||||
</ol>
|
||||
{text(metadata.roleArn) && <p className="muted compact-text pipe-role">Runs as <code>{text(metadata.roleArn)}</code></p>}
|
||||
|
||||
{sourceQueue && <>
|
||||
<div className="input-row resource-table-bar"><div><p className="eyebrow">Try it</p><span className="muted">Send a message to the source queue{filters.length > 0 ? '. Only messages matching the filter reach the target' : ''}.</span></div></div>
|
||||
<form className="dynamic-form" aria-label="Send test message" onSubmit={(event) => { event.preventDefault(); setNotice(''); sendMutation.mutate(sourceQueue) }}>
|
||||
<label className="dynamic-field dynamic-field--span"><span>Message body</span>
|
||||
<textarea className="textarea code-textarea small" rows={3} value={message} onChange={(event) => setMessage(event.target.value)} disabled={disabled} required/>
|
||||
</label>
|
||||
<div><button className="button primary" type="submit" disabled={disabled || !message.trim()}>{sendMutation.isPending ? 'Sending' : `Send to ${sourceQueue}`}</button></div>
|
||||
</form>
|
||||
</>}
|
||||
|
||||
{notice && <p className="cognito-outcome cognito-outcome--ok" role="status">{notice}</p>}
|
||||
{error instanceof Error && <p className="error-text compact-text" role="alert">{error.message}</p>}
|
||||
</section>
|
||||
}
|
||||
|
||||
function PipeStep({label, arn, name, detail}: {label: string; arn: string; name: unknown; detail?: ReactNode}) {
|
||||
const service = CONSOLE_SERVICE[arn.split(':')[2] ?? '']
|
||||
const title = typeof name === 'string' && name ? name : arn
|
||||
return <li className="pipe-step">
|
||||
<span className="eyebrow">{label}</span>
|
||||
{service ? <Link to={`/cloud-explorer/aws/${service}`} className="mono">{title}</Link> : <span className="mono">{title}</span>}
|
||||
<small className="muted mono pipe-arn">{arn}</small>
|
||||
{detail && <small className="muted">{detail}</small>}
|
||||
<ArrowRight size={14} className="pipe-arrow" aria-hidden/>
|
||||
</li>
|
||||
}
|
||||
|
||||
function sourceDetail(metadata: Record<string, unknown>): ReactNode {
|
||||
const parts = [
|
||||
metadata.batchSize ? `batches of ${String(metadata.batchSize)}` : '',
|
||||
metadata.startingPosition ? `from ${String(metadata.startingPosition)}` : '',
|
||||
metadata.topicName ? `topic ${String(metadata.topicName)}` : '',
|
||||
].filter(Boolean)
|
||||
return parts.length > 0 ? parts.join(' · ') : undefined
|
||||
}
|
||||
|
||||
function text(value: unknown): string {
|
||||
return typeof value === 'string' ? value : ''
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Container,
|
||||
Database,
|
||||
Earth,
|
||||
FileBadge,
|
||||
Globe,
|
||||
HardDrive,
|
||||
Hammer,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
MessageSquare,
|
||||
Network,
|
||||
Radio,
|
||||
Scaling,
|
||||
ScrollText,
|
||||
Scale,
|
||||
Server,
|
||||
@@ -36,6 +38,8 @@ import codeDeployIcon from '../../../../aws-icons-svg/Developer-Tools/CodeDeploy
|
||||
import cognitoIcon from '../../../../aws-icons-svg/Security-Identity-Compliance/Cognito.svg'
|
||||
import route53Icon from '../../../../aws-icons-svg/Networking-Content-Delivery/Route-53.svg'
|
||||
import cloudFrontIcon from '../../../../aws-icons-svg/Networking-Content-Delivery/CloudFront.svg'
|
||||
import autoScalingIcon from '../../../../aws-icons-svg/Compute/EC2-Auto-Scaling.svg'
|
||||
import certificateManagerIcon from '../../../../aws-icons-svg/Security-Identity-Compliance/Certificate-Manager.svg'
|
||||
import cloudWatchIcon from '../../../../aws-icons-svg/Management-Governance/CloudWatch.svg'
|
||||
import cloudFormationIcon from '@/assets/aws-services/cloudformation.svg'
|
||||
import cloudWatchLogsIcon from '@/assets/aws-services/cloudwatch-logs.svg'
|
||||
@@ -87,6 +91,7 @@ const SERVICE_ICONS: Record<string, LucideIcon> = {
|
||||
iac: Layers,
|
||||
messaging: MessageSquare,
|
||||
events: Radio,
|
||||
pipes: Workflow,
|
||||
email: MessageSquare,
|
||||
queue: MessageSquare,
|
||||
logs: ScrollText,
|
||||
@@ -95,6 +100,8 @@ const SERVICE_ICONS: Record<string, LucideIcon> = {
|
||||
userpools: UsersRound,
|
||||
dns: Globe,
|
||||
cdn: Earth,
|
||||
autoscaling: Scaling,
|
||||
certificates: FileBadge,
|
||||
monitoring: Activity,
|
||||
loadbalancing: Scale,
|
||||
parameters: SlidersHorizontal,
|
||||
@@ -121,6 +128,7 @@ const AWS_SERVICE_ICON_ASSETS: Record<string, string> = {
|
||||
ecr: ecrIcon,
|
||||
email: sesIcon,
|
||||
events: eventBridgeIcon,
|
||||
pipes: eventBridgeIcon,
|
||||
iac: cloudFormationIcon,
|
||||
iam: iamIcon,
|
||||
kms: kmsIcon,
|
||||
@@ -142,6 +150,8 @@ const AWS_SERVICE_ICON_ASSETS: Record<string, string> = {
|
||||
userpools: cognitoIcon,
|
||||
dns: route53Icon,
|
||||
cdn: cloudFrontIcon,
|
||||
autoscaling: autoScalingIcon,
|
||||
certificates: certificateManagerIcon,
|
||||
monitoring: cloudWatchIcon,
|
||||
workflows: stepFunctionsIcon,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import type {ReactNode} from 'react'
|
||||
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
||||
import {renderHook, waitFor} from '@testing-library/react'
|
||||
import {beforeEach, expect, test, vi} from 'vitest'
|
||||
import {useCloudConsoleHomeData} from './useCloudConsoleHomeData'
|
||||
import type {CloudServiceDescriptor} from '@/types/cloud'
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
listClouds: vi.fn(),
|
||||
listCloudServices: vi.fn(),
|
||||
getCloudStatus: vi.fn(),
|
||||
listCloudResources: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/cloudProxyClient', () => api)
|
||||
|
||||
function descriptor(service: CloudServiceDescriptor['service'], route: string, listable: boolean): CloudServiceDescriptor {
|
||||
return {cloud: 'aws', service, displayName: service, availability: 'available', route, iconKey: service, group: 'Storage', order: 1, listable}
|
||||
}
|
||||
|
||||
function wrapper({children}: {children: ReactNode}) {
|
||||
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||
return <QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
api.listClouds.mockResolvedValue([])
|
||||
api.getCloudStatus.mockResolvedValue({cloud: 'aws', adapterRegistered: true, runtime: 'reachable', endpoint: 'http://floci:4566', checkedAt: '2026-10-25T10:00:00Z', error: null})
|
||||
api.listCloudServices.mockResolvedValue([
|
||||
descriptor('storage', 'storage', true),
|
||||
descriptor('secrets', '/secretsmanager', true),
|
||||
descriptor('msk', 'msk', false),
|
||||
])
|
||||
api.listCloudResources.mockImplementation(async (_cloud: string, service: string) => {
|
||||
if (service === 'msk') throw new Error('501 operation_not_supported')
|
||||
return service === 'storage' ? [{id: 'a'}, {id: 'b'}] : [{id: 'c'}]
|
||||
})
|
||||
})
|
||||
|
||||
test('counts only services the generic list serves, so a pre-SPI page cannot fail the total', async () => {
|
||||
const {result} = renderHook(() => useCloudConsoleHomeData('aws'), {wrapper})
|
||||
|
||||
await waitFor(() => expect(result.current.resourceCount).toBe(3))
|
||||
expect(api.listCloudResources.mock.calls.map((call) => call[1]).sort()).toEqual(['secrets', 'storage'])
|
||||
expect(result.current.resourceDetail).toBe('Normalized resources across available services')
|
||||
expect(result.current.activeServices).toBe(3)
|
||||
|
||||
const msk = result.current.serviceCards.find((card) => card.id === 'msk')
|
||||
expect(msk).toMatchObject({route: '/cloud-explorer/aws/msk', meta: 'open service', count: undefined})
|
||||
expect(result.current.serviceCards.find((card) => card.id === 'secrets')).toMatchObject({route: '/secretsmanager', count: 1})
|
||||
})
|
||||
@@ -32,12 +32,12 @@ export function useCloudConsoleHomeData(cloud: CloudProvider) {
|
||||
const status = statusQuery.data
|
||||
const services = useMemo(() => servicesQuery.data ?? [], [servicesQuery.data])
|
||||
|
||||
// Availability is derived from adapter registration, so an available service
|
||||
// always has the generic list endpoint. Route shape only decides where the
|
||||
// card links, not whether it can be counted -- a legacy absolute-route page
|
||||
// (AWS Secrets Manager) is still counted through its adapter.
|
||||
// Only a service with a registered adapter has the generic list endpoint. Route
|
||||
// shape only decides where the card links: a legacy absolute-route page (AWS
|
||||
// Secrets Manager) is still counted through its adapter, while MSK, available
|
||||
// through its own pre-SPI page, is not listable and is never counted.
|
||||
const countable = useMemo(
|
||||
() => services.filter((service) => service.availability === 'available'),
|
||||
() => services.filter((service) => service.availability === 'available' && service.listable),
|
||||
[services],
|
||||
)
|
||||
|
||||
@@ -85,7 +85,7 @@ export function useCloudConsoleHomeData(cloud: CloudProvider) {
|
||||
: undefined,
|
||||
meta:
|
||||
service.availability === 'available'
|
||||
? isLegacyPage
|
||||
? isLegacyPage || !service.listable
|
||||
? 'open service'
|
||||
: serviceMetaLabel(status, counts?.isLoading ?? false, 'resources')
|
||||
: 'coming soon',
|
||||
|
||||
@@ -6566,6 +6566,19 @@ tr[aria-current="true"] td { background: var(--hover-md); }
|
||||
.cloudwatch-form-actions { display: flex; flex-wrap: wrap; gap: 8px; align-self: end; }
|
||||
.cognito-row-actions-cell { text-align: right; }
|
||||
|
||||
/* API Gateway v2 (HTTP and WebSocket APIs): one column of sections, unlike the REST API's two. */
|
||||
.http-api-workspace { display: grid; gap: 16px; min-width: 0; }
|
||||
.http-api-workspace .dynamic-form { padding: 4px 16px 16px; }
|
||||
.http-api-workspace .apigateway-test-workspace { padding: 0 16px 16px; }
|
||||
.http-api-urls { display: grid; gap: 8px; padding: 0 16px 12px; }
|
||||
.http-api-urls .copy-value { overflow-wrap: anywhere; min-width: 0; }
|
||||
.http-api-check-field { display: flex; align-items: center; gap: 8px; align-self: end; min-height: 36px; }
|
||||
.http-api-check-field span { text-transform: none; font-size: 13px; font-weight: 500; color: var(--text-1); }
|
||||
.http-api-checks { display: flex; flex-wrap: wrap; gap: 4px 12px; }
|
||||
.http-api-checks label { display: inline-flex; align-items: center; gap: 4px; }
|
||||
.http-api-access { min-width: 0; max-width: 220px; }
|
||||
.http-api-wrap { overflow-wrap: anywhere; }
|
||||
|
||||
.metric-chart { position: relative; width: 100%; min-width: 0; }
|
||||
.metric-chart svg { display: block; max-width: 100%; height: auto; overflow: visible; }
|
||||
.metric-chart svg:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
|
||||
@@ -6597,3 +6610,23 @@ tr[aria-current="true"] td { background: var(--hover-md); }
|
||||
.metric-chart-table { margin-top: 8px; }
|
||||
.metric-chart-table summary { cursor: pointer; color: var(--text-3); font-size: 12px; }
|
||||
.metric-chart-table table { margin-top: 8px; }
|
||||
|
||||
/* ACM: the resources using a certificate, and its PEM blocks. */
|
||||
.certificate-users { margin: 0; padding: 0 16px 12px 32px; display: grid; gap: 4px; overflow-wrap: anywhere; }
|
||||
.certificate-pem { display: grid; gap: 12px; padding: 0 16px 16px; }
|
||||
.certificate-pem-block { display: grid; gap: 6px; min-width: 0; }
|
||||
.certificate-pem-head { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
||||
|
||||
/* EventBridge Pipes: source, filter, enrichment and target as one flow. */
|
||||
.pipe-flow { display: flex; flex-wrap: wrap; gap: 8px 28px; margin: 0; padding: 4px 16px 12px; list-style: none; }
|
||||
.pipe-step { position: relative; display: grid; gap: 4px; align-content: start; min-width: 0; max-width: 100%; padding: 10px 12px; border: 1px solid var(--border); border-radius: 4px; background: var(--raised); }
|
||||
.pipe-step .pipe-arrow { position: absolute; top: 50%; right: -22px; transform: translateY(-50%); color: var(--text-3); }
|
||||
.pipe-step:last-child .pipe-arrow { display: none; }
|
||||
.pipe-arn, .pipe-code { overflow-wrap: anywhere; }
|
||||
.pipe-code { font-family: var(--mono); font-size: 11px; white-space: pre-wrap; }
|
||||
.pipe-role, .pipe-reason { padding: 0 16px 12px; margin: 0; overflow-wrap: anywhere; }
|
||||
|
||||
/* EC2 Auto Scaling: capacity fields side by side, instance actions in one row. */
|
||||
.autoscaling-capacity { grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); align-items: end; padding: 4px 16px 12px; }
|
||||
.autoscaling-note { padding: 0 16px 12px; margin: 0; }
|
||||
.autoscaling-instance-actions { display: flex; flex-wrap: wrap; gap: 6px; justify-content: flex-end; }
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// Shared by the REST API and HTTP/WebSocket API panels.
|
||||
|
||||
/** The runtime endpoint as the browser can reach it, for building invoke URLs. */
|
||||
export function invokeEndpoint(runtimeEndpoint?: string): string | null {
|
||||
if (!runtimeEndpoint) return null
|
||||
try {
|
||||
const endpoint = new URL(runtimeEndpoint)
|
||||
if (isDockerBridgeAddress(endpoint.hostname) && typeof window !== 'undefined') {
|
||||
endpoint.hostname = window.location.hostname
|
||||
}
|
||||
return endpoint.toString().replace(/\/$/, '')
|
||||
} catch {
|
||||
return runtimeEndpoint.replace(/\/$/, '')
|
||||
}
|
||||
}
|
||||
|
||||
function isDockerBridgeAddress(hostname: string): boolean {
|
||||
const octets = hostname.split('.').map(Number)
|
||||
return octets.length === 4
|
||||
&& octets.every((octet) => Number.isInteger(octet) && octet >= 0 && octet <= 255)
|
||||
&& octets[0] === 172
|
||||
&& octets[1] >= 16
|
||||
&& octets[1] <= 31
|
||||
}
|
||||
|
||||
export function parseHeaders(value: string): Record<string, string> {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(value || '{}')
|
||||
} catch {
|
||||
throw new Error('Request headers must be valid JSON.')
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed) || Object.values(parsed).some((header) => typeof header !== 'string')) {
|
||||
throw new Error('Request headers must be a JSON object with string values.')
|
||||
}
|
||||
return parsed as Record<string, string>
|
||||
}
|
||||
|
||||
export function formatResponseBody(body: string): string {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(body), null, 2)
|
||||
} catch {
|
||||
return body || '(empty response body)'
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,11 @@ export interface CloudServiceDescriptor {
|
||||
iconKey: string
|
||||
group: ServiceGroup
|
||||
order: number
|
||||
/**
|
||||
* Whether the generic resource list serves it. False for a service still on its
|
||||
* own pre-SPI page (MSK, MSK Connect): available, but not countable.
|
||||
*/
|
||||
listable: boolean
|
||||
}
|
||||
|
||||
export type RuntimeReachability = 'reachable' | 'unavailable' | 'coming_soon'
|
||||
|
||||
@@ -337,6 +337,31 @@ export interface AlarmHistoryItem {
|
||||
summary: string
|
||||
}
|
||||
|
||||
export interface CertificatePem {
|
||||
certificate: string
|
||||
certificateChain: string | null
|
||||
}
|
||||
|
||||
/** The private key is PEM, encrypted with the passphrase given to export. */
|
||||
export interface ExportedCertificate extends CertificatePem {
|
||||
privateKey: string
|
||||
}
|
||||
|
||||
export type CertificateRevocationReason =
|
||||
| 'UNSPECIFIED'
|
||||
| 'KEY_COMPROMISE'
|
||||
| 'CA_COMPROMISE'
|
||||
| 'AFFILIATION_CHANGED'
|
||||
| 'SUPERSEDED'
|
||||
| 'CESSATION_OF_OPERATION'
|
||||
| 'PRIVILEGE_WITHDRAWN'
|
||||
|
||||
export interface AutoScalingCapacityInput {
|
||||
minSize?: number
|
||||
desiredCapacity?: number
|
||||
maxSize?: number
|
||||
}
|
||||
|
||||
export interface DistributionInvalidation {
|
||||
id: string
|
||||
status: string
|
||||
|
||||
@@ -28,6 +28,8 @@ export type KubernetesActionName =
|
||||
| 'deleteFargateProfile'
|
||||
export type ItemActionName = 'list' | 'get' | 'put' | 'delete' | 'query'
|
||||
export type DistributionActionName = 'setEnabled' | 'createInvalidation'
|
||||
export type AutoScalingActionName = 'setCapacity' | 'terminateInstance'
|
||||
export type CertificateActionName = 'download' | 'export' | 'revoke'
|
||||
export type MonitoringActionName = 'listMetrics' | 'getMetricData' | 'alarmHistory' | 'setAlarmState' | 'setAlarmActions'
|
||||
export type UserPoolActionName = 'updateUser' | 'setUserGroups' | 'listGroups' | 'createGroup' | 'deleteGroup' | 'testSignIn' | 'describeClient' | 'updateClientOAuth'
|
||||
export type CapabilityStatus = 'available' | 'blocked' | 'partial' | 'coming_soon'
|
||||
@@ -91,6 +93,8 @@ export interface ServiceSchema {
|
||||
itemActions?: Array<CapabilitySchema<ItemActionName> | ItemActionName>
|
||||
userPoolActions?: Array<CapabilitySchema<UserPoolActionName>>
|
||||
distributionActions?: Array<CapabilitySchema<DistributionActionName>>
|
||||
autoScalingActions?: Array<CapabilitySchema<AutoScalingActionName>>
|
||||
certificateActions?: Array<CapabilitySchema<CertificateActionName>>
|
||||
monitoringActions?: Array<CapabilitySchema<MonitoringActionName>>
|
||||
}
|
||||
filters: FieldSchema[]
|
||||
|
||||
Generated
+72
@@ -10,12 +10,21 @@ importers:
|
||||
|
||||
packages/api:
|
||||
dependencies:
|
||||
'@aws-sdk/client-acm':
|
||||
specifier: ^3.1146.0
|
||||
version: 3.1156.0
|
||||
'@aws-sdk/client-api-gateway':
|
||||
specifier: ^3.1076.0
|
||||
version: 3.1096.0
|
||||
'@aws-sdk/client-apigatewayv2':
|
||||
specifier: ^3.1076.0
|
||||
version: 3.1156.0
|
||||
'@aws-sdk/client-appconfig':
|
||||
specifier: ^3.1076.0
|
||||
version: 3.1130.0
|
||||
'@aws-sdk/client-auto-scaling':
|
||||
specifier: ^3.1146.0
|
||||
version: 3.1156.0
|
||||
'@aws-sdk/client-cloudformation':
|
||||
specifier: ^3.1111.0
|
||||
version: 3.1111.0
|
||||
@@ -85,6 +94,9 @@ importers:
|
||||
'@aws-sdk/client-lightsail':
|
||||
specifier: ^3.1076.0
|
||||
version: 3.1148.0
|
||||
'@aws-sdk/client-pipes':
|
||||
specifier: ^3.1146.0
|
||||
version: 3.1156.0
|
||||
'@aws-sdk/client-rds':
|
||||
specifier: ^3.1076.0
|
||||
version: 3.1076.0
|
||||
@@ -295,14 +307,26 @@ packages:
|
||||
resolution: {integrity: sha512-USI1N1d+NbCcfjeiYLKIl94PSYykl5IIjj4X3WqdL33Ln5qktA6sjXYct0o/gG2Cte7cuzbXKkDBkzUA4VPHrg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/client-acm@3.1156.0':
|
||||
resolution: {integrity: sha512-vRy8mVgYOO1E/YXDlBuXCUKsmNKkVjKFIW2ZS30kWZydbS6CnqFxNkMajDzqJcm+PZ74rwgnhNHsJkc+UIEMzQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/client-api-gateway@3.1096.0':
|
||||
resolution: {integrity: sha512-RPzDojG3/YTdOMWGQ9mhE51db0IxL8YSjN9fKe5UawwgGrLa915ik71SMYk4z7hywkANuPTqRtt/kNJ863/j0g==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/client-apigatewayv2@3.1156.0':
|
||||
resolution: {integrity: sha512-+PbvEXc5ZbfDARcjf6hCeWneq0pQ/ToyuhujoA/2c5eNXnEzi4SSAvh9lMDyWMx/iXOSYrARvdBw0up0D8sFag==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/client-appconfig@3.1130.0':
|
||||
resolution: {integrity: sha512-CPVT+sEZLpYYLs6gIUr+O76QWzwjCIMp8ih7VZOQTzUhM8jXlDBwarupCMOkk830STVmKu5zhQjCzYI8i+XU3A==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/client-auto-scaling@3.1156.0':
|
||||
resolution: {integrity: sha512-iv4obhbLigmUcxWnUNd1CJ+jQ8QtCbPg9Ae0dSmv7D1hq/jw50eoMXW8UblNKnOl4FSwWeCtWs4aoUUQYTl7IQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/client-cloudformation@3.1111.0':
|
||||
resolution: {integrity: sha512-tZHW6n0vbA1fiJZtxCWVFWUssP47es/EAu7kH2FId6nY/svNoQVHYTqA7ALhLqB+W6ASzNTpnJeCq8evoXlKBQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@@ -395,6 +419,10 @@ packages:
|
||||
resolution: {integrity: sha512-sGXHmokMg79uwzFb9aAjC3gSE5LllEJii6aARHbnbDESFGIT73ZbUGBuNDWmF9N4DkkBAPlDeoATOHe4p7Qp5Q==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/client-pipes@3.1156.0':
|
||||
resolution: {integrity: sha512-sHtPycrT80c+bEkQgFJIh+CzpuqjGhhdGtVEQfBU+APmHF01SZEJrbnY7MZEHEJZl0iVgLuasxtlYQO+rhAkoQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/client-rds-data@3.1144.0':
|
||||
resolution: {integrity: sha512-6WIjf0rUcT2G//bjsHET1BbQ4sBJ/oR5CEon7ehOI86tr1zQj8XggGqJBuRHvZ+jePabWkTmQcrkpra58Jn2gQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@@ -2889,6 +2917,17 @@ snapshots:
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/client-acm@3.1156.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/credential-provider-node': 3.972.88
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/fetch-http-handler': 5.8.0
|
||||
'@smithy/node-http-handler': 4.12.1
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/client-api-gateway@3.1096.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.1
|
||||
@@ -2901,6 +2940,17 @@ snapshots:
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/client-apigatewayv2@3.1156.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/credential-provider-node': 3.972.88
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/fetch-http-handler': 5.8.0
|
||||
'@smithy/node-http-handler': 4.12.1
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/client-appconfig@3.1130.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.0
|
||||
@@ -2912,6 +2962,17 @@ snapshots:
|
||||
'@smithy/types': 4.17.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/client-auto-scaling@3.1156.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/credential-provider-node': 3.972.88
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/fetch-http-handler': 5.8.0
|
||||
'@smithy/node-http-handler': 4.12.1
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/client-cloudformation@3.1111.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.8
|
||||
@@ -3178,6 +3239,17 @@ snapshots:
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/client-pipes@3.1156.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/credential-provider-node': 3.972.88
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/fetch-http-handler': 5.8.0
|
||||
'@smithy/node-http-handler': 4.12.1
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/client-rds-data@3.1144.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
|
||||
Reference in New Issue
Block a user