Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bb5ca85a88 | |||
| 6acd3656a1 | |||
| 6dd33b8f65 | |||
| f4b3e45f3d | |||
| 91ce11274d | |||
| 7669b5387f | |||
| a86bee443b | |||
| 9cb164f211 | |||
| 6586f48539 | |||
| 5c3c73c582 | |||
| 2b4d4b146d | |||
| ac58bfc98b | |||
| 51a0a60a73 | |||
| a5c9de25d0 | |||
| 238bb1a3d5 | |||
| d411f632c9 | |||
| 954e2e8a47 | |||
| ce444f0851 | |||
| 18c1f5067f | |||
| d457c08b12 | |||
| 4bc8da6ee1 |
@@ -78,6 +78,8 @@ cd packages/api && bun run scripts/service-matrix.ts
|
||||
| Networking | Networking | Yes (list) | No | No |
|
||||
| Networking | ELB / Load Balancing | Yes (list, create, update, delete, inspect) | No | No |
|
||||
| Networking | ELB Target Groups / Target Groups | Yes (list, create, delete, inspect) | No | No |
|
||||
| Networking | Route 53 / DNS | Yes (list, create, delete, inspect) | No | No |
|
||||
| Networking | CloudFront / CDN | Yes (list, create, delete, inspect) | No | No |
|
||||
| Integration | SQS / Messaging / Pub/Sub | Yes (list, create, inspect, delete) | Yes (list, create, delete, inspect) | Yes (list, create, inspect, delete) |
|
||||
| Integration | API Gateway | Yes (list, create, delete, inspect) | No | No |
|
||||
| Integration | SNS / Notifications | Yes (list, create, inspect, delete) | No | No |
|
||||
@@ -95,13 +97,14 @@ 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 |
|
||||
| Observability | CloudWatch / Monitoring | Yes (list, create, delete, inspect) | No | No |
|
||||
| Observability | CloudWatch Logs / Logs | Yes (list, create, delete, inspect) | No | No |
|
||||
|
||||
Console Home is available for all three clouds.
|
||||
|
||||
Runtime gaps — an adapter exists but the local runtime does not implement it:
|
||||
|
||||
- Azure Serverless: the Floci-AZ runtime returns 501 NotImplemented for the Azure Functions endpoint.
|
||||
- Azure Serverless: TheCloud's Azure runtime returns 501 NotImplemented for the Azure Functions endpoint.
|
||||
|
||||
Services marked `No` render as a disabled sidebar row whose tooltip carries the
|
||||
server-supplied reason. Adding one is a catalog row in
|
||||
@@ -256,14 +259,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>
|
||||
|
||||
@@ -11,8 +11,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@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-cloudformation": "^3.1111.0",
|
||||
"@aws-sdk/client-cloudfront": "^3.1146.0",
|
||||
"@aws-sdk/client-cloudwatch": "^3.1076.0",
|
||||
"@aws-sdk/client-cloudwatch-logs": "^3.1096.0",
|
||||
"@aws-sdk/client-codebuild": "^3.1076.0",
|
||||
@@ -36,6 +38,7 @@
|
||||
"@aws-sdk/client-lightsail": "^3.1076.0",
|
||||
"@aws-sdk/client-rds": "^3.1076.0",
|
||||
"@aws-sdk/client-rds-data": "^3.1076.0",
|
||||
"@aws-sdk/client-route-53": "^3.1146.0",
|
||||
"@aws-sdk/client-s3": "^3.1076.0",
|
||||
"@aws-sdk/client-secrets-manager": "^3.1076.0",
|
||||
"@aws-sdk/client-sesv2": "^3.1076.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,182 @@
|
||||
import {describe, expect, test} from 'bun:test'
|
||||
import {
|
||||
CreateDistributionCommand,
|
||||
CreateInvalidationCommand,
|
||||
DeleteDistributionCommand,
|
||||
GetDistributionCommand,
|
||||
GetDistributionConfigCommand,
|
||||
ListDistributionsCommand,
|
||||
ListInvalidationsCommand,
|
||||
UpdateDistributionCommand,
|
||||
type CloudFrontClient,
|
||||
type DistributionConfig,
|
||||
} from '@aws-sdk/client-cloudfront'
|
||||
import {HeadBucketCommand, type S3Client} from '@aws-sdk/client-s3'
|
||||
import {AwsCdnAdapter} from './AwsCdnAdapter'
|
||||
import {ConflictError, 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}
|
||||
}
|
||||
|
||||
function awsError(name: string, status = 400): Error {
|
||||
return Object.assign(new Error(name), {name, $metadata: {httpStatusCode: status}})
|
||||
}
|
||||
|
||||
const ID = 'E7CPM0P0G0VC8D'
|
||||
const origin = {Id: 's3-site', DomainName: 'site.s3.amazonaws.com', S3OriginConfig: {OriginAccessIdentity: ''}}
|
||||
const config = {
|
||||
CallerReference: 'r', Comment: 'Docs site', Enabled: true, DefaultRootObject: 'index.html',
|
||||
Origins: {Quantity: 1, Items: [origin]},
|
||||
DefaultCacheBehavior: {TargetOriginId: 's3-site', ViewerProtocolPolicy: 'allow-all', AllowedMethods: {Quantity: 2, Items: ['GET', 'HEAD']}},
|
||||
CacheBehaviors: {Quantity: 1, Items: [{PathPattern: '/api/*', TargetOriginId: 'custom-api', ViewerProtocolPolicy: 'https-only', AllowedMethods: {Quantity: 7, Items: ['GET', 'HEAD', 'OPTIONS', 'PUT', 'POST', 'PATCH', 'DELETE']}}]},
|
||||
} as DistributionConfig
|
||||
const distribution = {Id: ID, ARN: `arn:aws:cloudfront::000000000000:distribution/${ID}`, DomainName: 'e7cpm0p0g0vc8d.cloudfront.net', Status: 'Deployed', LastModifiedTime: new Date('2026-10-24T18:00:00Z'), DistributionConfig: config}
|
||||
|
||||
function adapter(cloudfront: (command: object) => SendResult | Promise<SendResult>, s3: (command: object) => SendResult | Promise<SendResult> = () => ({})) {
|
||||
const cf = stub(cloudfront)
|
||||
const bucket = stub(s3)
|
||||
return {adapter: new AwsCdnAdapter(cf.client as unknown as CloudFrontClient, bucket.client as unknown as S3Client, 'http://floci:4566'), sent: cf.sent, s3Sent: bucket.sent}
|
||||
}
|
||||
|
||||
describe('AwsCdnAdapter distributions', () => {
|
||||
test('lists distributions with their local URL, origins and description, and filters them', async () => {
|
||||
const {adapter: cdn} = adapter((command) => command instanceof ListDistributionsCommand
|
||||
? {DistributionList: {Items: [{...distribution, Enabled: true, Comment: 'Docs site', Origins: config.Origins}], IsTruncated: false}}
|
||||
: {})
|
||||
|
||||
const [listed] = await cdn.list()
|
||||
expect(listed).toMatchObject({id: ID, name: 'e7cpm0p0g0vc8d.cloudfront.net', status: 'Deployed'})
|
||||
expect(listed.metadata).toMatchObject({viewerUrl: 'http://e7cpm0p0g0vc8d.cloudfront.localhost:4566/', enabled: true, comment: 'Docs site', originDomains: ['site.s3.amazonaws.com']})
|
||||
expect(await cdn.list({search: 'site.s3'})).toHaveLength(1)
|
||||
expect(await cdn.list({search: 'nothing'})).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('describes behaviours and recent invalidations, and treats a missing distribution as absent', async () => {
|
||||
const {adapter: cdn} = adapter((command) => {
|
||||
if (command instanceof GetDistributionCommand && command.input.Id === ID) return {Distribution: distribution, ETag: 'e1'}
|
||||
if (command instanceof GetDistributionCommand) throw awsError('NoSuchDistribution', 404)
|
||||
if (command instanceof ListInvalidationsCommand) return {InvalidationList: {Items: [{Id: 'I1', Status: 'Completed', CreateTime: new Date('2026-10-24T18:05:00Z')}]}}
|
||||
return {}
|
||||
})
|
||||
|
||||
const described = await cdn.get(ID)
|
||||
expect(described?.metadata).toMatchObject({
|
||||
defaultRootObject: 'index.html',
|
||||
behaviors: [
|
||||
{pathPattern: '/api/*', originId: 'custom-api', viewerProtocol: 'https-only', methods: ['GET', 'HEAD', 'OPTIONS', 'PUT', 'POST', 'PATCH', 'DELETE']},
|
||||
{pathPattern: 'Default (*)', originId: 's3-site', viewerProtocol: 'allow-all', methods: ['GET', 'HEAD']},
|
||||
],
|
||||
invalidations: [{id: 'I1', status: 'Completed', createdAt: '2026-10-24T18:05:00.000Z', paths: []}],
|
||||
})
|
||||
expect(await cdn.get('EMISSING')).toBeNull()
|
||||
})
|
||||
|
||||
test('creates a cached website from a bucket that exists', async () => {
|
||||
const {adapter: cdn, sent, s3Sent} = adapter((command) => command instanceof CreateDistributionCommand
|
||||
? {Distribution: {...distribution, DistributionConfig: command.input.DistributionConfig}}
|
||||
: {})
|
||||
|
||||
await cdn.create({values: {originType: 's3', bucket: 'site', defaultRootObject: '/index.html', comment: 'Docs'}})
|
||||
|
||||
expect(s3Sent[0]).toBeInstanceOf(HeadBucketCommand)
|
||||
const created = (sent[0] as CreateDistributionCommand).input.DistributionConfig!
|
||||
expect(created).toMatchObject({Enabled: true, Comment: 'Docs', DefaultRootObject: 'index.html', Origins: {Quantity: 1, Items: [{Id: 's3-site', DomainName: 'site.s3.amazonaws.com'}]}})
|
||||
expect(created.DefaultCacheBehavior).toMatchObject({TargetOriginId: 's3-site', ViewerProtocolPolicy: 'allow-all', CachePolicyId: '658327ea-f89d-4fab-a63d-7e88639e58f6', AllowedMethods: {Quantity: 2}})
|
||||
expect(created.DefaultCacheBehavior?.OriginRequestPolicyId).toBeUndefined()
|
||||
})
|
||||
|
||||
test('creates an uncached API in front of a custom origin, forwarding the viewer request', async () => {
|
||||
const {adapter: cdn, sent, s3Sent} = adapter((command) => command instanceof CreateDistributionCommand
|
||||
? {Distribution: {...distribution, DistributionConfig: command.input.DistributionConfig}}
|
||||
: {})
|
||||
|
||||
await cdn.create({values: {originType: 'custom', originDomain: 'API.example.com', originProtocol: 'http-only', originPort: '8080', allowedMethods: 'all', viewerProtocol: 'redirect-to-https'}})
|
||||
|
||||
expect(s3Sent).toHaveLength(0)
|
||||
const created = (sent[0] as CreateDistributionCommand).input.DistributionConfig!
|
||||
expect(created.Origins?.Items?.[0]).toMatchObject({Id: 'custom-api.example.com', DomainName: 'api.example.com', CustomOriginConfig: {HTTPPort: 8080, HTTPSPort: 8080, OriginProtocolPolicy: 'http-only'}})
|
||||
expect(created.DefaultCacheBehavior).toMatchObject({
|
||||
ViewerProtocolPolicy: 'redirect-to-https', AllowedMethods: {Quantity: 7},
|
||||
CachePolicyId: '4135ea2d-6df8-44a3-9df3-4b5a84be39ad', OriginRequestPolicyId: 'b689b0a8-53d0-40ab-baf2-68738e2966ac',
|
||||
})
|
||||
})
|
||||
|
||||
test('refuses a missing bucket and invalid settings before creating anything', async () => {
|
||||
const {adapter: cdn, sent} = adapter(() => ({}), () => { throw awsError('NotFound', 404) })
|
||||
|
||||
await expect(cdn.create({values: {originType: 's3', bucket: 'nope'}})).rejects.toThrow(/no S3 bucket named nope/)
|
||||
await expect(cdn.create({values: {originType: 's3', bucket: 'Bad_Name'}})).rejects.toThrow(ValidationError)
|
||||
await expect(cdn.create({values: {originType: 'custom', originDomain: 'not a host'}})).rejects.toThrow(ValidationError)
|
||||
await expect(cdn.create({values: {originType: 'custom', originDomain: 'api.example.com', originPort: '70000'}})).rejects.toThrow(/originPort/)
|
||||
await expect(cdn.create({values: {originType: 'ftp'}})).rejects.toThrow(/originType/)
|
||||
expect(sent.some((command) => command instanceof CreateDistributionCommand)).toBe(false)
|
||||
})
|
||||
|
||||
test('disables an enabled distribution, then deletes it with the new ETag', async () => {
|
||||
const {adapter: cdn, sent} = adapter((command) => {
|
||||
if (command instanceof GetDistributionCommand) return {Distribution: distribution, ETag: 'e1'}
|
||||
if (command instanceof GetDistributionConfigCommand) return {DistributionConfig: config, ETag: 'e1'}
|
||||
if (command instanceof UpdateDistributionCommand) return {Distribution: {...distribution, Status: 'Deployed'}, ETag: 'e2'}
|
||||
return {}
|
||||
})
|
||||
|
||||
await cdn.delete(ID)
|
||||
|
||||
const update = sent.find((command) => command instanceof UpdateDistributionCommand) as UpdateDistributionCommand
|
||||
expect(update.input).toMatchObject({Id: ID, IfMatch: 'e1', DistributionConfig: {Enabled: false, Comment: 'Docs site'}})
|
||||
expect((sent.find((command) => command instanceof DeleteDistributionCommand) as DeleteDistributionCommand).input).toEqual({Id: ID, IfMatch: 'e2'})
|
||||
})
|
||||
|
||||
test('says to try again while CloudFront is still deploying the disable', async () => {
|
||||
const {adapter: cdn, sent} = adapter((command) => {
|
||||
if (command instanceof GetDistributionCommand) return {Distribution: distribution, ETag: 'e1'}
|
||||
if (command instanceof GetDistributionConfigCommand) return {DistributionConfig: config, ETag: 'e1'}
|
||||
if (command instanceof UpdateDistributionCommand) return {Distribution: {...distribution, Status: 'InProgress'}, ETag: 'e2'}
|
||||
return {}
|
||||
})
|
||||
|
||||
await expect(cdn.delete(ID)).rejects.toThrow(ConflictError)
|
||||
expect(sent.some((command) => command instanceof DeleteDistributionCommand)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AwsCdnAdapter actions', () => {
|
||||
test('enables or disables a distribution, keeping the rest of its configuration', async () => {
|
||||
const {adapter: cdn, sent} = adapter((command) => {
|
||||
if (command instanceof GetDistributionConfigCommand) return {DistributionConfig: config, ETag: 'e1'}
|
||||
if (command instanceof GetDistributionCommand) return {Distribution: {...distribution, DistributionConfig: {...config, Enabled: false}}, ETag: 'e2'}
|
||||
if (command instanceof ListInvalidationsCommand) return {InvalidationList: {Items: []}}
|
||||
return {}
|
||||
})
|
||||
|
||||
const disabled = await cdn.setDistributionEnabled(ID, false)
|
||||
expect(disabled.metadata.enabled).toBe(false)
|
||||
expect((sent.find((command) => command instanceof UpdateDistributionCommand) as UpdateDistributionCommand).input.DistributionConfig).toEqual({...config, Enabled: false})
|
||||
|
||||
// The stored configuration is already enabled: nothing to change.
|
||||
const before = sent.length
|
||||
await cdn.setDistributionEnabled(ID, true)
|
||||
expect(sent.slice(before).some((command) => command instanceof UpdateDistributionCommand)).toBe(false)
|
||||
})
|
||||
|
||||
test('invalidates paths that start with a slash', async () => {
|
||||
const {adapter: cdn, sent} = adapter((command) => command instanceof CreateInvalidationCommand
|
||||
? {Invalidation: {Id: 'I2', Status: 'Completed', CreateTime: new Date('2026-10-24T18:10:00Z'), InvalidationBatch: command.input.InvalidationBatch}}
|
||||
: {})
|
||||
|
||||
expect(await cdn.createDistributionInvalidation(ID, [' /* ', '/index.html', '/*'])).toEqual({id: 'I2', status: 'Completed', createdAt: '2026-10-24T18:10:00.000Z', paths: ['/*', '/index.html']})
|
||||
await expect(cdn.createDistributionInvalidation(ID, ['index.html'])).rejects.toThrow(/must start with \//)
|
||||
await expect(cdn.createDistributionInvalidation(ID, [])).rejects.toThrow(/at least one path/)
|
||||
expect(sent.filter((command) => command instanceof CreateInvalidationCommand)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,301 @@
|
||||
import {
|
||||
CreateDistributionCommand,
|
||||
CreateInvalidationCommand,
|
||||
DeleteDistributionCommand,
|
||||
GetDistributionCommand,
|
||||
GetDistributionConfigCommand,
|
||||
ListDistributionsCommand,
|
||||
ListInvalidationsCommand,
|
||||
UpdateDistributionCommand,
|
||||
type CacheBehavior,
|
||||
type CloudFrontClient,
|
||||
type DefaultCacheBehavior,
|
||||
type Distribution,
|
||||
type DistributionConfig,
|
||||
type DistributionSummary,
|
||||
type Origin,
|
||||
} from '@aws-sdk/client-cloudfront'
|
||||
import {HeadBucketCommand, type S3Client} from '@aws-sdk/client-s3'
|
||||
import {
|
||||
CDN_ALLOWED_METHODS,
|
||||
CDN_BUCKET_PATTERN,
|
||||
CDN_ORIGIN_DOMAIN_PATTERN,
|
||||
CDN_ORIGIN_PROTOCOLS,
|
||||
CDN_ORIGIN_TYPES,
|
||||
CDN_VIEWER_PROTOCOLS,
|
||||
awsCdnSchema,
|
||||
} from '../cloud-spi/cdnSchema'
|
||||
import {ConflictError, NotFoundError, ValidationError} from '../cloud-spi/errors'
|
||||
import type {CloudResource, CloudServiceAdapter, CreateResourceInput, DistributionInvalidation, ResourceQuery, ServiceSchema} from '../cloud-spi/types'
|
||||
|
||||
/** AWS managed cache and origin request policies, which CloudFront and the runtime both know by ID. */
|
||||
const CACHING_OPTIMIZED = '658327ea-f89d-4fab-a63d-7e88639e58f6'
|
||||
const CACHING_DISABLED = '4135ea2d-6df8-44a3-9df3-4b5a84be39ad'
|
||||
const ALL_VIEWER_EXCEPT_HOST_HEADER = 'b689b0a8-53d0-40ab-baf2-68738e2966ac'
|
||||
const READ_METHODS = ['GET', 'HEAD']
|
||||
const ALL_METHODS = ['GET', 'HEAD', 'OPTIONS', 'PUT', 'POST', 'PATCH', 'DELETE']
|
||||
/** CloudFront's limit on paths in one invalidation that are not wildcards. */
|
||||
const MAX_INVALIDATION_PATHS = 3000
|
||||
const RECENT_INVALIDATIONS = 5
|
||||
|
||||
/**
|
||||
* Talks to CloudFront distributions, verified against Floci core (community, `latest`).
|
||||
*
|
||||
* Notes from probing the runtime:
|
||||
* - A distribution is `Deployed` as soon as it is created or updated; AWS takes minutes.
|
||||
* - `DeleteDistribution` refuses an enabled distribution with `DistributionNotDisabled`
|
||||
* (409), as on AWS, so delete disables it first.
|
||||
* - A missing distribution is `NoSuchDistribution` with HTTP 404.
|
||||
* - Viewer requests reach a distribution at `{id}.cloudfront.localhost` on the runtime's
|
||||
* port. Browsers and curl resolve every `.localhost` name to the machine itself (RFC 6761),
|
||||
* so no DNS or hosts-file entry is needed.
|
||||
*/
|
||||
export class AwsCdnAdapter implements CloudServiceAdapter {
|
||||
readonly cloud = 'aws' as const
|
||||
readonly service = 'cdn' as const
|
||||
|
||||
constructor(
|
||||
private readonly cloudfront: CloudFrontClient,
|
||||
private readonly s3: S3Client,
|
||||
private readonly endpoint = process.env.FLOCI_ENDPOINT ?? 'http://localhost:4566',
|
||||
) {}
|
||||
|
||||
schema(): ServiceSchema {
|
||||
return awsCdnSchema()
|
||||
}
|
||||
|
||||
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
|
||||
const summaries: DistributionSummary[] = []
|
||||
let marker: string | undefined
|
||||
do {
|
||||
const res = await this.cloudfront.send(new ListDistributionsCommand({...(marker ? {Marker: marker} : {})}))
|
||||
summaries.push(...(res.DistributionList?.Items ?? []))
|
||||
marker = res.DistributionList?.IsTruncated ? res.DistributionList.NextMarker : undefined
|
||||
} while (marker)
|
||||
|
||||
const search = query.search?.trim().toLowerCase()
|
||||
return summaries.map((summary) => this.toResource(summary, summary.Enabled ?? false, summary.Comment, summary.Origins?.Items))
|
||||
.filter((resource) => !search || [resource.id, resource.name, ...stringList(resource.metadata.originDomains)].some((value) => value.toLowerCase().includes(search)))
|
||||
}
|
||||
|
||||
async get(id: string): Promise<CloudResource | null> {
|
||||
let distribution: Distribution | undefined
|
||||
try {
|
||||
distribution = (await this.cloudfront.send(new GetDistributionCommand({Id: id}))).Distribution
|
||||
} catch (error) {
|
||||
if (errorName(error) === 'NoSuchDistribution') return null
|
||||
throw error
|
||||
}
|
||||
if (!distribution) return null
|
||||
const config = distribution.DistributionConfig
|
||||
const invalidations = await this.recentInvalidations(id)
|
||||
const resource = this.toResource(distribution, config?.Enabled ?? false, config?.Comment, config?.Origins?.Items)
|
||||
resource.metadata = {
|
||||
...resource.metadata,
|
||||
defaultRootObject: config?.DefaultRootObject || null,
|
||||
aliases: config?.Aliases?.Items ?? [],
|
||||
behaviors: behaviorsOf(config),
|
||||
invalidations,
|
||||
}
|
||||
return resource
|
||||
}
|
||||
|
||||
async create(input: CreateResourceInput): Promise<CloudResource> {
|
||||
const values = input.values
|
||||
const originType = oneOf(values.originType ?? 's3', CDN_ORIGIN_TYPES, 'originType')
|
||||
const viewerProtocol = oneOf(values.viewerProtocol || 'allow-all', CDN_VIEWER_PROTOCOLS, 'viewerProtocol')
|
||||
const methods = oneOf(values.allowedMethods || 'read', CDN_ALLOWED_METHODS, 'allowedMethods')
|
||||
const comment = text(values.comment)
|
||||
if (comment.length > 128) throw new ValidationError('comment must be at most 128 characters')
|
||||
const defaultRootObject = text(values.defaultRootObject).replace(/^\/+/, '')
|
||||
|
||||
const origin = originType === 's3' ? await this.s3Origin(text(values.bucket)) : customOrigin(values)
|
||||
const allowed = methods === 'all' ? ALL_METHODS : READ_METHODS
|
||||
const behavior: DefaultCacheBehavior = {
|
||||
TargetOriginId: origin.Id,
|
||||
ViewerProtocolPolicy: viewerProtocol,
|
||||
AllowedMethods: {Quantity: allowed.length, Items: allowed as never, CachedMethods: {Quantity: 2, Items: ['GET', 'HEAD']}},
|
||||
Compress: true,
|
||||
// A website is cached; an API, which sees every method, is not, and gets the viewer's request.
|
||||
CachePolicyId: methods === 'all' ? CACHING_DISABLED : CACHING_OPTIMIZED,
|
||||
...(methods === 'all' && originType === 'custom' ? {OriginRequestPolicyId: ALL_VIEWER_EXCEPT_HOST_HEADER} : {}),
|
||||
}
|
||||
const res = await this.cloudfront.send(new CreateDistributionCommand({
|
||||
DistributionConfig: {
|
||||
CallerReference: crypto.randomUUID(),
|
||||
Comment: comment,
|
||||
Enabled: true,
|
||||
DefaultRootObject: defaultRootObject || undefined,
|
||||
Origins: {Quantity: 1, Items: [origin]},
|
||||
DefaultCacheBehavior: behavior,
|
||||
},
|
||||
}))
|
||||
if (!res.Distribution) throw new ValidationError('CloudFront did not return the new distribution')
|
||||
const config = res.Distribution.DistributionConfig
|
||||
return this.toResource(res.Distribution, config?.Enabled ?? true, config?.Comment, config?.Origins?.Items)
|
||||
}
|
||||
|
||||
/** CloudFront deletes only a disabled distribution, so an enabled one is disabled first. */
|
||||
async delete(id: string): Promise<void> {
|
||||
let etag = (await this.cloudfront.send(new GetDistributionCommand({Id: id}))).ETag
|
||||
const current = await this.cloudfront.send(new GetDistributionConfigCommand({Id: id}))
|
||||
if (current.DistributionConfig?.Enabled) {
|
||||
const disabled = await this.cloudfront.send(new UpdateDistributionCommand({
|
||||
Id: id,
|
||||
IfMatch: current.ETag,
|
||||
DistributionConfig: {...current.DistributionConfig, Enabled: false},
|
||||
}))
|
||||
if (disabled.Distribution?.Status !== 'Deployed') {
|
||||
throw new ConflictError('The distribution is now disabled. CloudFront deletes it once the change is deployed: try again when its status is Deployed')
|
||||
}
|
||||
etag = disabled.ETag
|
||||
}
|
||||
try {
|
||||
await this.cloudfront.send(new DeleteDistributionCommand({Id: id, IfMatch: etag}))
|
||||
} catch (error) {
|
||||
if (errorName(error) === 'DistributionNotDisabled') {
|
||||
throw new ConflictError('CloudFront is still disabling the distribution: try again when its status is Deployed')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async setDistributionEnabled(id: string, enabled: boolean): Promise<CloudResource> {
|
||||
if (typeof enabled !== 'boolean') throw new ValidationError('enabled must be true or false')
|
||||
const current = await this.cloudfront.send(new GetDistributionConfigCommand({Id: id}))
|
||||
if (!current.DistributionConfig) throw new NotFoundError(`Distribution ${id} was not found`)
|
||||
if (current.DistributionConfig.Enabled !== enabled) {
|
||||
await this.cloudfront.send(new UpdateDistributionCommand({
|
||||
Id: id,
|
||||
IfMatch: current.ETag,
|
||||
DistributionConfig: {...current.DistributionConfig, Enabled: enabled},
|
||||
}))
|
||||
}
|
||||
const resource = await this.get(id)
|
||||
if (!resource) throw new NotFoundError(`Distribution ${id} was not found`)
|
||||
return resource
|
||||
}
|
||||
|
||||
async createDistributionInvalidation(id: string, paths: string[]): Promise<DistributionInvalidation> {
|
||||
if (!Array.isArray(paths)) throw new ValidationError('paths must be a list')
|
||||
const items = [...new Set(paths.map((path) => String(path).trim()).filter(Boolean))]
|
||||
if (items.length === 0) throw new ValidationError('Add at least one path, such as /* or /index.html')
|
||||
if (items.length > MAX_INVALIDATION_PATHS) throw new ValidationError(`An invalidation holds at most ${MAX_INVALIDATION_PATHS} paths`)
|
||||
const invalid = items.find((path) => !path.startsWith('/'))
|
||||
if (invalid) throw new ValidationError(`${invalid} must start with /`)
|
||||
const res = await this.cloudfront.send(new CreateInvalidationCommand({
|
||||
DistributionId: id,
|
||||
InvalidationBatch: {CallerReference: crypto.randomUUID(), Paths: {Quantity: items.length, Items: items}},
|
||||
}))
|
||||
return {
|
||||
id: res.Invalidation?.Id ?? '',
|
||||
status: res.Invalidation?.Status ?? 'InProgress',
|
||||
createdAt: res.Invalidation?.CreateTime?.toISOString() ?? null,
|
||||
paths: res.Invalidation?.InvalidationBatch?.Paths?.Items ?? items,
|
||||
}
|
||||
}
|
||||
|
||||
private async s3Origin(bucket: string): Promise<Origin> {
|
||||
if (!new RegExp(CDN_BUCKET_PATTERN).test(bucket)) throw new ValidationError('bucket must be the name of an existing S3 bucket')
|
||||
try {
|
||||
await this.s3.send(new HeadBucketCommand({Bucket: bucket}))
|
||||
} catch (error) {
|
||||
if (['NotFound', 'NoSuchBucket'].includes(errorName(error))) throw new ValidationError(`There is no S3 bucket named ${bucket}`)
|
||||
throw error
|
||||
}
|
||||
return {Id: `s3-${bucket}`, DomainName: `${bucket}.s3.amazonaws.com`, S3OriginConfig: {OriginAccessIdentity: ''}}
|
||||
}
|
||||
|
||||
private async recentInvalidations(id: string): Promise<DistributionInvalidation[]> {
|
||||
const res = await this.cloudfront.send(new ListInvalidationsCommand({DistributionId: id, MaxItems: RECENT_INVALIDATIONS}))
|
||||
return (res.InvalidationList?.Items ?? []).map((invalidation) => ({
|
||||
id: invalidation.Id ?? '',
|
||||
status: invalidation.Status ?? '',
|
||||
createdAt: invalidation.CreateTime?.toISOString() ?? null,
|
||||
paths: [],
|
||||
}))
|
||||
}
|
||||
|
||||
private toResource(distribution: Pick<Distribution, 'Id' | 'DomainName' | 'Status' | 'LastModifiedTime' | 'ARN'>, enabled: boolean, comment: string | undefined, origins: Origin[] | undefined): CloudResource {
|
||||
const id = distribution.Id ?? ''
|
||||
return {
|
||||
id,
|
||||
name: distribution.DomainName ?? id,
|
||||
cloud: 'aws',
|
||||
service: 'cdn',
|
||||
type: 'distribution',
|
||||
region: null,
|
||||
createdAt: distribution.LastModifiedTime?.toISOString() ?? null,
|
||||
status: distribution.Status ?? null,
|
||||
metadata: {
|
||||
arn: distribution.ARN ?? null,
|
||||
domainName: distribution.DomainName ?? null,
|
||||
viewerUrl: this.viewerUrl(id),
|
||||
enabled,
|
||||
comment: comment || null,
|
||||
originDomains: (origins ?? []).map((origin) => origin.DomainName ?? ''),
|
||||
origins: (origins ?? []).map((origin) => ({
|
||||
id: origin.Id ?? '',
|
||||
domainName: origin.DomainName ?? '',
|
||||
kind: origin.S3OriginConfig ? 's3' : 'custom',
|
||||
protocol: origin.CustomOriginConfig?.OriginProtocolPolicy ?? null,
|
||||
})),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Where a browser on this machine reaches the distribution through the runtime. */
|
||||
private viewerUrl(id: string): string {
|
||||
const port = new URL(this.endpoint).port || '4566'
|
||||
return `http://${id.toLowerCase()}.cloudfront.localhost:${port}/`
|
||||
}
|
||||
}
|
||||
|
||||
function customOrigin(values: Record<string, unknown>): Origin {
|
||||
const domain = text(values.originDomain).toLowerCase()
|
||||
if (!new RegExp(CDN_ORIGIN_DOMAIN_PATTERN).test(domain)) throw new ValidationError('originDomain must be a host name such as api.example.com')
|
||||
const protocol = oneOf(values.originProtocol || 'https-only', CDN_ORIGIN_PROTOCOLS, 'originProtocol')
|
||||
const port = text(values.originPort)
|
||||
const portNumber = port ? Number(port) : null
|
||||
if (portNumber !== null && (!Number.isInteger(portNumber) || portNumber < 1 || portNumber > 65535)) throw new ValidationError('originPort must be between 1 and 65535')
|
||||
return {
|
||||
Id: `custom-${domain}`,
|
||||
DomainName: domain,
|
||||
CustomOriginConfig: {
|
||||
HTTPPort: portNumber ?? 80,
|
||||
HTTPSPort: portNumber ?? 443,
|
||||
OriginProtocolPolicy: protocol,
|
||||
OriginSslProtocols: {Quantity: 1, Items: ['TLSv1.2']},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function behaviorsOf(config: DistributionConfig | undefined) {
|
||||
const describe = (behavior: DefaultCacheBehavior | CacheBehavior, pathPattern: string) => ({
|
||||
pathPattern,
|
||||
originId: behavior.TargetOriginId ?? '',
|
||||
viewerProtocol: behavior.ViewerProtocolPolicy ?? '',
|
||||
methods: behavior.AllowedMethods?.Items ?? READ_METHODS,
|
||||
})
|
||||
return [
|
||||
...(config?.CacheBehaviors?.Items ?? []).map((behavior) => describe(behavior, behavior.PathPattern ?? '')),
|
||||
...(config?.DefaultCacheBehavior ? [describe(config.DefaultCacheBehavior, 'Default (*)')] : []),
|
||||
]
|
||||
}
|
||||
|
||||
function oneOf<T extends string>(value: unknown, allowed: readonly T[], field: string): T {
|
||||
if (typeof value === 'string' && (allowed as readonly string[]).includes(value)) return value as T
|
||||
throw new ValidationError(`${field} must be one of ${allowed.join(', ')}`)
|
||||
}
|
||||
|
||||
function text(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
function stringList(value: unknown): string[] {
|
||||
return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : []
|
||||
}
|
||||
|
||||
function errorName(error: unknown): string {
|
||||
return (error as {name?: string} | null)?.name ?? ''
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import {describe, expect, test} from 'bun:test'
|
||||
import {
|
||||
ChangeResourceRecordSetsCommand,
|
||||
CreateHostedZoneCommand,
|
||||
DeleteHostedZoneCommand,
|
||||
GetHostedZoneCommand,
|
||||
ListHostedZonesCommand,
|
||||
ListResourceRecordSetsCommand,
|
||||
type ResourceRecordSet,
|
||||
type Route53Client,
|
||||
} from '@aws-sdk/client-route-53'
|
||||
import {AwsDnsAdapter} from './AwsDnsAdapter'
|
||||
import {ConflictError, ValidationError} from '../cloud-spi/errors'
|
||||
|
||||
type SendResult = Record<string, unknown>
|
||||
|
||||
function stubRoute53(handler: (command: object) => SendResult | Promise<SendResult>) {
|
||||
const sent: object[] = []
|
||||
const client = {
|
||||
async send(command: object) {
|
||||
sent.push(command)
|
||||
return handler(command)
|
||||
},
|
||||
} as unknown as Route53Client
|
||||
return {client, sent}
|
||||
}
|
||||
|
||||
function awsError(name: string, status = 400): Error {
|
||||
return Object.assign(new Error(name), {name, $metadata: {httpStatusCode: status}})
|
||||
}
|
||||
|
||||
const ZONE_ID = 'ZG9PAE0M9KRWJ4F'
|
||||
const zone = {Id: `/hostedzone/${ZONE_ID}`, Name: 'example.test.', CallerReference: 'r', ResourceRecordSetCount: 3, Config: {Comment: 'Local zone', PrivateZone: false}}
|
||||
const apex = [
|
||||
{Name: 'example.test.', Type: 'NS', TTL: 172800, ResourceRecords: [{Value: 'ns-1.awsdns-01.org.'}]},
|
||||
{Name: 'example.test.', Type: 'SOA', TTL: 900, ResourceRecords: [{Value: 'ns-1.awsdns-01.org awsdns-hostmaster.amazon.com. 1 7200 900 1209600 86400'}]},
|
||||
]
|
||||
|
||||
function zoneStub(records: object[] = apex, more: (command: object) => SendResult | undefined = () => undefined) {
|
||||
return stubRoute53((command) => {
|
||||
const extra = more(command)
|
||||
if (extra) return extra
|
||||
if (command instanceof GetHostedZoneCommand) return {HostedZone: zone, DelegationSet: {NameServers: ['ns-1.awsdns-01.org']}}
|
||||
if (command instanceof ListResourceRecordSetsCommand) return {ResourceRecordSets: records, IsTruncated: false}
|
||||
return {}
|
||||
})
|
||||
}
|
||||
|
||||
describe('AwsDnsAdapter hosted zones', () => {
|
||||
test('lists zones by bare ID and filters by name', async () => {
|
||||
const {client} = stubRoute53((command) => {
|
||||
if (command instanceof ListHostedZonesCommand) {
|
||||
return {HostedZones: [zone, {...zone, Id: '/hostedzone/ZPRIVATE', Name: 'internal.test.', Config: {PrivateZone: true}}], IsTruncated: false}
|
||||
}
|
||||
return {}
|
||||
})
|
||||
const adapter = new AwsDnsAdapter(client)
|
||||
|
||||
const zones = await adapter.list()
|
||||
expect(zones.map((resource) => [resource.id, resource.name, resource.metadata.visibility])).toEqual([[ZONE_ID, 'example.test', 'public'], ['ZPRIVATE', 'internal.test', 'private']])
|
||||
expect((await adapter.list({search: 'INTERNAL'})).map((resource) => resource.id)).toEqual(['ZPRIVATE'])
|
||||
})
|
||||
|
||||
test('describes a zone with its name servers, and treats a missing zone as absent', async () => {
|
||||
const {client} = stubRoute53((command) => {
|
||||
if (command instanceof GetHostedZoneCommand && command.input.Id === ZONE_ID) return {HostedZone: zone, DelegationSet: {NameServers: ['ns-1.awsdns-01.org', 'ns-2.awsdns-02.net']}}
|
||||
throw awsError('NoSuchHostedZone', 404)
|
||||
})
|
||||
const adapter = new AwsDnsAdapter(client)
|
||||
|
||||
expect((await adapter.get(ZONE_ID))?.metadata).toEqual({visibility: 'public', recordCount: 3, comment: 'Local zone', nameServers: ['ns-1.awsdns-01.org', 'ns-2.awsdns-02.net']})
|
||||
expect(await adapter.get('ZMISSING')).toBeNull()
|
||||
})
|
||||
|
||||
test('creates a public zone, or a private one in a VPC', async () => {
|
||||
const {client, sent} = stubRoute53((command) => command instanceof CreateHostedZoneCommand
|
||||
? {HostedZone: {...zone, Name: `${command.input.Name}.`, Config: command.input.HostedZoneConfig}, DelegationSet: {NameServers: ['ns-1.awsdns-01.org']}}
|
||||
: {})
|
||||
const adapter = new AwsDnsAdapter(client)
|
||||
|
||||
expect((await adapter.create({values: {name: ' Example.TEST ', comment: 'Local zone'}})).name).toBe('example.test')
|
||||
await adapter.create({values: {name: 'internal.test', vpcId: 'vpc-0abc1234'}})
|
||||
|
||||
const [publicZone, privateZone] = sent.filter((command) => command instanceof CreateHostedZoneCommand) as CreateHostedZoneCommand[]
|
||||
expect(publicZone.input).toMatchObject({Name: 'example.test', HostedZoneConfig: {Comment: 'Local zone', PrivateZone: false}})
|
||||
expect(publicZone.input.VPC).toBeUndefined()
|
||||
expect(privateZone.input).toMatchObject({HostedZoneConfig: {PrivateZone: true}, VPC: {VPCId: 'vpc-0abc1234', VPCRegion: 'us-east-1'}})
|
||||
await expect(adapter.create({values: {name: 'not a domain'}})).rejects.toThrow(ValidationError)
|
||||
await expect(adapter.create({values: {name: 'example.test', vpcId: 'subnet-1'}})).rejects.toThrow(ValidationError)
|
||||
})
|
||||
|
||||
test('explains that a zone with records cannot be deleted', async () => {
|
||||
const {client} = stubRoute53((command) => {
|
||||
if (command instanceof DeleteHostedZoneCommand) throw awsError('HostedZoneNotEmpty')
|
||||
return {}
|
||||
})
|
||||
await expect(new AwsDnsAdapter(client).delete(ZONE_ID)).rejects.toThrow(ConflictError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AwsDnsAdapter records', () => {
|
||||
test('lists records keyed by name and type, marking the zone\'s own NS and SOA', async () => {
|
||||
const {client} = zoneStub([
|
||||
...apex,
|
||||
{Name: '\\052.example.test.', Type: 'A', TTL: 60, ResourceRecords: [{Value: '10.0.0.9'}]},
|
||||
{Name: 'cdn.example.test.', Type: 'A', AliasTarget: {DNSName: 'd111.cloudfront.net.', HostedZoneId: 'Z2FDTNDATAQYW2', EvaluateTargetHealth: false}},
|
||||
{Name: 'api.example.test.', Type: 'A', SetIdentifier: 'blue', Weight: 10, TTL: 60, ResourceRecords: [{Value: '10.0.0.2'}]},
|
||||
])
|
||||
const {items} = await new AwsDnsAdapter(client).items.listItems(ZONE_ID)
|
||||
|
||||
expect(items.map((item) => item.id)).toEqual(['example.test|NS', 'example.test|SOA', '*.example.test|A', 'cdn.example.test|A', 'api.example.test|A|blue'])
|
||||
expect(items[0].body).toMatchObject({managed: true, editable: true})
|
||||
expect(items[2].body).toMatchObject({name: '*.example.test', ttl: 60, values: ['10.0.0.9'], managed: false, editable: true})
|
||||
expect(items[3].body).toMatchObject({alias: {dnsName: 'd111.cloudfront.net', hostedZoneId: 'Z2FDTNDATAQYW2', evaluateTargetHealth: false}, editable: false})
|
||||
expect(items[4].body).toMatchObject({setIdentifier: 'blue', editable: false})
|
||||
})
|
||||
|
||||
test('pages with an opaque cursor built from Route 53\'s next record', async () => {
|
||||
const {client, sent} = zoneStub(apex, (command) => command instanceof ListResourceRecordSetsCommand && !command.input.StartRecordName
|
||||
? {ResourceRecordSets: apex, IsTruncated: true, NextRecordName: 'www.example.test.', NextRecordType: 'A'}
|
||||
: undefined)
|
||||
const store = new AwsDnsAdapter(client).items
|
||||
|
||||
const first = await store.listItems(ZONE_ID, {limit: 2})
|
||||
expect(first.nextCursor).toBeTruthy()
|
||||
await store.listItems(ZONE_ID, {cursor: first.nextCursor!})
|
||||
const second = sent.filter((command) => command instanceof ListResourceRecordSetsCommand).at(-1) as ListResourceRecordSetsCommand
|
||||
expect(second.input).toMatchObject({StartRecordName: 'www.example.test.', StartRecordType: 'A'})
|
||||
await expect(store.listItems(ZONE_ID, {cursor: 'not-a-cursor'})).rejects.toThrow(ValidationError)
|
||||
})
|
||||
|
||||
test('upserts a record, naming it inside the zone and quoting TXT text', async () => {
|
||||
const {client, sent} = zoneStub()
|
||||
const store = new AwsDnsAdapter(client).items
|
||||
|
||||
expect((await store.putItem!(ZONE_ID, {name: 'www', type: 'a', ttl: '120', values: ['10.0.0.1', '10.0.0.2']})).id).toBe('www.example.test|A')
|
||||
await store.putItem!(ZONE_ID, {name: '@', type: 'TXT', values: 'v=spf1 -all\n"already quoted"'})
|
||||
await store.putItem!(ZONE_ID, {name: 'mail.example.test.', type: 'MX', values: ['10 mx.example.test']})
|
||||
|
||||
const changes = sent.filter((command) => command instanceof ChangeResourceRecordSetsCommand)
|
||||
.map((command) => (command as ChangeResourceRecordSetsCommand).input.ChangeBatch!.Changes![0])
|
||||
expect(changes[0]).toEqual({Action: 'UPSERT', ResourceRecordSet: {Name: 'www.example.test', Type: 'A', TTL: 120, ResourceRecords: [{Value: '10.0.0.1'}, {Value: '10.0.0.2'}]}})
|
||||
expect(changes[1].ResourceRecordSet).toMatchObject({Name: 'example.test', Type: 'TXT', TTL: 300, ResourceRecords: [{Value: '"v=spf1 -all"'}, {Value: '"already quoted"'}]})
|
||||
expect(changes[2].ResourceRecordSet).toMatchObject({Name: 'mail.example.test', Type: 'MX'})
|
||||
})
|
||||
|
||||
test('applies Route 53\'s value rules before calling it', async () => {
|
||||
const {client, sent} = zoneStub()
|
||||
const store = new AwsDnsAdapter(client).items
|
||||
|
||||
await expect(store.putItem!(ZONE_ID, {name: 'www', type: 'A', values: ['not-an-ip']})).rejects.toThrow(/IPv4/)
|
||||
await expect(store.putItem!(ZONE_ID, {name: 'www', type: 'AAAA', values: ['10.0.0.1']})).rejects.toThrow(/IPv6/)
|
||||
await expect(store.putItem!(ZONE_ID, {name: 'www', type: 'CNAME', values: ['a.test', 'b.test']})).rejects.toThrow(/exactly one/)
|
||||
await expect(store.putItem!(ZONE_ID, {name: '@', type: 'CNAME', values: ['a.test']})).rejects.toThrow(/apex/)
|
||||
await expect(store.putItem!(ZONE_ID, {name: 'mail', type: 'MX', values: ['mx.example.test']})).rejects.toThrow(/priority/)
|
||||
await expect(store.putItem!(ZONE_ID, {name: 'www', type: 'BOGUS', values: ['x']})).rejects.toThrow(/type/)
|
||||
await expect(store.putItem!(ZONE_ID, {name: 'www', type: 'A', values: []})).rejects.toThrow(/value/)
|
||||
await expect(store.putItem!(ZONE_ID, {name: 'www', type: 'A', ttl: -1, values: ['10.0.0.1']})).rejects.toThrow(/ttl/)
|
||||
expect(sent.some((command) => command instanceof ChangeResourceRecordSetsCommand)).toBe(false)
|
||||
})
|
||||
|
||||
test('deletes exactly the stored record set, and never the zone\'s own NS or SOA', async () => {
|
||||
const stored: ResourceRecordSet = {Name: 'www.example.test.', Type: 'A', TTL: 120, ResourceRecords: [{Value: '10.0.0.1'}]}
|
||||
const {client, sent} = zoneStub([stored])
|
||||
const store = new AwsDnsAdapter(client).items
|
||||
|
||||
await store.deleteItem!(ZONE_ID, 'www.example.test|A')
|
||||
const change = (sent.find((command) => command instanceof ChangeResourceRecordSetsCommand) as ChangeResourceRecordSetsCommand).input.ChangeBatch!.Changes![0]
|
||||
expect(change).toEqual({Action: 'DELETE', ResourceRecordSet: stored})
|
||||
|
||||
await expect(store.deleteItem!(ZONE_ID, 'example.test|NS')).rejects.toThrow(ConflictError)
|
||||
await expect(store.deleteItem!(ZONE_ID, 'gone.example.test|A')).rejects.toThrow(/not found/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,293 @@
|
||||
import {isIPv4, isIPv6} from 'node:net'
|
||||
import {
|
||||
ChangeResourceRecordSetsCommand,
|
||||
CreateHostedZoneCommand,
|
||||
DeleteHostedZoneCommand,
|
||||
GetHostedZoneCommand,
|
||||
ListHostedZonesCommand,
|
||||
ListResourceRecordSetsCommand,
|
||||
type HostedZone,
|
||||
type ResourceRecordSet,
|
||||
type Route53Client,
|
||||
type RRType,
|
||||
} from '@aws-sdk/client-route-53'
|
||||
import type {ChildItem, CollectionPage, ItemStoreAdapter, PageQuery} from '../cloud-spi/childCollections'
|
||||
import {DNS_RECORD_TYPES, DNS_ZONE_NAME_PATTERN, awsDnsSchema} from '../cloud-spi/dnsSchema'
|
||||
import {ConflictError, NotFoundError, ValidationError} from '../cloud-spi/errors'
|
||||
import type {CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types'
|
||||
|
||||
/** Route 53 caps ListResourceRecordSets pages at 300 and ListHostedZones at 100. */
|
||||
const RECORD_PAGE_LIMIT = 300
|
||||
const ZONE_PAGE_LIMIT = 100
|
||||
/** Route 53's TTL range. */
|
||||
const MAX_TTL = 2_147_483_647
|
||||
|
||||
/**
|
||||
* Talks to Route 53 hosted zones, verified against Floci core (community, `latest`).
|
||||
*
|
||||
* Notes from probing the runtime:
|
||||
* - Zone IDs come back as `/hostedzone/Z…`; the bare ID is accepted everywhere, so
|
||||
* resources are keyed by it.
|
||||
* - A new zone holds the apex NS and SOA records, and `DeleteHostedZone` refuses
|
||||
* with `HostedZoneNotEmpty` (400) while any other record remains, as on AWS.
|
||||
* - A missing zone is `NoSuchHostedZone` with HTTP 404.
|
||||
* - The runtime stores record values without checking them (an A record of
|
||||
* `not-an-ip`, unquoted TXT text), so the AWS rules are applied here.
|
||||
*
|
||||
* Records are the zone's child items, keyed by name, type and set identifier,
|
||||
* which is what makes a record set unique in Route 53.
|
||||
*/
|
||||
export class AwsDnsAdapter implements CloudServiceAdapter {
|
||||
readonly cloud = 'aws' as const
|
||||
readonly service = 'dns' as const
|
||||
readonly items: ItemStoreAdapter
|
||||
|
||||
constructor(private readonly route53: Route53Client, private readonly region = 'us-east-1') {
|
||||
this.items = new RecordStore(route53, (id) => this.zoneName(id))
|
||||
}
|
||||
|
||||
schema(): ServiceSchema {
|
||||
return awsDnsSchema()
|
||||
}
|
||||
|
||||
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
|
||||
const zones: HostedZone[] = []
|
||||
let marker: string | undefined
|
||||
do {
|
||||
const res = await this.route53.send(new ListHostedZonesCommand({MaxItems: ZONE_PAGE_LIMIT, ...(marker ? {Marker: marker} : {})}))
|
||||
zones.push(...(res.HostedZones ?? []))
|
||||
marker = res.IsTruncated ? res.NextMarker : undefined
|
||||
} while (marker)
|
||||
|
||||
const search = query.search?.trim().toLowerCase()
|
||||
return zones.map((zone) => toResource(zone))
|
||||
.filter((zone) => !search || zone.name.toLowerCase().includes(search) || zone.id.toLowerCase().includes(search))
|
||||
}
|
||||
|
||||
async get(id: string): Promise<CloudResource | null> {
|
||||
try {
|
||||
const res = await this.route53.send(new GetHostedZoneCommand({Id: zoneId(id)}))
|
||||
return res.HostedZone ? toResource(res.HostedZone, res.DelegationSet?.NameServers, res.VPCs?.map((vpc) => `${vpc.VPCId} (${vpc.VPCRegion})`)) : null
|
||||
} catch (error) {
|
||||
if (errorName(error) === 'NoSuchHostedZone') return null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async create(input: CreateResourceInput): Promise<CloudResource> {
|
||||
const name = typeof input.values.name === 'string' ? input.values.name.trim().toLowerCase() : ''
|
||||
if (!new RegExp(DNS_ZONE_NAME_PATTERN).test(name)) throw new ValidationError('name must be a domain name such as example.com')
|
||||
const comment = typeof input.values.comment === 'string' ? input.values.comment.trim() : ''
|
||||
const vpcId = typeof input.values.vpcId === 'string' ? input.values.vpcId.trim() : ''
|
||||
if (vpcId && !/^vpc-[0-9a-f]{8,17}$/.test(vpcId)) throw new ValidationError('vpcId must be a VPC ID such as vpc-0abc1234')
|
||||
|
||||
const res = await this.route53.send(new CreateHostedZoneCommand({
|
||||
Name: name,
|
||||
CallerReference: crypto.randomUUID(),
|
||||
HostedZoneConfig: {Comment: comment || undefined, PrivateZone: Boolean(vpcId)},
|
||||
...(vpcId ? {VPC: {VPCId: vpcId, VPCRegion: this.region as never}} : {}),
|
||||
}))
|
||||
if (!res.HostedZone) throw new ValidationError(`Route 53 did not return the hosted zone ${name}`)
|
||||
return toResource(res.HostedZone, res.DelegationSet?.NameServers, vpcId ? [`${vpcId} (${this.region})`] : undefined)
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
try {
|
||||
await this.route53.send(new DeleteHostedZoneCommand({Id: zoneId(id)}))
|
||||
} catch (error) {
|
||||
if (errorName(error) === 'HostedZoneNotEmpty') {
|
||||
throw new ConflictError('Delete the zone\'s records first: Route 53 only deletes a hosted zone that holds just its NS and SOA records')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async zoneName(id: string): Promise<string> {
|
||||
const zone = await this.get(id)
|
||||
if (!zone) throw new NotFoundError(`Hosted zone ${id} was not found`)
|
||||
return zone.name
|
||||
}
|
||||
}
|
||||
|
||||
class RecordStore implements ItemStoreAdapter {
|
||||
constructor(private readonly route53: Route53Client, private readonly zoneName: (id: string) => Promise<string>) {}
|
||||
|
||||
async listItems(resourceId: string, page: PageQuery = {}): Promise<CollectionPage<ChildItem>> {
|
||||
const start = decodeCursor(page.cursor)
|
||||
const res = await this.route53.send(new ListResourceRecordSetsCommand({
|
||||
HostedZoneId: zoneId(resourceId),
|
||||
MaxItems: Math.min(page.limit ?? RECORD_PAGE_LIMIT, RECORD_PAGE_LIMIT),
|
||||
...(start ? {StartRecordName: start.name, StartRecordType: start.type as RRType, StartRecordIdentifier: start.identifier} : {}),
|
||||
}))
|
||||
const zone = await this.zoneName(resourceId)
|
||||
return {
|
||||
items: (res.ResourceRecordSets ?? []).map((record) => toRecordItem(record, zone)),
|
||||
nextCursor: res.IsTruncated && res.NextRecordName
|
||||
? encodeCursor({name: res.NextRecordName, type: res.NextRecordType ?? '', identifier: res.NextRecordIdentifier})
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
/** Creates the record set, or replaces the one with the same name and type (UPSERT). */
|
||||
async putItem(resourceId: string, body: Record<string, unknown>): Promise<ChildItem> {
|
||||
const zone = await this.zoneName(resourceId)
|
||||
const record = recordFromInput(body, zone)
|
||||
await this.route53.send(new ChangeResourceRecordSetsCommand({
|
||||
HostedZoneId: zoneId(resourceId),
|
||||
ChangeBatch: {Changes: [{Action: 'UPSERT', ResourceRecordSet: record}]},
|
||||
}))
|
||||
return toRecordItem(record, zone)
|
||||
}
|
||||
|
||||
/** Route 53 deletes a record set only when given it exactly, so the stored set is read first. */
|
||||
async deleteItem(resourceId: string, itemId: string): Promise<void> {
|
||||
const key = parseRecordId(itemId)
|
||||
const zone = await this.zoneName(resourceId)
|
||||
if (key.name === zone && (key.type === 'NS' || key.type === 'SOA')) {
|
||||
throw new ConflictError(`The zone's own ${key.type} record cannot be deleted`)
|
||||
}
|
||||
const res = await this.route53.send(new ListResourceRecordSetsCommand({
|
||||
HostedZoneId: zoneId(resourceId),
|
||||
StartRecordName: key.name,
|
||||
StartRecordType: key.type as RRType,
|
||||
...(key.identifier ? {StartRecordIdentifier: key.identifier} : {}),
|
||||
MaxItems: 1,
|
||||
}))
|
||||
const stored = res.ResourceRecordSets?.find((record) => recordId(record) === itemId)
|
||||
if (!stored) throw new NotFoundError(`Record ${key.type} ${key.name} was not found`)
|
||||
await this.route53.send(new ChangeResourceRecordSetsCommand({
|
||||
HostedZoneId: zoneId(resourceId),
|
||||
ChangeBatch: {Changes: [{Action: 'DELETE', ResourceRecordSet: stored}]},
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
function toResource(zone: HostedZone, nameServers?: string[], vpcs?: string[]): CloudResource {
|
||||
const id = zoneId(zone.Id ?? '')
|
||||
const privateZone = zone.Config?.PrivateZone ?? false
|
||||
return {
|
||||
id,
|
||||
name: withoutDot(zone.Name ?? id),
|
||||
cloud: 'aws',
|
||||
service: 'dns',
|
||||
type: 'hosted-zone',
|
||||
region: null,
|
||||
createdAt: null,
|
||||
status: null,
|
||||
metadata: {
|
||||
visibility: privateZone ? 'private' : 'public',
|
||||
recordCount: zone.ResourceRecordSetCount ?? null,
|
||||
comment: zone.Config?.Comment ?? null,
|
||||
...(nameServers ? {nameServers} : {}),
|
||||
...(vpcs ? {vpcs} : {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function toRecordItem(record: ResourceRecordSet, zone: string): ChildItem {
|
||||
const name = withoutDot(unescapeName(record.Name ?? ''))
|
||||
return {
|
||||
id: recordId(record),
|
||||
collectionId: null,
|
||||
timestamp: null,
|
||||
body: {
|
||||
name,
|
||||
type: record.Type ?? '',
|
||||
ttl: record.TTL ?? null,
|
||||
values: (record.ResourceRecords ?? []).map((value) => value.Value ?? ''),
|
||||
alias: record.AliasTarget ? {dnsName: withoutDot(record.AliasTarget.DNSName ?? ''), hostedZoneId: record.AliasTarget.HostedZoneId ?? '', evaluateTargetHealth: record.AliasTarget.EvaluateTargetHealth ?? false} : null,
|
||||
setIdentifier: record.SetIdentifier ?? null,
|
||||
// The zone's own NS and SOA records, and routing-policy sets this console cannot edit faithfully.
|
||||
managed: (name === zone && (record.Type === 'NS' || record.Type === 'SOA')),
|
||||
editable: !record.AliasTarget && !record.SetIdentifier,
|
||||
},
|
||||
metadata: {},
|
||||
}
|
||||
}
|
||||
|
||||
/** Validates a simple record the way Route 53 does and returns it as a record set. */
|
||||
function recordFromInput(body: Record<string, unknown>, zone: string): ResourceRecordSet {
|
||||
const type = typeof body.type === 'string' ? body.type.trim().toUpperCase() : ''
|
||||
if (!(DNS_RECORD_TYPES as readonly string[]).includes(type)) throw new ValidationError(`type must be one of ${DNS_RECORD_TYPES.join(', ')}`)
|
||||
|
||||
const name = qualifiedName(typeof body.name === 'string' ? body.name : '', zone)
|
||||
const ttl = Number(body.ttl ?? 300)
|
||||
if (!Number.isInteger(ttl) || ttl < 0 || ttl > MAX_TTL) throw new ValidationError('ttl must be a whole number of seconds')
|
||||
|
||||
const rawValues = Array.isArray(body.values) ? body.values : typeof body.values === 'string' ? body.values.split('\n') : []
|
||||
const values = rawValues.map((value) => String(value).trim()).filter(Boolean)
|
||||
if (values.length === 0) throw new ValidationError('Add at least one value')
|
||||
if (type === 'CNAME' && values.length > 1) throw new ValidationError('A CNAME record has exactly one value')
|
||||
if (type === 'CNAME' && name === zone) throw new ValidationError('The zone apex cannot have a CNAME record; use an A or alias record')
|
||||
for (const value of values) {
|
||||
if (type === 'A' && !isIPv4(value)) throw new ValidationError(`${value} is not an IPv4 address`)
|
||||
if (type === 'AAAA' && !isIPv6(value)) throw new ValidationError(`${value} is not an IPv6 address`)
|
||||
if (type === 'MX' && !/^\d{1,5}\s+\S+$/.test(value)) throw new ValidationError(`MX values are a priority and a mail server, such as "10 mail.${zone}"`)
|
||||
}
|
||||
|
||||
return {
|
||||
Name: name,
|
||||
Type: type as RRType,
|
||||
TTL: ttl,
|
||||
ResourceRecords: values.map((value) => ({Value: type === 'TXT' || type === 'SPF' ? quoted(value) : value})),
|
||||
}
|
||||
}
|
||||
|
||||
/** "www", "www.example.com" and "@" all name a record in example.com. */
|
||||
function qualifiedName(input: string, zone: string): string {
|
||||
const name = withoutDot(input.trim().toLowerCase())
|
||||
if (!name || name === '@') return zone
|
||||
const full = name === zone || name.endsWith(`.${zone}`) ? name : `${name}.${zone}`
|
||||
if (!/^(\*\.)?([a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?\.)*[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?$/.test(full) || full.length > 253) {
|
||||
throw new ValidationError(`${input} is not a valid record name`)
|
||||
}
|
||||
return full
|
||||
}
|
||||
|
||||
/** Route 53 wants each TXT string quoted; text the user already quoted is kept as written. */
|
||||
function quoted(value: string): string {
|
||||
if (value.startsWith('"') && value.endsWith('"') && value.length > 1) return value
|
||||
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
|
||||
}
|
||||
|
||||
function recordId(record: Pick<ResourceRecordSet, 'Name' | 'Type' | 'SetIdentifier'>): string {
|
||||
return [withoutDot(unescapeName(record.Name ?? '')), record.Type ?? '', ...(record.SetIdentifier ? [record.SetIdentifier] : [])].join('|')
|
||||
}
|
||||
|
||||
function parseRecordId(id: string): {name: string; type: string; identifier?: string} {
|
||||
const [name, type, ...rest] = id.split('|')
|
||||
if (!name || !type) throw new ValidationError('Record id must be name|type')
|
||||
return {name, type, ...(rest.length ? {identifier: rest.join('|')} : {})}
|
||||
}
|
||||
|
||||
function encodeCursor(cursor: {name: string; type: string; identifier?: string}): string {
|
||||
return Buffer.from(JSON.stringify(cursor)).toString('base64url')
|
||||
}
|
||||
|
||||
function decodeCursor(cursor: string | undefined): {name: string; type: string; identifier?: string} | null {
|
||||
if (!cursor) return null
|
||||
try {
|
||||
const value = JSON.parse(Buffer.from(cursor, 'base64url').toString()) as {name?: unknown; type?: unknown; identifier?: unknown}
|
||||
if (typeof value.name !== 'string' || typeof value.type !== 'string') throw new Error('bad cursor')
|
||||
return {name: value.name, type: value.type, ...(typeof value.identifier === 'string' ? {identifier: value.identifier} : {})}
|
||||
} catch {
|
||||
throw new ValidationError('cursor is not valid for this hosted zone')
|
||||
}
|
||||
}
|
||||
|
||||
/** Route 53 returns `*` as `\052` and other special characters as octal escapes. */
|
||||
function unescapeName(name: string): string {
|
||||
return name.replace(/\\(\d{3})/g, (_match, octal: string) => String.fromCharCode(parseInt(octal, 8)))
|
||||
}
|
||||
|
||||
function zoneId(id: string): string {
|
||||
return id.replace(/^\/hostedzone\//, '')
|
||||
}
|
||||
|
||||
function withoutDot(name: string): string {
|
||||
return name.endsWith('.') ? name.slice(0, -1) : name
|
||||
}
|
||||
|
||||
function errorName(error: unknown): string {
|
||||
return (error as {name?: string} | null)?.name ?? ''
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -16,7 +16,9 @@ import {AwsLightsailAdapter} from './AwsLightsailAdapter'
|
||||
const originalEndpoint = process.env.FLOCI_ENDPOINT
|
||||
|
||||
afterEach(() => {
|
||||
process.env.FLOCI_ENDPOINT = originalEndpoint
|
||||
// Assigning undefined would store the string "undefined" for every later test file.
|
||||
if (originalEndpoint === undefined) delete process.env.FLOCI_ENDPOINT
|
||||
else process.env.FLOCI_ENDPOINT = originalEndpoint
|
||||
})
|
||||
|
||||
function stubLightsail() {
|
||||
|
||||
@@ -135,7 +135,7 @@ export class AwsLogsAdapter implements CloudServiceAdapter {
|
||||
limit: input.limit,
|
||||
}),
|
||||
)
|
||||
if (!queryId) throw new NotFoundError(`Floci did not return a query id for ${logGroupName}.`)
|
||||
if (!queryId) throw new NotFoundError(`The runtime did not return a query id for ${logGroupName}.`)
|
||||
|
||||
const deadline = Date.now() + (this.queryPoll.timeoutMs ?? QUERY_POLL_TIMEOUT_MS)
|
||||
const pollInterval = this.queryPoll.intervalMs ?? QUERY_POLL_INTERVAL_MS
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import {describe, expect, test} from 'bun:test'
|
||||
import {
|
||||
DeleteAlarmsCommand,
|
||||
DescribeAlarmHistoryCommand,
|
||||
DescribeAlarmsCommand,
|
||||
DisableAlarmActionsCommand,
|
||||
EnableAlarmActionsCommand,
|
||||
GetMetricDataCommand,
|
||||
ListMetricsCommand,
|
||||
PutCompositeAlarmCommand,
|
||||
PutMetricAlarmCommand,
|
||||
SetAlarmStateCommand,
|
||||
type CloudWatchClient,
|
||||
} from '@aws-sdk/client-cloudwatch'
|
||||
import {AwsMonitoringAdapter} from './AwsMonitoringAdapter'
|
||||
import {ValidationError} from '../cloud-spi/errors'
|
||||
|
||||
type SendResult = Record<string, unknown>
|
||||
|
||||
function stubCloudWatch(handler: (command: object) => SendResult | Promise<SendResult>) {
|
||||
const sent: object[] = []
|
||||
const client = {
|
||||
async send(command: object) {
|
||||
sent.push(command)
|
||||
return handler(command)
|
||||
},
|
||||
} as unknown as CloudWatchClient
|
||||
return {adapter: new AwsMonitoringAdapter(client), sent}
|
||||
}
|
||||
|
||||
const UPDATED = new Date('2026-10-25T10:00:00Z')
|
||||
const metricAlarm = {
|
||||
AlarmName: 'high-errors', AlarmArn: 'arn:aws:cloudwatch:us-east-1:000000000000:alarm:high-errors',
|
||||
StateValue: 'ALARM', StateReason: 'Threshold crossed', StateUpdatedTimestamp: UPDATED, AlarmConfigurationUpdatedTimestamp: UPDATED,
|
||||
ActionsEnabled: true, AlarmActions: ['arn:aws:sns:us-east-1:000000000000:alerts'], OKActions: [],
|
||||
Namespace: 'App', MetricName: 'Errors', Dimensions: [{Name: 'FunctionName', Value: 'checkout'}],
|
||||
Statistic: 'Sum', Period: 300, EvaluationPeriods: 2, Threshold: 5, ComparisonOperator: 'GreaterThanThreshold',
|
||||
}
|
||||
const compositeAlarm = {
|
||||
AlarmName: 'app-unhealthy', AlarmArn: 'arn:aws:cloudwatch:us-east-1:000000000000:alarm:app-unhealthy',
|
||||
StateValue: 'OK', AlarmRule: 'ALARM("high-errors")', ActionsEnabled: false, AlarmConfigurationUpdatedTimestamp: UPDATED,
|
||||
}
|
||||
|
||||
describe('AwsMonitoringAdapter alarms', () => {
|
||||
test('lists metric and composite alarms with a readable condition', async () => {
|
||||
const {adapter, sent} = stubCloudWatch((command) => command instanceof DescribeAlarmsCommand
|
||||
? {MetricAlarms: [metricAlarm], CompositeAlarms: [compositeAlarm]}
|
||||
: {})
|
||||
|
||||
const alarms = await adapter.list()
|
||||
expect((sent[0] as DescribeAlarmsCommand).input.AlarmTypes).toEqual(['MetricAlarm', 'CompositeAlarm'])
|
||||
expect(alarms.map((alarm) => [alarm.name, alarm.status, alarm.metadata.alarmType])).toEqual([['app-unhealthy', 'OK', 'composite'], ['high-errors', 'ALARM', 'metric']])
|
||||
expect(alarms[1].metadata).toMatchObject({condition: 'Errors Sum > 5 for 2 × 5 min', dimensions: [{name: 'FunctionName', value: 'checkout'}], threshold: 5, periodSeconds: 300})
|
||||
expect(alarms[0].metadata).toMatchObject({condition: 'ALARM("high-errors")', actionsEnabled: false})
|
||||
expect(await adapter.list({search: 'errors'})).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('creates a metric alarm that notifies an SNS topic both ways', async () => {
|
||||
const {adapter, sent} = stubCloudWatch((command) => command instanceof DescribeAlarmsCommand ? {MetricAlarms: [metricAlarm]} : {})
|
||||
|
||||
await adapter.create({values: {
|
||||
alarmType: 'metric', name: 'high-errors', namespace: 'App', metricName: 'Errors', dimensions: 'FunctionName=checkout\n',
|
||||
statistic: 'Sum', period: '300', comparison: 'GreaterThanThreshold', threshold: '5', evaluationPeriods: '2',
|
||||
topicArn: 'arn:aws:sns:us-east-1:000000000000:alerts',
|
||||
}})
|
||||
|
||||
expect((sent[0] as PutMetricAlarmCommand).input).toMatchObject({
|
||||
AlarmName: 'high-errors', Namespace: 'App', MetricName: 'Errors', Dimensions: [{Name: 'FunctionName', Value: 'checkout'}],
|
||||
Statistic: 'Sum', Period: 300, EvaluationPeriods: 2, Threshold: 5, ComparisonOperator: 'GreaterThanThreshold',
|
||||
AlarmActions: ['arn:aws:sns:us-east-1:000000000000:alerts'], OKActions: ['arn:aws:sns:us-east-1:000000000000:alerts'],
|
||||
})
|
||||
})
|
||||
|
||||
test('creates a composite alarm from its rule', async () => {
|
||||
const {adapter, sent} = stubCloudWatch((command) => command instanceof DescribeAlarmsCommand ? {CompositeAlarms: [compositeAlarm]} : {})
|
||||
|
||||
const created = await adapter.create({values: {alarmType: 'composite', name: 'app-unhealthy', alarmRule: 'ALARM("high-errors")'}})
|
||||
expect((sent[0] as PutCompositeAlarmCommand).input).toMatchObject({AlarmName: 'app-unhealthy', AlarmRule: 'ALARM("high-errors")', AlarmActions: []})
|
||||
expect(created.metadata.alarmType).toBe('composite')
|
||||
})
|
||||
|
||||
test('checks the alarm before creating it', async () => {
|
||||
const {adapter, sent} = stubCloudWatch(() => ({}))
|
||||
const valid = {alarmType: 'metric', name: 'a', namespace: 'App', metricName: 'Errors', threshold: '5'}
|
||||
|
||||
await expect(adapter.create({values: {...valid, threshold: 'lots'}})).rejects.toThrow(/threshold/)
|
||||
await expect(adapter.create({values: {...valid, metricName: ''}})).rejects.toThrow(/metricName/)
|
||||
await expect(adapter.create({values: {...valid, dimensions: 'FunctionName'}})).rejects.toThrow(/Name=Value/)
|
||||
await expect(adapter.create({values: {...valid, topicArn: 'my-topic'}})).rejects.toThrow(/SNS topic ARN/)
|
||||
await expect(adapter.create({values: {...valid, period: '7'}})).rejects.toThrow(ValidationError)
|
||||
await expect(adapter.create({values: {alarmType: 'composite', name: 'b', alarmRule: ' '}})).rejects.toThrow(/rule/)
|
||||
expect(sent.some((command) => command instanceof PutMetricAlarmCommand || command instanceof PutCompositeAlarmCommand)).toBe(false)
|
||||
})
|
||||
|
||||
test('deletes, sets the state and toggles the actions of an alarm', async () => {
|
||||
const {adapter, sent} = stubCloudWatch(() => ({}))
|
||||
|
||||
await adapter.delete('high-errors')
|
||||
await adapter.setAlarmState('high-errors', 'ALARM', '')
|
||||
await adapter.setAlarmActionsEnabled('high-errors', false)
|
||||
await adapter.setAlarmActionsEnabled('high-errors', true)
|
||||
|
||||
expect((sent[0] as DeleteAlarmsCommand).input).toEqual({AlarmNames: ['high-errors']})
|
||||
expect((sent[1] as SetAlarmStateCommand).input).toMatchObject({AlarmName: 'high-errors', StateValue: 'ALARM'})
|
||||
expect((sent[1] as SetAlarmStateCommand).input.StateReason).toContain('TheCloud')
|
||||
expect(sent[2]).toBeInstanceOf(DisableAlarmActionsCommand)
|
||||
expect(sent[3]).toBeInstanceOf(EnableAlarmActionsCommand)
|
||||
await expect(adapter.setAlarmState('high-errors', 'BROKEN' as never, '')).rejects.toThrow(/state/)
|
||||
})
|
||||
|
||||
test('reads the alarm history newest first', async () => {
|
||||
const {adapter, sent} = stubCloudWatch(() => ({AlarmHistoryItems: [{Timestamp: UPDATED, HistoryItemType: 'StateUpdate', HistorySummary: 'Alarm updated from OK to ALARM'}]}))
|
||||
|
||||
expect(await adapter.describeAlarmHistory('high-errors')).toEqual([{timestamp: '2026-10-25T10:00:00.000Z', type: 'StateUpdate', summary: 'Alarm updated from OK to ALARM'}])
|
||||
expect((sent[0] as DescribeAlarmHistoryCommand).input).toMatchObject({AlarmName: 'high-errors', ScanBy: 'TimestampDescending'})
|
||||
})
|
||||
})
|
||||
|
||||
describe('AwsMonitoringAdapter metrics', () => {
|
||||
test('lists metrics sorted by namespace and filters them', async () => {
|
||||
const {adapter} = stubCloudWatch((command) => command instanceof ListMetricsCommand
|
||||
? {Metrics: [
|
||||
{Namespace: 'AWS/SQS', MetricName: 'NumberOfMessagesSent', Dimensions: [{Name: 'QueueName', Value: 'orders'}]},
|
||||
{Namespace: 'App', MetricName: 'Errors', Dimensions: []},
|
||||
]}
|
||||
: {})
|
||||
|
||||
expect((await adapter.listMetrics()).map((metric) => `${metric.namespace}/${metric.metricName}`)).toEqual(['App/Errors', 'AWS/SQS/NumberOfMessagesSent'])
|
||||
expect(await adapter.listMetrics({search: 'orders'})).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('charts a metric with a period that suits the range', async () => {
|
||||
const {adapter, sent} = stubCloudWatch((command) => command instanceof GetMetricDataCommand
|
||||
? {MetricDataResults: [{Id: 'm1', Label: 'Errors', Timestamps: [new Date('2026-10-25T10:01:00Z'), new Date('2026-10-25T10:00:00Z')], Values: [7, 3]}]}
|
||||
: {})
|
||||
|
||||
const series = await adapter.getMetricSeries({namespace: 'App', metricName: 'Errors', statistic: 'Sum', rangeMinutes: 60, dimensions: [{name: 'FunctionName', value: 'checkout'}]})
|
||||
expect(series).toEqual({label: 'Errors', statistic: 'Sum', periodSeconds: 60, points: [{timestamp: '2026-10-25T10:00:00.000Z', value: 3}, {timestamp: '2026-10-25T10:01:00.000Z', value: 7}]})
|
||||
const query = (sent[0] as GetMetricDataCommand).input.MetricDataQueries![0]
|
||||
expect(query.MetricStat).toEqual({Metric: {Namespace: 'App', MetricName: 'Errors', Dimensions: [{Name: 'FunctionName', Value: 'checkout'}]}, Period: 60, Stat: 'Sum'})
|
||||
|
||||
await adapter.getMetricSeries({namespace: 'App', metricName: 'Errors', rangeMinutes: 24 * 60})
|
||||
expect((sent[1] as GetMetricDataCommand).input.MetricDataQueries![0].MetricStat!.Period).toBe(300)
|
||||
await expect(adapter.getMetricSeries({namespace: 'App', metricName: 'Errors', rangeMinutes: 1})).rejects.toThrow(/rangeMinutes/)
|
||||
await expect(adapter.getMetricSeries({namespace: '', metricName: 'Errors'})).rejects.toThrow(/namespace/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,326 @@
|
||||
import {
|
||||
DeleteAlarmsCommand,
|
||||
DescribeAlarmHistoryCommand,
|
||||
DescribeAlarmsCommand,
|
||||
DisableAlarmActionsCommand,
|
||||
EnableAlarmActionsCommand,
|
||||
GetMetricDataCommand,
|
||||
ListMetricsCommand,
|
||||
PutCompositeAlarmCommand,
|
||||
PutMetricAlarmCommand,
|
||||
SetAlarmStateCommand,
|
||||
type CloudWatchClient,
|
||||
type ComparisonOperator,
|
||||
type CompositeAlarm,
|
||||
type MetricAlarm,
|
||||
type Statistic,
|
||||
} from '@aws-sdk/client-cloudwatch'
|
||||
import {
|
||||
ALARM_COMPARISONS,
|
||||
ALARM_NAME_PATTERN,
|
||||
ALARM_PERIODS,
|
||||
ALARM_STATES,
|
||||
ALARM_STATISTICS,
|
||||
ALARM_TYPES,
|
||||
awsMonitoringSchema,
|
||||
} from '../cloud-spi/monitoringSchema'
|
||||
import {NotFoundError, ValidationError} from '../cloud-spi/errors'
|
||||
import type {
|
||||
AlarmHistoryItem,
|
||||
AlarmStateValue,
|
||||
CloudResource,
|
||||
CloudServiceAdapter,
|
||||
CreateResourceInput,
|
||||
MetricDimension,
|
||||
MetricSeries,
|
||||
MetricSeriesQuery,
|
||||
MetricSummary,
|
||||
ResourceQuery,
|
||||
ServiceSchema,
|
||||
} from '../cloud-spi/types'
|
||||
|
||||
/** CloudWatch's page and request limits. */
|
||||
const ALARM_PAGE_LIMIT = 100
|
||||
const METRIC_LIST_CAP = 500
|
||||
const HISTORY_LIMIT = 25
|
||||
const MAX_DIMENSIONS = 30
|
||||
const MAX_RANGE_MINUTES = 14 * 24 * 60
|
||||
const SYMBOL: Record<string, string> = {
|
||||
GreaterThanThreshold: '>',
|
||||
GreaterThanOrEqualToThreshold: '>=',
|
||||
LessThanThreshold: '<',
|
||||
LessThanOrEqualToThreshold: '<=',
|
||||
}
|
||||
|
||||
/**
|
||||
* Talks to CloudWatch metrics and alarms. Alarms, metric and composite, are the resources;
|
||||
* metrics are browsed and charted through the monitoring actions.
|
||||
*
|
||||
* Verified against TheCloud's runtime: alarm actions publish to SNS on every state change,
|
||||
* `SetAlarmState` changes the state until the next evaluation, and `PutMetricData` datums
|
||||
* are returned by `GetMetricData` straight away.
|
||||
*/
|
||||
export class AwsMonitoringAdapter implements CloudServiceAdapter {
|
||||
readonly cloud = 'aws' as const
|
||||
readonly service = 'monitoring' as const
|
||||
|
||||
constructor(private readonly cloudwatch: CloudWatchClient) {}
|
||||
|
||||
schema(): ServiceSchema {
|
||||
return awsMonitoringSchema()
|
||||
}
|
||||
|
||||
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
|
||||
const metricAlarms: MetricAlarm[] = []
|
||||
const compositeAlarms: CompositeAlarm[] = []
|
||||
let nextToken: string | undefined
|
||||
do {
|
||||
const res = await this.cloudwatch.send(new DescribeAlarmsCommand({
|
||||
AlarmTypes: ['MetricAlarm', 'CompositeAlarm'],
|
||||
MaxRecords: ALARM_PAGE_LIMIT,
|
||||
...(nextToken ? {NextToken: nextToken} : {}),
|
||||
}))
|
||||
metricAlarms.push(...(res.MetricAlarms ?? []))
|
||||
compositeAlarms.push(...(res.CompositeAlarms ?? []))
|
||||
nextToken = res.NextToken || undefined
|
||||
} while (nextToken)
|
||||
|
||||
const search = query.search?.trim().toLowerCase()
|
||||
return [...metricAlarms.map(metricAlarmResource), ...compositeAlarms.map(compositeAlarmResource)]
|
||||
.filter((alarm) => !search || [alarm.name, String(alarm.metadata.metricName ?? ''), String(alarm.metadata.namespace ?? '')].some((value) => value.toLowerCase().includes(search)))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
async get(name: string): Promise<CloudResource | null> {
|
||||
const res = await this.cloudwatch.send(new DescribeAlarmsCommand({AlarmNames: [name], AlarmTypes: ['MetricAlarm', 'CompositeAlarm']}))
|
||||
const metric = res.MetricAlarms?.[0]
|
||||
if (metric) return metricAlarmResource(metric)
|
||||
const composite = res.CompositeAlarms?.[0]
|
||||
return composite ? compositeAlarmResource(composite) : null
|
||||
}
|
||||
|
||||
async create(input: CreateResourceInput): Promise<CloudResource> {
|
||||
const values = input.values
|
||||
const type = oneOf(values.alarmType ?? 'metric', ALARM_TYPES, 'alarmType')
|
||||
const name = text(values.name)
|
||||
if (!new RegExp(ALARM_NAME_PATTERN).test(name)) throw new ValidationError('name must be 1 to 255 characters, without control characters')
|
||||
const description = text(values.description) || undefined
|
||||
const topicArn = text(values.topicArn)
|
||||
if (topicArn && !/^arn:aws[a-z-]*:sns:[a-z0-9-]+:\d{12}:[A-Za-z0-9_-]{1,256}(\.fifo)?$/.test(topicArn)) {
|
||||
throw new ValidationError('topicArn must be an SNS topic ARN, such as arn:aws:sns:us-east-1:000000000000:alerts')
|
||||
}
|
||||
const actions = topicArn ? [topicArn] : []
|
||||
|
||||
if (type === 'composite') {
|
||||
const rule = text(values.alarmRule)
|
||||
if (!rule) throw new ValidationError('Write the rule, such as ALARM("high-errors")')
|
||||
if (rule.length > 10240) throw new ValidationError('The rule must be at most 10240 characters')
|
||||
await this.cloudwatch.send(new PutCompositeAlarmCommand({
|
||||
AlarmName: name,
|
||||
AlarmDescription: description,
|
||||
AlarmRule: rule,
|
||||
AlarmActions: actions,
|
||||
OKActions: actions,
|
||||
ActionsEnabled: true,
|
||||
}))
|
||||
} else {
|
||||
const namespace = text(values.namespace)
|
||||
const metricName = text(values.metricName)
|
||||
if (!namespace) throw new ValidationError('namespace is required, such as AWS/Lambda')
|
||||
if (!metricName) throw new ValidationError('metricName is required, such as Errors')
|
||||
const threshold = Number(text(values.threshold))
|
||||
if (text(values.threshold) === '' || !Number.isFinite(threshold)) throw new ValidationError('threshold must be a number')
|
||||
const evaluationPeriods = Number(text(values.evaluationPeriods) || '1')
|
||||
if (!Number.isInteger(evaluationPeriods) || evaluationPeriods < 1) throw new ValidationError('evaluationPeriods must be a whole number from 1')
|
||||
await this.cloudwatch.send(new PutMetricAlarmCommand({
|
||||
AlarmName: name,
|
||||
AlarmDescription: description,
|
||||
Namespace: namespace,
|
||||
MetricName: metricName,
|
||||
Dimensions: parseDimensions(values.dimensions).map((dimension) => ({Name: dimension.name, Value: dimension.value})),
|
||||
Statistic: oneOf(values.statistic || 'Average', ALARM_STATISTICS, 'statistic') as Statistic,
|
||||
Period: Number(oneOf(String(values.period || '300'), ALARM_PERIODS, 'period')),
|
||||
EvaluationPeriods: evaluationPeriods,
|
||||
Threshold: threshold,
|
||||
ComparisonOperator: oneOf(values.comparison || 'GreaterThanThreshold', ALARM_COMPARISONS, 'comparison') as ComparisonOperator,
|
||||
TreatMissingData: 'missing',
|
||||
AlarmActions: actions,
|
||||
OKActions: actions,
|
||||
ActionsEnabled: true,
|
||||
}))
|
||||
}
|
||||
const created = await this.get(name)
|
||||
if (!created) throw new NotFoundError(`CloudWatch did not return the alarm ${name}`)
|
||||
return created
|
||||
}
|
||||
|
||||
async delete(name: string): Promise<void> {
|
||||
await this.cloudwatch.send(new DeleteAlarmsCommand({AlarmNames: [name]}))
|
||||
}
|
||||
|
||||
async listMetrics(query: {namespace?: string; search?: string} = {}): Promise<MetricSummary[]> {
|
||||
const metrics: MetricSummary[] = []
|
||||
let nextToken: string | undefined
|
||||
do {
|
||||
const res = await this.cloudwatch.send(new ListMetricsCommand({
|
||||
...(query.namespace ? {Namespace: query.namespace} : {}),
|
||||
...(nextToken ? {NextToken: nextToken} : {}),
|
||||
}))
|
||||
for (const metric of res.Metrics ?? []) {
|
||||
metrics.push({
|
||||
namespace: metric.Namespace ?? '',
|
||||
metricName: metric.MetricName ?? '',
|
||||
dimensions: (metric.Dimensions ?? []).map((dimension) => ({name: dimension.Name ?? '', value: dimension.Value ?? ''})),
|
||||
})
|
||||
}
|
||||
nextToken = res.NextToken || undefined
|
||||
} while (nextToken && metrics.length < METRIC_LIST_CAP)
|
||||
|
||||
const search = query.search?.trim().toLowerCase()
|
||||
return metrics.slice(0, METRIC_LIST_CAP)
|
||||
.filter((metric) => !search || [metric.namespace, metric.metricName, ...metric.dimensions.map((dimension) => dimension.value)].some((value) => value.toLowerCase().includes(search)))
|
||||
.sort((a, b) => a.namespace.localeCompare(b.namespace) || a.metricName.localeCompare(b.metricName))
|
||||
}
|
||||
|
||||
async getMetricSeries(query: MetricSeriesQuery): Promise<MetricSeries> {
|
||||
const namespace = text(query?.namespace)
|
||||
const metricName = text(query.metricName)
|
||||
if (!namespace || !metricName) throw new ValidationError('namespace and metricName are required')
|
||||
const statistic = oneOf(query.statistic || 'Average', ALARM_STATISTICS, 'statistic')
|
||||
const rangeMinutes = Number(query.rangeMinutes ?? 60)
|
||||
if (!Number.isFinite(rangeMinutes) || rangeMinutes < 5 || rangeMinutes > MAX_RANGE_MINUTES) throw new ValidationError('rangeMinutes must be between 5 and 20160')
|
||||
// A point every minute for short ranges, coarser for long ones, staying near 60-300 points.
|
||||
const periodSeconds = Number(query.periodSeconds) || (rangeMinutes <= 180 ? 60 : rangeMinutes <= 1440 ? 300 : 3600)
|
||||
if (![60, 300, 900, 3600].includes(periodSeconds)) throw new ValidationError('periodSeconds must be 60, 300, 900 or 3600')
|
||||
const dimensions = (query.dimensions ?? []).map((dimension) => ({Name: text(dimension?.name), Value: text(dimension?.value)}))
|
||||
if (dimensions.some((dimension) => !dimension.Name)) throw new ValidationError('Every dimension needs a name')
|
||||
|
||||
const end = new Date()
|
||||
const start = new Date(end.getTime() - rangeMinutes * 60_000)
|
||||
const res = await this.cloudwatch.send(new GetMetricDataCommand({
|
||||
StartTime: start,
|
||||
EndTime: end,
|
||||
ScanBy: 'TimestampAscending',
|
||||
MetricDataQueries: [{
|
||||
Id: 'm1',
|
||||
ReturnData: true,
|
||||
MetricStat: {Metric: {Namespace: namespace, MetricName: metricName, Dimensions: dimensions}, Period: periodSeconds, Stat: statistic},
|
||||
}],
|
||||
}))
|
||||
const result = res.MetricDataResults?.[0]
|
||||
const timestamps = result?.Timestamps ?? []
|
||||
const points = timestamps.map((timestamp, index) => ({timestamp: timestamp.toISOString(), value: result?.Values?.[index] ?? 0}))
|
||||
.sort((a, b) => a.timestamp.localeCompare(b.timestamp))
|
||||
return {label: result?.Label || metricName, statistic, periodSeconds, points}
|
||||
}
|
||||
|
||||
async describeAlarmHistory(name: string): Promise<AlarmHistoryItem[]> {
|
||||
const res = await this.cloudwatch.send(new DescribeAlarmHistoryCommand({AlarmName: name, MaxRecords: HISTORY_LIMIT, ScanBy: 'TimestampDescending'}))
|
||||
return (res.AlarmHistoryItems ?? []).map((item) => ({
|
||||
timestamp: item.Timestamp?.toISOString() ?? null,
|
||||
type: item.HistoryItemType ?? '',
|
||||
summary: item.HistorySummary ?? '',
|
||||
}))
|
||||
}
|
||||
|
||||
/** Changes the state until the next evaluation, which is how alarm actions are tested on AWS. */
|
||||
async setAlarmState(name: string, state: AlarmStateValue, reason: string): Promise<void> {
|
||||
const value = oneOf(state, ALARM_STATES, 'state')
|
||||
const why = text(reason) || `Set to ${value} from TheCloud to test the alarm's actions`
|
||||
if (why.length > 1023) throw new ValidationError('reason must be at most 1023 characters')
|
||||
await this.cloudwatch.send(new SetAlarmStateCommand({AlarmName: name, StateValue: value, StateReason: why}))
|
||||
}
|
||||
|
||||
async setAlarmActionsEnabled(name: string, enabled: boolean): Promise<void> {
|
||||
if (typeof enabled !== 'boolean') throw new ValidationError('enabled must be true or false')
|
||||
await this.cloudwatch.send(enabled ? new EnableAlarmActionsCommand({AlarmNames: [name]}) : new DisableAlarmActionsCommand({AlarmNames: [name]}))
|
||||
}
|
||||
}
|
||||
|
||||
function metricAlarmResource(alarm: MetricAlarm): CloudResource {
|
||||
const dimensions = (alarm.Dimensions ?? []).map((dimension) => ({name: dimension.Name ?? '', value: dimension.Value ?? ''}))
|
||||
const period = alarm.Period ?? 0
|
||||
return {
|
||||
...baseResource(alarm.AlarmName ?? '', alarm.AlarmArn, alarm.StateValue, alarm.AlarmConfigurationUpdatedTimestamp),
|
||||
metadata: {
|
||||
alarmType: 'metric',
|
||||
arn: alarm.AlarmArn ?? null,
|
||||
description: alarm.AlarmDescription ?? null,
|
||||
stateReason: alarm.StateReason ?? null,
|
||||
stateUpdatedAt: alarm.StateUpdatedTimestamp?.toISOString() ?? null,
|
||||
actionsEnabled: alarm.ActionsEnabled ?? true,
|
||||
alarmActions: alarm.AlarmActions ?? [],
|
||||
okActions: alarm.OKActions ?? [],
|
||||
namespace: alarm.Namespace ?? null,
|
||||
metricName: alarm.MetricName ?? null,
|
||||
dimensions,
|
||||
statistic: alarm.Statistic ?? alarm.ExtendedStatistic ?? null,
|
||||
periodSeconds: period || null,
|
||||
threshold: alarm.Threshold ?? null,
|
||||
comparison: alarm.ComparisonOperator ?? null,
|
||||
evaluationPeriods: alarm.EvaluationPeriods ?? null,
|
||||
condition: alarm.MetricName
|
||||
? `${alarm.MetricName} ${alarm.Statistic ?? ''} ${SYMBOL[alarm.ComparisonOperator ?? ''] ?? alarm.ComparisonOperator ?? ''} ${alarm.Threshold ?? ''} for ${alarm.EvaluationPeriods ?? 1} × ${periodLabel(period)}`.replace(/\s+/g, ' ').trim()
|
||||
: 'Metric math',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function compositeAlarmResource(alarm: CompositeAlarm): CloudResource {
|
||||
return {
|
||||
...baseResource(alarm.AlarmName ?? '', alarm.AlarmArn, alarm.StateValue, alarm.AlarmConfigurationUpdatedTimestamp),
|
||||
metadata: {
|
||||
alarmType: 'composite',
|
||||
arn: alarm.AlarmArn ?? null,
|
||||
description: alarm.AlarmDescription ?? null,
|
||||
stateReason: alarm.StateReason ?? null,
|
||||
stateUpdatedAt: alarm.StateUpdatedTimestamp?.toISOString() ?? null,
|
||||
actionsEnabled: alarm.ActionsEnabled ?? true,
|
||||
alarmActions: alarm.AlarmActions ?? [],
|
||||
okActions: alarm.OKActions ?? [],
|
||||
alarmRule: alarm.AlarmRule ?? null,
|
||||
condition: alarm.AlarmRule ?? null,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function baseResource(name: string, arn: string | undefined, state: string | undefined, updated: Date | undefined) {
|
||||
return {
|
||||
id: name,
|
||||
name,
|
||||
cloud: 'aws' as const,
|
||||
service: 'monitoring' as const,
|
||||
type: 'alarm',
|
||||
region: arn?.split(':')[3] || null,
|
||||
createdAt: updated?.toISOString() ?? null,
|
||||
status: state ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
/** One Name=Value per line, as the create form takes it. */
|
||||
function parseDimensions(value: unknown): MetricDimension[] {
|
||||
const lines = typeof value === 'string' ? value.split('\n').map((line) => line.trim()).filter(Boolean) : []
|
||||
if (lines.length > MAX_DIMENSIONS) throw new ValidationError(`An alarm takes at most ${MAX_DIMENSIONS} dimensions`)
|
||||
return lines.map((line) => {
|
||||
const index = line.indexOf('=')
|
||||
const name = index > 0 ? line.slice(0, index).trim() : ''
|
||||
const dimensionValue = index > 0 ? line.slice(index + 1).trim() : ''
|
||||
if (!name || !dimensionValue) throw new ValidationError(`Write each dimension as Name=Value (got "${line}")`)
|
||||
return {name, value: dimensionValue}
|
||||
})
|
||||
}
|
||||
|
||||
function periodLabel(seconds: number): string {
|
||||
if (seconds >= 3600 && seconds % 3600 === 0) return `${seconds / 3600} h`
|
||||
if (seconds >= 60 && seconds % 60 === 0) return `${seconds / 60} min`
|
||||
return `${seconds} s`
|
||||
}
|
||||
|
||||
function oneOf<T extends string>(value: unknown, allowed: readonly T[], field: string): T {
|
||||
if (typeof value === 'string' && (allowed as readonly string[]).includes(value)) return value as T
|
||||
throw new ValidationError(`${field} must be one of ${allowed.join(', ')}`)
|
||||
}
|
||||
|
||||
function text(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : typeof value === 'number' ? String(value) : ''
|
||||
}
|
||||
@@ -46,6 +46,31 @@ describe('AwsSesAdapter', () => {
|
||||
})])
|
||||
})
|
||||
|
||||
test('picks out the code from verification emails', async () => {
|
||||
const adapter = new AwsSesAdapter('http://floci.test', async () => jsonResponse({
|
||||
messages: [
|
||||
{Id: 'cognito', Subject: 'Your verification code', Body: {text_part: 'Your verification code is 869472.'}},
|
||||
{Id: 'html', Subject: 'Reset', Body: {html_part: '<p>Your confirmation code is: <b>4410</b></p>'}},
|
||||
{Id: 'plain', Subject: 'Invoice', Body: {text_part: 'Order 123456 shipped.'}},
|
||||
],
|
||||
}))
|
||||
|
||||
const codes = (await adapter.list()).map((resource) => resource.metadata.code)
|
||||
expect(codes).toEqual(['869472', '4410', null])
|
||||
})
|
||||
|
||||
test('lists the newest email first', async () => {
|
||||
const adapter = new AwsSesAdapter('http://floci.test', async () => jsonResponse({
|
||||
messages: [
|
||||
{Id: 'old', Timestamp: '2026-10-23T21:12:41.732Z'},
|
||||
{Id: 'newest', Timestamp: '2026-10-24T05:00:00.000Z'},
|
||||
{Id: 'middle', Timestamp: '2026-10-23T21:56:04.866Z'},
|
||||
],
|
||||
}))
|
||||
|
||||
expect((await adapter.list()).map((resource) => resource.id)).toEqual(['newest', 'middle', 'old'])
|
||||
})
|
||||
|
||||
test('filters messages by subject, sender, and recipients', async () => {
|
||||
const adapter = new AwsSesAdapter('http://floci.test', async () => jsonResponse({
|
||||
messages: [
|
||||
|
||||
@@ -47,7 +47,7 @@ export class AwsSesAdapter implements CloudServiceAdapter {
|
||||
|
||||
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
|
||||
const messages = await this.fetchInbox()
|
||||
const resources = messages.map(messageToResource)
|
||||
const resources = messages.map(messageToResource).sort(newestFirst)
|
||||
return filterBySearch(resources, query.search)
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ export class AwsSesAdapter implements CloudServiceAdapter {
|
||||
}
|
||||
|
||||
async delete(_id: string): Promise<void> {
|
||||
throw new NotSupportedError('Deleting individual captured emails is not supported by Floci SES')
|
||||
throw new NotSupportedError('Deleting individual captured emails is not supported by TheCloud SES')
|
||||
}
|
||||
|
||||
async clearEmailInbox(): Promise<void> {
|
||||
@@ -76,10 +76,10 @@ export class AwsSesAdapter implements CloudServiceAdapter {
|
||||
try {
|
||||
body = await response.json() as FlociSesInbox
|
||||
} catch (error) {
|
||||
throw new RuntimeError('Floci SES inspection returned invalid JSON', {cause: error})
|
||||
throw new RuntimeError('TheCloud SES inspection returned invalid JSON', {cause: error})
|
||||
}
|
||||
if (!Array.isArray(body.messages)) {
|
||||
throw new RuntimeError('Floci SES inspection response did not contain messages')
|
||||
throw new RuntimeError('TheCloud SES inspection response did not contain messages')
|
||||
}
|
||||
return body.messages
|
||||
}
|
||||
@@ -89,14 +89,14 @@ export class AwsSesAdapter implements CloudServiceAdapter {
|
||||
try {
|
||||
response = await this.fetcher(`${this.endpoint.replace(/\/$/, '')}${path}`, init)
|
||||
} catch (error) {
|
||||
throw new RuntimeUnavailableError(`Cannot reach Floci SES at ${this.endpoint}`, {cause: error})
|
||||
throw new RuntimeUnavailableError(`Cannot reach TheCloud SES at ${this.endpoint}`, {cause: error})
|
||||
}
|
||||
if (response.ok) return response
|
||||
|
||||
const detail = await response.text().catch(() => '')
|
||||
throw httpStatusToCloudError(
|
||||
response.status,
|
||||
detail || `Floci SES inspection returned HTTP ${response.status}`,
|
||||
detail || `TheCloud SES inspection returned HTTP ${response.status}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -125,10 +125,22 @@ function messageToResource(message: FlociSesMessage): CloudResource {
|
||||
htmlBody: message.Body?.html_part ?? null,
|
||||
rawData: message.RawData ?? null,
|
||||
messageType: raw ? 'raw' : 'simple',
|
||||
code: findCode(message.Body?.text_part ?? message.Body?.html_part),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Matches the wording of Cognito's default and most custom messages: "Your verification code is 123456."
|
||||
const CODE_PATTERN = /\bcode\s+is:?\s*(?:<[^>]+>\s*)*(\d{4,8})\b/i
|
||||
|
||||
function findCode(body: string | null | undefined): string | null {
|
||||
return body?.match(CODE_PATTERN)?.[1] ?? null
|
||||
}
|
||||
|
||||
function newestFirst(a: CloudResource, b: CloudResource): number {
|
||||
return (b.createdAt ?? '').localeCompare(a.createdAt ?? '')
|
||||
}
|
||||
|
||||
function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] {
|
||||
const normalized = search?.trim().toLowerCase()
|
||||
if (!normalized) return resources
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
ListUserPoolClientsCommand,
|
||||
ListUserPoolsCommand,
|
||||
ListUsersCommand,
|
||||
UpdateUserPoolClientCommand,
|
||||
type CognitoIdentityProviderClient,
|
||||
} from '@aws-sdk/client-cognito-identity-provider'
|
||||
import {AwsUserPoolsAdapter} from './AwsUserPoolsAdapter'
|
||||
@@ -125,9 +126,31 @@ describe('AwsUserPoolsAdapter', () => {
|
||||
mfaConfiguration: 'OFF',
|
||||
deletionProtection: 'INACTIVE',
|
||||
appClients: [{clientId: 'a8e5c7d38d3b4a1a97c9d822de', clientName: 'orders-users-client'}],
|
||||
lambdaTriggers: [],
|
||||
})
|
||||
})
|
||||
|
||||
test('lists the pool\'s Lambda triggers in sign-in order, with the trigger version when set', async () => {
|
||||
const arn = (name: string) => `arn:aws:lambda:us-east-1:000000000000:function:${name}`
|
||||
const {client} = stubCognito((command) => {
|
||||
if (command instanceof DescribeUserPoolCommand) return {UserPool: {...pool, LambdaConfig: {
|
||||
PreTokenGeneration: arn('claims'),
|
||||
PreTokenGenerationConfig: {LambdaArn: arn('claims'), LambdaVersion: 'V2_0'},
|
||||
PreSignUp: `${arn('signup')}:live`,
|
||||
CustomEmailSender: {LambdaArn: arn('mailer'), LambdaVersion: 'V1_0'},
|
||||
KMSKeyID: 'arn:aws:kms:us-east-1:000000000000:key/1',
|
||||
}}}
|
||||
return {}
|
||||
})
|
||||
|
||||
const resource = await new AwsUserPoolsAdapter(client).get(POOL_ID)
|
||||
expect(resource?.metadata.lambdaTriggers).toEqual([
|
||||
{trigger: 'PreSignUp', label: 'Pre sign-up', functionArn: `${arn('signup')}:live`, functionName: 'signup', version: null},
|
||||
{trigger: 'PreTokenGeneration', label: 'Pre token generation', functionArn: arn('claims'), functionName: 'claims', version: 'V2_0'},
|
||||
{trigger: 'CustomEmailSender', label: 'Custom email sender', functionArn: arn('mailer'), functionName: 'mailer', version: 'V1_0'},
|
||||
])
|
||||
})
|
||||
|
||||
test('counts users itself, since the runtime leaves EstimatedNumberOfUsers at 0', async () => {
|
||||
const {client} = stubCognito((command) => {
|
||||
if (command instanceof DescribeUserPoolCommand) return {UserPool: {...pool, EstimatedNumberOfUsers: 0}}
|
||||
@@ -387,3 +410,63 @@ describe('AwsUserPoolsAdapter user actions and groups', () => {
|
||||
.toEqual({outcome: 'challenge', challengeName: 'NEW_PASSWORD_REQUIRED'})
|
||||
})
|
||||
})
|
||||
|
||||
describe('app client OAuth settings', () => {
|
||||
const CLIENT_ID = '40808d60e188483d876bbbf646'
|
||||
const stored = {
|
||||
UserPoolId: POOL_ID, ClientId: CLIENT_ID, ClientName: 'notes-demo-web',
|
||||
ExplicitAuthFlows: ['ALLOW_USER_PASSWORD_AUTH', 'ALLOW_REFRESH_TOKEN_AUTH'],
|
||||
PreventUserExistenceErrors: 'ENABLED', RefreshTokenValidity: 30, EnableTokenRevocation: true,
|
||||
SupportedIdentityProviders: ['COGNITO'],
|
||||
}
|
||||
|
||||
function clientStub() {
|
||||
return stubCognito((command) => {
|
||||
if (command instanceof DescribeUserPoolClientCommand) return {UserPoolClient: stored}
|
||||
if (command instanceof UpdateUserPoolClientCommand) return {UserPoolClient: {...stored, ...command.input}}
|
||||
return {}
|
||||
})
|
||||
}
|
||||
|
||||
test('reads a client with no OAuth set up', async () => {
|
||||
const settings = await new AwsUserPoolsAdapter(clientStub().client).describeUserPoolClient(POOL_ID, CLIENT_ID)
|
||||
expect(settings).toEqual({
|
||||
clientId: CLIENT_ID, clientName: 'notes-demo-web', hasSecret: false,
|
||||
explicitAuthFlows: ['ALLOW_USER_PASSWORD_AUTH', 'ALLOW_REFRESH_TOKEN_AUTH'],
|
||||
callbackUrls: [], logoutUrls: [], oauthFlows: [], oauthScopes: [], identityProviders: ['COGNITO'],
|
||||
})
|
||||
})
|
||||
|
||||
test('turns on the code flow and sends the client\'s other settings back unchanged', async () => {
|
||||
const {client, sent} = clientStub()
|
||||
const settings = await new AwsUserPoolsAdapter(client).updateUserPoolClientOAuth(POOL_ID, CLIENT_ID, {
|
||||
callbackUrls: ['http://localhost:4600/api/auth/callback', ' http://localhost:4600/api/auth/callback '],
|
||||
logoutUrls: ['http://localhost:4600/'],
|
||||
oauthFlows: ['code'], oauthScopes: ['openid', 'email'], identityProviders: ['COGNITO'],
|
||||
})
|
||||
|
||||
const update = sent.find((command) => command instanceof UpdateUserPoolClientCommand) as UpdateUserPoolClientCommand
|
||||
expect(update.input).toMatchObject({
|
||||
ClientName: 'notes-demo-web', ExplicitAuthFlows: ['ALLOW_USER_PASSWORD_AUTH', 'ALLOW_REFRESH_TOKEN_AUTH'],
|
||||
PreventUserExistenceErrors: 'ENABLED', RefreshTokenValidity: 30, EnableTokenRevocation: true,
|
||||
CallbackURLs: ['http://localhost:4600/api/auth/callback'], LogoutURLs: ['http://localhost:4600/'],
|
||||
AllowedOAuthFlows: ['code'], AllowedOAuthScopes: ['openid', 'email'], AllowedOAuthFlowsUserPoolClient: true,
|
||||
SupportedIdentityProviders: ['COGNITO'],
|
||||
})
|
||||
expect(settings.oauthFlows).toEqual(['code'])
|
||||
})
|
||||
|
||||
test('applies Cognito\'s rules before calling it', async () => {
|
||||
const {client, sent} = clientStub()
|
||||
const adapter = new AwsUserPoolsAdapter(client)
|
||||
const valid = {callbackUrls: ['https://app.example/callback'], logoutUrls: [], oauthFlows: ['code' as const], oauthScopes: ['openid'], identityProviders: ['COGNITO']}
|
||||
|
||||
await expect(adapter.updateUserPoolClientOAuth(POOL_ID, CLIENT_ID, {...valid, callbackUrls: ['http://app.example/callback']})).rejects.toThrow(/https/)
|
||||
await expect(adapter.updateUserPoolClientOAuth(POOL_ID, CLIENT_ID, {...valid, callbackUrls: ['https://app.example/#done']})).rejects.toThrow(/fragment/)
|
||||
await expect(adapter.updateUserPoolClientOAuth(POOL_ID, CLIENT_ID, {...valid, callbackUrls: []})).rejects.toThrow(/callback URL/)
|
||||
await expect(adapter.updateUserPoolClientOAuth(POOL_ID, CLIENT_ID, {...valid, oauthScopes: []})).rejects.toThrow(/scope/)
|
||||
await expect(adapter.updateUserPoolClientOAuth(POOL_ID, CLIENT_ID, {...valid, oauthFlows: ['code', 'client_credentials']})).rejects.toThrow(ValidationError)
|
||||
await expect(adapter.updateUserPoolClientOAuth(POOL_ID, CLIENT_ID, {...valid, oauthFlows: ['client_credentials']})).rejects.toThrow(/secret/)
|
||||
expect(sent.some((command) => command instanceof UpdateUserPoolClientCommand)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -24,11 +24,13 @@ import {
|
||||
ListUserPoolClientsCommand,
|
||||
ListUserPoolsCommand,
|
||||
ListUsersCommand,
|
||||
UpdateUserPoolClientCommand,
|
||||
type AttributeType,
|
||||
type CognitoIdentityProviderClient,
|
||||
type GroupType,
|
||||
type PasswordPolicyType,
|
||||
type UserPoolClientDescription,
|
||||
type UserPoolClientType,
|
||||
type UserPoolDescriptionType,
|
||||
type UserPoolType,
|
||||
type UserType,
|
||||
@@ -38,6 +40,7 @@ import {ConflictError, RuntimeError, ValidationError} from '../cloud-spi/errors'
|
||||
import {
|
||||
USER_POOL_GROUP_NAME_PATTERN,
|
||||
USER_POOL_NAME_PATTERN,
|
||||
USER_POOL_OAUTH_FLOWS,
|
||||
USER_POOL_PASSWORD_POLICIES,
|
||||
USER_POOL_SIGN_IN,
|
||||
awsUserPoolsSchema,
|
||||
@@ -49,7 +52,10 @@ import type {
|
||||
CreateUserPoolGroupInput,
|
||||
ResourceQuery,
|
||||
ServiceSchema,
|
||||
UpdateUserPoolClientOAuthInput,
|
||||
UserPoolClientSettings,
|
||||
UserPoolGroup,
|
||||
UserPoolOAuthFlow,
|
||||
UserPoolSignInTestInput,
|
||||
UserPoolSignInTestResult,
|
||||
UserPoolUserAction,
|
||||
@@ -285,6 +291,54 @@ export class AwsUserPoolsAdapter implements CloudServiceAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
async describeUserPoolClient(poolId: string, clientId: string): Promise<UserPoolClientSettings> {
|
||||
return toClientSettings(await this.fetchClient(poolId, clientId))
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the settings the hosted login and OAuth endpoints use. UpdateUserPoolClient
|
||||
* resets every setting it is not given, so the client's other settings are sent back as they are.
|
||||
*/
|
||||
async updateUserPoolClientOAuth(poolId: string, clientId: string, input: UpdateUserPoolClientOAuthInput): Promise<UserPoolClientSettings> {
|
||||
const settings = validateOAuthSettings(input)
|
||||
const current = await this.fetchClient(poolId, clientId)
|
||||
if (settings.oauthFlows.includes('client_credentials') && !current.ClientSecret) {
|
||||
throw new ValidationError('The client_credentials flow needs an app client with a secret')
|
||||
}
|
||||
const res = await this.cognito.send(new UpdateUserPoolClientCommand({
|
||||
UserPoolId: poolId,
|
||||
ClientId: clientId,
|
||||
ClientName: current.ClientName,
|
||||
RefreshTokenValidity: current.RefreshTokenValidity,
|
||||
AccessTokenValidity: current.AccessTokenValidity,
|
||||
IdTokenValidity: current.IdTokenValidity,
|
||||
TokenValidityUnits: current.TokenValidityUnits,
|
||||
ReadAttributes: current.ReadAttributes,
|
||||
WriteAttributes: current.WriteAttributes,
|
||||
ExplicitAuthFlows: current.ExplicitAuthFlows,
|
||||
DefaultRedirectURI: current.DefaultRedirectURI && settings.callbackUrls.includes(current.DefaultRedirectURI) ? current.DefaultRedirectURI : undefined,
|
||||
AnalyticsConfiguration: current.AnalyticsConfiguration,
|
||||
PreventUserExistenceErrors: current.PreventUserExistenceErrors,
|
||||
EnableTokenRevocation: current.EnableTokenRevocation,
|
||||
EnablePropagateAdditionalUserContextData: current.EnablePropagateAdditionalUserContextData,
|
||||
AuthSessionValidity: current.AuthSessionValidity,
|
||||
RefreshTokenRotation: current.RefreshTokenRotation,
|
||||
CallbackURLs: settings.callbackUrls,
|
||||
LogoutURLs: settings.logoutUrls,
|
||||
AllowedOAuthFlows: settings.oauthFlows,
|
||||
AllowedOAuthScopes: settings.oauthScopes,
|
||||
AllowedOAuthFlowsUserPoolClient: settings.oauthFlows.length > 0,
|
||||
SupportedIdentityProviders: settings.identityProviders,
|
||||
}))
|
||||
return toClientSettings(res.UserPoolClient ?? current)
|
||||
}
|
||||
|
||||
private async fetchClient(poolId: string, clientId: string): Promise<UserPoolClientType> {
|
||||
const res = await this.cognito.send(new DescribeUserPoolClientCommand({UserPoolId: poolId, ClientId: requiredString(clientId, 'clientId')}))
|
||||
if (!res.UserPoolClient) throw new RuntimeError(`Cognito returned no app client ${clientId}`)
|
||||
return res.UserPoolClient
|
||||
}
|
||||
|
||||
private async listGroupsForUser(poolId: string, username: string): Promise<string[]> {
|
||||
return listGroupNamesForUser(this.cognito, poolId, username)
|
||||
}
|
||||
@@ -460,10 +514,103 @@ function toResource(pool: UserPoolType, clients: UserPoolClientDescription[], us
|
||||
mfaConfiguration: pool.MfaConfiguration ?? null,
|
||||
deletionProtection: pool.DeletionProtection ?? null,
|
||||
appClients: clients.map((client) => ({clientId: client.ClientId ?? '', clientName: client.ClientName ?? ''})),
|
||||
lambdaTriggers: lambdaTriggersOf(pool.LambdaConfig),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Labels as the AWS console names them, in the order a sign-up and sign-in reach them.
|
||||
const LAMBDA_TRIGGERS = [
|
||||
['PreSignUp', 'Pre sign-up'],
|
||||
['UserMigration', 'User migration'],
|
||||
['CustomMessage', 'Custom message'],
|
||||
['PostConfirmation', 'Post confirmation'],
|
||||
['PreAuthentication', 'Pre authentication'],
|
||||
['DefineAuthChallenge', 'Define auth challenge'],
|
||||
['CreateAuthChallenge', 'Create auth challenge'],
|
||||
['VerifyAuthChallengeResponse', 'Verify auth challenge response'],
|
||||
['PostAuthentication', 'Post authentication'],
|
||||
['PreTokenGeneration', 'Pre token generation'],
|
||||
['CustomEmailSender', 'Custom email sender'],
|
||||
['CustomSMSSender', 'Custom SMS sender'],
|
||||
] as const
|
||||
|
||||
type LambdaTrigger = {trigger: string; label: string; functionArn: string; functionName: string; version: string | null}
|
||||
|
||||
function lambdaTriggersOf(config: UserPoolType['LambdaConfig']): LambdaTrigger[] {
|
||||
if (!config) return []
|
||||
return LAMBDA_TRIGGERS.flatMap(([trigger, label]) => {
|
||||
// PreTokenGenerationConfig carries the trigger version and supersedes the older PreTokenGeneration field.
|
||||
const versioned = trigger === 'PreTokenGeneration' ? config.PreTokenGenerationConfig
|
||||
: trigger === 'CustomEmailSender' ? config.CustomEmailSender
|
||||
: trigger === 'CustomSMSSender' ? config.CustomSMSSender
|
||||
: undefined
|
||||
const functionArn = versioned?.LambdaArn ?? config[trigger]
|
||||
if (typeof functionArn !== 'string' || !functionArn) return []
|
||||
return [{trigger, label, functionArn, functionName: functionArn.split(':')[6] ?? functionArn, version: versioned?.LambdaVersion ?? null}]
|
||||
})
|
||||
}
|
||||
|
||||
function toClientSettings(client: UserPoolClientType): UserPoolClientSettings {
|
||||
return {
|
||||
clientId: client.ClientId ?? '',
|
||||
clientName: client.ClientName ?? '',
|
||||
hasSecret: Boolean(client.ClientSecret),
|
||||
explicitAuthFlows: client.ExplicitAuthFlows ?? [],
|
||||
callbackUrls: client.CallbackURLs ?? [],
|
||||
logoutUrls: client.LogoutURLs ?? [],
|
||||
oauthFlows: client.AllowedOAuthFlowsUserPoolClient ? (client.AllowedOAuthFlows ?? []).filter(isOAuthFlow) : [],
|
||||
oauthScopes: client.AllowedOAuthScopes ?? [],
|
||||
identityProviders: client.SupportedIdentityProviders ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
function isOAuthFlow(value: string): value is UserPoolOAuthFlow {
|
||||
return (USER_POOL_OAUTH_FLOWS as readonly string[]).includes(value)
|
||||
}
|
||||
|
||||
/** Cognito's own rules for these settings, checked here so the form can say what is wrong. */
|
||||
function validateOAuthSettings(input: UpdateUserPoolClientOAuthInput): UpdateUserPoolClientOAuthInput {
|
||||
const callbackUrls = urlList(input?.callbackUrls, 'Callback URL')
|
||||
const logoutUrls = urlList(input.logoutUrls, 'Sign-out URL')
|
||||
const oauthFlows = stringList(input.oauthFlows, 'oauthFlows')
|
||||
const unknown = oauthFlows.find((flow) => !isOAuthFlow(flow))
|
||||
if (unknown) throw new ValidationError(`Unknown OAuth flow ${unknown}`)
|
||||
if (oauthFlows.includes('client_credentials') && oauthFlows.length > 1) {
|
||||
throw new ValidationError('client_credentials cannot be combined with the code or implicit flows')
|
||||
}
|
||||
if ((oauthFlows.includes('code') || oauthFlows.includes('implicit')) && callbackUrls.length === 0) {
|
||||
throw new ValidationError('Add at least one callback URL for the code or implicit flow')
|
||||
}
|
||||
const oauthScopes = stringList(input.oauthScopes, 'oauthScopes')
|
||||
if (oauthFlows.length > 0 && oauthScopes.length === 0) throw new ValidationError('Choose at least one OAuth scope')
|
||||
const identityProviders = stringList(input.identityProviders, 'identityProviders')
|
||||
return {callbackUrls, logoutUrls, oauthFlows: oauthFlows as UserPoolOAuthFlow[], oauthScopes, identityProviders}
|
||||
}
|
||||
|
||||
function stringList(value: unknown, field: string): string[] {
|
||||
if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string')) throw new ValidationError(`${field} must be a list of strings`)
|
||||
return [...new Set(value.map((entry: string) => entry.trim()).filter(Boolean))]
|
||||
}
|
||||
|
||||
// Cognito accepts https URLs, and http only for localhost; fragments are never allowed.
|
||||
function urlList(value: unknown, label: string): string[] {
|
||||
return stringList(value, label).map((entry) => {
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(entry)
|
||||
} catch {
|
||||
throw new ValidationError(`${label} ${entry} is not a valid URL`)
|
||||
}
|
||||
const local = url.hostname === 'localhost' || url.hostname === '127.0.0.1'
|
||||
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && local)) {
|
||||
throw new ValidationError(`${label} ${entry} must use https (http is allowed only for localhost)`)
|
||||
}
|
||||
if (url.hash || entry.includes('#')) throw new ValidationError(`${label} ${entry} must not contain a fragment`)
|
||||
return entry
|
||||
})
|
||||
}
|
||||
|
||||
function toUserItem(
|
||||
username: string | undefined,
|
||||
attributes: AttributeType[] | undefined,
|
||||
|
||||
@@ -106,7 +106,7 @@ export class AzureAksAdapter implements CloudServiceAdapter {
|
||||
|
||||
const body = await this.json<ArmList<{subscriptionId?: string}>>('/subscriptions')
|
||||
const id = body?.value?.[0]?.subscriptionId
|
||||
if (!id) throw new RuntimeError('Floci-AZ returned no subscription to scope cluster requests to')
|
||||
if (!id) throw new RuntimeError('The Azure runtime returned no subscription to scope cluster requests to')
|
||||
|
||||
this.subscriptionId = id
|
||||
return id
|
||||
|
||||
@@ -182,7 +182,7 @@ export class AzureComputeAdapter implements CloudServiceAdapter {
|
||||
|
||||
const body = await this.json<ArmList<{subscriptionId?: string}>>('/subscriptions')
|
||||
const id = body?.value?.[0]?.subscriptionId
|
||||
if (!id) throw new RuntimeError('Floci-AZ returned no subscription to scope compute requests to')
|
||||
if (!id) throw new RuntimeError('The Azure runtime returned no subscription to scope compute requests to')
|
||||
|
||||
this.subscriptionId = id
|
||||
return id
|
||||
|
||||
@@ -49,7 +49,7 @@ export class AzureServerlessAdapter implements CloudServiceAdapter {
|
||||
descriptorOverride(): CloudServiceDescriptorOverride {
|
||||
return {
|
||||
availability: 'coming_soon',
|
||||
reason: 'The Floci-AZ runtime returns 501 NotImplemented for the Azure Functions endpoint.',
|
||||
reason: 'TheCloud\'s Azure runtime returns 501 NotImplemented for the Azure Functions endpoint.',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,5 +8,5 @@ import {CloudError, type CloudErrorStatus} from '../cloud-spi/errors'
|
||||
export class CosmosNoSqlUnavailableError extends CloudError {
|
||||
readonly status: CloudErrorStatus = 502
|
||||
readonly code = 'cosmos_nosql_unavailable' as const
|
||||
protected readonly label = 'Cosmos NoSQL endpoint is not available on the selected Floci-AZ runtime'
|
||||
protected readonly label = 'Cosmos NoSQL endpoint is not available on the selected Azure runtime'
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ function connectionConfig(connection: SqlDataConnection): MssqlConfig {
|
||||
options: {
|
||||
encrypt: connection.tlsMode !== 'disable',
|
||||
trustServerCertificate: connection.tlsMode !== 'verify',
|
||||
appName: 'Floci UI',
|
||||
appName: 'TheCloud',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ export class PostgresDataClient implements SqlDataClient {
|
||||
connectionTimeoutMillis: 10_000,
|
||||
query_timeout: 30_000,
|
||||
statement_timeout: 30_000,
|
||||
application_name: 'Floci UI',
|
||||
application_name: 'TheCloud',
|
||||
})
|
||||
const startedAt = performance.now()
|
||||
let phase: SqlDataPhase = 'connect'
|
||||
|
||||
@@ -13,7 +13,9 @@ const realFetch = globalThis.fetch
|
||||
let passwordHash: string
|
||||
|
||||
beforeAll(async () => {
|
||||
passwordHash = await Bun.password.hash('correct-horse-battery')
|
||||
// Cheap parameters keep the many sign-in attempts below fast on slow CI runners; verification
|
||||
// reads the parameters from the hash, so the code under test is unchanged.
|
||||
passwordHash = await Bun.password.hash('correct-horse-battery', {algorithm: 'argon2id', memoryCost: 1024, timeCost: 1})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -239,7 +241,8 @@ describe('auth state cleanup', () => {
|
||||
test('caps pending OIDC requests and sweeps expired state', async () => {
|
||||
const config = authConfig(oidcEnv({FLOCI_UI_OIDC_ALLOW_ALL_USERS: 'true'}))
|
||||
const hono = app(config)
|
||||
globalThis.fetch = (async () => Response.json({authorization_endpoint: 'https://idp.test/authorize', token_endpoint: 'https://idp.test/token', jwks_uri: 'https://idp.test/jwks'})) as unknown as typeof fetch
|
||||
// The same document as mockIdp: discovery is cached per issuer for the rest of the run.
|
||||
globalThis.fetch = (async () => Response.json({authorization_endpoint: 'https://idp.test/authorize', token_endpoint: 'https://idp.test/token', jwks_uri: 'https://idp.test/jwks', userinfo_endpoint: 'https://idp.test/userinfo'})) as unknown as typeof fetch
|
||||
for (let index = 0; index < 1_050; index++) await request(hono, '/api/auth/oidc/login', `10.1.${Math.floor(index / 250)}.${index % 250}`)
|
||||
|
||||
expect(authStateSizes().oidcRequests).toBe(1_000)
|
||||
|
||||
@@ -270,7 +270,7 @@ function loginPage(c: Context, config: AuthConfig): Response {
|
||||
? `<form method="get" action="/api/auth/oidc/login"><input type="hidden" name="returnTo" value="${escapeHtml(returnTo)}"><button class="sso-button" type="submit"><span class="ready"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2 4 5v6c0 5 3.4 9.5 8 11 4.6-1.5 8-6 8-11V5l-8-3Zm0 4 4 1.5V11c0 3.2-1.9 6.2-4 7.4C9.9 17.2 8 14.2 8 11V7.5L12 6Z"/></svg>Sign in with Authentik</span><span class="loading"><i></i>Redirecting...</span></button></form>`
|
||||
: `<form method="post" action="/api/auth/login"><input type="hidden" name="csrf" value="${csrf}"><input type="hidden" name="returnTo" value="${escapeHtml(returnTo)}"><label for="username">Username</label><input id="username" name="username" autocomplete="username" required autofocus><label for="password">Password</label><input id="password" name="password" type="password" autocomplete="current-password" required><button class="sso-button" type="submit">Sign in</button></form>`
|
||||
const error = loginErrorMessage(c.req.query('error'))
|
||||
return c.html(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Welcome back | TheCloud</title><style>:root{color-scheme:dark;--bg:#0f141a;--surface:#161d26;--raised:#232b37;--border:#424650;--text:#c6c6cd;--bright:#f9f9fa;--muted:#a4a4ad;--accent:#42b4ff;--danger:#ff9b93}@media(prefers-color-scheme:light){:root{color-scheme:light;--bg:#efe7d2;--surface:#fffaf0;--raised:#ece4cf;--border:rgba(21,20,15,.16);--text:#2a2620;--bright:#15140f;--muted:#5a5448;--accent:#ed6f5c;--danger:#bd3f30}}*{box-sizing:border-box}body{margin:0;min-width:320px;min-height:100vh;display:grid;place-items:center;padding:28px 20px;color:var(--text);font:14px Sora,Inter,system-ui,sans-serif;background:radial-gradient(circle at 14% 12%,color-mix(in srgb,var(--accent) 16%,transparent),transparent 28rem),linear-gradient(135deg,var(--bg),var(--raised));}.shell{width:min(420px,100%);animation:enter .45s ease-out}.brand{display:flex;align-items:center;gap:12px;margin:0 0 20px 8px;color:var(--bright)}.brand svg{width:34px;height:34px;fill:none;stroke:var(--accent);stroke-width:1.6}.brand strong{display:block;font-size:16px;letter-spacing:-.02em}.brand small{display:block;margin-top:2px;color:var(--muted);font-size:11px}.card{padding:30px;border:1px solid var(--border);border-radius:14px;background:color-mix(in srgb,var(--surface) 94%,transparent);box-shadow:0 24px 64px #0005}.eyebrow{margin:0 0 8px;color:var(--accent);font-size:11px;font-weight:700;letter-spacing:.12em;text-transform:uppercase}h1{margin:0;color:var(--bright);font-size:26px;letter-spacing:-.04em}p{margin:9px 0 24px;color:var(--muted);line-height:1.55}.alert{display:flex;gap:9px;margin:0 0 18px;padding:11px 12px;border:1px solid color-mix(in srgb,var(--danger) 55%,var(--border));border-radius:8px;color:var(--danger);background:color-mix(in srgb,var(--danger) 10%,transparent);line-height:1.4}.sso-button{display:grid;place-items:center;width:100%;min-height:46px;border:0;border-radius:8px;background:var(--accent);color:#06131d;font:700 14px inherit;cursor:pointer;transition:transform .15s,filter .15s,box-shadow .15s}.sso-button:hover{filter:brightness(1.08);box-shadow:0 8px 20px color-mix(in srgb,var(--accent) 28%,transparent)}.sso-button:active{transform:translateY(1px)}.sso-button:focus-visible{outline:3px solid color-mix(in srgb,var(--accent) 58%,white);outline-offset:3px}.ready,.loading{display:flex;align-items:center;gap:9px}.ready svg{width:19px;height:19px;fill:currentColor;stroke:none}.loading{display:none}.sso-button:active .ready{display:none}.sso-button:active .loading{display:flex}.loading i{width:15px;height:15px;border:2px solid #06131d55;border-top-color:#06131d;border-radius:50%;animation:spin .7s linear infinite}.footer{margin:19px 0 0;text-align:center;font-size:12px;color:var(--muted)}label{display:block;margin:14px 0 6px}input{width:100%;padding:10px;border:1px solid var(--border);border-radius:7px;background:var(--bg);color:var(--bright)}@keyframes enter{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:none}}@keyframes spin{to{transform:rotate(360deg)}}@media(max-width:380px){body{padding:18px 14px}.card{padding:24px 20px}h1{font-size:23px}}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;transition:none!important}}</style></head><body><main class="shell" aria-labelledby="welcome"><header class="brand"><svg viewBox="0 0 40 40" aria-hidden="true"><path d="M7 25c-3-7 3-13 10-11 4-8 16-5 16 4 5 1 6 8 1 10H12c-2 0-4-1-5-3Z"/><path d="M13 31h14"/></svg><span><strong>Floci DevTools</strong><small>Local AWS, on your own infrastructure.</small></span></header><section class="card" aria-label="Sign in"><div class="eyebrow">Secure console</div><h1 id="welcome">Welcome back</h1><p>Sign in with your account to access the console.</p>${error ? `<div class="alert" role="alert">${error}</div>` : ''}${content}<div class="footer">Protected by Authentik SSO</div></section></main></body></html>`)
|
||||
return c.html(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Welcome back | TheCloud</title><style>:root{color-scheme:dark;--bg:#0f141a;--surface:#161d26;--raised:#232b37;--border:#424650;--text:#c6c6cd;--bright:#f9f9fa;--muted:#a4a4ad;--accent:#42b4ff;--danger:#ff9b93}@media(prefers-color-scheme:light){:root{color-scheme:light;--bg:#efe7d2;--surface:#fffaf0;--raised:#ece4cf;--border:rgba(21,20,15,.16);--text:#2a2620;--bright:#15140f;--muted:#5a5448;--accent:#ed6f5c;--danger:#bd3f30}}*{box-sizing:border-box}body{margin:0;min-width:320px;min-height:100vh;display:grid;place-items:center;padding:28px 20px;color:var(--text);font:14px Sora,Inter,system-ui,sans-serif;background:radial-gradient(circle at 14% 12%,color-mix(in srgb,var(--accent) 16%,transparent),transparent 28rem),linear-gradient(135deg,var(--bg),var(--raised));}.shell{width:min(420px,100%);animation:enter .45s ease-out}.brand{display:flex;align-items:center;gap:12px;margin:0 0 20px 8px;color:var(--bright)}.brand svg{width:34px;height:34px;fill:none;stroke:var(--accent);stroke-width:1.6}.brand strong{display:block;font-size:16px;letter-spacing:-.02em}.brand small{display:block;margin-top:2px;color:var(--muted);font-size:11px}.card{padding:30px;border:1px solid var(--border);border-radius:14px;background:color-mix(in srgb,var(--surface) 94%,transparent);box-shadow:0 24px 64px #0005}.eyebrow{margin:0 0 8px;color:var(--accent);font-size:11px;font-weight:700;letter-spacing:.12em;text-transform:uppercase}h1{margin:0;color:var(--bright);font-size:26px;letter-spacing:-.04em}p{margin:9px 0 24px;color:var(--muted);line-height:1.55}.alert{display:flex;gap:9px;margin:0 0 18px;padding:11px 12px;border:1px solid color-mix(in srgb,var(--danger) 55%,var(--border));border-radius:8px;color:var(--danger);background:color-mix(in srgb,var(--danger) 10%,transparent);line-height:1.4}.sso-button{display:grid;place-items:center;width:100%;min-height:46px;border:0;border-radius:8px;background:var(--accent);color:#06131d;font:700 14px inherit;cursor:pointer;transition:transform .15s,filter .15s,box-shadow .15s}.sso-button:hover{filter:brightness(1.08);box-shadow:0 8px 20px color-mix(in srgb,var(--accent) 28%,transparent)}.sso-button:active{transform:translateY(1px)}.sso-button:focus-visible{outline:3px solid color-mix(in srgb,var(--accent) 58%,white);outline-offset:3px}.ready,.loading{display:flex;align-items:center;gap:9px}.ready svg{width:19px;height:19px;fill:currentColor;stroke:none}.loading{display:none}.sso-button:active .ready{display:none}.sso-button:active .loading{display:flex}.loading i{width:15px;height:15px;border:2px solid #06131d55;border-top-color:#06131d;border-radius:50%;animation:spin .7s linear infinite}.footer{margin:19px 0 0;text-align:center;font-size:12px;color:var(--muted)}label{display:block;margin:14px 0 6px}input{width:100%;padding:10px;border:1px solid var(--border);border-radius:7px;background:var(--bg);color:var(--bright)}@keyframes enter{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:none}}@keyframes spin{to{transform:rotate(360deg)}}@media(max-width:380px){body{padding:18px 14px}.card{padding:24px 20px}h1{font-size:23px}}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;transition:none!important}}</style></head><body><main class="shell" aria-labelledby="welcome"><header class="brand"><svg viewBox="0 0 40 40" aria-hidden="true"><path d="M7 25c-3-7 3-13 10-11 4-8 16-5 16 4 5 1 6 8 1 10H12c-2 0-4-1-5-3Z"/><path d="M13 31h14"/></svg><span><strong>TheCloud</strong><small>Local AWS, on your own infrastructure.</small></span></header><section class="card" aria-label="Sign in"><div class="eyebrow">Secure console</div><h1 id="welcome">Welcome back</h1><p>Sign in with your account to access the console.</p>${error ? `<div class="alert" role="alert">${error}</div>` : ''}${content}<div class="footer">Protected by Authentik SSO</div></section></main></body></html>`)
|
||||
}
|
||||
|
||||
function loginFailure(c: Context, status: 401 | 403 | 429): Response {
|
||||
|
||||
@@ -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";
|
||||
@@ -31,6 +32,8 @@ import { CloudWatchClient } from "@aws-sdk/client-cloudwatch";
|
||||
import { ElastiCacheClient } from "@aws-sdk/client-elasticache";
|
||||
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";
|
||||
|
||||
const endpoint = process.env.FLOCI_ENDPOINT;
|
||||
const region = process.env.AWS_REGION || "us-east-1";
|
||||
@@ -77,6 +80,7 @@ export type AwsClients = {
|
||||
iam: IAMClient;
|
||||
dynamodb: DynamoDBClient;
|
||||
apiGateway: APIGatewayClient;
|
||||
apiGatewayV2: ApiGatewayV2Client;
|
||||
cloudformation: CloudFormationClient;
|
||||
appConfig: AppConfigClient;
|
||||
sns: SNSClient;
|
||||
@@ -93,6 +97,8 @@ export type AwsClients = {
|
||||
elasticache: ElastiCacheClient;
|
||||
lightsail: LightsailClient;
|
||||
cognito: CognitoIdentityProviderClient;
|
||||
route53: Route53Client;
|
||||
cloudfront: CloudFrontClient;
|
||||
};
|
||||
|
||||
export type AwsClientName = keyof AwsClients;
|
||||
@@ -123,6 +129,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),
|
||||
@@ -139,6 +146,8 @@ function buildClients(accountId: string): AwsClients {
|
||||
elasticache: new ElastiCacheClient(base),
|
||||
lightsail: new LightsailClient(base),
|
||||
cognito: new CognitoIdentityProviderClient(base),
|
||||
route53: new Route53Client(base),
|
||||
cloudfront: new CloudFrontClient(base),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -175,6 +184,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;
|
||||
|
||||
@@ -32,7 +32,7 @@ describe('AzureRestRuntimeClient', () => {
|
||||
const client = new AzureRestRuntimeClient('http://localhost:4577', 'devstoreaccount1')
|
||||
|
||||
await expect(client.fetch('/container?restype=container', {method: 'PUT'})).rejects.toThrow(
|
||||
'Cannot reach Floci-AZ at http://localhost:4577: connection refused',
|
||||
"Cannot reach TheCloud's Azure runtime at http://localhost:4577: connection refused",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -29,7 +29,7 @@ export class AzureRestRuntimeClient implements AzureRuntimeClient {
|
||||
})
|
||||
} catch (error) {
|
||||
throw new RuntimeUnavailableError(
|
||||
`Cannot reach Floci-AZ at ${this.endpoint}: ${errorMessage(error)}`,
|
||||
`Cannot reach TheCloud's Azure runtime at ${this.endpoint}: ${errorMessage(error)}`,
|
||||
{cause: error},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,131 @@
|
||||
import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types'
|
||||
|
||||
export const CDN_ORIGIN_TYPES = ['s3', 'custom'] as const
|
||||
export const CDN_VIEWER_PROTOCOLS = ['redirect-to-https', 'allow-all', 'https-only'] as const
|
||||
export const CDN_ORIGIN_PROTOCOLS = ['https-only', 'http-only', 'match-viewer'] as const
|
||||
export const CDN_ALLOWED_METHODS = ['read', 'all'] as const
|
||||
/** An S3 bucket name as S3 allows it for new buckets. */
|
||||
export const CDN_BUCKET_PATTERN = '^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$'
|
||||
/** A host name, optionally with a port, as a custom origin's DomainName. */
|
||||
export const CDN_ORIGIN_DOMAIN_PATTERN = '^[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])?)*$'
|
||||
|
||||
const distributionColumns: TableColumnSchema[] = [
|
||||
{name: 'id', label: 'Distribution ID', format: 'code'},
|
||||
{name: 'viewerUrl', label: 'Local URL', path: 'metadata.viewerUrl', format: 'code'},
|
||||
{name: 'status', label: 'Status', format: 'badge'},
|
||||
{name: 'enabled', label: 'Enabled', path: 'metadata.enabled', format: 'boolean'},
|
||||
{name: 'origins', label: 'Origins', path: 'metadata.originDomains', format: 'list'},
|
||||
{name: 'comment', label: 'Description', path: 'metadata.comment', emptyText: '—'},
|
||||
]
|
||||
|
||||
const distributionFilters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}]
|
||||
|
||||
export function awsCdnSchema(): ServiceSchema {
|
||||
return {
|
||||
cloud: 'aws',
|
||||
service: 'cdn',
|
||||
displayName: 'Amazon CloudFront distributions',
|
||||
fields: [
|
||||
{
|
||||
name: 'originType',
|
||||
label: 'Origin',
|
||||
type: 'select',
|
||||
required: true,
|
||||
defaultValue: 's3',
|
||||
options: [{label: 'S3 bucket', value: 's3'}, {label: 'Custom origin (a web server or API)', value: 'custom'}],
|
||||
},
|
||||
{
|
||||
name: 'bucket',
|
||||
label: 'S3 bucket',
|
||||
type: 'text',
|
||||
required: false,
|
||||
requiredWhen: {field: 'originType', equals: 's3'},
|
||||
visibleWhen: {field: 'originType', values: ['s3']},
|
||||
validation: {pattern: CDN_BUCKET_PATTERN, message: 'Use the name of an existing bucket'},
|
||||
},
|
||||
{
|
||||
name: 'originDomain',
|
||||
label: 'Origin domain',
|
||||
type: 'text',
|
||||
required: false,
|
||||
requiredWhen: {field: 'originType', equals: 'custom'},
|
||||
visibleWhen: {field: 'originType', values: ['custom']},
|
||||
description: 'For example api.example.com. A local server needs FLOCI_SERVICES_CLOUDFRONT_ALLOWED_PRIVATE_ORIGIN_HOSTS on the runtime.',
|
||||
validation: {pattern: CDN_ORIGIN_DOMAIN_PATTERN, message: 'Use a host name such as api.example.com'},
|
||||
},
|
||||
{
|
||||
name: 'originProtocol',
|
||||
label: 'Origin protocol',
|
||||
type: 'select',
|
||||
required: false,
|
||||
defaultValue: 'https-only',
|
||||
visibleWhen: {field: 'originType', values: ['custom']},
|
||||
options: [
|
||||
{label: 'HTTPS only', value: 'https-only'},
|
||||
{label: 'HTTP only', value: 'http-only'},
|
||||
{label: 'Match the viewer', value: 'match-viewer'},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'originPort',
|
||||
label: 'Origin port',
|
||||
type: 'text',
|
||||
required: false,
|
||||
visibleWhen: {field: 'originType', values: ['custom']},
|
||||
description: 'Leave empty for 443 (HTTPS) and 80 (HTTP).',
|
||||
validation: {pattern: '^\\d{1,5}$', message: 'Use a port number'},
|
||||
},
|
||||
{
|
||||
name: 'defaultRootObject',
|
||||
label: 'Default root object',
|
||||
type: 'text',
|
||||
required: false,
|
||||
defaultValue: 'index.html',
|
||||
description: 'What the distribution serves for a request to /.',
|
||||
},
|
||||
{
|
||||
name: 'viewerProtocol',
|
||||
label: 'Viewer protocol',
|
||||
type: 'select',
|
||||
required: false,
|
||||
defaultValue: 'allow-all',
|
||||
options: [
|
||||
{label: 'HTTP and HTTPS', value: 'allow-all'},
|
||||
{label: 'Redirect HTTP to HTTPS', value: 'redirect-to-https'},
|
||||
{label: 'HTTPS only', value: 'https-only'},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'allowedMethods',
|
||||
label: 'Allowed methods',
|
||||
type: 'select',
|
||||
required: false,
|
||||
defaultValue: 'read',
|
||||
options: [
|
||||
{label: 'GET and HEAD (a website or files)', value: 'read'},
|
||||
{label: 'All methods (an API)', value: 'all'},
|
||||
],
|
||||
},
|
||||
{name: 'comment', label: 'Description', type: 'text', required: false},
|
||||
],
|
||||
actions: ['list', 'create', 'delete', 'inspect'],
|
||||
capabilities: {
|
||||
resourceActions: [
|
||||
{name: 'list', label: 'List distributions', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'create', label: 'Create distribution', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'delete', label: 'Disable and delete distribution', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'inspect', label: 'Inspect distribution', enabled: true, status: 'available', runtimeRequired: true},
|
||||
],
|
||||
distributionActions: [
|
||||
{name: 'setEnabled', label: 'Enable or disable a distribution', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'createInvalidation', label: 'Invalidate cached paths', enabled: true, status: 'available', runtimeRequired: true},
|
||||
],
|
||||
},
|
||||
filters: distributionFilters,
|
||||
columns: distributionColumns,
|
||||
}
|
||||
}
|
||||
|
||||
export function cdnSchemaFor(cloud: CloudProvider): ServiceSchema | null {
|
||||
return cloud === 'aws' ? awsCdnSchema() : null
|
||||
}
|
||||
@@ -36,7 +36,7 @@ export function awsCodeBuildSchema(): ServiceSchema {
|
||||
{
|
||||
name: 'buildspec', label: 'Buildspec', type: 'textarea', required: false, group: 'Source', span: true,
|
||||
defaultValueWhen: {field: 'sourceType', values: {NO_SOURCE: DEFAULT_DOCKER_TO_ECR_BUILDSPEC, S3: ''}},
|
||||
description: 'For no source, this is a Floci Node 20 Docker-to-ECR buildspec. For S3, leave blank to use buildspec.yml from the source, or provide an override. Include taskdef.json and appspec.yaml in the source for CodeDeployToECS.',
|
||||
description: 'For no source, this is TheCloud\'s Node 20 Docker-to-ECR buildspec. For S3, leave blank to use buildspec.yml from the source, or provide an override. Include taskdef.json and appspec.yaml in the source for CodeDeployToECS.',
|
||||
},
|
||||
{
|
||||
name: 'artifactsType', label: 'Artifact type', type: 'select', required: true, group: 'Artifacts', defaultValue: 'NO_ARTIFACTS',
|
||||
|
||||
@@ -131,7 +131,7 @@ export function awsDatabaseSchema(): ServiceSchema {
|
||||
label: 'Create DB snapshot',
|
||||
enabled: true,
|
||||
status: 'partial',
|
||||
reason: 'The Cloud Proxy operation is available, but the current Floci runtime does not implement CreateDBSnapshot.',
|
||||
reason: 'The Cloud Proxy operation is available, but the current runtime does not implement CreateDBSnapshot.',
|
||||
runtimeRequired: true,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types'
|
||||
|
||||
/** A domain name of dot-separated labels, with or without the trailing dot. */
|
||||
export const DNS_ZONE_NAME_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 record types Route 53 accepts in ChangeResourceRecordSets. */
|
||||
export const DNS_RECORD_TYPES = ['A', 'AAAA', 'CAA', 'CNAME', 'DS', 'HTTPS', 'MX', 'NAPTR', 'NS', 'PTR', 'SOA', 'SPF', 'SRV', 'SSHFP', 'SVCB', 'TLSA', 'TXT'] as const
|
||||
|
||||
const zoneColumns: TableColumnSchema[] = [
|
||||
{name: 'name', label: 'Domain'},
|
||||
{name: 'visibility', label: 'Type', path: 'metadata.visibility', format: 'badge'},
|
||||
{name: 'recordCount', label: 'Records', path: 'metadata.recordCount'},
|
||||
{name: 'comment', label: 'Description', path: 'metadata.comment', emptyText: '—'},
|
||||
{name: 'id', label: 'Hosted zone ID', format: 'code'},
|
||||
]
|
||||
|
||||
const zoneFilters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}]
|
||||
|
||||
export function awsDnsSchema(): ServiceSchema {
|
||||
return {
|
||||
cloud: 'aws',
|
||||
service: 'dns',
|
||||
displayName: 'Amazon Route 53 hosted zones',
|
||||
fields: [
|
||||
{
|
||||
name: 'name',
|
||||
label: 'Domain name',
|
||||
type: 'text',
|
||||
required: true,
|
||||
description: 'For example example.com. TheCloud does not resolve DNS: records are stored and returned by the Route 53 API.',
|
||||
validation: {pattern: DNS_ZONE_NAME_PATTERN, message: 'Use a domain name such as example.com'},
|
||||
},
|
||||
{name: 'comment', label: 'Description', type: 'text', required: false},
|
||||
{
|
||||
name: 'vpcId',
|
||||
label: 'VPC ID (private zone)',
|
||||
type: 'text',
|
||||
required: false,
|
||||
description: 'Leave empty for a public zone. With a VPC ID the zone is private and answers only inside that VPC.',
|
||||
validation: {pattern: '^vpc-[0-9a-f]{8,17}$', message: 'Use a VPC ID such as vpc-0abc1234'},
|
||||
},
|
||||
],
|
||||
actions: ['list', 'create', 'delete', 'inspect'],
|
||||
capabilities: {
|
||||
resourceActions: [
|
||||
{name: 'list', label: 'List hosted zones', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'create', label: 'Create hosted zone', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'delete', label: 'Delete hosted zone', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'inspect', label: 'Inspect hosted zone', enabled: true, status: 'available', runtimeRequired: true},
|
||||
],
|
||||
itemActions: [
|
||||
{name: 'list', label: 'List records', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'put', label: 'Create or update a record', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'delete', label: 'Delete a record', enabled: true, status: 'available', runtimeRequired: true},
|
||||
],
|
||||
},
|
||||
filters: zoneFilters,
|
||||
columns: zoneColumns,
|
||||
}
|
||||
}
|
||||
|
||||
export function dnsSchemaFor(cloud: CloudProvider): ServiceSchema | null {
|
||||
return cloud === 'aws' ? awsDnsSchema() : null
|
||||
}
|
||||
@@ -4,6 +4,7 @@ const emailColumns: TableColumnSchema[] = [
|
||||
{name: 'name', label: 'Subject'},
|
||||
{name: 'source', label: 'From', path: 'metadata.source', format: 'code'},
|
||||
{name: 'to', label: 'To', path: 'metadata.toAddresses', format: 'list'},
|
||||
{name: 'code', label: 'Code', path: 'metadata.code', format: 'code'},
|
||||
{name: 'messageType', label: 'Type', path: 'metadata.messageType', format: 'badge'},
|
||||
{name: 'createdAt', label: 'Captured At', format: 'datetime'},
|
||||
]
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types'
|
||||
|
||||
export const ALARM_TYPES = ['metric', 'composite'] as const
|
||||
export const ALARM_STATISTICS = ['Average', 'Sum', 'Minimum', 'Maximum', 'SampleCount'] as const
|
||||
export const ALARM_COMPARISONS = [
|
||||
'GreaterThanThreshold',
|
||||
'GreaterThanOrEqualToThreshold',
|
||||
'LessThanThreshold',
|
||||
'LessThanOrEqualToThreshold',
|
||||
] as const
|
||||
export const ALARM_PERIODS = ['60', '300', '900', '3600'] as const
|
||||
export const ALARM_STATES = ['OK', 'ALARM', 'INSUFFICIENT_DATA'] as const
|
||||
/** CloudWatch's alarm name rule: 1-255 characters, no control characters. */
|
||||
export const ALARM_NAME_PATTERN = '^[^\\x00-\\x1f\\x7f]{1,255}$'
|
||||
|
||||
const alarmColumns: TableColumnSchema[] = [
|
||||
{name: 'name', label: 'Alarm'},
|
||||
{name: 'status', label: 'State', format: 'badge'},
|
||||
{name: 'alarmType', label: 'Type', path: 'metadata.alarmType'},
|
||||
{name: 'condition', label: 'Condition', path: 'metadata.condition', format: 'code', emptyText: '—'},
|
||||
{name: 'stateUpdatedAt', label: 'State changed', path: 'metadata.stateUpdatedAt', format: 'relative'},
|
||||
{name: 'actionsEnabled', label: 'Actions', path: 'metadata.actionsEnabled', format: 'boolean'},
|
||||
]
|
||||
|
||||
const alarmFilters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}]
|
||||
|
||||
const metricOnly = {field: 'alarmType', values: ['metric']}
|
||||
|
||||
export function awsMonitoringSchema(): ServiceSchema {
|
||||
return {
|
||||
cloud: 'aws',
|
||||
service: 'monitoring',
|
||||
displayName: 'Amazon CloudWatch alarms',
|
||||
fields: [
|
||||
{
|
||||
name: 'alarmType',
|
||||
label: 'Alarm type',
|
||||
type: 'select',
|
||||
required: true,
|
||||
defaultValue: 'metric',
|
||||
options: [
|
||||
{label: 'Metric alarm (a threshold on one metric)', value: 'metric'},
|
||||
{label: 'Composite alarm (a rule over other alarms)', value: 'composite'},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
label: 'Alarm name',
|
||||
type: 'text',
|
||||
required: true,
|
||||
validation: {pattern: ALARM_NAME_PATTERN, message: 'Use up to 255 characters, without control characters'},
|
||||
},
|
||||
{name: 'description', label: 'Description', type: 'text', required: false},
|
||||
{name: 'namespace', label: 'Namespace', type: 'text', required: false, requiredWhen: {field: 'alarmType', equals: 'metric'}, visibleWhen: metricOnly, description: 'For example AWS/Lambda, AWS/SQS or your own, such as MyApp.'},
|
||||
{name: 'metricName', label: 'Metric name', type: 'text', required: false, requiredWhen: {field: 'alarmType', equals: 'metric'}, visibleWhen: metricOnly, description: 'For example Errors or Duration.'},
|
||||
{
|
||||
name: 'dimensions',
|
||||
label: 'Dimensions',
|
||||
type: 'textarea',
|
||||
required: false,
|
||||
visibleWhen: metricOnly,
|
||||
description: 'One Name=Value per line, for example FunctionName=checkout. Leave empty for the metric across all dimensions.',
|
||||
},
|
||||
{
|
||||
name: 'statistic',
|
||||
label: 'Statistic',
|
||||
type: 'select',
|
||||
required: false,
|
||||
defaultValue: 'Average',
|
||||
visibleWhen: metricOnly,
|
||||
options: ALARM_STATISTICS.map((value) => ({label: value, value})),
|
||||
},
|
||||
{
|
||||
name: 'period',
|
||||
label: 'Period',
|
||||
type: 'select',
|
||||
required: false,
|
||||
defaultValue: '300',
|
||||
visibleWhen: metricOnly,
|
||||
options: [{label: '1 minute', value: '60'}, {label: '5 minutes', value: '300'}, {label: '15 minutes', value: '900'}, {label: '1 hour', value: '3600'}],
|
||||
},
|
||||
{
|
||||
name: 'comparison',
|
||||
label: 'Condition',
|
||||
type: 'select',
|
||||
required: false,
|
||||
defaultValue: 'GreaterThanThreshold',
|
||||
visibleWhen: metricOnly,
|
||||
options: [
|
||||
{label: 'Greater than the threshold', value: 'GreaterThanThreshold'},
|
||||
{label: 'Greater than or equal to the threshold', value: 'GreaterThanOrEqualToThreshold'},
|
||||
{label: 'Less than the threshold', value: 'LessThanThreshold'},
|
||||
{label: 'Less than or equal to the threshold', value: 'LessThanOrEqualToThreshold'},
|
||||
],
|
||||
},
|
||||
{name: 'threshold', label: 'Threshold', type: 'text', required: false, requiredWhen: {field: 'alarmType', equals: 'metric'}, visibleWhen: metricOnly, validation: {pattern: '^-?\\d+(\\.\\d+)?$', message: 'Use a number'}},
|
||||
{name: 'evaluationPeriods', label: 'Periods to breach', type: 'text', required: false, defaultValue: '1', visibleWhen: metricOnly, description: 'How many periods in a row must breach before the alarm fires.', validation: {pattern: '^[1-9]\\d{0,2}$', message: 'Use a whole number from 1'}},
|
||||
{
|
||||
name: 'alarmRule',
|
||||
label: 'Rule',
|
||||
type: 'textarea',
|
||||
required: false,
|
||||
requiredWhen: {field: 'alarmType', equals: 'composite'},
|
||||
visibleWhen: {field: 'alarmType', values: ['composite']},
|
||||
description: 'For example ALARM("high-errors") OR ALARM("slow-checkout"). AND, OR, NOT and parentheses work; OK() and INSUFFICIENT_DATA() too.',
|
||||
},
|
||||
{name: 'topicArn', label: 'Notify SNS topic', type: 'text', required: false, description: 'Optional. An SNS topic ARN told when the alarm fires and when it recovers.'},
|
||||
],
|
||||
actions: ['list', 'create', 'delete', 'inspect'],
|
||||
capabilities: {
|
||||
resourceActions: [
|
||||
{name: 'list', label: 'List alarms', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'create', label: 'Create alarm', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'delete', label: 'Delete alarm', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'inspect', label: 'Inspect alarm', enabled: true, status: 'available', runtimeRequired: true},
|
||||
],
|
||||
monitoringActions: [
|
||||
{name: 'listMetrics', label: 'Browse metrics', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'getMetricData', label: 'Chart a metric', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'alarmHistory', label: 'Show alarm history', enabled: false, status: 'blocked', reason: "TheCloud's runtime does not implement DescribeAlarmHistory yet.", runtimeRequired: true},
|
||||
{name: 'setAlarmState', label: 'Set an alarm state to test its actions', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'setAlarmActions', label: 'Turn alarm actions on or off', enabled: true, status: 'available', runtimeRequired: true},
|
||||
],
|
||||
},
|
||||
filters: alarmFilters,
|
||||
columns: alarmColumns,
|
||||
}
|
||||
}
|
||||
|
||||
export function monitoringSchemaFor(cloud: CloudProvider): ServiceSchema | null {
|
||||
return cloud === 'aws' ? awsMonitoringSchema() : null
|
||||
}
|
||||
@@ -239,6 +239,20 @@ export const SERVICE_CATALOG = {
|
||||
// AWS retains its dedicated page while Azure and GCP use Cloud Explorer.
|
||||
routeByCloud: {aws: '/secretsmanager'},
|
||||
},
|
||||
cdn: {
|
||||
displayName: 'CDN',
|
||||
displayNameByCloud: {aws: 'CloudFront'},
|
||||
iconKey: 'cdn',
|
||||
group: 'Networking',
|
||||
order: 50,
|
||||
},
|
||||
dns: {
|
||||
displayName: 'DNS',
|
||||
displayNameByCloud: {aws: 'Route 53'},
|
||||
iconKey: 'dns',
|
||||
group: 'Networking',
|
||||
order: 40,
|
||||
},
|
||||
userpools: {
|
||||
displayName: 'User Directories',
|
||||
displayNameByCloud: {aws: 'Cognito'},
|
||||
@@ -267,6 +281,13 @@ export const SERVICE_CATALOG = {
|
||||
group: 'Integration',
|
||||
order: 20,
|
||||
},
|
||||
monitoring: {
|
||||
displayName: 'Monitoring',
|
||||
displayNameByCloud: {aws: 'CloudWatch'},
|
||||
iconKey: 'monitoring',
|
||||
group: 'Observability',
|
||||
order: 5,
|
||||
},
|
||||
logs: {
|
||||
displayName: 'Logs',
|
||||
displayNameByCloud: {aws: 'CloudWatch Logs'},
|
||||
|
||||
@@ -178,6 +178,23 @@ export type UserPoolActionName =
|
||||
| 'createGroup'
|
||||
| 'deleteGroup'
|
||||
| 'testSignIn'
|
||||
| 'describeClient'
|
||||
| 'updateClientOAuth'
|
||||
export type DistributionActionName = 'setEnabled' | 'createInvalidation'
|
||||
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> {
|
||||
@@ -222,6 +239,10 @@ export interface ServiceSchema {
|
||||
databaseActions?: CapabilitySchema<DatabaseActionName>[]
|
||||
kubernetesActions?: CapabilitySchema<KubernetesActionName>[]
|
||||
userPoolActions?: CapabilitySchema<UserPoolActionName>[]
|
||||
distributionActions?: CapabilitySchema<DistributionActionName>[]
|
||||
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.
|
||||
@@ -453,6 +474,72 @@ export interface UserPoolSignInTestInput {
|
||||
}
|
||||
|
||||
/** Decoded claims only: the console shows what a sign-in yields without handing out tokens. */
|
||||
export interface MetricDimension {
|
||||
name: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface MetricSummary {
|
||||
namespace: string
|
||||
metricName: string
|
||||
dimensions: MetricDimension[]
|
||||
}
|
||||
|
||||
export interface MetricSeriesQuery {
|
||||
namespace: string
|
||||
metricName: string
|
||||
dimensions?: MetricDimension[]
|
||||
statistic?: string
|
||||
periodSeconds?: number
|
||||
/** How far back from now, in minutes. */
|
||||
rangeMinutes?: number
|
||||
}
|
||||
|
||||
export interface MetricSeries {
|
||||
label: string
|
||||
statistic: string
|
||||
periodSeconds: number
|
||||
points: {timestamp: string; value: number}[]
|
||||
}
|
||||
|
||||
export type AlarmStateValue = 'OK' | 'ALARM' | 'INSUFFICIENT_DATA'
|
||||
|
||||
export interface AlarmHistoryItem {
|
||||
timestamp: string | null
|
||||
type: string
|
||||
summary: string
|
||||
}
|
||||
|
||||
export interface DistributionInvalidation {
|
||||
id: string
|
||||
status: string
|
||||
createdAt: string | null
|
||||
paths: string[]
|
||||
}
|
||||
|
||||
export type UserPoolOAuthFlow = 'code' | 'implicit' | 'client_credentials'
|
||||
|
||||
/** The app client settings that decide how the hosted login and OAuth endpoints treat it. */
|
||||
export interface UserPoolClientSettings {
|
||||
clientId: string
|
||||
clientName: string
|
||||
hasSecret: boolean
|
||||
explicitAuthFlows: string[]
|
||||
callbackUrls: string[]
|
||||
logoutUrls: string[]
|
||||
oauthFlows: UserPoolOAuthFlow[]
|
||||
oauthScopes: string[]
|
||||
identityProviders: string[]
|
||||
}
|
||||
|
||||
export interface UpdateUserPoolClientOAuthInput {
|
||||
callbackUrls: string[]
|
||||
logoutUrls: string[]
|
||||
oauthFlows: UserPoolOAuthFlow[]
|
||||
oauthScopes: string[]
|
||||
identityProviders: string[]
|
||||
}
|
||||
|
||||
export type UserPoolSignInTestResult =
|
||||
| {outcome: 'signed-in'; expiresIn: number | null; idToken: Record<string, unknown>; accessToken: Record<string, unknown>}
|
||||
| {outcome: 'challenge'; challengeName: string}
|
||||
@@ -697,6 +784,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[]
|
||||
@@ -891,6 +1079,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>
|
||||
@@ -970,6 +1169,15 @@ export interface CloudServiceAdapter {
|
||||
createUserPoolGroup?(poolId: string, input: CreateUserPoolGroupInput): Promise<UserPoolGroup>
|
||||
deleteUserPoolGroup?(poolId: string, groupName: string): Promise<void>
|
||||
testUserPoolSignIn?(poolId: string, input: UserPoolSignInTestInput): Promise<UserPoolSignInTestResult>
|
||||
describeUserPoolClient?(poolId: string, clientId: string): Promise<UserPoolClientSettings>
|
||||
setDistributionEnabled?(distributionId: string, enabled: boolean): Promise<CloudResource>
|
||||
listMetrics?(query: {namespace?: string; search?: string}): Promise<MetricSummary[]>
|
||||
getMetricSeries?(query: MetricSeriesQuery): Promise<MetricSeries>
|
||||
describeAlarmHistory?(alarmName: string): Promise<AlarmHistoryItem[]>
|
||||
setAlarmState?(alarmName: string, state: AlarmStateValue, reason: string): Promise<void>
|
||||
setAlarmActionsEnabled?(alarmName: string, enabled: boolean): Promise<void>
|
||||
createDistributionInvalidation?(distributionId: string, paths: string[]): Promise<DistributionInvalidation>
|
||||
updateUserPoolClientOAuth?(poolId: string, clientId: string, input: UpdateUserPoolClientOAuthInput): Promise<UserPoolClientSettings>
|
||||
/** Clears the provider's locally captured email inbox, if it exposes one. */
|
||||
clearEmailInbox?(): Promise<void>
|
||||
// AppConfig nested resources, keyed by the ids the provider needs. Inspect
|
||||
|
||||
@@ -81,6 +81,8 @@ export function awsUserPoolsSchema(): ServiceSchema {
|
||||
{name: 'createGroup', label: 'Create group', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'deleteGroup', label: 'Delete group', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'testSignIn', label: 'Test sign-in', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'describeClient', label: 'Show app client settings', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'updateClientOAuth', label: 'Change hosted sign-in and OAuth settings', enabled: true, status: 'available', runtimeRequired: true},
|
||||
],
|
||||
},
|
||||
filters: userPoolFilters,
|
||||
@@ -92,6 +94,10 @@ export function awsUserPoolsSchema(): ServiceSchema {
|
||||
}
|
||||
}
|
||||
|
||||
export const USER_POOL_OAUTH_FLOWS = ['code', 'implicit', 'client_credentials'] as const
|
||||
/** Scopes every pool offers; resource servers can add their own. */
|
||||
export const USER_POOL_STANDARD_SCOPES = ['openid', 'email', 'phone', 'profile', 'aws.cognito.signin.user.admin'] as const
|
||||
|
||||
export function userPoolsSchemaFor(cloud: CloudProvider): ServiceSchema | null {
|
||||
return cloud === 'aws' ? awsUserPoolsSchema() : null
|
||||
}
|
||||
|
||||
@@ -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, KubernetesActionName, ResourceActionName, UserPoolActionName} from './cloud-spi/types'
|
||||
import type {CloudProvider, CloudServiceAdapter, CloudServiceType, DatabaseActionName, DistributionActionName, HttpApiActionName, KubernetesActionName, MonitoringActionName, ResourceActionName, UserPoolActionName} from './cloud-spi/types'
|
||||
|
||||
/**
|
||||
* Guards the schema-to-implementation contract for every registered adapter.
|
||||
@@ -47,6 +47,34 @@ const METHOD_FOR_KUBERNETES_ACTION: Record<KubernetesActionName, keyof CloudServ
|
||||
deleteFargateProfile: 'deleteKubernetesFargateProfile',
|
||||
}
|
||||
|
||||
const METHOD_FOR_MONITORING_ACTION: Record<MonitoringActionName, keyof CloudServiceAdapter> = {
|
||||
listMetrics: 'listMetrics',
|
||||
getMetricData: 'getMetricSeries',
|
||||
alarmHistory: 'describeAlarmHistory',
|
||||
setAlarmState: 'setAlarmState',
|
||||
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_USER_POOL_ACTION: Record<UserPoolActionName, keyof CloudServiceAdapter> = {
|
||||
updateUser: 'updateUserPoolUser',
|
||||
setUserGroups: 'setUserPoolUserGroups',
|
||||
@@ -54,6 +82,8 @@ const METHOD_FOR_USER_POOL_ACTION: Record<UserPoolActionName, keyof CloudService
|
||||
createGroup: 'createUserPoolGroup',
|
||||
deleteGroup: 'deleteUserPoolGroup',
|
||||
testSignIn: 'testUserPoolSignIn',
|
||||
describeClient: 'describeUserPoolClient',
|
||||
updateClientOAuth: 'updateUserPoolClientOAuth',
|
||||
}
|
||||
|
||||
const registry = createCloudAdapterRegistry()
|
||||
@@ -139,6 +169,33 @@ describe('registered adapters honour their schema', () => {
|
||||
`${label} advertises ${capability.name} as available but has no ${String(method)}()`,
|
||||
).toBe('function')
|
||||
}
|
||||
|
||||
const monitoringClaims = (adapter.schema().capabilities?.monitoringActions ?? []).filter((capability) => capability.status === 'available')
|
||||
for (const capability of monitoringClaims) {
|
||||
const method = METHOD_FOR_MONITORING_ACTION[capability.name]
|
||||
expect(
|
||||
typeof adapter[method],
|
||||
`${label} advertises ${capability.name} as available but has no ${String(method)}()`,
|
||||
).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 distributionClaims = (adapter.schema().capabilities?.distributionActions ?? []).filter((capability) => capability.status === 'available')
|
||||
for (const capability of distributionClaims) {
|
||||
const method = METHOD_FOR_DISTRIBUTION_ACTION[capability.name]
|
||||
expect(
|
||||
typeof adapter[method],
|
||||
`${label} advertises ${capability.name} as available but has no ${String(method)}()`,
|
||||
).toBe('function')
|
||||
}
|
||||
})
|
||||
|
||||
test(`${label} child collections match their advertised capabilities`, () => {
|
||||
@@ -152,6 +209,9 @@ describe('registered adapters honour their schema', () => {
|
||||
...(adapter.schema().capabilities?.databaseActions ?? []),
|
||||
...(adapter.schema().capabilities?.kubernetesActions ?? []),
|
||||
...(adapter.schema().capabilities?.userPoolActions ?? []),
|
||||
...(adapter.schema().capabilities?.distributionActions ?? []),
|
||||
...(adapter.schema().capabilities?.monitoringActions ?? []),
|
||||
...(adapter.schema().capabilities?.httpApiActions ?? []),
|
||||
...(adapter.schema().capabilities?.collectionActions ?? []),
|
||||
...(adapter.schema().capabilities?.itemActions ?? []),
|
||||
]
|
||||
@@ -216,7 +276,7 @@ describe('adapter capability advertisements match the runtime reality', () => {
|
||||
label: 'Create DB snapshot',
|
||||
enabled: true,
|
||||
status: 'partial',
|
||||
reason: 'The Cloud Proxy operation is available, but the current Floci runtime does not implement CreateDBSnapshot.',
|
||||
reason: 'The Cloud Proxy operation is available, but the current runtime does not implement CreateDBSnapshot.',
|
||||
runtimeRequired: true,
|
||||
},
|
||||
])
|
||||
|
||||
@@ -48,6 +48,9 @@ import {AwsKinesisAdapter} from './adapter-aws/AwsKinesisAdapter'
|
||||
import {AwsElastiCacheAdapter} from './adapter-aws/AwsElastiCacheAdapter'
|
||||
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 {AwsMonitoringAdapter} from './adapter-aws/AwsMonitoringAdapter'
|
||||
import {awsClientsForAccount, resolveAccountId} from './aws'
|
||||
import {createEc2Service} from './services/ec2'
|
||||
import {createEksService} from './services/eks'
|
||||
@@ -82,7 +85,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(),
|
||||
@@ -98,6 +101,9 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap
|
||||
new AwsElastiCacheAdapter(clients.elasticache),
|
||||
new AwsLightsailAdapter(clients.lightsail),
|
||||
new AwsUserPoolsAdapter(clients.cognito),
|
||||
new AwsDnsAdapter(clients.route53),
|
||||
new AwsCdnAdapter(clients.cloudfront, clients.s3),
|
||||
new AwsMonitoringAdapter(clients.cloudwatch),
|
||||
new AzureStorageAdapter(),
|
||||
new AzureServiceBusAdapter(),
|
||||
new AzureDatabaseAdapter(),
|
||||
|
||||
@@ -122,6 +122,6 @@ describe('GcpRestRuntimeClient.health', () => {
|
||||
stubFetch(() => {
|
||||
throw Object.assign(new Error('connect ECONNREFUSED'), {code: 'ECONNREFUSED'})
|
||||
})
|
||||
await expect(client().health()).rejects.toThrow(`Cannot reach Floci-GCP at ${ENDPOINT}`)
|
||||
await expect(client().health()).rejects.toThrow(`Cannot reach TheCloud's GCP runtime at ${ENDPOINT}`)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -40,7 +40,7 @@ export class GcpRestRuntimeClient implements GcpRuntimeClient {
|
||||
res = await globalThis.fetch(`${this.endpoint}${path}`, init)
|
||||
} catch (error) {
|
||||
throw new RuntimeUnavailableError(
|
||||
`Cannot reach Floci-GCP at ${this.endpoint}: ${errorMessage(error)}`,
|
||||
`Cannot reach TheCloud's GCP runtime at ${this.endpoint}: ${errorMessage(error)}`,
|
||||
{cause: error},
|
||||
)
|
||||
}
|
||||
@@ -76,12 +76,12 @@ export class GcpRestRuntimeClient implements GcpRuntimeClient {
|
||||
res = await globalThis.fetch(url, {method: 'GET'})
|
||||
} catch (error) {
|
||||
throw new RuntimeUnavailableError(
|
||||
`Cannot reach Floci-GCP at ${this.endpoint}: ${errorMessage(error)}`,
|
||||
`Cannot reach TheCloud's GCP runtime at ${this.endpoint}: ${errorMessage(error)}`,
|
||||
{cause: error},
|
||||
)
|
||||
}
|
||||
if (res.status >= 500) {
|
||||
throw new RuntimeUnavailableError(`Floci-GCP at ${this.endpoint} returned HTTP ${res.status}`)
|
||||
throw new RuntimeUnavailableError(`TheCloud's GCP runtime at ${this.endpoint} returned HTTP ${res.status}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ import {awsKmsSchema} from '../cloud-spi/kmsSchema'
|
||||
import {awsStorageSchema, azureStorageSchema, gcpStorageSchema} from '../cloud-spi/storageSchema'
|
||||
import {awsSesEmailSchema} from '../cloud-spi/emailSchema'
|
||||
import {awsUserPoolsSchema} from '../cloud-spi/userPoolsSchema'
|
||||
import {awsCdnSchema} from '../cloud-spi/cdnSchema'
|
||||
import {awsMonitoringSchema} from '../cloud-spi/monitoringSchema'
|
||||
import {awsAppConfigSchema} from '../cloud-spi/appConfigSchema'
|
||||
import {awsEventBridgeSchema} from '../cloud-spi/eventbridgeSchema'
|
||||
import {awsNotificationsSchema} from '../cloud-spi/notificationsSchema'
|
||||
@@ -747,6 +749,80 @@ describe('cloud schema routes', () => {
|
||||
])
|
||||
})
|
||||
|
||||
test('reads and updates an app client\'s OAuth settings', async () => {
|
||||
const settings = {clientId: 'c1', clientName: 'web', hasSecret: false, explicitAuthFlows: [], callbackUrls: [], logoutUrls: [], oauthFlows: [], oauthScopes: [], identityProviders: ['COGNITO']}
|
||||
const calls: unknown[] = []
|
||||
const app = appWithRoutes([mockAdapter('aws', {
|
||||
service: 'userpools',
|
||||
schema: awsUserPoolsSchema,
|
||||
describeUserPoolClient: async () => settings,
|
||||
updateUserPoolClientOAuth: async (poolId, clientId, input) => {
|
||||
calls.push([poolId, clientId, input])
|
||||
return {...settings, ...input}
|
||||
},
|
||||
})])
|
||||
const base = '/api/clouds/aws/services/userpools/resources/us-east-1_x/clients/c1'
|
||||
const input = {callbackUrls: ['http://localhost:4600/cb'], logoutUrls: [], oauthFlows: ['code'], oauthScopes: ['openid'], identityProviders: ['COGNITO']}
|
||||
|
||||
expect(await (await app.request(base)).json()).toEqual(settings)
|
||||
const updated = await app.request(`${base}/oauth`, {method: 'PUT', headers: {'content-type': 'application/json'}, body: JSON.stringify(input)})
|
||||
expect(updated.status).toBe(200)
|
||||
expect((await updated.json()).oauthFlows).toEqual(['code'])
|
||||
expect(calls).toEqual([['us-east-1_x', 'c1', input]])
|
||||
})
|
||||
|
||||
test('routes CloudWatch metrics, history, alarm state and actions to the adapter', async () => {
|
||||
const calls: unknown[] = []
|
||||
const app = appWithRoutes([mockAdapter('aws', {
|
||||
service: 'monitoring',
|
||||
schema: awsMonitoringSchema,
|
||||
listMetrics: async (query) => { calls.push(['metrics', query]); return [{namespace: 'App', metricName: 'Errors', dimensions: []}] },
|
||||
getMetricSeries: async (query) => { calls.push(['series', query.metricName, query.rangeMinutes]); return {label: 'Errors', statistic: 'Sum', periodSeconds: 60, points: []} },
|
||||
describeAlarmHistory: async (name) => [{timestamp: null, type: 'StateUpdate', summary: `${name} changed`}],
|
||||
setAlarmState: async (name, state, reason) => { calls.push(['state', name, state, reason]) },
|
||||
setAlarmActionsEnabled: async (name, enabled) => { calls.push(['actions', name, enabled]) },
|
||||
})])
|
||||
const json = (method: string, body: unknown) => ({method, headers: {'content-type': 'application/json'}, body: JSON.stringify(body)})
|
||||
const base = '/api/clouds/aws/services/monitoring'
|
||||
|
||||
expect(await (await app.request(`${base}/metrics?namespace=App`)).json()).toEqual([{namespace: 'App', metricName: 'Errors', dimensions: []}])
|
||||
expect((await (await app.request(`${base}/metric-data`, json('POST', {namespace: 'App', metricName: 'Errors', rangeMinutes: 60}))).json()).label).toBe('Errors')
|
||||
expect((await (await app.request(`${base}/resources/high-errors/history`)).json())[0].summary).toBe('high-errors changed')
|
||||
expect((await app.request(`${base}/resources/high-errors/state`, json('PUT', {state: 'ALARM', reason: 'test'}))).status).toBe(200)
|
||||
expect((await app.request(`${base}/resources/high-errors/actions`, json('PUT', {enabled: false}))).status).toBe(200)
|
||||
expect(calls).toEqual([
|
||||
['metrics', {namespace: 'App', search: undefined}],
|
||||
['series', 'Errors', 60],
|
||||
['state', 'high-errors', 'ALARM', 'test'],
|
||||
['actions', 'high-errors', false],
|
||||
])
|
||||
})
|
||||
|
||||
test('enables a distribution and invalidates its paths through the CloudFront adapter', async () => {
|
||||
const calls: unknown[] = []
|
||||
const app = appWithRoutes([mockAdapter('aws', {
|
||||
service: 'cdn',
|
||||
schema: awsCdnSchema,
|
||||
setDistributionEnabled: async (id, enabled) => {
|
||||
calls.push(['enabled', id, enabled])
|
||||
return {id, name: 'd.cloudfront.net', cloud: 'aws', service: 'cdn', type: 'distribution', region: null, createdAt: null, metadata: {enabled}}
|
||||
},
|
||||
createDistributionInvalidation: async (id, paths) => {
|
||||
calls.push(['invalidate', id, paths])
|
||||
return {id: 'I1', status: 'Completed', createdAt: null, paths}
|
||||
},
|
||||
})])
|
||||
const json = (method: string, body: unknown) => ({method, headers: {'content-type': 'application/json'}, body: JSON.stringify(body)})
|
||||
const base = '/api/clouds/aws/services/cdn/resources/E123'
|
||||
|
||||
const enabled = await app.request(`${base}/enabled`, json('PUT', {enabled: false}))
|
||||
expect(enabled.status).toBe(200)
|
||||
expect((await enabled.json()).metadata.enabled).toBe(false)
|
||||
const invalidated = await app.request(`${base}/invalidations`, json('POST', {paths: ['/*']}))
|
||||
expect(invalidated.status).toBe(201)
|
||||
expect(calls).toEqual([['enabled', 'E123', false], ['invalidate', 'E123', ['/*']]])
|
||||
})
|
||||
|
||||
test('clears the SES mailbox through the email adapter', async () => {
|
||||
let cleared = false
|
||||
const app = appWithRoutes([mockAdapter('aws', {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {Hono} from 'hono'
|
||||
import type {Context} from 'hono'
|
||||
import type {
|
||||
AlarmStateValue,
|
||||
CloudProvider,
|
||||
CloudServiceType,
|
||||
CreateDatabaseSnapshotInput,
|
||||
@@ -447,6 +448,83 @@ export function createCloudRoutes(injectedService?: CloudProxyService) {
|
||||
return withRuntime(c, async () => c.json(await svc(c).testUserPoolSignIn(cloud, c.req.param('id'), await c.req.json())))
|
||||
})
|
||||
|
||||
app.get('/:cloud/services/userpools/resources/:id/clients/:clientId', 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).describeUserPoolClient(cloud, c.req.param('id'), c.req.param('clientId'))))
|
||||
})
|
||||
|
||||
app.put('/:cloud/services/userpools/resources/:id/clients/:clientId/oauth', 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).updateUserPoolClientOAuth(cloud, c.req.param('id'), c.req.param('clientId'), await c.req.json())))
|
||||
})
|
||||
|
||||
app.get('/:cloud/services/monitoring/metrics', 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).listMetrics(cloud, {namespace: c.req.query('namespace') || undefined, search: c.req.query('search') || undefined})))
|
||||
})
|
||||
|
||||
app.post('/:cloud/services/monitoring/metric-data', 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).getMetricSeries(cloud, await c.req.json())))
|
||||
})
|
||||
|
||||
app.get('/:cloud/services/monitoring/resources/:id/history', 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).describeAlarmHistory(cloud, c.req.param('id'))))
|
||||
})
|
||||
|
||||
app.put('/:cloud/services/monitoring/resources/:id/state', 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<{state?: AlarmStateValue; reason?: string}>()
|
||||
await svc(c).setAlarmState(cloud, c.req.param('id'), body.state as AlarmStateValue, body.reason ?? '')
|
||||
return c.json({ok: true})
|
||||
})
|
||||
})
|
||||
|
||||
app.put('/:cloud/services/monitoring/resources/:id/actions', 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<{enabled?: boolean}>()
|
||||
await svc(c).setAlarmActionsEnabled(cloud, c.req.param('id'), body.enabled as boolean)
|
||||
return c.json({ok: true})
|
||||
})
|
||||
})
|
||||
|
||||
app.put('/:cloud/services/cdn/resources/:id/enabled', 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<{enabled?: boolean}>()
|
||||
return c.json(await svc(c).setDistributionEnabled(cloud, c.req.param('id'), body.enabled as boolean))
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/:cloud/services/cdn/resources/:id/invalidations', 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<{paths?: string[]}>()
|
||||
return c.json(await svc(c).createDistributionInvalidation(cloud, c.req.param('id'), body.paths as string[]), 201)
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
@@ -725,6 +803,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)
|
||||
@@ -1584,6 +1787,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)) {
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import {beforeEach, describe, expect, mock, test} from 'bun:test'
|
||||
|
||||
// The secretsmanager router imports the `secretsManager` singleton directly from
|
||||
// `../aws`, so we replace that module with a stub whose `send()` is driven per
|
||||
// test. `responder` returns the fake AWS SDK response and `lastCommand` captures
|
||||
// `../aws`, so we replace that export with a stub whose `send()` is driven per
|
||||
// test. Bun keeps a module mock for the rest of the run, so every other export
|
||||
// stays real: test files that load `../aws` after this one still get it whole. `responder` returns the fake AWS SDK response and `lastCommand` captures
|
||||
// the command that was dispatched so we can assert on its input shape.
|
||||
let lastCommand: {constructor: {name: string}; input: Record<string, unknown>} | null = null
|
||||
let responder: (commandName: string, command: {input: Record<string, unknown>}) => unknown
|
||||
|
||||
const realAws = await import('../aws')
|
||||
mock.module('../aws', () => ({
|
||||
...realAws,
|
||||
secretsManager: {
|
||||
async send(command: {constructor: {name: string}; input: Record<string, unknown>}) {
|
||||
lastCommand = command
|
||||
|
||||
@@ -30,6 +30,16 @@ import type {
|
||||
ApiGatewayDeleteRouteInput,
|
||||
ApiGatewayCorsInput,
|
||||
ApiGatewayCorsResult,
|
||||
HttpApiAuthorizerInput,
|
||||
HttpApiAuthorizerOverview,
|
||||
HttpApiCors,
|
||||
HttpApiOverview,
|
||||
HttpApiRouteInput,
|
||||
HttpApiRouteOverview,
|
||||
HttpApiStageInput,
|
||||
HttpApiStageOverview,
|
||||
HttpApiWebSocketTestInput,
|
||||
HttpApiWebSocketTestResult,
|
||||
S3LambdaTriggerInput,
|
||||
S3LambdaTriggerOverview,
|
||||
S3BucketWebsiteConfiguration,
|
||||
@@ -81,6 +91,14 @@ import type {
|
||||
UserPoolGroup,
|
||||
UserPoolSignInTestInput,
|
||||
UserPoolSignInTestResult,
|
||||
UserPoolClientSettings,
|
||||
DistributionInvalidation,
|
||||
AlarmHistoryItem,
|
||||
AlarmStateValue,
|
||||
MetricSeries,
|
||||
MetricSeriesQuery,
|
||||
MetricSummary,
|
||||
UpdateUserPoolClientOAuthInput,
|
||||
UserPoolUserAction,
|
||||
} from '../cloud-spi/types'
|
||||
import type {
|
||||
@@ -322,6 +340,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}`)
|
||||
@@ -831,6 +899,60 @@ export class CloudProxyService {
|
||||
return adapter.testUserPoolSignIn(poolId, input)
|
||||
}
|
||||
|
||||
async listMetrics(cloud: CloudProvider, query: {namespace?: string; search?: string}): Promise<MetricSummary[]> {
|
||||
const adapter = this.requireAdapter(cloud, 'monitoring')
|
||||
if (!adapter.listMetrics) throw new NotSupportedError(`Metrics are not supported for ${cloud}/monitoring`)
|
||||
return adapter.listMetrics(query)
|
||||
}
|
||||
|
||||
async getMetricSeries(cloud: CloudProvider, query: MetricSeriesQuery): Promise<MetricSeries> {
|
||||
const adapter = this.requireAdapter(cloud, 'monitoring')
|
||||
if (!adapter.getMetricSeries) throw new NotSupportedError(`Metric data is not supported for ${cloud}/monitoring`)
|
||||
return adapter.getMetricSeries(query)
|
||||
}
|
||||
|
||||
async describeAlarmHistory(cloud: CloudProvider, alarmName: string): Promise<AlarmHistoryItem[]> {
|
||||
const adapter = this.requireAdapter(cloud, 'monitoring')
|
||||
if (!adapter.describeAlarmHistory) throw new NotSupportedError(`Alarm history is not supported for ${cloud}/monitoring`)
|
||||
return adapter.describeAlarmHistory(alarmName)
|
||||
}
|
||||
|
||||
async setAlarmState(cloud: CloudProvider, alarmName: string, state: AlarmStateValue, reason: string): Promise<void> {
|
||||
const adapter = this.requireAdapter(cloud, 'monitoring')
|
||||
if (!adapter.setAlarmState) throw new NotSupportedError(`Setting alarm states is not supported for ${cloud}/monitoring`)
|
||||
await adapter.setAlarmState(alarmName, state, reason)
|
||||
}
|
||||
|
||||
async setAlarmActionsEnabled(cloud: CloudProvider, alarmName: string, enabled: boolean): Promise<void> {
|
||||
const adapter = this.requireAdapter(cloud, 'monitoring')
|
||||
if (!adapter.setAlarmActionsEnabled) throw new NotSupportedError(`Alarm actions are not supported for ${cloud}/monitoring`)
|
||||
await adapter.setAlarmActionsEnabled(alarmName, enabled)
|
||||
}
|
||||
|
||||
async setDistributionEnabled(cloud: CloudProvider, distributionId: string, enabled: boolean): Promise<CloudResource> {
|
||||
const adapter = this.requireAdapter(cloud, 'cdn')
|
||||
if (!adapter.setDistributionEnabled) throw new NotSupportedError(`Enabling distributions is not supported for ${cloud}/cdn`)
|
||||
return adapter.setDistributionEnabled(distributionId, enabled)
|
||||
}
|
||||
|
||||
async createDistributionInvalidation(cloud: CloudProvider, distributionId: string, paths: string[]): Promise<DistributionInvalidation> {
|
||||
const adapter = this.requireAdapter(cloud, 'cdn')
|
||||
if (!adapter.createDistributionInvalidation) throw new NotSupportedError(`Invalidations are not supported for ${cloud}/cdn`)
|
||||
return adapter.createDistributionInvalidation(distributionId, paths)
|
||||
}
|
||||
|
||||
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`)
|
||||
return adapter.describeUserPoolClient(poolId, clientId)
|
||||
}
|
||||
|
||||
async updateUserPoolClientOAuth(cloud: CloudProvider, poolId: string, clientId: string, input: UpdateUserPoolClientOAuthInput): Promise<UserPoolClientSettings> {
|
||||
const adapter = this.requireAdapter(cloud, 'userpools')
|
||||
if (!adapter.updateUserPoolClientOAuth) throw new NotSupportedError(`App client settings are not supported for ${cloud}/userpools`)
|
||||
return adapter.updateUserPoolClientOAuth(poolId, clientId, input)
|
||||
}
|
||||
|
||||
async listKubernetesNodegroups(cloud: CloudProvider, clusterId: string): Promise<KubernetesNodegroup[]> {
|
||||
const adapter = this.requireAdapter(cloud, 'k8s')
|
||||
if (!adapter.listKubernetesNodegroups) throw new NotSupportedError(`Nodegroups are not supported for ${cloud}/k8s`)
|
||||
|
||||
@@ -16,7 +16,7 @@ export type RuntimeProbe = () => Promise<void>
|
||||
export const runtimeProbes: Record<CloudProvider, RuntimeProbe> = {
|
||||
// Every runtime exposes a health endpoint, but under its own path: core and
|
||||
// Floci-AZ use /_floci/health, Floci-GCP uses /_floci-gcp/health.
|
||||
aws: () => probeHttp(`${awsEndpoint()}/_floci/health`, 'Floci core'),
|
||||
aws: () => probeHttp(`${awsEndpoint()}/_floci/health`, 'TheCloud AWS runtime'),
|
||||
azure: async () => {
|
||||
await azure.fetch('/_floci/health', {method: 'GET'})
|
||||
},
|
||||
|
||||
@@ -5,7 +5,9 @@ const originalEndpoint = process.env.FLOCI_ENDPOINT
|
||||
const originalFetch = globalThis.fetch
|
||||
|
||||
afterEach(() => {
|
||||
process.env.FLOCI_ENDPOINT = originalEndpoint
|
||||
// Assigning undefined would store the string "undefined" for every later test file.
|
||||
if (originalEndpoint === undefined) delete process.env.FLOCI_ENDPOINT
|
||||
else process.env.FLOCI_ENDPOINT = originalEndpoint
|
||||
globalThis.fetch = originalFetch
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ export type LightsailNodeDeployment = {status: 'deployed'; files: number; port:
|
||||
|
||||
export async function deployLightsailStaticFiles(instanceName: string, form: FormData): Promise<LightsailStaticDeployment> {
|
||||
const endpoint = process.env.FLOCI_ENDPOINT
|
||||
if (!endpoint) throw new RuntimeUnavailableError('Floci diagnostics are unavailable')
|
||||
if (!endpoint) throw new RuntimeUnavailableError('TheCloud diagnostics are unavailable')
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
@@ -13,19 +13,19 @@ export async function deployLightsailStaticFiles(instanceName: string, form: For
|
||||
method: 'POST', body: form, redirect: 'error', signal: AbortSignal.timeout(30_000),
|
||||
})
|
||||
} catch (error) {
|
||||
throw new RuntimeUnavailableError('Floci diagnostics are unavailable', {cause: error})
|
||||
throw new RuntimeUnavailableError('TheCloud diagnostics are unavailable', {cause: error})
|
||||
}
|
||||
const body = await response.json().catch(() => ({})) as {message?: unknown; files?: unknown; status?: unknown}
|
||||
if (!response.ok) throw httpStatusToCloudError(response.status, typeof body.message === 'string' ? body.message : 'Static file deployment failed')
|
||||
if (body.status !== 'deployed' || typeof body.files !== 'number') {
|
||||
throw new RuntimeUnavailableError('Floci diagnostics returned an invalid deployment response')
|
||||
throw new RuntimeUnavailableError('TheCloud diagnostics returned an invalid deployment response')
|
||||
}
|
||||
return {status: 'deployed', files: body.files}
|
||||
}
|
||||
|
||||
export async function deployLightsailNodeApplication(instanceName: string, form: FormData): Promise<LightsailNodeDeployment> {
|
||||
const endpoint = process.env.FLOCI_ENDPOINT
|
||||
if (!endpoint) throw new RuntimeUnavailableError('Floci diagnostics are unavailable')
|
||||
if (!endpoint) throw new RuntimeUnavailableError('TheCloud diagnostics are unavailable')
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
@@ -33,12 +33,12 @@ export async function deployLightsailNodeApplication(instanceName: string, form:
|
||||
method: 'POST', body: form, redirect: 'error', signal: AbortSignal.timeout(360_000),
|
||||
})
|
||||
} catch (error) {
|
||||
throw new RuntimeUnavailableError('Floci diagnostics are unavailable', {cause: error})
|
||||
throw new RuntimeUnavailableError('TheCloud diagnostics are unavailable', {cause: error})
|
||||
}
|
||||
const body = await response.json().catch(() => ({})) as {message?: unknown; files?: unknown; status?: unknown; port?: unknown; releaseId?: unknown}
|
||||
if (!response.ok) throw httpStatusToCloudError(response.status, typeof body.message === 'string' ? body.message : 'Node.js deployment failed')
|
||||
if (body.status !== 'deployed' || typeof body.files !== 'number' || typeof body.port !== 'number' || typeof body.releaseId !== 'string') {
|
||||
throw new RuntimeUnavailableError('Floci diagnostics returned an invalid Node.js deployment response')
|
||||
throw new RuntimeUnavailableError('TheCloud diagnostics returned an invalid Node.js deployment response')
|
||||
}
|
||||
return {status: 'deployed', files: body.files, port: body.port, releaseId: body.releaseId}
|
||||
}
|
||||
|
||||
@@ -59,23 +59,23 @@ type SshCheckDependencies = {
|
||||
async function fetchDiagnostics(): Promise<DiagnosticResponse> {
|
||||
const endpoint = process.env.FLOCI_ENDPOINT
|
||||
if (!endpoint) {
|
||||
throw new SshCheckError('DIAGNOSTICS_UNAVAILABLE', 'Floci diagnostics are unavailable', 503)
|
||||
throw new SshCheckError('DIAGNOSTICS_UNAVAILABLE', 'TheCloud diagnostics are unavailable', 503)
|
||||
}
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(`${endpoint}/_floci/diagnose`, {signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS)})
|
||||
} catch {
|
||||
throw new SshCheckError('DIAGNOSTICS_UNAVAILABLE', 'Floci diagnostics are unavailable', 503)
|
||||
throw new SshCheckError('DIAGNOSTICS_UNAVAILABLE', 'TheCloud diagnostics are unavailable', 503)
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new SshCheckError('DIAGNOSTICS_UNAVAILABLE', 'Floci diagnostics are unavailable', 503)
|
||||
throw new SshCheckError('DIAGNOSTICS_UNAVAILABLE', 'TheCloud diagnostics are unavailable', 503)
|
||||
}
|
||||
|
||||
try {
|
||||
return await response.json() as DiagnosticResponse
|
||||
} catch {
|
||||
throw new SshCheckError('DIAGNOSTICS_UNAVAILABLE', 'Floci diagnostics returned an invalid response', 502)
|
||||
throw new SshCheckError('DIAGNOSTICS_UNAVAILABLE', 'TheCloud diagnostics returned an invalid response', 502)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,12 +149,12 @@ export function createSshCheckService(dependencies: SshCheckDependencies = {}) {
|
||||
diagnostics = await diagnosticFetcher()
|
||||
} catch (error) {
|
||||
if (error instanceof SshCheckError) throw error
|
||||
throw new SshCheckError('DIAGNOSTICS_UNAVAILABLE', 'Floci diagnostics are unavailable', 503)
|
||||
throw new SshCheckError('DIAGNOSTICS_UNAVAILABLE', 'TheCloud diagnostics are unavailable', 503)
|
||||
}
|
||||
|
||||
if (!diagnostics || typeof diagnostics !== 'object'
|
||||
|| !Array.isArray(diagnostics.ec2?.instances)) {
|
||||
throw new SshCheckError('DIAGNOSTICS_UNAVAILABLE', 'Floci diagnostics returned an invalid response', 502)
|
||||
throw new SshCheckError('DIAGNOSTICS_UNAVAILABLE', 'TheCloud diagnostics returned an invalid response', 502)
|
||||
}
|
||||
const instance = diagnostics.ec2.instances.find((candidate) => candidate.instanceId === instanceId)
|
||||
if (!instance) {
|
||||
|
||||
@@ -130,6 +130,30 @@ 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",
|
||||
history: "clouds.services.monitoring.history",
|
||||
state: "clouds.services.monitoring.state",
|
||||
actions: "clouds.services.monitoring.actions",
|
||||
},
|
||||
cdn: {
|
||||
enabled: "clouds.services.cdn.enabled",
|
||||
invalidations: "clouds.services.cdn.invalidations",
|
||||
},
|
||||
userpools: {
|
||||
userAction: "clouds.services.userpools.users.action",
|
||||
userGroups: "clouds.services.userpools.users.groups",
|
||||
@@ -139,6 +163,10 @@ export const apiEndpointKeys = {
|
||||
delete: "clouds.services.userpools.groups.delete",
|
||||
},
|
||||
signInTest: "clouds.services.userpools.sign-in-test",
|
||||
client: {
|
||||
get: "clouds.services.userpools.clients.get",
|
||||
updateOAuth: "clouds.services.userpools.clients.update-oauth",
|
||||
},
|
||||
},
|
||||
k8s: {
|
||||
nodegroups: {
|
||||
@@ -882,6 +910,50 @@ 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.cdn.enabled,
|
||||
{
|
||||
path: "/clouds/:cloud/services/cdn/resources/:id/enabled",
|
||||
method: "PUT",
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.cdn.invalidations,
|
||||
{
|
||||
path: "/clouds/:cloud/services/cdn/resources/:id/invalidations",
|
||||
method: "POST",
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.userpools.client.get,
|
||||
{
|
||||
path: "/clouds/:cloud/services/userpools/resources/:id/clients/:clientId",
|
||||
method: "GET",
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.userpools.client.updateOAuth,
|
||||
{
|
||||
path: "/clouds/:cloud/services/userpools/resources/:id/clients/:clientId/oauth",
|
||||
method: "PUT",
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.k8s.nodegroups.list,
|
||||
{
|
||||
|
||||
@@ -31,6 +31,14 @@ import type {
|
||||
UserPoolGroup,
|
||||
UserPoolSignInTestInput,
|
||||
UserPoolSignInTestResult,
|
||||
UserPoolClientSettings,
|
||||
DistributionInvalidation,
|
||||
AlarmHistoryItem,
|
||||
AlarmStateValue,
|
||||
MetricSeries,
|
||||
MetricSeriesQuery,
|
||||
MetricSummary,
|
||||
UpdateUserPoolClientOAuthInput,
|
||||
UserPoolUserAction,
|
||||
LogsInsightsQueryInput,
|
||||
LogsInsightsQueryResult,
|
||||
@@ -1474,6 +1482,257 @@ export async function testUserPoolSignIn(
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function listMetrics(
|
||||
cloud: CloudProvider,
|
||||
query: { namespace?: string; search?: string } = {},
|
||||
signal?: AbortSignal,
|
||||
): Promise<MetricSummary[]> {
|
||||
const res = await apiClient.call<MetricSummary[]>(
|
||||
apiEndpointKeys.clouds.monitoring.metrics,
|
||||
requestOptions(cloud, "monitoring", { signal, params: { namespace: query.namespace || undefined, search: query.search || undefined } }),
|
||||
{ cloud },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getMetricSeries(
|
||||
cloud: CloudProvider,
|
||||
query: MetricSeriesQuery,
|
||||
signal?: AbortSignal,
|
||||
): Promise<MetricSeries> {
|
||||
const res = await apiClient.call<MetricSeries, MetricSeriesQuery>(
|
||||
apiEndpointKeys.clouds.monitoring.metricData,
|
||||
requestOptions(cloud, "monitoring", { signal, body: query }),
|
||||
{ cloud },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function describeAlarmHistory(
|
||||
cloud: CloudProvider,
|
||||
alarmName: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<AlarmHistoryItem[]> {
|
||||
const res = await apiClient.call<AlarmHistoryItem[]>(
|
||||
apiEndpointKeys.clouds.monitoring.history,
|
||||
requestOptions(cloud, "monitoring", { signal }),
|
||||
{ cloud, id: alarmName },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function setAlarmState(
|
||||
cloud: CloudProvider,
|
||||
alarmName: string,
|
||||
state: AlarmStateValue,
|
||||
reason: string,
|
||||
): Promise<void> {
|
||||
await apiClient.call<void, { state: AlarmStateValue; reason: string }>(
|
||||
apiEndpointKeys.clouds.monitoring.state,
|
||||
requestOptions(cloud, "monitoring", { body: { state, reason } }),
|
||||
{ cloud, id: alarmName },
|
||||
);
|
||||
}
|
||||
|
||||
export async function setAlarmActionsEnabled(
|
||||
cloud: CloudProvider,
|
||||
alarmName: string,
|
||||
enabled: boolean,
|
||||
): Promise<void> {
|
||||
await apiClient.call<void, { enabled: boolean }>(
|
||||
apiEndpointKeys.clouds.monitoring.actions,
|
||||
requestOptions(cloud, "monitoring", { body: { enabled } }),
|
||||
{ cloud, id: alarmName },
|
||||
);
|
||||
}
|
||||
|
||||
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 setDistributionEnabled(
|
||||
cloud: CloudProvider,
|
||||
distributionId: string,
|
||||
enabled: boolean,
|
||||
): Promise<CloudResource> {
|
||||
const res = await apiClient.call<CloudResource, { enabled: boolean }>(
|
||||
apiEndpointKeys.clouds.cdn.enabled,
|
||||
requestOptions(cloud, "cdn", { body: { enabled } }),
|
||||
{ cloud, id: distributionId },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function createDistributionInvalidation(
|
||||
cloud: CloudProvider,
|
||||
distributionId: string,
|
||||
paths: string[],
|
||||
): Promise<DistributionInvalidation> {
|
||||
const res = await apiClient.call<DistributionInvalidation, { paths: string[] }>(
|
||||
apiEndpointKeys.clouds.cdn.invalidations,
|
||||
requestOptions(cloud, "cdn", { body: { paths } }),
|
||||
{ cloud, id: distributionId },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function describeUserPoolClient(
|
||||
cloud: CloudProvider,
|
||||
poolId: string,
|
||||
clientId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<UserPoolClientSettings> {
|
||||
const res = await apiClient.call<UserPoolClientSettings>(
|
||||
apiEndpointKeys.clouds.userpools.client.get,
|
||||
requestOptions(cloud, "userpools", { signal }),
|
||||
{ cloud, id: poolId, clientId },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function updateUserPoolClientOAuth(
|
||||
cloud: CloudProvider,
|
||||
poolId: string,
|
||||
clientId: string,
|
||||
input: UpdateUserPoolClientOAuthInput,
|
||||
): Promise<UserPoolClientSettings> {
|
||||
const res = await apiClient.call<UserPoolClientSettings, UpdateUserPoolClientOAuthInput>(
|
||||
apiEndpointKeys.clouds.userpools.client.updateOAuth,
|
||||
requestOptions(cloud, "userpools", { body: input }),
|
||||
{ cloud, id: poolId, clientId },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function putFlatItem(
|
||||
cloud: CloudProvider,
|
||||
service: CloudServiceType,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -234,7 +234,7 @@ export function AzureSqlPanel({
|
||||
)}
|
||||
<small className="sql-credential-note">
|
||||
{isAwsRds
|
||||
? "Queries use Floci's local RDS Data API credentials. Database passwords are not sent by the console."
|
||||
? "Queries use TheCloud's local RDS Data API credentials. Database passwords are not sent by the console."
|
||||
: "Credentials remain in this page only and are sent to the local API per request."}
|
||||
</small>
|
||||
{connectionError && <div className="form-error">{connectionError}</div>}
|
||||
@@ -243,7 +243,7 @@ export function AzureSqlPanel({
|
||||
{!connected ? (
|
||||
<div className="empty compact sql-connect-empty">
|
||||
<h3>Connect to browse SQL data</h3>
|
||||
<p>{isAwsRds ? "Connect through Floci's local RDS Data API to browse databases and run SQL." : "Use the administrator login configured for this local engine."}</p>
|
||||
<p>{isAwsRds ? "Connect through TheCloud's local RDS Data API to browse databases and run SQL." : "Use the administrator login configured for this local engine."}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="sql-explorer">
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
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 {CloudFrontPanel} from './CloudFrontPanel'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
getCloudResource: vi.fn(),
|
||||
setDistributionEnabled: vi.fn(),
|
||||
createDistributionInvalidation: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/cloudProxyClient', () => api)
|
||||
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
|
||||
|
||||
const VIEWER_URL = 'http://e7cpm0p0g0vc8d.cloudfront.localhost:4566/'
|
||||
const distribution: CloudResource = {
|
||||
id: 'E7CPM0P0G0VC8D', name: 'e7cpm0p0g0vc8d.cloudfront.net', cloud: 'aws', service: 'cdn', type: 'distribution',
|
||||
region: null, createdAt: null, status: 'Deployed',
|
||||
metadata: {
|
||||
viewerUrl: VIEWER_URL, domainName: 'e7cpm0p0g0vc8d.cloudfront.net', enabled: true, defaultRootObject: 'index.html',
|
||||
origins: [{id: 's3-site', domainName: 'site.s3.amazonaws.com', kind: 's3', protocol: null}],
|
||||
behaviors: [{pathPattern: 'Default (*)', originId: 's3-site', viewerProtocol: 'redirect-to-https', methods: ['GET', 'HEAD']}],
|
||||
invalidations: [{id: 'I1', status: 'Completed', createdAt: null, paths: []}],
|
||||
},
|
||||
}
|
||||
|
||||
function renderPanel() {
|
||||
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||
return render(<QueryClientProvider client={client}><CloudFrontPanel resource={distribution} runtimeReachable/></QueryClientProvider>)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
api.getCloudResource.mockResolvedValue(distribution)
|
||||
api.setDistributionEnabled.mockImplementation(async (_cloud, _id, enabled) => ({...distribution, metadata: {...distribution.metadata, enabled}}))
|
||||
api.createDistributionInvalidation.mockImplementation(async (_cloud, _id, paths) => ({id: 'I2', status: 'Completed', createdAt: null, paths}))
|
||||
})
|
||||
|
||||
test('opens the distribution locally and shows its origins and behaviours', async () => {
|
||||
renderPanel()
|
||||
|
||||
expect(await screen.findByRole('link', {name: /Open/})).toHaveAttribute('href', VIEWER_URL)
|
||||
expect(screen.getByText('site.s3.amazonaws.com')).toBeInTheDocument()
|
||||
expect(screen.getByText('Redirect to HTTPS')).toBeInTheDocument()
|
||||
expect(screen.getByText('I1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('disables the distribution', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPanel()
|
||||
|
||||
await user.click(await screen.findByRole('button', {name: 'Disable'}))
|
||||
expect(api.setDistributionEnabled).toHaveBeenCalledWith('aws', distribution.id, false)
|
||||
expect(await screen.findByText(/Distribution disabled/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('invalidates the paths entered, one per line', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPanel()
|
||||
|
||||
const form = await screen.findByRole('form', {name: 'Create invalidation'})
|
||||
const field = within(form).getByLabelText(/Paths/)
|
||||
await user.clear(field)
|
||||
await user.type(field, '/index.html{enter}/assets/*')
|
||||
await user.click(within(form).getByRole('button', {name: 'Create invalidation'}))
|
||||
|
||||
expect(api.createDistributionInvalidation).toHaveBeenCalledWith('aws', distribution.id, ['/index.html', '/assets/*'])
|
||||
expect(await screen.findByText(/Invalidation I2 completed for \/index.html, \/assets\/\*/)).toBeInTheDocument()
|
||||
})
|
||||
@@ -0,0 +1,124 @@
|
||||
import {ExternalLink, RefreshCw} from 'lucide-react'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
import {useEffect, useState} from 'react'
|
||||
import {createDistributionInvalidation, getCloudResource, setDistributionEnabled} from '@/api/cloudProxyClient'
|
||||
import {CopyValueButton} from '@/components/CopyValueButton'
|
||||
import {useAccountId} from '@/lib/accountStore'
|
||||
import {formatDateTime} from '@/lib/format'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
|
||||
type Props = {
|
||||
resource?: CloudResource
|
||||
runtimeReachable: boolean
|
||||
}
|
||||
|
||||
type Origin = {id: string; domainName: string; kind: string; protocol: string | null}
|
||||
type Behavior = {pathPattern: string; originId: string; viewerProtocol: string; methods: string[]}
|
||||
type Invalidation = {id: string; status: string; createdAt: string | null}
|
||||
|
||||
const VIEWER_PROTOCOL: Record<string, string> = {
|
||||
'allow-all': 'HTTP and HTTPS',
|
||||
'redirect-to-https': 'Redirect to HTTPS',
|
||||
'https-only': 'HTTPS only',
|
||||
}
|
||||
|
||||
export function CloudFrontPanel({resource, runtimeReachable}: Props) {
|
||||
const accountId = useAccountId()
|
||||
const queryClient = useQueryClient()
|
||||
const [paths, setPaths] = useState('/*')
|
||||
const [notice, setNotice] = useState('')
|
||||
const distributionKey = ['cdn-distribution', accountId, resource?.id]
|
||||
|
||||
// The table row is a list summary; behaviours and invalidations come from the full distribution.
|
||||
const distributionQuery = useQuery({
|
||||
queryKey: distributionKey,
|
||||
queryFn: ({signal}) => getCloudResource('aws', 'cdn', resource?.id ?? '', signal),
|
||||
enabled: Boolean(resource) && runtimeReachable,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setPaths('/*')
|
||||
setNotice('')
|
||||
}, [resource?.id])
|
||||
|
||||
const refresh = () => Promise.all([
|
||||
queryClient.invalidateQueries({queryKey: distributionKey}),
|
||||
queryClient.invalidateQueries({queryKey: ['cloud-resources', 'aws', 'cdn']}),
|
||||
])
|
||||
|
||||
const enabledMutation = useMutation({
|
||||
mutationFn: (enabled: boolean) => setDistributionEnabled('aws', resource?.id ?? '', enabled),
|
||||
onSuccess: (updated) => {
|
||||
setNotice(updated.metadata.enabled ? 'Distribution enabled.' : 'Distribution disabled. It no longer answers viewer requests.')
|
||||
return refresh()
|
||||
},
|
||||
})
|
||||
const invalidationMutation = useMutation({
|
||||
mutationFn: () => createDistributionInvalidation('aws', resource?.id ?? '', paths.split('\n')),
|
||||
onSuccess: (invalidation) => {
|
||||
setNotice(`Invalidation ${invalidation.id} ${invalidation.status === 'Completed' ? 'completed' : 'started'} for ${invalidation.paths.join(', ')}.`)
|
||||
return refresh()
|
||||
},
|
||||
})
|
||||
|
||||
if (!resource) {
|
||||
return <section className="table-panel"><div className="empty compact"><h3>Select a distribution</h3><p>Choose a distribution to open it, change its state or invalidate cached files.</p></div></section>
|
||||
}
|
||||
|
||||
const distribution = distributionQuery.data ?? resource
|
||||
const metadata = distribution.metadata
|
||||
const viewerUrl = typeof metadata.viewerUrl === 'string' ? metadata.viewerUrl : ''
|
||||
const enabled = metadata.enabled === true
|
||||
const origins = list<Origin>(metadata.origins)
|
||||
const behaviors = list<Behavior>(metadata.behaviors)
|
||||
const invalidations = list<Invalidation>(metadata.invalidations)
|
||||
const pending = enabledMutation.isPending || invalidationMutation.isPending
|
||||
const disabled = !runtimeReachable || pending
|
||||
const error = distributionQuery.error ?? enabledMutation.error ?? invalidationMutation.error
|
||||
|
||||
return <section className="table-panel" style={{marginTop: 16}} aria-label={`Distribution ${distribution.id}`}>
|
||||
<div className="input-row resource-table-bar">
|
||||
<div><p className="eyebrow">Distribution</p><span className="muted">{enabled ? 'Enabled' : 'Disabled'} · {distribution.status ?? 'Unknown'}{metadata.defaultRootObject ? ` · serves ${String(metadata.defaultRootObject)} at /` : ''}</span></div>
|
||||
<div className="cdn-actions">
|
||||
<button className="button" type="button" disabled={disabled} onClick={() => { setNotice(''); enabledMutation.mutate(!enabled) }}>{enabled ? 'Disable' : 'Enable'}</button>
|
||||
<button className="button" type="button" disabled={!runtimeReachable || distributionQuery.isFetching} onClick={() => { void refresh() }}><RefreshCw size={14}/>{distributionQuery.isFetching ? 'Loading' : 'Refresh'}</button>
|
||||
</div>
|
||||
</div>
|
||||
{viewerUrl && <div className="cdn-viewer">
|
||||
<span className="copy-value mono">{viewerUrl}<CopyValueButton value={viewerUrl} label="Local URL"/></span>
|
||||
<a className="button" href={viewerUrl} target="_blank" rel="noreferrer"><ExternalLink size={13}/> Open</a>
|
||||
<small className="muted">Served by TheCloud on this machine. On AWS the same distribution answers at <code>{String(metadata.domainName ?? distribution.name)}</code>.</small>
|
||||
</div>}
|
||||
|
||||
<div className="input-row resource-table-bar"><div><p className="eyebrow">Origins</p></div></div>
|
||||
{origins.length > 0 && <div className="cognito-table"><table className="table"><thead><tr><th>Origin</th><th>Domain</th><th>Type</th></tr></thead><tbody>{origins.map((origin) => (
|
||||
<tr key={origin.id}><td className="mono">{origin.id}</td><td className="mono">{origin.domainName}</td><td>{origin.kind === 's3' ? 'S3 bucket' : `Custom (${origin.protocol ?? 'https-only'})`}</td></tr>
|
||||
))}</tbody></table></div>}
|
||||
|
||||
{behaviors.length > 0 && <>
|
||||
<div className="input-row resource-table-bar"><div><p className="eyebrow">Cache behaviours</p><span className="muted">The first path pattern that matches a request decides how it is served.</span></div></div>
|
||||
<div className="cognito-table"><table className="table"><thead><tr><th>Path pattern</th><th>Origin</th><th>Viewer protocol</th><th>Methods</th></tr></thead><tbody>{behaviors.map((behavior) => (
|
||||
<tr key={behavior.pathPattern}><td className="mono">{behavior.pathPattern}</td><td className="mono">{behavior.originId}</td><td>{VIEWER_PROTOCOL[behavior.viewerProtocol] ?? behavior.viewerProtocol}</td><td>{behavior.methods.join(', ')}</td></tr>
|
||||
))}</tbody></table></div>
|
||||
</>}
|
||||
|
||||
<div className="input-row resource-table-bar"><div><p className="eyebrow">Invalidations</p><span className="muted">Clear cached copies so the next request fetches them from the origin.</span></div></div>
|
||||
<form className="dynamic-form" aria-label="Create invalidation" onSubmit={(event) => { event.preventDefault(); setNotice(''); invalidationMutation.mutate() }}>
|
||||
<label className="dynamic-field dynamic-field--span"><span>Paths</span>
|
||||
<textarea className="textarea code-textarea small" rows={2} value={paths} onChange={(event) => setPaths(event.target.value)} disabled={disabled} required/>
|
||||
<small className="muted">One per line, each starting with /. Use /* for everything.</small>
|
||||
</label>
|
||||
<div><button className="button primary" type="submit" disabled={disabled || !paths.trim()}>{invalidationMutation.isPending ? 'Invalidating' : 'Create invalidation'}</button></div>
|
||||
</form>
|
||||
{invalidations.length > 0 && <div className="cognito-table"><table className="table"><thead><tr><th>Invalidation</th><th>Status</th><th>Created</th></tr></thead><tbody>{invalidations.map((invalidation) => (
|
||||
<tr key={invalidation.id}><td className="mono">{invalidation.id}</td><td><code>{invalidation.status}</code></td><td>{formatDateTime(invalidation.createdAt) ?? '—'}</td></tr>
|
||||
))}</tbody></table></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 list<T>(value: unknown): T[] {
|
||||
return Array.isArray(value) ? value as T[] : []
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
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 {CloudWatchAlarmPanel} from './CloudWatchAlarmPanel'
|
||||
import {HttpError} from '@/api/HttpClient'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
getCloudResource: vi.fn(),
|
||||
describeAlarmHistory: vi.fn(),
|
||||
getMetricSeries: vi.fn(),
|
||||
setAlarmState: vi.fn(),
|
||||
setAlarmActionsEnabled: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/cloudProxyClient', () => api)
|
||||
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
|
||||
|
||||
const alarm: CloudResource = {
|
||||
id: 'high-errors', name: 'high-errors', cloud: 'aws', service: 'monitoring', type: 'alarm', region: 'us-east-1', createdAt: null, status: 'ALARM',
|
||||
metadata: {
|
||||
alarmType: 'metric', stateReason: 'Threshold crossed', actionsEnabled: true, alarmActions: ['arn:aws:sns:us-east-1:000000000000:alerts'], okActions: [],
|
||||
namespace: 'App', metricName: 'Errors', dimensions: [], statistic: 'Sum', threshold: 5, condition: 'Errors Sum > 5 for 1 × 5 min',
|
||||
},
|
||||
}
|
||||
|
||||
function renderPanel() {
|
||||
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||
return render(<QueryClientProvider client={client}><CloudWatchAlarmPanel resource={alarm} runtimeReachable/></QueryClientProvider>)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
api.getCloudResource.mockResolvedValue(alarm)
|
||||
api.describeAlarmHistory.mockResolvedValue([{timestamp: null, type: 'StateUpdate', summary: 'Alarm updated from OK to ALARM'}])
|
||||
api.getMetricSeries.mockResolvedValue({label: 'Errors', statistic: 'Sum', periodSeconds: 60, points: [{timestamp: '2026-10-25T10:00:00Z', value: 7}]})
|
||||
api.setAlarmState.mockResolvedValue(undefined)
|
||||
api.setAlarmActionsEnabled.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
test('shows the state, its metric with the threshold, the targets and the history', async () => {
|
||||
renderPanel()
|
||||
|
||||
expect(screen.getByText('Threshold crossed')).toBeInTheDocument()
|
||||
expect(await screen.findByText('Threshold 5')).toBeInTheDocument()
|
||||
expect(screen.getByText('arn:aws:sns:us-east-1:000000000000:alerts')).toBeInTheDocument()
|
||||
expect(await screen.findByText('Alarm updated from OK to ALARM')).toBeInTheDocument()
|
||||
expect(api.getMetricSeries).toHaveBeenCalledWith('aws', expect.objectContaining({namespace: 'App', metricName: 'Errors', statistic: 'Sum', rangeMinutes: 180}), expect.anything())
|
||||
})
|
||||
|
||||
test('sets a state to test the actions, and turns the actions off', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPanel()
|
||||
|
||||
const form = await screen.findByRole('form', {name: 'Set alarm state'})
|
||||
await user.selectOptions(within(form).getByLabelText('State'), 'OK')
|
||||
await user.type(within(form).getByLabelText('Reason'), 'Testing')
|
||||
await user.click(within(form).getByRole('button', {name: 'Set state'}))
|
||||
expect(api.setAlarmState).toHaveBeenCalledWith('aws', 'high-errors', 'OK', 'Testing')
|
||||
expect(await screen.findByText(/Set to OK/)).toBeInTheDocument()
|
||||
|
||||
await user.click(within(form).getByRole('button', {name: 'Turn actions off'}))
|
||||
expect(api.setAlarmActionsEnabled).toHaveBeenCalledWith('aws', 'high-errors', false)
|
||||
})
|
||||
|
||||
test('says when the runtime does not record alarm history, without an error', async () => {
|
||||
api.describeAlarmHistory.mockRejectedValue(new HttpError(501, 'Not Implemented', {code: 'operation_not_implemented', error: 'Operation is not implemented by the selected runtime'}, '/history'))
|
||||
renderPanel()
|
||||
|
||||
expect(await screen.findByText(/does not record alarm history yet/)).toBeInTheDocument()
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import {RefreshCw} from 'lucide-react'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
import {useEffect, useState} from 'react'
|
||||
import {describeAlarmHistory, getCloudResource, getMetricSeries, setAlarmActionsEnabled, setAlarmState} from '@/api/cloudProxyClient'
|
||||
import {HttpError} from '@/api/HttpClient'
|
||||
import {MetricChart} from '@/components/MetricChart'
|
||||
import {RANGES} from '@/lib/metricChart'
|
||||
import {useAccountId} from '@/lib/accountStore'
|
||||
import {formatDateTime} from '@/lib/format'
|
||||
import type {AlarmStateValue, CloudResource, MetricDimension} from '@/types/resource'
|
||||
|
||||
type Props = {
|
||||
resource?: CloudResource
|
||||
runtimeReachable: boolean
|
||||
}
|
||||
|
||||
const STATES: AlarmStateValue[] = ['ALARM', 'OK', 'INSUFFICIENT_DATA']
|
||||
|
||||
export function CloudWatchAlarmPanel({resource, runtimeReachable}: Props) {
|
||||
const accountId = useAccountId()
|
||||
const queryClient = useQueryClient()
|
||||
const [rangeMinutes, setRangeMinutes] = useState(180)
|
||||
const [nextState, setNextState] = useState<AlarmStateValue>('ALARM')
|
||||
const [reason, setReason] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const alarmKey = ['cloudwatch-alarm', accountId, resource?.id]
|
||||
const historyKey = ['cloudwatch-alarm-history', accountId, resource?.id]
|
||||
|
||||
useEffect(() => {
|
||||
setNotice('')
|
||||
setReason('')
|
||||
}, [resource?.id])
|
||||
|
||||
const alarmQuery = useQuery({
|
||||
queryKey: alarmKey,
|
||||
queryFn: ({signal}) => getCloudResource('aws', 'monitoring', resource?.id ?? '', signal),
|
||||
enabled: Boolean(resource) && runtimeReachable,
|
||||
})
|
||||
const historyQuery = useQuery({
|
||||
queryKey: historyKey,
|
||||
queryFn: ({signal}) => describeAlarmHistory('aws', resource?.id ?? '', signal),
|
||||
enabled: Boolean(resource) && runtimeReachable,
|
||||
})
|
||||
const alarm = alarmQuery.data ?? resource
|
||||
const metadata = alarm?.metadata ?? {}
|
||||
const isMetric = metadata.alarmType === 'metric' && typeof metadata.metricName === 'string'
|
||||
const dimensions = Array.isArray(metadata.dimensions) ? metadata.dimensions as MetricDimension[] : []
|
||||
const seriesQuery = useQuery({
|
||||
queryKey: ['cloudwatch-alarm-series', accountId, resource?.id, rangeMinutes, metadata.metricName, metadata.statistic],
|
||||
queryFn: ({signal}) => getMetricSeries('aws', {
|
||||
namespace: String(metadata.namespace),
|
||||
metricName: String(metadata.metricName),
|
||||
dimensions,
|
||||
statistic: String(metadata.statistic ?? 'Average'),
|
||||
rangeMinutes,
|
||||
}, signal),
|
||||
enabled: Boolean(resource) && runtimeReachable && isMetric,
|
||||
})
|
||||
|
||||
const refresh = () => Promise.all([
|
||||
queryClient.invalidateQueries({queryKey: alarmKey}),
|
||||
queryClient.invalidateQueries({queryKey: historyKey}),
|
||||
queryClient.invalidateQueries({queryKey: ['cloud-resources', 'aws', 'monitoring']}),
|
||||
])
|
||||
const stateMutation = useMutation({
|
||||
mutationFn: () => setAlarmState('aws', resource?.id ?? '', nextState, reason),
|
||||
onSuccess: () => {
|
||||
setNotice(`Set to ${nextState}. Its actions ran if they are on; CloudWatch puts it back at the next evaluation.`)
|
||||
setReason('')
|
||||
return refresh()
|
||||
},
|
||||
})
|
||||
const actionsMutation = useMutation({
|
||||
mutationFn: (enabled: boolean) => setAlarmActionsEnabled('aws', resource?.id ?? '', enabled),
|
||||
onSuccess: (_result, enabled) => {
|
||||
setNotice(enabled ? 'Actions on: the alarm notifies its targets when its state changes.' : 'Actions off: the alarm still changes state but notifies nobody.')
|
||||
return refresh()
|
||||
},
|
||||
})
|
||||
|
||||
if (!resource || !alarm) {
|
||||
return <section className="table-panel"><div className="empty compact"><h3>Select an alarm</h3><p>Choose an alarm to see its metric, test its actions and read its history.</p></div></section>
|
||||
}
|
||||
|
||||
const state = alarm.status ?? 'INSUFFICIENT_DATA'
|
||||
const actionsEnabled = metadata.actionsEnabled !== false
|
||||
const targets = [...new Set([...list(metadata.alarmActions), ...list(metadata.okActions)])]
|
||||
const disabled = !runtimeReachable || stateMutation.isPending || actionsMutation.isPending
|
||||
// TheCloud's runtime does not implement DescribeAlarmHistory yet; say so rather than fail.
|
||||
const historyUnsupported = isNotImplemented(historyQuery.error)
|
||||
const error = alarmQuery.error ?? (historyUnsupported ? null : historyQuery.error) ?? seriesQuery.error ?? stateMutation.error ?? actionsMutation.error
|
||||
const history = historyQuery.data ?? []
|
||||
|
||||
return <section className="table-panel" style={{marginTop: 16}} aria-label={`Alarm ${alarm.name}`}>
|
||||
<div className="input-row resource-table-bar">
|
||||
<div>
|
||||
<p className="eyebrow">Alarm</p>
|
||||
<span className="cloudwatch-state-line"><span className={`badge ${state.toLowerCase().replace(/_/g, '-')}`}>{state}</span> <span className="muted">{String(metadata.stateReason ?? '')}</span></span>
|
||||
</div>
|
||||
<button className="button" type="button" disabled={!runtimeReachable || alarmQuery.isFetching} onClick={() => { void refresh(); void seriesQuery.refetch() }}><RefreshCw size={14}/>{alarmQuery.isFetching ? 'Loading' : 'Refresh'}</button>
|
||||
</div>
|
||||
<dl className="cognito-facts cloudwatch-facts">
|
||||
<dt>{isMetric ? 'Condition' : 'Rule'}</dt><dd className="mono">{String(metadata.condition ?? '—')}</dd>
|
||||
{isMetric && <><dt>Metric</dt><dd className="mono">{String(metadata.namespace)} / {String(metadata.metricName)}{dimensions.map((dimension) => ` · ${dimension.name}=${dimension.value}`).join('')}</dd></>}
|
||||
<dt>State changed</dt><dd>{formatDateTime(typeof metadata.stateUpdatedAt === 'string' ? metadata.stateUpdatedAt : null) ?? '—'}</dd>
|
||||
<dt>Notifies</dt><dd className="mono">{targets.length ? targets.join(', ') : 'No actions'}</dd>
|
||||
</dl>
|
||||
|
||||
{isMetric && <>
|
||||
<div className="cloudwatch-range" role="group" aria-label="Time range">
|
||||
{RANGES.map((range) => (
|
||||
<button key={range.minutes} type="button" className={`button compact${rangeMinutes === range.minutes ? ' primary' : ''}`} aria-pressed={rangeMinutes === range.minutes} onClick={() => setRangeMinutes(range.minutes)}>{range.label}</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="cloudwatch-chart">
|
||||
<p className="cloudwatch-chart-title">{String(metadata.metricName)} <span className="muted">· {String(metadata.statistic ?? '')}</span></p>
|
||||
{seriesQuery.isLoading && <p className="muted">Loading metric data</p>}
|
||||
{seriesQuery.data && <MetricChart series={seriesQuery.data} threshold={typeof metadata.threshold === 'number' ? metadata.threshold : null}/>}
|
||||
</div>
|
||||
</>}
|
||||
|
||||
<div className="input-row resource-table-bar"><div><p className="eyebrow">Test the alarm</p><span className="muted">Sets the state now, which runs its actions. CloudWatch evaluates it again at the next period.</span></div></div>
|
||||
<form className="dynamic-form" aria-label="Set alarm state" onSubmit={(event) => { event.preventDefault(); setNotice(''); stateMutation.mutate() }}>
|
||||
<label className="dynamic-field"><span>State</span>
|
||||
<select className="input" value={nextState} onChange={(event) => setNextState(event.target.value as AlarmStateValue)} disabled={disabled}>
|
||||
{STATES.map((value) => <option key={value} value={value}>{value}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="dynamic-field"><span>Reason</span><input className="input" value={reason} placeholder="Testing the on-call notification" onChange={(event) => setReason(event.target.value)} disabled={disabled}/></label>
|
||||
<div className="cloudwatch-form-actions">
|
||||
<button className="button primary" type="submit" disabled={disabled}>{stateMutation.isPending ? 'Setting' : 'Set state'}</button>
|
||||
<button className="button" type="button" disabled={disabled} onClick={() => { setNotice(''); actionsMutation.mutate(!actionsEnabled) }}>{actionsEnabled ? 'Turn actions off' : 'Turn actions on'}</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>}
|
||||
|
||||
<div className="input-row resource-table-bar"><div><p className="eyebrow">History</p>{history.length > 0 && <span className="muted">Newest first.</span>}</div></div>
|
||||
{historyUnsupported && <p className="muted padded">TheCloud's runtime does not record alarm history yet. State changes still notify the alarm's actions.</p>}
|
||||
{historyQuery.isSuccess && history.length === 0 && <p className="muted padded">No history yet.</p>}
|
||||
{history.length > 0 && <div className="cognito-table"><table className="table"><thead><tr><th>When</th><th>Type</th><th>What happened</th></tr></thead><tbody>{history.map((item, index) => (
|
||||
<tr key={`${item.timestamp}-${index}`}><td>{formatDateTime(item.timestamp) ?? '—'}</td><td><code>{item.type}</code></td><td>{item.summary}</td></tr>
|
||||
))}</tbody></table></div>}
|
||||
</section>
|
||||
}
|
||||
|
||||
function isNotImplemented(error: unknown): boolean {
|
||||
return error instanceof HttpError && typeof error.body === 'object' && error.body !== null
|
||||
&& (error.body as {code?: unknown}).code === 'operation_not_implemented'
|
||||
}
|
||||
|
||||
function list(value: unknown): string[] {
|
||||
return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : []
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
||||
import {render, screen} from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import {beforeEach, expect, test, vi} from 'vitest'
|
||||
import {CloudWatchMetricsExplorer} from './CloudWatchMetricsExplorer'
|
||||
|
||||
const api = vi.hoisted(() => ({listMetrics: vi.fn(), getMetricSeries: vi.fn()}))
|
||||
|
||||
vi.mock('@/api/cloudProxyClient', () => api)
|
||||
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
api.listMetrics.mockResolvedValue([
|
||||
{namespace: 'App', metricName: 'Errors', dimensions: []},
|
||||
{namespace: 'AWS/Lambda', metricName: 'Invocations', dimensions: [{name: 'FunctionName', value: 'checkout'}]},
|
||||
])
|
||||
api.getMetricSeries.mockResolvedValue({label: 'Invocations', statistic: 'Sum', periodSeconds: 60, points: [{timestamp: '2026-10-25T10:00:00Z', value: 12}]})
|
||||
})
|
||||
|
||||
function renderExplorer() {
|
||||
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||
return render(<QueryClientProvider client={client}><CloudWatchMetricsExplorer runtimeReachable/></QueryClientProvider>)
|
||||
}
|
||||
|
||||
test('filters metrics by namespace and charts the chosen one', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderExplorer()
|
||||
|
||||
expect(await screen.findByText('2 metrics in 2 namespaces. Pick one to chart it.')).toBeInTheDocument()
|
||||
await user.selectOptions(screen.getByLabelText('Namespace'), 'AWS/Lambda')
|
||||
expect(screen.queryByText('Errors')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', {name: 'Chart Invocations for checkout'}))
|
||||
await user.selectOptions(screen.getByLabelText('Statistic'), 'Sum')
|
||||
expect(await screen.findByRole('img', {name: /Invocations \(Sum\)/})).toBeInTheDocument()
|
||||
expect(api.getMetricSeries).toHaveBeenLastCalledWith('aws', expect.objectContaining({namespace: 'AWS/Lambda', metricName: 'Invocations', statistic: 'Sum', dimensions: [{name: 'FunctionName', value: 'checkout'}]}), expect.anything())
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import {RefreshCw} from 'lucide-react'
|
||||
import {useQuery} from '@tanstack/react-query'
|
||||
import {useMemo, useState} from 'react'
|
||||
import {getMetricSeries, listMetrics} from '@/api/cloudProxyClient'
|
||||
import {MetricChart} from '@/components/MetricChart'
|
||||
import {RANGES} from '@/lib/metricChart'
|
||||
import {useAccountId} from '@/lib/accountStore'
|
||||
import type {MetricSummary} from '@/types/resource'
|
||||
|
||||
const STATISTICS = ['Average', 'Sum', 'Minimum', 'Maximum', 'SampleCount']
|
||||
|
||||
export function CloudWatchMetricsExplorer({runtimeReachable}: {runtimeReachable: boolean}) {
|
||||
const accountId = useAccountId()
|
||||
const [namespace, setNamespace] = useState('')
|
||||
const [search, setSearch] = useState('')
|
||||
const [selected, setSelected] = useState<MetricSummary | null>(null)
|
||||
const [statistic, setStatistic] = useState('Average')
|
||||
const [rangeMinutes, setRangeMinutes] = useState(180)
|
||||
|
||||
const metricsQuery = useQuery({
|
||||
queryKey: ['cloudwatch-metrics', accountId],
|
||||
queryFn: ({signal}) => listMetrics('aws', {}, signal),
|
||||
enabled: runtimeReachable,
|
||||
})
|
||||
const seriesQuery = useQuery({
|
||||
queryKey: ['cloudwatch-series', accountId, selected && metricKey(selected), statistic, rangeMinutes],
|
||||
queryFn: ({signal}) => getMetricSeries('aws', {...selected!, statistic, rangeMinutes}, signal),
|
||||
enabled: runtimeReachable && selected !== null,
|
||||
})
|
||||
|
||||
const metrics = useMemo(() => metricsQuery.data ?? [], [metricsQuery.data])
|
||||
const namespaces = useMemo(() => [...new Set(metrics.map((metric) => metric.namespace))].sort(), [metrics])
|
||||
const term = search.trim().toLowerCase()
|
||||
const shown = metrics.filter((metric) => (!namespace || metric.namespace === namespace)
|
||||
&& (!term || [metric.metricName, ...metric.dimensions.map((dimension) => `${dimension.name}=${dimension.value}`)].some((value) => value.toLowerCase().includes(term))))
|
||||
const error = metricsQuery.error ?? seriesQuery.error
|
||||
|
||||
return <section className="table-panel" style={{marginTop: 16}} aria-label="Metrics">
|
||||
<div className="input-row resource-table-bar">
|
||||
<div><p className="eyebrow">Metrics</p><span className="muted">{metrics.length} {metrics.length === 1 ? 'metric' : 'metrics'} in {namespaces.length} {namespaces.length === 1 ? 'namespace' : 'namespaces'}. Pick one to chart it.</span></div>
|
||||
<button className="button" type="button" disabled={!runtimeReachable || metricsQuery.isFetching} onClick={() => { void metricsQuery.refetch(); if (selected) void seriesQuery.refetch() }}><RefreshCw size={14}/>{metricsQuery.isFetching ? 'Loading' : 'Refresh'}</button>
|
||||
</div>
|
||||
<div className="cloudwatch-filters">
|
||||
<label className="dynamic-field"><span>Namespace</span>
|
||||
<select className="input" value={namespace} onChange={(event) => setNamespace(event.target.value)}>
|
||||
<option value="">All namespaces</option>
|
||||
{namespaces.map((value) => <option key={value} value={value}>{value}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="dynamic-field"><span>Search</span><input className="input" value={search} placeholder="Metric or dimension" onChange={(event) => setSearch(event.target.value)}/></label>
|
||||
</div>
|
||||
|
||||
{selected && <div className="cloudwatch-chart">
|
||||
<div className="cloudwatch-chart-head">
|
||||
<p className="cloudwatch-chart-title">{selected.metricName} <span className="muted">· {selected.namespace}{selected.dimensions.map((dimension) => ` · ${dimension.name}=${dimension.value}`).join('')}</span></p>
|
||||
<div className="cloudwatch-range" role="group" aria-label="Chart settings">
|
||||
<select className="input" aria-label="Statistic" value={statistic} onChange={(event) => setStatistic(event.target.value)}>
|
||||
{STATISTICS.map((value) => <option key={value} value={value}>{value}</option>)}
|
||||
</select>
|
||||
{RANGES.map((range) => (
|
||||
<button key={range.minutes} type="button" className={`button compact${rangeMinutes === range.minutes ? ' primary' : ''}`} aria-pressed={rangeMinutes === range.minutes} onClick={() => setRangeMinutes(range.minutes)}>{range.label}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{seriesQuery.isLoading && <p className="muted">Loading metric data</p>}
|
||||
{seriesQuery.data && <MetricChart series={seriesQuery.data}/>}
|
||||
</div>}
|
||||
|
||||
{error instanceof Error && <p className="error-text compact-text" role="alert">{error.message}</p>}
|
||||
{metricsQuery.isSuccess && metrics.length === 0 && <div className="empty compact"><h3>No metrics yet</h3><p>Metrics appear when services or your apps publish them, for example with PutMetricData.</p></div>}
|
||||
{shown.length > 0 && <div className="cognito-table"><table className="table"><thead><tr><th>Metric</th><th>Namespace</th><th>Dimensions</th><th/></tr></thead><tbody>{shown.map((metric) => {
|
||||
const isSelected = selected !== null && metricKey(selected) === metricKey(metric)
|
||||
return <tr key={metricKey(metric)} aria-current={isSelected ? 'true' : undefined}>
|
||||
<td className="mono">{metric.metricName}</td>
|
||||
<td className="mono">{metric.namespace}</td>
|
||||
<td className="mono">{metric.dimensions.length ? metric.dimensions.map((dimension) => `${dimension.name}=${dimension.value}`).join(', ') : '—'}</td>
|
||||
<td className="cognito-row-actions-cell"><button className="button compact" type="button" aria-label={`Chart ${metric.metricName}${metric.dimensions.length ? ` for ${metric.dimensions.map((dimension) => dimension.value).join(', ')}` : ''}`} onClick={() => setSelected(isSelected ? null : metric)}>{isSelected ? 'Hide' : 'Chart'}</button></td>
|
||||
</tr>
|
||||
})}</tbody></table></div>}
|
||||
{metricsQuery.isSuccess && metrics.length > 0 && shown.length === 0 && <p className="muted padded">No metrics match.</p>}
|
||||
</section>
|
||||
}
|
||||
|
||||
function metricKey(metric: MetricSummary): string {
|
||||
return [metric.namespace, metric.metricName, ...metric.dimensions.map((dimension) => `${dimension.name}=${dimension.value}`)].join('|')
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import {X} from 'lucide-react'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
import {useEffect, useState} from 'react'
|
||||
import {describeUserPoolClient, updateUserPoolClientOAuth} from '@/api/cloudProxyClient'
|
||||
import {useAccountId} from '@/lib/accountStore'
|
||||
import type {UserPoolClientSettings, UserPoolOAuthFlow} from '@/types/resource'
|
||||
|
||||
type Props = {
|
||||
poolId: string
|
||||
clientId: string
|
||||
runtimeReachable: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const FLOWS: {flow: UserPoolOAuthFlow; label: string; hint: string}[] = [
|
||||
{flow: 'code', label: 'Authorization code', hint: 'Recommended for web and mobile apps, with PKCE.'},
|
||||
{flow: 'implicit', label: 'Implicit', hint: 'Legacy: tokens come back in the URL.'},
|
||||
{flow: 'client_credentials', label: 'Client credentials', hint: 'Machine-to-machine. Needs a client secret.'},
|
||||
]
|
||||
const STANDARD_SCOPES = ['openid', 'email', 'phone', 'profile', 'aws.cognito.signin.user.admin']
|
||||
const COGNITO = 'COGNITO'
|
||||
|
||||
type Draft = {callbackUrls: string; logoutUrls: string; oauthFlows: UserPoolOAuthFlow[]; oauthScopes: string[]; identityProviders: string[]}
|
||||
|
||||
export function CognitoAppClientSettings({poolId, clientId, runtimeReachable, onClose}: Props) {
|
||||
const accountId = useAccountId()
|
||||
const queryClient = useQueryClient()
|
||||
const clientKey = ['cognito-client', accountId, poolId, clientId]
|
||||
const [draft, setDraft] = useState<Draft | null>(null)
|
||||
const [notice, setNotice] = useState('')
|
||||
|
||||
const clientQuery = useQuery({
|
||||
queryKey: clientKey,
|
||||
queryFn: ({signal}) => describeUserPoolClient('aws', poolId, clientId, signal),
|
||||
enabled: runtimeReachable,
|
||||
})
|
||||
const client = clientQuery.data
|
||||
|
||||
useEffect(() => {
|
||||
if (client) setDraft(draftFrom(client))
|
||||
}, [client])
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (value: Draft) => updateUserPoolClientOAuth('aws', poolId, clientId, {
|
||||
callbackUrls: lines(value.callbackUrls),
|
||||
logoutUrls: lines(value.logoutUrls),
|
||||
oauthFlows: value.oauthFlows,
|
||||
oauthScopes: value.oauthScopes,
|
||||
identityProviders: value.identityProviders,
|
||||
}),
|
||||
onSuccess: (saved) => {
|
||||
queryClient.setQueryData(clientKey, saved)
|
||||
setNotice(saved.oauthFlows.includes('code') && saved.identityProviders.includes(COGNITO)
|
||||
? 'Saved. Apps using this client can now send users to the hosted login page.'
|
||||
: 'Saved.')
|
||||
},
|
||||
})
|
||||
|
||||
const disabled = !runtimeReachable || saveMutation.isPending
|
||||
const scopes = draft ? [...STANDARD_SCOPES, ...draft.oauthScopes.filter((scope) => !STANDARD_SCOPES.includes(scope))] : STANDARD_SCOPES
|
||||
const otherProviders = draft?.identityProviders.filter((provider) => provider !== COGNITO) ?? []
|
||||
const changed = Boolean(client && draft && JSON.stringify(normalized(draft)) !== JSON.stringify(normalized(draftFrom(client))))
|
||||
const update = (patch: Partial<Draft>) => { setNotice(''); setDraft((current) => current && {...current, ...patch}) }
|
||||
const toggle = <T extends string>(list: T[], value: T, on: boolean) => on ? [...list.filter((entry) => entry !== value), value] : list.filter((entry) => entry !== value)
|
||||
const error = clientQuery.error ?? saveMutation.error
|
||||
|
||||
return <section className="cognito-user" aria-label={`App client ${client?.clientName || clientId}`}>
|
||||
<div className="input-row resource-table-bar">
|
||||
<div><p className="eyebrow">App client</p><strong>{client?.clientName || clientId}</strong></div>
|
||||
<button className="icon-btn" type="button" title="Close" aria-label="Close app client settings" onClick={onClose}><X size={14}/></button>
|
||||
</div>
|
||||
{clientQuery.isLoading && <p className="muted padded">Loading app client</p>}
|
||||
{client && draft && <form className="cognito-user-grid" aria-label="Hosted sign-in settings" onSubmit={(event) => { event.preventDefault(); saveMutation.mutate(draft) }}>
|
||||
<dl className="cognito-facts">
|
||||
<dt>Client ID</dt><dd className="mono">{client.clientId}</dd>
|
||||
<dt>Client secret</dt><dd>{client.hasSecret ? 'Yes' : 'No (public client)'}</dd>
|
||||
<dt>Sign-in flows</dt><dd>{client.explicitAuthFlows.length ? client.explicitAuthFlows.map((flow) => <code key={flow} className="cognito-flow">{flow.replace(/^ALLOW_/, '')}</code>) : '—'}</dd>
|
||||
</dl>
|
||||
|
||||
<div>
|
||||
<p className="eyebrow">Hosted sign-in</p>
|
||||
<label className="cognito-check">
|
||||
<input type="checkbox" checked={draft.identityProviders.includes(COGNITO)} disabled={disabled} onChange={(event) => update({identityProviders: toggle(draft.identityProviders, COGNITO, event.target.checked)})}/>
|
||||
Hosted login page for this pool's users
|
||||
</label>
|
||||
{otherProviders.length > 0 && <p className="muted compact-text">Also signs in with: {otherProviders.map((provider) => <code key={provider}>{provider}</code>)}</p>}
|
||||
<p className="eyebrow">OAuth flows</p>
|
||||
{FLOWS.map(({flow, label, hint}) => (
|
||||
<label key={flow} className="cognito-check" title={hint}>
|
||||
<input type="checkbox" checked={draft.oauthFlows.includes(flow)} disabled={disabled || (flow === 'client_credentials' && !client.hasSecret)} onChange={(event) => update({oauthFlows: toggle(draft.oauthFlows, flow, event.target.checked)})}/>
|
||||
{label} <small className="muted">{hint}</small>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="eyebrow">Scopes</p>
|
||||
{scopes.map((scope) => (
|
||||
<label key={scope} className="cognito-check">
|
||||
<input type="checkbox" checked={draft.oauthScopes.includes(scope)} disabled={disabled} onChange={(event) => update({oauthScopes: toggle(draft.oauthScopes, scope, event.target.checked)})}/>
|
||||
<code>{scope}</code>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="cognito-client-urls">
|
||||
<label className="dynamic-field"><span>Callback URLs</span>
|
||||
<textarea className="textarea code-textarea small" rows={3} value={draft.callbackUrls} disabled={disabled} placeholder="http://localhost:3000/api/auth/callback" onChange={(event) => update({callbackUrls: event.target.value})}/>
|
||||
<small className="muted">One per line. Where Cognito sends users back with a code. https, or http for localhost.</small>
|
||||
</label>
|
||||
<label className="dynamic-field"><span>Sign-out URLs</span>
|
||||
<textarea className="textarea code-textarea small" rows={3} value={draft.logoutUrls} disabled={disabled} placeholder="http://localhost:3000/" onChange={(event) => update({logoutUrls: event.target.value})}/>
|
||||
<small className="muted">One per line. Allowed <code>logout_uri</code> values after sign-out.</small>
|
||||
</label>
|
||||
<div><button className="button primary" type="submit" disabled={disabled || !changed}>{saveMutation.isPending ? 'Saving' : 'Save settings'}</button></div>
|
||||
</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 draftFrom(client: UserPoolClientSettings): Draft {
|
||||
return {
|
||||
callbackUrls: client.callbackUrls.join('\n'),
|
||||
logoutUrls: client.logoutUrls.join('\n'),
|
||||
oauthFlows: client.oauthFlows,
|
||||
oauthScopes: client.oauthScopes,
|
||||
identityProviders: client.identityProviders,
|
||||
}
|
||||
}
|
||||
|
||||
function normalized(draft: Draft) {
|
||||
return {
|
||||
callbackUrls: lines(draft.callbackUrls),
|
||||
logoutUrls: lines(draft.logoutUrls),
|
||||
oauthFlows: [...draft.oauthFlows].sort(),
|
||||
oauthScopes: [...draft.oauthScopes].sort(),
|
||||
identityProviders: [...draft.identityProviders].sort(),
|
||||
}
|
||||
}
|
||||
|
||||
function lines(value: string): string[] {
|
||||
return value.split('\n').map((line) => line.trim()).filter(Boolean)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import {ScrollText, Zap} from 'lucide-react'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
|
||||
type LambdaTrigger = {trigger: string; label: string; functionArn: string; functionName: string; version: string | null}
|
||||
|
||||
export function CognitoTriggersSection({pool}: {pool?: CloudResource}) {
|
||||
const triggers = triggersOf(pool)
|
||||
return <>
|
||||
<div className="input-row resource-table-bar">
|
||||
<div><p className="eyebrow">Lambda triggers</p><span className="muted">Functions Cognito runs during sign-up, sign-in and token issue</span></div>
|
||||
</div>
|
||||
{pool && triggers.length === 0 && <p className="muted padded">This pool runs no Lambda triggers.</p>}
|
||||
{triggers.length > 0 && <div className="cognito-table"><table className="table"><thead><tr><th>Trigger</th><th>Function</th><th>Version</th><th/></tr></thead><tbody>{triggers.map((trigger) => (
|
||||
<tr key={trigger.trigger}>
|
||||
<td>{trigger.label}</td>
|
||||
<td><code title={trigger.functionArn}>{trigger.functionName}</code></td>
|
||||
<td>{trigger.version ? <code>{trigger.version}</code> : '—'}</td>
|
||||
<td className="cognito-row-actions">
|
||||
<a className="button" href={`/cloud-explorer/aws/serverless?resource=${encodeURIComponent(trigger.functionName)}`} aria-label={`Open function ${trigger.functionName}`}><Zap size={13}/> Function</a>
|
||||
<a className="button" href={`/cloud-explorer/aws/logs?resource=${encodeURIComponent(`/aws/lambda/${trigger.functionName}`)}`} aria-label={`Open logs for ${trigger.functionName}`}><ScrollText size={13}/> Logs</a>
|
||||
</td>
|
||||
</tr>
|
||||
))}</tbody></table></div>}
|
||||
</>
|
||||
}
|
||||
|
||||
function triggersOf(pool?: CloudResource): LambdaTrigger[] {
|
||||
const triggers = pool?.metadata.lambdaTriggers
|
||||
if (!Array.isArray(triggers)) return []
|
||||
return triggers.filter((trigger): trigger is LambdaTrigger => typeof trigger?.functionName === 'string' && typeof trigger?.label === 'string')
|
||||
}
|
||||
@@ -17,6 +17,8 @@ const api = vi.hoisted(() => ({
|
||||
createUserPoolGroup: vi.fn(),
|
||||
deleteUserPoolGroup: vi.fn(),
|
||||
testUserPoolSignIn: vi.fn(),
|
||||
describeUserPoolClient: vi.fn(),
|
||||
updateUserPoolClientOAuth: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/cloudProxyClient', () => api)
|
||||
@@ -64,6 +66,50 @@ test('shows the app client id and the pool users', async () => {
|
||||
expect(api.getCloudResource).toHaveBeenCalledWith('aws', 'userpools', summary.id, expect.anything())
|
||||
})
|
||||
|
||||
test('lists the pool\'s Lambda triggers with links to the function and its logs', async () => {
|
||||
api.getCloudResource.mockResolvedValue({...pool('email'), metadata: {...pool('email').metadata, lambdaTriggers: [
|
||||
{trigger: 'PreTokenGeneration', label: 'Pre token generation', functionArn: 'arn:aws:lambda:us-east-1:000000000000:function:notes-demo-pre-token', functionName: 'notes-demo-pre-token', version: null},
|
||||
]}})
|
||||
renderPanel()
|
||||
|
||||
expect(await screen.findByText('Pre token generation')).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', {name: 'Open function notes-demo-pre-token'})).toHaveAttribute('href', '/cloud-explorer/aws/serverless?resource=notes-demo-pre-token')
|
||||
expect(screen.getByRole('link', {name: 'Open logs for notes-demo-pre-token'})).toHaveAttribute('href', '/cloud-explorer/aws/logs?resource=%2Faws%2Flambda%2Fnotes-demo-pre-token')
|
||||
})
|
||||
|
||||
test('says when a pool has no Lambda triggers', async () => {
|
||||
api.getCloudResource.mockResolvedValue(pool('email'))
|
||||
renderPanel()
|
||||
|
||||
expect(await screen.findByText('This pool runs no Lambda triggers.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('turns on hosted sign-in for an app client', async () => {
|
||||
const user = userEvent.setup()
|
||||
const settings = {clientId: 'a8e5c7d38d3b4a1a97c9d822de', clientName: 'orders-users-client', hasSecret: false, explicitAuthFlows: ['ALLOW_REFRESH_TOKEN_AUTH'],
|
||||
callbackUrls: [], logoutUrls: [], oauthFlows: [], oauthScopes: [], identityProviders: ['COGNITO']}
|
||||
api.getCloudResource.mockResolvedValue(pool('email'))
|
||||
api.describeUserPoolClient.mockResolvedValue(settings)
|
||||
api.updateUserPoolClientOAuth.mockImplementation(async (_cloud, _pool, _client, input) => ({...settings, ...input}))
|
||||
renderPanel()
|
||||
|
||||
await user.click(await screen.findByRole('button', {name: 'Manage app client orders-users-client'}))
|
||||
const form = await screen.findByRole('form', {name: 'Hosted sign-in settings'})
|
||||
expect(within(form).getByRole('checkbox', {name: /Client credentials/})).toBeDisabled()
|
||||
await user.click(within(form).getByRole('checkbox', {name: /Authorization code/}))
|
||||
await user.click(within(form).getByRole('checkbox', {name: 'openid'}))
|
||||
await user.click(within(form).getByRole('checkbox', {name: 'email'}))
|
||||
await user.type(within(form).getByLabelText(/Callback URLs/), 'http://localhost:4600/api/auth/callback')
|
||||
await user.type(within(form).getByLabelText(/Sign-out URLs/), 'http://localhost:4600/')
|
||||
await user.click(within(form).getByRole('button', {name: 'Save settings'}))
|
||||
|
||||
expect(api.updateUserPoolClientOAuth).toHaveBeenCalledWith('aws', summary.id, 'a8e5c7d38d3b4a1a97c9d822de', {
|
||||
callbackUrls: ['http://localhost:4600/api/auth/callback'], logoutUrls: ['http://localhost:4600/'],
|
||||
oauthFlows: ['code'], oauthScopes: ['openid', 'email'], identityProviders: ['COGNITO'],
|
||||
})
|
||||
expect(await screen.findByText(/can now send users to the hosted login page/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('creates an email user who can sign in immediately', async () => {
|
||||
const user = userEvent.setup()
|
||||
api.getCloudResource.mockResolvedValue(pool('email'))
|
||||
|
||||
@@ -2,8 +2,10 @@ import {RefreshCw, Trash2} from 'lucide-react'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
import {useEffect, useState} from 'react'
|
||||
import {deleteFlatItem, getCloudResource, listFlatItems, listUserPoolGroups, putFlatItem} from '@/api/cloudProxyClient'
|
||||
import {CognitoAppClientSettings} from '@/components/CognitoAppClientSettings'
|
||||
import {CognitoGroupsSection} from '@/components/CognitoGroupsSection'
|
||||
import {CognitoSignInTest} from '@/components/CognitoSignInTest'
|
||||
import {CognitoTriggersSection} from '@/components/CognitoTriggersSection'
|
||||
import {CognitoUserDetail} from '@/components/CognitoUserDetail'
|
||||
import {CopyValueButton} from '@/components/CopyValueButton'
|
||||
import {useAccountId} from '@/lib/accountStore'
|
||||
@@ -24,6 +26,7 @@ export function CognitoUsersPanel({resource, runtimeReachable}: Props) {
|
||||
const [password, setPassword] = useState('')
|
||||
const [permanent, setPermanent] = useState(true)
|
||||
const [openUser, setOpenUser] = useState<string | null>(null)
|
||||
const [openClient, setOpenClient] = useState<string | null>(null)
|
||||
const usersKey = ['cognito-users', accountId, resource?.id]
|
||||
const groupsKey = ['cognito-groups', accountId, resource?.id]
|
||||
|
||||
@@ -93,9 +96,16 @@ export function CognitoUsersPanel({resource, runtimeReachable}: Props) {
|
||||
<div><p className="eyebrow">App clients</p><span className="muted">Use a client ID with the pool ID to sign users in from your app</span></div>
|
||||
</div>
|
||||
{poolQuery.isSuccess && appClients.length === 0 && <p className="muted padded">This pool has no app clients.</p>}
|
||||
{appClients.length > 0 && <div className="cognito-table"><table className="table"><thead><tr><th>Name</th><th>Client ID</th></tr></thead><tbody>{appClients.map((client) => (
|
||||
<tr key={client.clientId}><td>{client.clientName || '—'}</td><td><span className="copy-value mono">{client.clientId}<CopyValueButton value={client.clientId} label="Client ID"/></span></td></tr>
|
||||
{appClients.length > 0 && <div className="cognito-table"><table className="table"><thead><tr><th>Name</th><th>Client ID</th><th/></tr></thead><tbody>{appClients.map((client) => (
|
||||
<tr key={client.clientId}>
|
||||
<td>{client.clientName || '—'}</td>
|
||||
<td><span className="copy-value mono">{client.clientId}<CopyValueButton value={client.clientId} label="Client ID"/></span></td>
|
||||
<td className="cognito-row-actions"><button className="button" type="button" aria-expanded={openClient === client.clientId} aria-label={`Manage app client ${client.clientName || client.clientId}`} onClick={() => setOpenClient(openClient === client.clientId ? null : client.clientId)}>Manage</button></td>
|
||||
</tr>
|
||||
))}</tbody></table></div>}
|
||||
{openClient && <CognitoAppClientSettings key={openClient} poolId={resource.id} clientId={openClient} runtimeReachable={runtimeReachable} onClose={() => setOpenClient(null)}/>}
|
||||
|
||||
<CognitoTriggersSection pool={poolQuery.data ?? undefined}/>
|
||||
|
||||
<CognitoSignInTest poolId={resource.id} appClients={appClients} loginLabel={loginLabel} runtimeReachable={runtimeReachable}/>
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ export function DatabaseSnapshotsPanel({
|
||||
"code" in error.body &&
|
||||
(error.body as { code?: unknown }).code === "operation_not_implemented"
|
||||
) {
|
||||
return "Snapshot creation is available in Floci UI, but the selected Floci runtime does not implement CreateDBSnapshot.";
|
||||
return "Snapshot creation is available in TheCloud, but the selected runtime does not implement CreateDBSnapshot.";
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
|
||||
@@ -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";
|
||||
@@ -65,6 +66,10 @@ import { LoadBalancerPanel } from "@/components/LoadBalancerPanel";
|
||||
import { TargetGroupPanel } from "@/components/TargetGroupPanel";
|
||||
import { LoadBalancerListenerPanel } from "@/components/LoadBalancerListenerPanel";
|
||||
import { CognitoUsersPanel } from "@/components/CognitoUsersPanel";
|
||||
import { Route53RecordsPanel } from "@/components/Route53RecordsPanel";
|
||||
import { CloudFrontPanel } from "@/components/CloudFrontPanel";
|
||||
import { CloudWatchAlarmPanel } from "@/components/CloudWatchAlarmPanel";
|
||||
import { CloudWatchMetricsExplorer } from "@/components/CloudWatchMetricsExplorer";
|
||||
import { WorkflowExecutionDebugger } from "@/components/WorkflowExecutionDebugger";
|
||||
import { CodeBuildPanel } from "@/components/CodeBuildPanel";
|
||||
import { CodePipelinePanel } from "@/components/CodePipelinePanel";
|
||||
@@ -632,7 +637,6 @@ export function DynamicResourceView({
|
||||
className={`resource-workbench${activeSelected && !showDatabaseSnapshots ? " with-inspector" : ""}`}
|
||||
>
|
||||
<section className="resource-main">
|
||||
{cloud === 'aws' && service === 'email' && <SesConsolePanel runtimeReachable={canUseRuntime}/>}
|
||||
{isAwsDatabase && (
|
||||
<div className="drawer-tabs" style={{ marginBottom: 12 }}>
|
||||
<button
|
||||
@@ -938,6 +942,7 @@ export function DynamicResourceView({
|
||||
})}
|
||||
</section>
|
||||
)}
|
||||
{cloud === 'aws' && service === 'email' && <SesConsolePanel runtimeReachable={canUseRuntime}/>}
|
||||
</section>
|
||||
{activeSelected && !showDatabaseSnapshots && (
|
||||
<div>
|
||||
@@ -979,6 +984,18 @@ export function DynamicResourceView({
|
||||
{service === "userpools" && cloud === "aws" && (
|
||||
<CognitoUsersPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
||||
)}
|
||||
{service === "dns" && cloud === "aws" && (
|
||||
<Route53RecordsPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
||||
)}
|
||||
{service === "cdn" && cloud === "aws" && (
|
||||
<CloudFrontPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
||||
)}
|
||||
{service === "monitoring" && cloud === "aws" && (
|
||||
<>
|
||||
<CloudWatchAlarmPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
||||
<CloudWatchMetricsExplorer runtimeReachable={canUseRuntime} />
|
||||
</>
|
||||
)}
|
||||
{service === "compute" && (
|
||||
<ComputePanel
|
||||
cloud={cloud}
|
||||
@@ -1021,14 +1038,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')
|
||||
}
|
||||
@@ -190,7 +190,7 @@ export function Layout() {
|
||||
</nav>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
<span className="sidebar-footer-text">Floci DevTools · Local</span>
|
||||
<span className="sidebar-footer-text">TheCloud · Local</span>
|
||||
<button
|
||||
ref={toggleRef}
|
||||
className="icon-btn"
|
||||
|
||||
@@ -58,7 +58,7 @@ export function LightsailLaunchPanel({isSubmitting, submitError, onCancel, onSub
|
||||
<div>
|
||||
<p className="eyebrow">Amazon Lightsail</p>
|
||||
<h2>Create an instance</h2>
|
||||
<p className="muted">Choose a blueprint, a plan, and a name. The instance launches locally through Floci.</p>
|
||||
<p className="muted">Choose a blueprint, a plan, and a name. The instance launches locally on TheCloud.</p>
|
||||
</div>
|
||||
<button className="icon-btn" type="button" onClick={onCancel} aria-label="Close create instance">×</button>
|
||||
</div>
|
||||
|
||||
@@ -190,7 +190,7 @@ function LogsQueryResults({result, expanded}: {result?: LogsInsightsQueryResult;
|
||||
</div>
|
||||
{stillRunning && (
|
||||
<div className="muted padded">
|
||||
Floci hadn't finished this query when the console stopped waiting — these rows are a partial,
|
||||
The runtime hadn't finished this query when the console stopped waiting — these rows are a partial,
|
||||
in-progress result, not the full answer. Run the query again to see if it has finished
|
||||
(query id: {result.queryId}).
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import {fireEvent, render, screen} from '@testing-library/react'
|
||||
import {expect, test} from 'vitest'
|
||||
import {MetricChart} from './MetricChart'
|
||||
|
||||
const series = {label: 'Errors', statistic: 'Sum', periodSeconds: 60, points: [
|
||||
{timestamp: '2026-10-25T10:00:00Z', value: 3},
|
||||
{timestamp: '2026-10-25T10:01:00Z', value: 7},
|
||||
]}
|
||||
|
||||
test('draws the line, labels the latest value and the threshold, and tabulates the points', () => {
|
||||
const {container} = render(<MetricChart series={series} threshold={5}/>)
|
||||
|
||||
expect(container.querySelector('.metric-chart-line')?.getAttribute('d')).toMatch(/^M[\d.]+,[\d.]+ L[\d.]+,[\d.]+$/)
|
||||
expect(screen.getByText('Threshold 5')).toBeInTheDocument()
|
||||
expect(screen.getByRole('img', {name: /Errors \(Sum\), 2 points; latest 7 .*threshold 5/})).toBeInTheDocument()
|
||||
expect(screen.getByText('Data table (2 points)')).toBeInTheDocument()
|
||||
expect(container.querySelectorAll('.metric-chart-table tbody tr')).toHaveLength(2)
|
||||
})
|
||||
|
||||
test('shows the tooltip from the keyboard, value first', () => {
|
||||
render(<MetricChart series={series}/>)
|
||||
const chart = screen.getByRole('img')
|
||||
|
||||
fireEvent.focus(chart)
|
||||
expect(screen.getByRole('status')).toHaveTextContent(/^7Errors · Sum/)
|
||||
fireEvent.keyDown(chart, {key: 'ArrowLeft'})
|
||||
expect(screen.getByRole('status')).toHaveTextContent(/^3/)
|
||||
fireEvent.keyDown(chart, {key: 'Escape'})
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('says when there is no data', () => {
|
||||
render(<MetricChart series={{...series, points: []}}/>)
|
||||
expect(screen.getByText('No data points in this time range.')).toBeInTheDocument()
|
||||
})
|
||||
@@ -0,0 +1,145 @@
|
||||
import {useEffect, useId, useMemo, useRef, useState, type KeyboardEvent, type PointerEvent} from 'react'
|
||||
import {formatValue, niceTicks, timeTicks} from '@/lib/metricChart'
|
||||
import type {MetricSeries} from '@/types/resource'
|
||||
|
||||
type Props = {
|
||||
series: MetricSeries
|
||||
/** The alarm threshold, drawn as a dashed reference line. */
|
||||
threshold?: number | null
|
||||
height?: number
|
||||
}
|
||||
|
||||
const MARGIN = {top: 16, right: 56, bottom: 28, left: 48}
|
||||
/** Few points read as a scatter of values, so each gets a marker. */
|
||||
const MARKER_LIMIT = 24
|
||||
|
||||
/**
|
||||
* One metric over time: a 2px line, a solid hairline grid, the latest value labelled at the end,
|
||||
* an optional dashed threshold, a crosshair tooltip that snaps to the nearest point (keyboard too)
|
||||
* and a table view of the same values.
|
||||
*/
|
||||
export function MetricChart({series, threshold = null, height = 220}: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [width, setWidth] = useState(640)
|
||||
const [active, setActive] = useState<number | null>(null)
|
||||
const titleId = useId()
|
||||
|
||||
useEffect(() => {
|
||||
const element = containerRef.current
|
||||
// Without ResizeObserver (old browsers, tests) the chart keeps its default width.
|
||||
if (!element || typeof ResizeObserver === 'undefined') return
|
||||
const observer = new ResizeObserver(([entry]) => setWidth(Math.max(280, Math.round(entry.contentRect.width))))
|
||||
observer.observe(element)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
const points = series.points
|
||||
const geometry = useMemo(() => {
|
||||
const innerWidth = width - MARGIN.left - MARGIN.right
|
||||
const innerHeight = height - MARGIN.top - MARGIN.bottom
|
||||
const times = points.map((point) => Date.parse(point.timestamp))
|
||||
const values = points.map((point) => point.value)
|
||||
const maxValue = Math.max(...values, threshold ?? -Infinity, 0)
|
||||
const minValue = Math.min(...values, threshold ?? Infinity, 0)
|
||||
const ticks = niceTicks(minValue, maxValue === minValue ? minValue + 1 : maxValue)
|
||||
const yMin = ticks[0]
|
||||
const yMax = ticks[ticks.length - 1]
|
||||
const tMin = times.length ? Math.min(...times) : 0
|
||||
const tMax = times.length ? Math.max(...times) : 1
|
||||
const x = (time: number) => MARGIN.left + (tMax === tMin ? innerWidth / 2 : ((time - tMin) / (tMax - tMin)) * innerWidth)
|
||||
const y = (value: number) => MARGIN.top + innerHeight - ((value - yMin) / (yMax - yMin)) * innerHeight
|
||||
return {innerWidth, innerHeight, times, ticks, x, y, tMin, tMax}
|
||||
}, [points, threshold, width, height])
|
||||
|
||||
if (points.length === 0) {
|
||||
return <div className="metric-chart metric-chart--empty" ref={containerRef}><p className="muted">No data points in this time range.</p></div>
|
||||
}
|
||||
|
||||
const {times, ticks, x, y, tMin, tMax} = geometry
|
||||
const path = points.map((point, index) => `${index ? 'L' : 'M'}${x(times[index]).toFixed(1)},${y(point.value).toFixed(1)}`).join(' ')
|
||||
const last = points.length - 1
|
||||
const spansDays = tMax - tMin > 24 * 3600_000
|
||||
const xTicks = timeTicks(tMin, tMax, Math.max(2, Math.floor(geometry.innerWidth / 120)))
|
||||
const shown = active ?? null
|
||||
|
||||
const nearest = (clientX: number, svg: SVGSVGElement) => {
|
||||
const box = svg.getBoundingClientRect()
|
||||
const px = ((clientX - box.left) / box.width) * width
|
||||
let best = 0
|
||||
for (let index = 1; index < times.length; index++) {
|
||||
if (Math.abs(x(times[index]) - px) < Math.abs(x(times[best]) - px)) best = index
|
||||
}
|
||||
return best
|
||||
}
|
||||
const onPointerMove = (event: PointerEvent<SVGSVGElement>) => setActive(nearest(event.clientX, event.currentTarget))
|
||||
const onKeyDown = (event: KeyboardEvent<SVGSVGElement>) => {
|
||||
if (event.key === 'ArrowLeft') { event.preventDefault(); setActive((index) => Math.max(0, (index ?? last) - 1)) }
|
||||
if (event.key === 'ArrowRight') { event.preventDefault(); setActive((index) => Math.min(last, (index ?? last) + 1)) }
|
||||
if (event.key === 'Home') { event.preventDefault(); setActive(0) }
|
||||
if (event.key === 'End') { event.preventDefault(); setActive(last) }
|
||||
if (event.key === 'Escape') setActive(null)
|
||||
}
|
||||
const summary = `${series.label} (${series.statistic}), ${points.length} points; latest ${formatValue(points[last].value)} at ${formatTime(times[last], spansDays)}${threshold === null ? '' : `; threshold ${formatValue(threshold)}`}`
|
||||
|
||||
return <div className="metric-chart" ref={containerRef}>
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
role="img"
|
||||
aria-labelledby={titleId}
|
||||
tabIndex={0}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerLeave={() => setActive(null)}
|
||||
onFocus={() => setActive(last)}
|
||||
onBlur={() => setActive(null)}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
<title id={titleId}>{summary}</title>
|
||||
{ticks.map((tick) => (
|
||||
<g key={tick}>
|
||||
<line className="metric-chart-grid" x1={MARGIN.left} x2={width - MARGIN.right} y1={y(tick)} y2={y(tick)}/>
|
||||
<text className="metric-chart-axis" x={MARGIN.left - 8} y={y(tick)} dy="0.32em" textAnchor="end">{formatValue(tick)}</text>
|
||||
</g>
|
||||
))}
|
||||
{xTicks.map((tick) => (
|
||||
<text key={tick} className="metric-chart-axis" x={x(tick)} y={height - 8} textAnchor="middle">{formatTime(tick, spansDays)}</text>
|
||||
))}
|
||||
{threshold !== null && <g>
|
||||
<line className="metric-chart-threshold" x1={MARGIN.left} x2={width - MARGIN.right} y1={y(threshold)} y2={y(threshold)}/>
|
||||
<text className="metric-chart-label" x={width - MARGIN.right} y={y(threshold) - 6} textAnchor="end">{`Threshold ${formatValue(threshold)}`}</text>
|
||||
</g>}
|
||||
<path className="metric-chart-line" d={path}/>
|
||||
{points.length <= MARKER_LIMIT && points.map((point, index) => (
|
||||
<circle key={point.timestamp} className="metric-chart-marker" cx={x(times[index])} cy={y(point.value)} r={4}/>
|
||||
))}
|
||||
<text className="metric-chart-label metric-chart-label--strong" x={x(times[last]) + 8} y={y(points[last].value)} dy="0.32em">{formatValue(points[last].value)}</text>
|
||||
{shown !== null && <g pointerEvents="none">
|
||||
<line className="metric-chart-crosshair" x1={x(times[shown])} x2={x(times[shown])} y1={MARGIN.top} y2={height - MARGIN.bottom}/>
|
||||
<circle className="metric-chart-marker metric-chart-marker--active" cx={x(times[shown])} cy={y(points[shown].value)} r={5}/>
|
||||
</g>}
|
||||
</svg>
|
||||
{shown !== null && <div className="metric-chart-tooltip" role="status" style={tooltipPosition(x(times[shown]), width)}>
|
||||
<strong>{formatValue(points[shown].value)}</strong>
|
||||
<span><span className="metric-chart-key" aria-hidden="true"/>{series.label} · {series.statistic}</span>
|
||||
<span className="muted">{formatTime(times[shown], true)}</span>
|
||||
</div>}
|
||||
<details className="metric-chart-table">
|
||||
<summary>Data table ({points.length} points)</summary>
|
||||
<table className="table"><thead><tr><th>Time</th><th>{series.statistic}</th></tr></thead><tbody>{points.slice().reverse().map((point) => (
|
||||
<tr key={point.timestamp}><td>{new Date(point.timestamp).toLocaleString()}</td><td className="mono">{formatValue(point.value)}</td></tr>
|
||||
))}</tbody></table>
|
||||
</details>
|
||||
</div>
|
||||
}
|
||||
|
||||
function tooltipPosition(px: number, width: number) {
|
||||
return px > width * 0.6 ? {right: width - px + 12} : {left: px + 12}
|
||||
}
|
||||
|
||||
function formatTime(time: number, withDate: boolean): string {
|
||||
const date = new Date(time)
|
||||
return withDate
|
||||
? date.toLocaleString(undefined, {month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'})
|
||||
: date.toLocaleTimeString(undefined, {hour: '2-digit', minute: '2-digit'})
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
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 {Route53RecordsPanel} from './Route53RecordsPanel'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
deleteFlatItem: vi.fn(),
|
||||
getCloudResource: vi.fn(),
|
||||
listFlatItems: vi.fn(),
|
||||
putFlatItem: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/cloudProxyClient', () => api)
|
||||
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
|
||||
|
||||
const zone: CloudResource = {
|
||||
id: 'ZG9PAE0M9KRWJ4F', name: 'example.test', cloud: 'aws', service: 'dns', type: 'hosted-zone',
|
||||
region: null, createdAt: null, metadata: {visibility: 'public', recordCount: 3},
|
||||
}
|
||||
|
||||
function item(id: string, body: Record<string, unknown>) {
|
||||
return {id, collectionId: null, timestamp: null, metadata: {}, body: {ttl: 300, values: [], alias: null, setIdentifier: null, managed: false, editable: true, ...body}}
|
||||
}
|
||||
|
||||
const records = [
|
||||
item('example.test|NS', {name: 'example.test', type: 'NS', ttl: 172800, values: ['ns-1.awsdns-01.org.'], managed: true}),
|
||||
item('www.example.test|A', {name: 'www.example.test', type: 'A', values: ['10.0.0.1']}),
|
||||
item('example.test|TXT', {name: 'example.test', type: 'TXT', values: ['"v=spf1 -all"']}),
|
||||
item('cdn.example.test|A', {name: 'cdn.example.test', type: 'A', ttl: null, alias: {dnsName: 'd111.cloudfront.net', hostedZoneId: 'Z2'}, editable: false}),
|
||||
]
|
||||
|
||||
function renderPanel() {
|
||||
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||
return render(<QueryClientProvider client={client}><Route53RecordsPanel resource={zone} runtimeReachable/></QueryClientProvider>)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
api.getCloudResource.mockResolvedValue({...zone, metadata: {...zone.metadata, nameServers: ['ns-1.awsdns-01.org', 'ns-2.awsdns-02.net']}})
|
||||
api.listFlatItems.mockResolvedValue({items: records, nextCursor: null})
|
||||
api.putFlatItem.mockImplementation(async (_cloud, _service, _zone, body) => item('x', {name: `${body.name}.example.test`, type: body.type}))
|
||||
api.deleteFlatItem.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
test('shows the name servers and records, keeping the zone defaults read-only', async () => {
|
||||
renderPanel()
|
||||
|
||||
expect(await screen.findByText('ns-2.awsdns-02.net')).toBeInTheDocument()
|
||||
expect(await screen.findByText('10.0.0.1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Alias → d111.cloudfront.net')).toBeInTheDocument()
|
||||
expect(screen.getByText('zone default')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', {name: 'Delete NS example.test'})).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', {name: 'Edit A cdn.example.test'})).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', {name: 'Delete A cdn.example.test'})).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('creates a record from a name inside the zone', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPanel()
|
||||
|
||||
const form = await screen.findByRole('form', {name: 'Create record'})
|
||||
await user.type(within(form).getByLabelText(/Record name/), 'api')
|
||||
await user.selectOptions(within(form).getByLabelText('Type'), 'CNAME')
|
||||
await user.clear(within(form).getByLabelText(/TTL/))
|
||||
await user.type(within(form).getByLabelText(/TTL/), '60')
|
||||
await user.type(within(form).getByLabelText(/Values/), 'app.example.net')
|
||||
await user.click(within(form).getByRole('button', {name: 'Create record'}))
|
||||
|
||||
expect(api.putFlatItem).toHaveBeenCalledWith('aws', 'dns', zone.id, {name: 'api', type: 'CNAME', ttl: 60, values: ['app.example.net']})
|
||||
expect(await screen.findByText(/Created CNAME api.example.test/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('edits a record with its name and type fixed, showing TXT text without quotes', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPanel()
|
||||
|
||||
await user.click(await screen.findByRole('button', {name: 'Edit TXT example.test'}))
|
||||
const form = screen.getByRole('form', {name: 'Edit record'})
|
||||
expect(within(form).getByLabelText(/Record name/)).toHaveValue('@')
|
||||
expect(within(form).getByLabelText(/Record name/)).toBeDisabled()
|
||||
expect(within(form).getByLabelText('Type')).toBeDisabled()
|
||||
expect(within(form).getByLabelText(/Values/)).toHaveValue('v=spf1 -all')
|
||||
})
|
||||
|
||||
test('deletes a record by its name and type', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPanel()
|
||||
|
||||
await user.click(await screen.findByRole('button', {name: 'Delete A www.example.test'}))
|
||||
expect(api.deleteFlatItem).toHaveBeenCalledWith('aws', 'dns', zone.id, 'www.example.test|A')
|
||||
expect(await screen.findByText('Deleted A www.example.test.')).toBeInTheDocument()
|
||||
})
|
||||
@@ -0,0 +1,205 @@
|
||||
import {Pencil, RefreshCw, Trash2} from 'lucide-react'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
import {useEffect, useState} from 'react'
|
||||
import {deleteFlatItem, getCloudResource, listFlatItems, putFlatItem} from '@/api/cloudProxyClient'
|
||||
import {CopyValueButton} from '@/components/CopyValueButton'
|
||||
import {useAccountId} from '@/lib/accountStore'
|
||||
import type {ChildItem, CloudResource} from '@/types/resource'
|
||||
|
||||
type Props = {
|
||||
resource?: CloudResource
|
||||
runtimeReachable: boolean
|
||||
}
|
||||
|
||||
type DnsRecord = {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
ttl: number | null
|
||||
values: string[]
|
||||
alias: {dnsName: string; hostedZoneId: string} | null
|
||||
setIdentifier: string | null
|
||||
managed: boolean
|
||||
editable: boolean
|
||||
}
|
||||
|
||||
const RECORD_TYPES = ['A', 'AAAA', 'CNAME', 'TXT', 'MX', 'SRV', 'CAA', 'NS', 'PTR', 'SPF', 'DS', 'HTTPS', 'SVCB', 'NAPTR', 'SSHFP', 'TLSA']
|
||||
const VALUE_HINTS: Record<string, string> = {
|
||||
A: 'IPv4 addresses, one per line: 192.0.2.10',
|
||||
AAAA: 'IPv6 addresses, one per line: 2001:db8::10',
|
||||
CNAME: 'One host name: app.example.net',
|
||||
TXT: 'Text, one string per line. Quotes are added for you.',
|
||||
MX: 'Priority and mail server, one per line: 10 mail.example.com',
|
||||
SRV: 'Priority, weight, port and target: 10 5 5060 sip.example.com',
|
||||
CAA: 'Flags, tag and value: 0 issue "amazon.com"',
|
||||
}
|
||||
/** Every page of a zone is fetched; zones on a local runtime are small. */
|
||||
const MAX_PAGES = 20
|
||||
|
||||
const emptyDraft = {name: '', type: 'A', ttl: '300', values: ''}
|
||||
|
||||
export function Route53RecordsPanel({resource, runtimeReachable}: Props) {
|
||||
const accountId = useAccountId()
|
||||
const queryClient = useQueryClient()
|
||||
const [draft, setDraft] = useState(emptyDraft)
|
||||
const [editing, setEditing] = useState<string | null>(null)
|
||||
const [notice, setNotice] = useState('')
|
||||
const recordsKey = ['dns-records', accountId, resource?.id]
|
||||
|
||||
const zoneQuery = useQuery({
|
||||
queryKey: ['dns-zone', accountId, resource?.id],
|
||||
queryFn: ({signal}) => getCloudResource('aws', 'dns', resource?.id ?? '', signal),
|
||||
enabled: Boolean(resource) && runtimeReachable,
|
||||
})
|
||||
const recordsQuery = useQuery({
|
||||
queryKey: recordsKey,
|
||||
queryFn: async ({signal}) => {
|
||||
const items: ChildItem[] = []
|
||||
let cursor: string | undefined
|
||||
for (let page = 0; page < MAX_PAGES; page++) {
|
||||
const result = await listFlatItems('aws', 'dns', resource?.id ?? '', cursor, signal)
|
||||
items.push(...result.items)
|
||||
if (!result.nextCursor) break
|
||||
cursor = result.nextCursor
|
||||
}
|
||||
return items.map(toRecord)
|
||||
},
|
||||
enabled: Boolean(resource) && runtimeReachable,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(emptyDraft)
|
||||
setEditing(null)
|
||||
setNotice('')
|
||||
}, [resource?.id])
|
||||
|
||||
// The zone list shows each zone's record count.
|
||||
const refresh = () => Promise.all([
|
||||
queryClient.invalidateQueries({queryKey: recordsKey}),
|
||||
queryClient.invalidateQueries({queryKey: ['dns-zone', accountId, resource?.id]}),
|
||||
queryClient.invalidateQueries({queryKey: ['cloud-resources', 'aws', 'dns']}),
|
||||
])
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () => putFlatItem('aws', 'dns', resource?.id ?? '', {
|
||||
name: draft.name,
|
||||
type: draft.type,
|
||||
ttl: Number(draft.ttl),
|
||||
values: draft.values.split('\n'),
|
||||
}),
|
||||
onSuccess: (saved) => {
|
||||
setNotice(`${editing ? 'Saved' : 'Created'} ${String(saved.body.type)} ${String(saved.body.name)}.`)
|
||||
setDraft(emptyDraft)
|
||||
setEditing(null)
|
||||
return refresh()
|
||||
},
|
||||
})
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => deleteFlatItem('aws', 'dns', resource?.id ?? '', id),
|
||||
onSuccess: (_result, id) => {
|
||||
setNotice(`Deleted ${id.split('|').slice(0, 2).reverse().join(' ')}.`)
|
||||
if (editing === id) { setEditing(null); setDraft(emptyDraft) }
|
||||
return refresh()
|
||||
},
|
||||
})
|
||||
|
||||
if (!resource) {
|
||||
return <section className="table-panel"><div className="empty compact"><h3>Select a hosted zone</h3><p>Choose a hosted zone to see and change its DNS records.</p></div></section>
|
||||
}
|
||||
|
||||
const zoneName = resource.name
|
||||
const records = recordsQuery.data ?? []
|
||||
const nameServers = stringList(zoneQuery.data?.metadata.nameServers)
|
||||
const pending = saveMutation.isPending || deleteMutation.isPending
|
||||
const disabled = !runtimeReachable || pending
|
||||
const error = zoneQuery.error ?? recordsQuery.error ?? saveMutation.error ?? deleteMutation.error
|
||||
const edit = (record: DnsRecord) => {
|
||||
setNotice('')
|
||||
setEditing(record.id)
|
||||
setDraft({name: relativeName(record.name, zoneName), type: record.type, ttl: String(record.ttl ?? 300), values: record.values.map((value) => record.type === 'TXT' || record.type === 'SPF' ? unquote(value) : value).join('\n')})
|
||||
}
|
||||
|
||||
return <section className="table-panel" style={{marginTop: 16}} aria-label={`Records in ${zoneName}`}>
|
||||
{nameServers.length > 0 && <>
|
||||
<div className="input-row resource-table-bar">
|
||||
<div><p className="eyebrow">Name servers</p><span className="muted">Delegate {zoneName} to these at your registrar. TheCloud stores records but does not answer DNS queries.</span></div>
|
||||
</div>
|
||||
<ul className="dns-name-servers">{nameServers.map((server) => <li key={server}><span className="copy-value mono">{server}<CopyValueButton value={server} label="Name server"/></span></li>)}</ul>
|
||||
</>}
|
||||
|
||||
<div className="input-row resource-table-bar">
|
||||
<div><p className="eyebrow">Records</p><span className="muted">{records.length} {records.length === 1 ? 'record' : 'records'} in {zoneName}</span></div>
|
||||
<button className="button" type="button" disabled={!runtimeReachable || recordsQuery.isFetching} onClick={() => { void refresh() }}><RefreshCw size={14}/>{recordsQuery.isFetching ? 'Loading' : 'Refresh'}</button>
|
||||
</div>
|
||||
|
||||
<form className="dynamic-form" aria-label={editing ? 'Edit record' : 'Create record'} onSubmit={(event) => { event.preventDefault(); setNotice(''); saveMutation.mutate() }}>
|
||||
<label className="dynamic-field"><span>Record name</span>
|
||||
<div className="dns-name-input"><input className="input" value={draft.name} placeholder="www" onChange={(event) => setDraft({...draft, name: event.target.value})} disabled={disabled || Boolean(editing)} autoComplete="off"/><span className="dns-zone-suffix">.{zoneName}</span></div>
|
||||
<small className="muted">Leave empty or use @ for {zoneName} itself.</small>
|
||||
</label>
|
||||
<label className="dynamic-field"><span>Type</span>
|
||||
<select className="input" value={draft.type} onChange={(event) => setDraft({...draft, type: event.target.value})} disabled={disabled || Boolean(editing)}>
|
||||
{RECORD_TYPES.map((type) => <option key={type} value={type}>{type}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="dynamic-field"><span>TTL (seconds)</span>
|
||||
<input className="input" type="number" min={0} value={draft.ttl} onChange={(event) => setDraft({...draft, ttl: event.target.value})} disabled={disabled} required/>
|
||||
</label>
|
||||
<label className="dynamic-field dynamic-field--span"><span>Values</span>
|
||||
<textarea className="textarea code-textarea small" rows={3} value={draft.values} onChange={(event) => setDraft({...draft, values: event.target.value})} disabled={disabled} required/>
|
||||
<small className="muted">{VALUE_HINTS[draft.type] ?? 'One value per line.'}</small>
|
||||
</label>
|
||||
<div className="dns-form-actions">
|
||||
<button className="button primary" type="submit" disabled={disabled || !draft.values.trim()}>{saveMutation.isPending ? 'Saving' : editing ? 'Save record' : 'Create record'}</button>
|
||||
{editing && <button className="button" type="button" disabled={pending} onClick={() => { setEditing(null); setDraft(emptyDraft) }}>Cancel</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>}
|
||||
{recordsQuery.isLoading && <div className="muted padded">Loading records</div>}
|
||||
{records.length > 0 && <div className="cognito-table"><table className="table"><thead><tr><th>Name</th><th>Type</th><th>TTL</th><th>Value</th><th/></tr></thead><tbody>{records.map((record) => (
|
||||
<tr key={record.id} aria-current={editing === record.id ? 'true' : undefined}>
|
||||
<td className="mono">{record.name}{record.managed && <span className="badge dns-badge">zone default</span>}{record.setIdentifier && <span className="badge dns-badge">{record.setIdentifier}</span>}</td>
|
||||
<td><code>{record.type}</code></td>
|
||||
<td>{record.alias ? '—' : record.ttl ?? '—'}</td>
|
||||
<td className="dns-values">{record.alias
|
||||
? <span className="mono">Alias → {record.alias.dnsName}</span>
|
||||
: record.values.map((value, index) => <div key={index} className="mono">{value}</div>)}</td>
|
||||
<td><div className="cognito-row-actions">
|
||||
{record.editable && !record.managed && <button className="icon-btn" type="button" title="Edit record" aria-label={`Edit ${record.type} ${record.name}`} disabled={disabled} onClick={() => edit(record)}><Pencil size={14}/></button>}
|
||||
{!record.managed && <button className="icon-btn danger" type="button" title="Delete record" aria-label={`Delete ${record.type} ${record.name}`} disabled={disabled} onClick={() => { setNotice(''); deleteMutation.mutate(record.id) }}><Trash2 size={14}/></button>}
|
||||
</div></td>
|
||||
</tr>
|
||||
))}</tbody></table></div>}
|
||||
</section>
|
||||
}
|
||||
|
||||
function toRecord(item: ChildItem): DnsRecord {
|
||||
const body = item.body
|
||||
const alias = body.alias && typeof body.alias === 'object' ? body.alias as {dnsName?: unknown; hostedZoneId?: unknown} : null
|
||||
return {
|
||||
id: item.id,
|
||||
name: String(body.name ?? ''),
|
||||
type: String(body.type ?? ''),
|
||||
ttl: typeof body.ttl === 'number' ? body.ttl : null,
|
||||
values: stringList(body.values),
|
||||
alias: alias ? {dnsName: String(alias.dnsName ?? ''), hostedZoneId: String(alias.hostedZoneId ?? '')} : null,
|
||||
setIdentifier: typeof body.setIdentifier === 'string' ? body.setIdentifier : null,
|
||||
managed: body.managed === true,
|
||||
editable: body.editable === true,
|
||||
}
|
||||
}
|
||||
|
||||
function relativeName(name: string, zone: string): string {
|
||||
if (name === zone) return '@'
|
||||
return name.endsWith(`.${zone}`) ? name.slice(0, -zone.length - 1) : name
|
||||
}
|
||||
|
||||
function unquote(value: string): string {
|
||||
return value.length > 1 && value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\') : value
|
||||
}
|
||||
|
||||
function stringList(value: unknown): string[] {
|
||||
return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : []
|
||||
}
|
||||
@@ -13,12 +13,12 @@ export function SesConsolePanel({runtimeReachable}: {runtimeReachable: boolean})
|
||||
const [from, setFrom] = useState('')
|
||||
const [to, setTo] = useState(SIMULATORS[0])
|
||||
const [subject, setSubject] = useState('Local SES test')
|
||||
const [text, setText] = useState('Sent from the Floci SES console.')
|
||||
const [text, setText] = useState('Sent from TheCloud\'s SES console.')
|
||||
const [html, setHtml] = useState('')
|
||||
const [raw, setRaw] = useState('From: sender@example.test\r\nTo: success@simulator.amazonses.com\r\nSubject: Raw SES test\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\nHello from a raw MIME message.')
|
||||
const [configurationSet, setConfigurationSet] = useState('')
|
||||
const [templateName, setTemplateName] = useState('')
|
||||
const [templateData, setTemplateData] = useState('{"name":"Floci"}')
|
||||
const [templateData, setTemplateData] = useState('{"name":"TheCloud"}')
|
||||
const [editingTemplate, setEditingTemplate] = useState('')
|
||||
const [templateSubject, setTemplateSubject] = useState('Hello {{name}}')
|
||||
const [templateText, setTemplateText] = useState('Hello {{name}}')
|
||||
@@ -61,8 +61,8 @@ export function SesConsolePanel({runtimeReachable}: {runtimeReachable: boolean})
|
||||
const pending = send.isPending || createIdentity.isPending || saveTemplate.isPending || addSuppression.isPending || createConfigurationSet.isPending || addDestination.isPending
|
||||
|
||||
return <div className="ses-console">
|
||||
<section className="table-panel"><div className="dynamic-stage-header"><div><p className="eyebrow">SES identities</p><h3>Verify senders</h3><p className="muted compact-text">Floci verifies identities immediately for AWS-compatible workflows. Local sending is not sandbox-gated.</p></div><button className="button compact" type="button" onClick={() => identities.refetch()}><RefreshCw size={13}/>Refresh</button></div><form className="input-row" onSubmit={(event) => {event.preventDefault(); if (identity.trim()) createIdentity.mutate()}}><input className="input" value={identity} onChange={(event) => setIdentity(event.target.value)} placeholder="sender@example.test or example.test" required/><button className="button primary" disabled={!runtimeReachable || createIdentity.isPending}><Plus size={13}/>Verify identity</button></form>{identities.data?.map((item) => <div className="apigateway-route-item" key={item.IdentityName}><div><code>{item.IdentityName}</code><small>{item.IdentityType} · {item.VerifiedForSendingStatus ? 'verified for sending' : 'not verified'}</small></div><button className="icon-btn danger" onClick={() => deleteSesIdentity(item.IdentityName).then(() => qc.invalidateQueries({queryKey: ['ses-identities']}))}><Trash2 size={13}/></button></div>)}</section>
|
||||
<section className="table-panel"><div className="dynamic-stage-header"><div><p className="eyebrow">Compose</p><h3><Mail size={15}/> Send through SES</h3><p className="muted compact-text">Simulator recipients produce documented delivery, bounce, and complaint events locally.</p></div></div><div className="button-row">{SIMULATORS.map((address) => <button className="button compact" type="button" key={address} onClick={() => setTo(address)}>{address.split('@')[0]}</button>)}</div><form className="dynamic-form" onSubmit={(event) => {event.preventDefault(); send.mutate()}}><label className="dynamic-field"><span>Format</span><select className="input" value={mode} onChange={(event) => setMode(event.target.value as typeof mode)}><option value="simple">Simple</option><option value="raw">Raw MIME</option><option value="template">Template</option></select></label><label className="dynamic-field"><span>From</span><input className="input" value={from} onChange={(event) => setFrom(event.target.value)} placeholder="Verified identity" required={mode !== 'raw'}/></label><label className="dynamic-field"><span>To</span><input className="input" value={to} onChange={(event) => setTo(event.target.value)} required/></label><label className="dynamic-field"><span>Configuration set</span><select className="input" value={configurationSet} onChange={(event) => setConfigurationSet(event.target.value)}><option value="">None</option>{configurationSets.data?.map((item) => <option key={item.ConfigurationSetName} value={item.ConfigurationSetName}>{item.ConfigurationSetName}</option>)}</select></label>{mode === 'simple' && <><label className="dynamic-field dynamic-field--span"><span>Subject</span><input className="input" value={subject} onChange={(event) => setSubject(event.target.value)} required/></label><label className="dynamic-field"><span>Text body</span><textarea className="textarea code-textarea small" value={text} onChange={(event) => setText(event.target.value)}/></label><label className="dynamic-field"><span>HTML body</span><textarea className="textarea code-textarea small" value={html} onChange={(event) => setHtml(event.target.value)}/></label></>}{mode === 'raw' && <label className="dynamic-field dynamic-field--span"><span>Raw MIME</span><textarea className="textarea code-textarea" value={raw} onChange={(event) => setRaw(event.target.value)} required/></label>}{mode === 'template' && <><label className="dynamic-field"><span>Template</span><select className="input" value={templateName} onChange={(event) => setTemplateName(event.target.value)} required><option value="">Select template</option>{templates.data?.map((item) => <option key={item.TemplateName} value={item.TemplateName}>{item.TemplateName}</option>)}</select></label><label className="dynamic-field"><span>Template data (JSON)</span><textarea className="textarea code-textarea small" value={templateData} onChange={(event) => setTemplateData(event.target.value)} required/></label></>}<button className="button primary" disabled={!runtimeReachable || pending}>{send.isPending ? <Loader2 className="spin" size={13}/> : <Send size={13}/>}{send.isPending ? 'Sending' : 'Send email'}</button></form>{send.isSuccess && <p className="success-text compact-text">Accepted by SES. Refresh the mailbox below to inspect the captured message.</p>}</section>
|
||||
<section className="table-panel"><div className="dynamic-stage-header"><div><p className="eyebrow">SES identities</p><h3>Verify senders</h3><p className="muted compact-text">TheCloud verifies identities immediately for AWS-compatible workflows. Local sending is not sandbox-gated.</p></div><button className="button compact" type="button" onClick={() => identities.refetch()}><RefreshCw size={13}/>Refresh</button></div><form className="input-row" onSubmit={(event) => {event.preventDefault(); if (identity.trim()) createIdentity.mutate()}}><input className="input" value={identity} onChange={(event) => setIdentity(event.target.value)} placeholder="sender@example.test or example.test" required/><button className="button primary" disabled={!runtimeReachable || createIdentity.isPending}><Plus size={13}/>Verify identity</button></form>{identities.data?.map((item) => <div className="apigateway-route-item" key={item.IdentityName}><div><code>{item.IdentityName}</code><small>{item.IdentityType} · {item.VerifiedForSendingStatus ? 'verified for sending' : 'not verified'}</small></div><button className="icon-btn danger" onClick={() => deleteSesIdentity(item.IdentityName).then(() => qc.invalidateQueries({queryKey: ['ses-identities']}))}><Trash2 size={13}/></button></div>)}</section>
|
||||
<section className="table-panel"><div className="dynamic-stage-header"><div><p className="eyebrow">Compose</p><h3><Mail size={15}/> Send through SES</h3><p className="muted compact-text">Simulator recipients produce documented delivery, bounce, and complaint events locally.</p></div></div><div className="button-row">{SIMULATORS.map((address) => <button className="button compact" type="button" key={address} onClick={() => setTo(address)}>{address.split('@')[0]}</button>)}</div><form className="dynamic-form" onSubmit={(event) => {event.preventDefault(); send.mutate()}}><label className="dynamic-field"><span>Format</span><select className="input" value={mode} onChange={(event) => setMode(event.target.value as typeof mode)}><option value="simple">Simple</option><option value="raw">Raw MIME</option><option value="template">Template</option></select></label><label className="dynamic-field"><span>From</span><input className="input" value={from} onChange={(event) => setFrom(event.target.value)} placeholder="Verified identity" required={mode !== 'raw'}/></label><label className="dynamic-field"><span>To</span><input className="input" value={to} onChange={(event) => setTo(event.target.value)} required/></label><label className="dynamic-field"><span>Configuration set</span><select className="input" value={configurationSet} onChange={(event) => setConfigurationSet(event.target.value)}><option value="">None</option>{configurationSets.data?.map((item) => <option key={item.ConfigurationSetName} value={item.ConfigurationSetName}>{item.ConfigurationSetName}</option>)}</select></label>{mode === 'simple' && <><label className="dynamic-field dynamic-field--span"><span>Subject</span><input className="input" value={subject} onChange={(event) => setSubject(event.target.value)} required/></label><label className="dynamic-field"><span>Text body</span><textarea className="textarea code-textarea small" value={text} onChange={(event) => setText(event.target.value)}/></label><label className="dynamic-field"><span>HTML body</span><textarea className="textarea code-textarea small" value={html} onChange={(event) => setHtml(event.target.value)}/></label></>}{mode === 'raw' && <label className="dynamic-field dynamic-field--span"><span>Raw MIME</span><textarea className="textarea code-textarea" value={raw} onChange={(event) => setRaw(event.target.value)} required/></label>}{mode === 'template' && <><label className="dynamic-field"><span>Template</span><select className="input" value={templateName} onChange={(event) => setTemplateName(event.target.value)} required><option value="">Select template</option>{templates.data?.map((item) => <option key={item.TemplateName} value={item.TemplateName}>{item.TemplateName}</option>)}</select></label><label className="dynamic-field"><span>Template data (JSON)</span><textarea className="textarea code-textarea small" value={templateData} onChange={(event) => setTemplateData(event.target.value)} required/></label></>}<button className="button primary" disabled={!runtimeReachable || pending}>{send.isPending ? <Loader2 className="spin" size={13}/> : <Send size={13}/>}{send.isPending ? 'Sending' : 'Send email'}</button></form>{send.isSuccess && <p className="success-text compact-text">Accepted by SES. Refresh the mailbox above to inspect the captured message.</p>}</section>
|
||||
<section className="table-panel"><div className="dynamic-stage-header"><div><p className="eyebrow">Templates</p><h3>Author and render</h3></div></div><form className="dynamic-form" onSubmit={(event) => {event.preventDefault(); saveTemplate.mutate()}}><label className="dynamic-field"><span>Template name</span><input className="input" value={editingTemplate} onChange={(event) => setEditingTemplate(event.target.value)} required/></label><label className="dynamic-field"><span>Subject</span><input className="input" value={templateSubject} onChange={(event) => setTemplateSubject(event.target.value)} required/></label><label className="dynamic-field"><span>Text</span><textarea className="textarea code-textarea small" value={templateText} onChange={(event) => setTemplateText(event.target.value)}/></label><label className="dynamic-field"><span>HTML</span><textarea className="textarea code-textarea small" value={templateHtml} onChange={(event) => setTemplateHtml(event.target.value)}/></label><div className="button-row"><button className="button primary" disabled={!runtimeReachable || !editingTemplate.trim() || saveTemplate.isPending}>Save template</button><button className="button" type="button" disabled={!editingTemplate.trim() || renderTemplate.isPending} onClick={() => renderTemplate.mutate()}>Test render</button></div></form>{rendered && <pre className="metadata-block">{rendered}</pre>}{templates.data?.map((item) => <div className="apigateway-route-item" key={item.TemplateName}><code>{item.TemplateName}</code><div><button className="button compact" onClick={() => void loadTemplate(item.TemplateName)}>Edit</button><button className="icon-btn danger" onClick={() => deleteSesTemplate(item.TemplateName).then(() => qc.invalidateQueries({queryKey: ['ses-templates']}))}><Trash2 size={13}/></button></div></div>)}</section>
|
||||
<section className="table-panel"><div className="dynamic-stage-header"><div><p className="eyebrow">Suppression list</p><h3>Accepted but suppressed</h3><p className="muted compact-text">Suppressed mail is still accepted and captured, but it is not relayed.</p></div></div><form className="input-row" onSubmit={(event) => {event.preventDefault(); addSuppression.mutate()}}><input className="input" type="email" value={suppressionAddress} onChange={(event) => setSuppressionAddress(event.target.value)} placeholder="recipient@example.test" required/><select className="input" value={suppressionReason} onChange={(event) => setSuppressionReason(event.target.value as typeof suppressionReason)}><option value="BOUNCE">Bounce</option><option value="COMPLAINT">Complaint</option></select><button className="button primary" disabled={addSuppression.isPending}>Add</button></form>{suppressions.data?.map((item) => <div className="apigateway-route-item" key={item.EmailAddress}><div><code>{item.EmailAddress}</code><small>{item.Reason}</small></div><button className="icon-btn danger" onClick={() => deleteSesSuppression(item.EmailAddress ?? '').then(() => qc.invalidateQueries({queryKey: ['ses-suppressions']}))}><Trash2 size={13}/></button></div>)}</section>
|
||||
<section className="table-panel"><div className="dynamic-stage-header"><div><p className="eyebrow">Configuration sets</p><h3>Publish SES events</h3><p className="muted compact-text">Send, delivery, bounce, and complaint events use real SNS, EventBridge, and CloudWatch destinations.</p></div></div><form className="input-row" onSubmit={(event) => {event.preventDefault(); createConfigurationSet.mutate()}}><input className="input" value={configurationSetName} onChange={(event) => setConfigurationSetName(event.target.value)} placeholder="Configuration set name" required/><button className="button primary" disabled={createConfigurationSet.isPending}>Create</button></form><label className="dynamic-field"><span>Active configuration set</span><select className="input" value={selectedConfigurationSet} onChange={(event) => setSelectedConfigurationSet(event.target.value)}><option value="">Select configuration set</option>{configurationSets.data?.map((item) => <option key={item.ConfigurationSetName} value={item.ConfigurationSetName}>{item.ConfigurationSetName}</option>)}</select></label>{selectedConfigurationSet && <><form className="dynamic-form" onSubmit={(event) => {event.preventDefault(); addDestination.mutate()}}><label className="dynamic-field"><span>Destination name</span><input className="input" value={destinationName} onChange={(event) => setDestinationName(event.target.value)} required/></label><label className="dynamic-field"><span>Target type</span><select className="input" value={destinationKind} onChange={(event) => {setDestinationKind(event.target.value as typeof destinationKind); setDestinationTarget('')}}><option value="sns">SNS topic</option><option value="eventbridge">EventBridge bus</option><option value="cloudwatch">CloudWatch metric</option></select></label>{destinationKind !== 'cloudwatch' ? <label className="dynamic-field dynamic-field--span"><span>{destinationKind === 'sns' ? 'SNS topic' : 'Event bus'}</span><select className="input" value={destinationTarget} onChange={(event) => setDestinationTarget(event.target.value)} required><option value="">Select target</option>{(destinationKind === 'sns' ? topics.data?.map((item) => ({value: item.TopicArn, label: item.TopicArn})) : buses.data?.map((item) => ({value: item.Arn, label: item.Name ?? item.Arn})))?.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</select></label> : <label className="dynamic-field dynamic-field--span"><span>CloudWatch dimension</span><input className="input" value={dimensionName} onChange={(event) => setDimensionName(event.target.value)} required/><small>Events publish counts to AWS/SES using this message-tag dimension.</small></label>}<fieldset className="dynamic-field dynamic-field--span"><span>Events</span><div className="button-row">{EVENT_TYPES.map((type) => <label key={type}><input type="checkbox" checked={events.includes(type)} onChange={() => setEvents((current) => current.includes(type) ? current.filter((item) => item !== type) : [...current, type])}/>{type}</label>)}</div></fieldset><button className="button primary" disabled={!destinationName.trim() || events.length === 0 || (destinationKind !== 'cloudwatch' && !destinationTarget) || addDestination.isPending}>Add destination</button></form>{destinations.data?.map((item) => <div className="apigateway-route-item" key={item.EventDestinationName}><div><code>{item.EventDestinationName}</code><small>{item.MatchingEventTypes?.join(', ')} · {item.SnsDestination?.TopicArn ?? item.EventBridgeDestination?.EventBusArn ?? 'CloudWatch'}</small></div><button className="icon-btn danger" onClick={() => deleteSesEventDestination(selectedConfigurationSet, item.EventDestinationName ?? '').then(() => qc.invalidateQueries({queryKey: ['ses-event-destinations', selectedConfigurationSet]}))}><Trash2 size={13}/></button></div>)}</>} {configurationSets.data?.map((item) => <button className="button danger compact" key={`delete-${item.ConfigurationSetName}`} onClick={() => deleteSesConfigurationSet(item.ConfigurationSetName).then(() => qc.invalidateQueries({queryKey: ['ses-configuration-sets']}))}>Delete {item.ConfigurationSetName}</button>)}</section>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import {
|
||||
Activity,
|
||||
Boxes,
|
||||
Circle,
|
||||
Clock,
|
||||
Container,
|
||||
Database,
|
||||
Earth,
|
||||
Globe,
|
||||
HardDrive,
|
||||
Hammer,
|
||||
KeyRound,
|
||||
@@ -31,6 +34,9 @@ import codeBuildIcon from '../../../../aws-icons-svg/Developer-Tools/CodeBuild.s
|
||||
import codePipelineIcon from '../../../../aws-icons-svg/Developer-Tools/CodePipeline.svg'
|
||||
import codeDeployIcon from '../../../../aws-icons-svg/Developer-Tools/CodeDeploy.svg'
|
||||
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 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'
|
||||
import dynamoDbIcon from '@/assets/aws-services/dynamodb.svg'
|
||||
@@ -87,6 +93,9 @@ const SERVICE_ICONS: Record<string, LucideIcon> = {
|
||||
iam: ShieldCheck,
|
||||
kms: Lock,
|
||||
userpools: UsersRound,
|
||||
dns: Globe,
|
||||
cdn: Earth,
|
||||
monitoring: Activity,
|
||||
loadbalancing: Scale,
|
||||
parameters: SlidersHorizontal,
|
||||
configuration: SlidersHorizontal,
|
||||
@@ -131,6 +140,9 @@ const AWS_SERVICE_ICON_ASSETS: Record<string, string> = {
|
||||
serverless: lambdaIcon,
|
||||
storage: s3Icon,
|
||||
userpools: cognitoIcon,
|
||||
dns: route53Icon,
|
||||
cdn: cloudFrontIcon,
|
||||
monitoring: cloudWatchIcon,
|
||||
workflows: stepFunctionsIcon,
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ export function runtimeClassFor(status: CloudStatus | undefined, loading: boolea
|
||||
export function runtimeDetailFor(cloud: CloudProvider, status?: CloudStatus): string {
|
||||
if (status?.runtime === 'reachable') return 'Connected through Cloud Proxy API'
|
||||
if (status?.runtime === 'unavailable') return 'Start the selected runtime to load resources'
|
||||
if (cloud === 'gcp') return 'Waiting for Floci-GCP runtime status'
|
||||
if (cloud === 'gcp') return 'Waiting for the GCP runtime status'
|
||||
return 'Waiting for runtime status'
|
||||
}
|
||||
|
||||
@@ -52,9 +52,9 @@ export function cloudName(cloud: CloudProvider): string {
|
||||
}
|
||||
|
||||
export function providerDescription(cloud: CloudProvider): string {
|
||||
if (cloud === 'aws') return 'Storage, runtime infrastructure, and AWS-specific pages are backed by Floci AWS Core.'
|
||||
if (cloud === 'azure') return 'Storage is backed by Floci-AZ through the same normalized storage contract.'
|
||||
return 'Storage is backed by Floci-GCP through the same normalized storage contract.'
|
||||
if (cloud === 'aws') return 'Storage, runtime infrastructure, and AWS-specific pages are backed by TheCloud\'s AWS runtime.'
|
||||
if (cloud === 'azure') return 'Storage is backed by TheCloud\'s Azure runtime through the same normalized storage contract.'
|
||||
return 'Storage is backed by TheCloud\'s GCP runtime through the same normalized storage contract.'
|
||||
}
|
||||
|
||||
export function adapterLabel(cloud: CloudProvider, status?: CloudStatus): string {
|
||||
@@ -63,7 +63,7 @@ export function adapterLabel(cloud: CloudProvider, status?: CloudStatus): string
|
||||
}
|
||||
|
||||
export function runtimeName(cloud: CloudProvider): string {
|
||||
if (cloud === 'aws') return 'Floci AWS Core'
|
||||
if (cloud === 'azure') return 'Floci-AZ'
|
||||
return 'Floci-GCP'
|
||||
if (cloud === 'aws') return 'TheCloud AWS runtime'
|
||||
if (cloud === 'azure') return 'TheCloud Azure runtime'
|
||||
return 'TheCloud GCP runtime'
|
||||
}
|
||||
|
||||
@@ -243,13 +243,13 @@ export function EKSPage() {
|
||||
<EmptyState
|
||||
icon={Boxes}
|
||||
title="Cannot load clusters"
|
||||
description="EKS did not respond from the Floci endpoint."
|
||||
description="EKS did not respond from TheCloud's runtime."
|
||||
/>
|
||||
) : clusters.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Boxes}
|
||||
title="No EKS clusters"
|
||||
description="No Kubernetes clusters were returned by Floci."
|
||||
description="No Kubernetes clusters were returned by TheCloud's runtime."
|
||||
/>
|
||||
) : (
|
||||
clusters.map((cluster) => (
|
||||
|
||||
@@ -729,7 +729,7 @@ export function SecretsManagerPage() {
|
||||
<h3>Secrets</h3>
|
||||
</div>
|
||||
{query.isError ? (
|
||||
<EmptyState icon={KeyRound} title="Cannot load secrets" description="Secrets Manager did not respond from the Floci endpoint." />
|
||||
<EmptyState icon={KeyRound} title="Cannot load secrets" description="Secrets Manager did not respond from TheCloud's runtime." />
|
||||
) : query.isLoading ? (
|
||||
<div className="empty"><p>Loading secrets…</p></div>
|
||||
) : secrets.length === 0 ? (
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
--status-error: #ff9a9a;
|
||||
|
||||
--accent: #6cc4ff;
|
||||
--chart-line: #2f9ce0;
|
||||
--accent-tint: rgba(108, 196, 255, 0.16);
|
||||
--accent-tint-faint: rgba(108, 196, 255, 0.07);
|
||||
--accent-tint-border: rgba(108, 196, 255, 0.35);
|
||||
@@ -148,6 +149,7 @@
|
||||
--status-error: #bd3f30;
|
||||
|
||||
--accent: var(--coral);
|
||||
--chart-line: #d9573f;
|
||||
--accent-tint: rgba(237, 111, 92, 0.14);
|
||||
--accent-tint-faint: rgba(237, 111, 92, 0.07);
|
||||
--accent-tint-border: rgba(237, 111, 92, 0.3);
|
||||
@@ -244,6 +246,7 @@
|
||||
--status-error: #ff8da1;
|
||||
|
||||
--accent: #4eff96;
|
||||
--chart-line: #2fae67;
|
||||
--accent-tint: rgba(78, 255, 150, 0.14);
|
||||
--accent-tint-faint: rgba(78, 255, 150, 0.07);
|
||||
--accent-tint-border: rgba(78, 255, 150, 0.34);
|
||||
@@ -2033,6 +2036,10 @@ pre,
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.resource-main > .ses-console {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.resource-main > .drawer-tabs {
|
||||
padding: 0 4px;
|
||||
}
|
||||
@@ -4391,6 +4398,21 @@ pre,
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.cognito-check small { flex-basis: 100%; padding-left: 24px; margin-top: -4px; }
|
||||
.cognito-check:has(small) { flex-wrap: wrap; row-gap: 0; }
|
||||
|
||||
.cognito-flow { display: inline-block; margin: 0 6px 4px 0; }
|
||||
|
||||
.cognito-client-urls {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.cognito-client-urls > div { align-self: end; }
|
||||
|
||||
.delete-impact {
|
||||
color: var(--status-warn);
|
||||
font-size: 13px;
|
||||
@@ -6470,3 +6492,121 @@ button.console-service-card {
|
||||
.lightsail-port-form { align-items: end; margin: 16px 0; }
|
||||
.lightsail-delete-confirm { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 0 18px 18px; padding: 10px; border: 1px solid color-mix(in srgb, var(--status-error, #dc2626) 45%, var(--border)); border-radius: 7px; }
|
||||
@media (max-width: 640px) { .lightsail-instance-header { padding: 14px; flex-direction: column; } .lightsail-instance-controls { width: 100%; justify-content: space-between; } .lightsail-instance-tabs { margin: 0 14px; } .lightsail-overview, .lightsail-tab-content { padding: 14px; } .lightsail-detail-grid { grid-template-columns: 1fr; } .lightsail-delete-confirm { margin: 0 14px 14px; } }
|
||||
|
||||
.dns-name-servers {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
margin: 0;
|
||||
padding: 0 16px 12px;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.dns-name-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dns-name-input .input { flex: 1; min-width: 0; }
|
||||
.dynamic-field .dns-zone-suffix {
|
||||
color: var(--text-3);
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
text-transform: none;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.dns-form-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-self: end;
|
||||
}
|
||||
|
||||
.dns-values { overflow-wrap: anywhere; }
|
||||
.dns-badge { margin-left: 8px; }
|
||||
tr[aria-current="true"] td { background: var(--hover-md); }
|
||||
|
||||
.cdn-actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
|
||||
.cdn-viewer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px 12px;
|
||||
padding: 0 16px 12px;
|
||||
}
|
||||
|
||||
.cdn-viewer .copy-value { overflow-wrap: anywhere; min-width: 0; }
|
||||
.cdn-viewer small { flex-basis: 100%; }
|
||||
|
||||
/* CloudWatch: alarm states carry a dot in the status colour next to their label. */
|
||||
.badge.ok::before, .badge.alarm::before, .badge.insufficient-data::before {
|
||||
content: "";
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.badge.ok::before { background: var(--status-ok); }
|
||||
.badge.alarm::before { background: var(--status-error); }
|
||||
.badge.insufficient-data::before { background: var(--text-3); }
|
||||
.badge.alarm { border: 1px solid var(--status-error); }
|
||||
|
||||
.cloudwatch-state-line { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
||||
.cloudwatch-facts { padding: 0 16px 12px; }
|
||||
.cloudwatch-filters { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px; padding: 0 16px 12px; }
|
||||
.cloudwatch-range { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding: 0 16px 8px; }
|
||||
.cloudwatch-range .input { width: auto; }
|
||||
.cloudwatch-chart { padding: 4px 16px 12px; }
|
||||
.cloudwatch-chart-head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.cloudwatch-chart-head .cloudwatch-range { padding: 0; }
|
||||
.cloudwatch-chart-title { margin: 0 0 6px; font-weight: 600; overflow-wrap: anywhere; }
|
||||
.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; }
|
||||
.metric-chart--empty { padding: 24px 0; }
|
||||
.metric-chart-grid { stroke: var(--border-faint); stroke-width: 1; }
|
||||
.metric-chart-axis { fill: var(--text-3); font-size: 11px; }
|
||||
.metric-chart-line { fill: none; stroke: var(--chart-line); stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
|
||||
.metric-chart-marker { fill: var(--chart-line); stroke: var(--surface); stroke-width: 2; }
|
||||
.metric-chart-marker--active { stroke-width: 2.5; }
|
||||
.metric-chart-threshold { stroke: var(--text-3); stroke-width: 1.5; stroke-dasharray: 5 4; }
|
||||
.metric-chart-crosshair { stroke: var(--text-3); stroke-width: 1; }
|
||||
.metric-chart-label { fill: var(--text-3); font-size: 11px; }
|
||||
.metric-chart-label--strong { fill: var(--text-bright, var(--text)); font-weight: 600; }
|
||||
.metric-chart-tooltip {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--elevated);
|
||||
font-size: 12px;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.metric-chart-tooltip strong { font-size: 14px; color: var(--text-bright, var(--text)); }
|
||||
.metric-chart-key { display: inline-block; width: 12px; height: 2px; margin: 0 6px 3px 0; background: var(--chart-line); vertical-align: middle; }
|
||||
.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; }
|
||||
|
||||
@@ -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)'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import {expect, test} from 'vitest'
|
||||
import {formatValue, niceTicks} from './metricChart'
|
||||
|
||||
test('picks about five round ticks that include zero', () => {
|
||||
expect(niceTicks(0, 7)).toEqual([0, 2, 4, 6, 8])
|
||||
expect(niceTicks(0, 100)).toEqual([0, 25, 50, 75, 100])
|
||||
expect(niceTicks(-3, 3)).toEqual([-4, -2, 0, 2, 4])
|
||||
})
|
||||
|
||||
test('formats values compactly from a thousand up', () => {
|
||||
expect([formatValue(7), formatValue(0.25), formatValue(1500), formatValue(2_000_000), formatValue(12.5)]).toEqual(['7', '0.25', '1.5K', '2M', '12.5'])
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
/** Time ranges offered above CloudWatch charts. */
|
||||
export const RANGES = [{label: '1 hour', minutes: 60}, {label: '3 hours', minutes: 180}, {label: '24 hours', minutes: 1440}, {label: '7 days', minutes: 10080}]
|
||||
|
||||
/** About five round ticks covering [min, max], always including zero when the range does. */
|
||||
export function niceTicks(min: number, max: number, count = 5): number[] {
|
||||
const span = max - min
|
||||
const raw = span / (count - 1)
|
||||
const magnitude = 10 ** Math.floor(Math.log10(raw))
|
||||
const step = [1, 2, 2.5, 5, 10].map((factor) => factor * magnitude).find((candidate) => candidate >= raw) ?? 10 * magnitude
|
||||
const start = Math.floor(min / step) * step
|
||||
const end = Math.ceil(max / step) * step
|
||||
const ticks: number[] = []
|
||||
for (let value = start; value <= end + step / 2; value += step) ticks.push(Number(value.toPrecision(12)))
|
||||
return ticks
|
||||
}
|
||||
|
||||
export function timeTicks(start: number, end: number, count: number): number[] {
|
||||
if (end <= start) return [start]
|
||||
const step = (end - start) / count
|
||||
return Array.from({length: count + 1}, (_, index) => start + index * step)
|
||||
}
|
||||
|
||||
export function formatValue(value: number): string {
|
||||
const abs = Math.abs(value)
|
||||
if (abs >= 1e9) return `${trim(value / 1e9)}B`
|
||||
if (abs >= 1e6) return `${trim(value / 1e6)}M`
|
||||
if (abs >= 1e3) return `${trim(value / 1e3)}K`
|
||||
return trim(value)
|
||||
}
|
||||
|
||||
function trim(value: number): string {
|
||||
return Number.isInteger(value) ? String(value) : value.toFixed(Math.abs(value) < 10 ? 2 : 1).replace(/\.?0+$/, '')
|
||||
}
|
||||
|
||||
@@ -302,6 +302,64 @@ export interface UserPoolSignInTestInput {
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface MetricDimension {
|
||||
name: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface MetricSummary {
|
||||
namespace: string
|
||||
metricName: string
|
||||
dimensions: MetricDimension[]
|
||||
}
|
||||
|
||||
export interface MetricSeriesQuery {
|
||||
namespace: string
|
||||
metricName: string
|
||||
dimensions?: MetricDimension[]
|
||||
statistic?: string
|
||||
periodSeconds?: number
|
||||
rangeMinutes?: number
|
||||
}
|
||||
|
||||
export interface MetricSeries {
|
||||
label: string
|
||||
statistic: string
|
||||
periodSeconds: number
|
||||
points: Array<{timestamp: string; value: number}>
|
||||
}
|
||||
|
||||
export type AlarmStateValue = 'OK' | 'ALARM' | 'INSUFFICIENT_DATA'
|
||||
|
||||
export interface AlarmHistoryItem {
|
||||
timestamp: string | null
|
||||
type: string
|
||||
summary: string
|
||||
}
|
||||
|
||||
export interface DistributionInvalidation {
|
||||
id: string
|
||||
status: string
|
||||
createdAt: string | null
|
||||
paths: string[]
|
||||
}
|
||||
|
||||
export type UserPoolOAuthFlow = 'code' | 'implicit' | 'client_credentials'
|
||||
|
||||
export interface UserPoolClientSettings {
|
||||
clientId: string
|
||||
clientName: string
|
||||
hasSecret: boolean
|
||||
explicitAuthFlows: string[]
|
||||
callbackUrls: string[]
|
||||
logoutUrls: string[]
|
||||
oauthFlows: UserPoolOAuthFlow[]
|
||||
oauthScopes: string[]
|
||||
identityProviders: string[]
|
||||
}
|
||||
|
||||
export type UpdateUserPoolClientOAuthInput = Pick<UserPoolClientSettings, 'callbackUrls' | 'logoutUrls' | 'oauthFlows' | 'oauthScopes' | 'identityProviders'>
|
||||
|
||||
export type UserPoolSignInTestResult =
|
||||
| {outcome: 'signed-in'; expiresIn: number | null; idToken: Record<string, unknown>; accessToken: Record<string, unknown>}
|
||||
| {outcome: 'challenge'; challengeName: string}
|
||||
|
||||
@@ -27,7 +27,9 @@ export type KubernetesActionName =
|
||||
| 'createFargateProfile'
|
||||
| 'deleteFargateProfile'
|
||||
export type ItemActionName = 'list' | 'get' | 'put' | 'delete' | 'query'
|
||||
export type UserPoolActionName = 'updateUser' | 'setUserGroups' | 'listGroups' | 'createGroup' | 'deleteGroup' | 'testSignIn'
|
||||
export type DistributionActionName = 'setEnabled' | 'createInvalidation'
|
||||
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'
|
||||
|
||||
export interface CapabilitySchema<TAction extends string> {
|
||||
@@ -88,6 +90,8 @@ export interface ServiceSchema {
|
||||
kubernetesActions?: Array<CapabilitySchema<KubernetesActionName> | KubernetesActionName>
|
||||
itemActions?: Array<CapabilitySchema<ItemActionName> | ItemActionName>
|
||||
userPoolActions?: Array<CapabilitySchema<UserPoolActionName>>
|
||||
distributionActions?: Array<CapabilitySchema<DistributionActionName>>
|
||||
monitoringActions?: Array<CapabilitySchema<MonitoringActionName>>
|
||||
}
|
||||
filters: FieldSchema[]
|
||||
columns: TableColumnSchema[]
|
||||
|
||||
Generated
+119
-54
@@ -13,12 +13,18 @@ importers:
|
||||
'@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-cloudformation':
|
||||
specifier: ^3.1111.0
|
||||
version: 3.1111.0
|
||||
'@aws-sdk/client-cloudfront':
|
||||
specifier: ^3.1146.0
|
||||
version: 3.1156.0
|
||||
'@aws-sdk/client-cloudwatch':
|
||||
specifier: ^3.1076.0
|
||||
version: 3.1146.0
|
||||
@@ -88,6 +94,9 @@ importers:
|
||||
'@aws-sdk/client-rds-data':
|
||||
specifier: ^3.1076.0
|
||||
version: 3.1144.0
|
||||
'@aws-sdk/client-route-53':
|
||||
specifier: ^3.1146.0
|
||||
version: 3.1156.0
|
||||
'@aws-sdk/client-s3':
|
||||
specifier: ^3.1076.0
|
||||
version: 3.1076.0
|
||||
@@ -293,6 +302,10 @@ packages:
|
||||
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'}
|
||||
@@ -301,6 +314,10 @@ packages:
|
||||
resolution: {integrity: sha512-tZHW6n0vbA1fiJZtxCWVFWUssP47es/EAu7kH2FId6nY/svNoQVHYTqA7ALhLqB+W6ASzNTpnJeCq8evoXlKBQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/client-cloudfront@3.1156.0':
|
||||
resolution: {integrity: sha512-z4V/ecv6b5uGwVVMaE8NbczaPVfM2HwbZAGUFp9XDjm+Q8WoFrF0vRlWaRWpW+rox4yPZc4tSo+cv8drud+r8A==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/client-cloudwatch-logs@3.1131.0':
|
||||
resolution: {integrity: sha512-FmV57mQjXtze2y/TCepl0E+4vvHqXCkiAMn24E5ogYC/xEGAYDPupTkzCQdSUmbyW6/tmPtmcOD+6LbUP1AKog==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@@ -393,6 +410,10 @@ packages:
|
||||
resolution: {integrity: sha512-GqkUvUd1KFltgqNJ1yibN4HvDoMevRNmC4CU+pm+bAVIUusj9Lk3D22JYeIXsO5yVInpWQMx5F8l810UnSZXKQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/client-route-53@3.1156.0':
|
||||
resolution: {integrity: sha512-1IwDSboTwUovZhuyDBhjAgPxFeIdy5+XCmZNKYFdg0XJax27qx4Ir7/m5CA60pdAHWBTMpUKm0jtyNEOlZnsuA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/client-s3@3.1076.0':
|
||||
resolution: {integrity: sha512-gqI0asnN73cm5W71rixc7NYvOr3+/tuyzMhG2hW+4aC5q5XcHP0JAeF/Dpac6vEsQhJtmlhXJ0oDCsXrFFnvUQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@@ -708,6 +729,10 @@ packages:
|
||||
resolution: {integrity: sha512-37S4hocQzAoCDKVPk+uFRZZlIivnDNAOkObDp59tjGau60amEJVSh8SQnSGxakRDwoMpDCThIDI2+XFFrKD+tA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/middleware-sdk-route53@3.972.28':
|
||||
resolution: {integrity: sha512-93l3lB5ngw0XQDGvuK/R9R3UAN4+xeYgLOW8E6nzldlNhu0E8vfkYMTlh5km643i9F193b5ZO21wOWWT47RxHw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/middleware-sdk-s3@3.972.59':
|
||||
resolution: {integrity: sha512-KCf/UjbnzV3QIXR1C2MeE9eRUG8EUXUf0V4y65hf2bKWQXzol5f3I8wOhE6OBdEYohn8zHfnC/cyy5+s7/HwLw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@@ -2883,6 +2908,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
|
||||
@@ -2905,6 +2941,17 @@ snapshots:
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/client-cloudfront@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-cloudwatch-logs@3.1131.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.0
|
||||
@@ -3174,6 +3221,18 @@ snapshots:
|
||||
'@smithy/types': 4.15.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/client-route-53@3.1156.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/credential-provider-node': 3.972.88
|
||||
'@aws-sdk/middleware-sdk-route53': 3.972.28
|
||||
'@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-s3@3.1076.0':
|
||||
dependencies:
|
||||
'@aws-crypto/sha1-browser': 5.2.0
|
||||
@@ -3578,55 +3637,55 @@ snapshots:
|
||||
|
||||
'@aws-sdk/credential-provider-login@3.972.60':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/nested-clients': 3.997.45
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-login@3.972.69':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/nested-clients': 3.997.36
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-login@3.972.76':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/nested-clients': 3.997.45
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-login@3.972.77':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/nested-clients': 3.997.45
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-login@3.972.78':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/nested-clients': 3.997.45
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-login@3.972.79':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/nested-clients': 3.997.46
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
@@ -3990,6 +4049,12 @@ snapshots:
|
||||
'@smithy/types': 4.17.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/middleware-sdk-route53@3.972.28':
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/middleware-sdk-s3@3.972.59':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.0
|
||||
@@ -4008,10 +4073,10 @@ snapshots:
|
||||
|
||||
'@aws-sdk/nested-clients@3.997.28':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/signature-v4-multi-region': 3.996.47
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@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
|
||||
@@ -4019,10 +4084,10 @@ snapshots:
|
||||
|
||||
'@aws-sdk/nested-clients@3.997.36':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/signature-v4-multi-region': 3.996.47
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@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
|
||||
@@ -4030,10 +4095,10 @@ snapshots:
|
||||
|
||||
'@aws-sdk/nested-clients@3.997.43':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/signature-v4-multi-region': 3.996.47
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@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
|
||||
@@ -4041,10 +4106,10 @@ snapshots:
|
||||
|
||||
'@aws-sdk/nested-clients@3.997.44':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/signature-v4-multi-region': 3.996.47
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@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
|
||||
@@ -4052,10 +4117,10 @@ snapshots:
|
||||
|
||||
'@aws-sdk/nested-clients@3.997.45':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/signature-v4-multi-region': 3.996.47
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@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
|
||||
@@ -4063,10 +4128,10 @@ snapshots:
|
||||
|
||||
'@aws-sdk/nested-clients@3.997.46':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/signature-v4-multi-region': 3.996.47
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@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
|
||||
@@ -4113,55 +4178,55 @@ snapshots:
|
||||
|
||||
'@aws-sdk/token-providers@3.1080.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/nested-clients': 3.997.45
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/token-providers@3.1096.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/nested-clients': 3.997.36
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/token-providers@3.1111.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/nested-clients': 3.997.45
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/token-providers@3.1116.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/nested-clients': 3.997.45
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/token-providers@3.1129.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/nested-clients': 3.997.45
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/token-providers@3.1138.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/nested-clients': 3.997.46
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
|
||||
Reference in New Issue
Block a user