feat: add SQS + EventBridge
This commit is contained in:
@@ -3,6 +3,7 @@ import {
|
|||||||
CreateDeploymentCommand,
|
CreateDeploymentCommand,
|
||||||
CreateResourceCommand,
|
CreateResourceCommand,
|
||||||
CreateRestApiCommand,
|
CreateRestApiCommand,
|
||||||
|
DeleteMethodCommand,
|
||||||
DeleteRestApiCommand,
|
DeleteRestApiCommand,
|
||||||
GetRestApiCommand,
|
GetRestApiCommand,
|
||||||
GetRestApisCommand,
|
GetRestApisCommand,
|
||||||
@@ -13,7 +14,7 @@ import {
|
|||||||
PutMethodCommand,
|
PutMethodCommand,
|
||||||
type APIGatewayClient,
|
type APIGatewayClient,
|
||||||
} from '@aws-sdk/client-api-gateway'
|
} from '@aws-sdk/client-api-gateway'
|
||||||
import {AddPermissionCommand, GetFunctionCommand, type LambdaClient} from '@aws-sdk/client-lambda'
|
import {AddPermissionCommand, GetFunctionCommand, RemovePermissionCommand, type LambdaClient} from '@aws-sdk/client-lambda'
|
||||||
import {AwsApiGatewayAdapter} from './AwsApiGatewayAdapter'
|
import {AwsApiGatewayAdapter} from './AwsApiGatewayAdapter'
|
||||||
|
|
||||||
function fakeClient(handler: (command: unknown) => unknown): APIGatewayClient {
|
function fakeClient(handler: (command: unknown) => unknown): APIGatewayClient {
|
||||||
@@ -155,16 +156,16 @@ describe('AwsApiGatewayAdapter', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
expect(apiCommands.map((command) => (command as {constructor: {name: string}}).constructor.name)).toEqual([
|
expect(apiCommands.map((command) => (command as {constructor: {name: string}}).constructor.name)).toEqual([
|
||||||
'GetResourcesCommand', 'CreateResourceCommand', 'PutMethodCommand',
|
'GetResourcesCommand', 'GetResourcesCommand', 'CreateResourceCommand', 'PutMethodCommand',
|
||||||
'PutIntegrationCommand', 'CreateDeploymentCommand',
|
'PutIntegrationCommand', 'CreateDeploymentCommand',
|
||||||
])
|
])
|
||||||
expect((apiCommands[1] as CreateResourceCommand).input).toMatchObject({
|
expect((apiCommands[2] as CreateResourceCommand).input).toMatchObject({
|
||||||
restApiId: 'a1b2c3', parentId: 'root123', pathPart: 'hello',
|
restApiId: 'a1b2c3', parentId: 'root123', pathPart: 'hello',
|
||||||
})
|
})
|
||||||
expect((apiCommands[2] as PutMethodCommand).input).toMatchObject({
|
expect((apiCommands[3] as PutMethodCommand).input).toMatchObject({
|
||||||
httpMethod: 'GET', authorizationType: 'NONE',
|
httpMethod: 'GET', authorizationType: 'NONE',
|
||||||
})
|
})
|
||||||
expect((apiCommands[3] as PutIntegrationCommand).input).toMatchObject({
|
expect((apiCommands[4] as PutIntegrationCommand).input).toMatchObject({
|
||||||
type: 'AWS_PROXY', integrationHttpMethod: 'POST',
|
type: 'AWS_PROXY', integrationHttpMethod: 'POST',
|
||||||
uri: 'arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:000000000000:function:hello/invocations',
|
uri: 'arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:000000000000:function:hello/invocations',
|
||||||
})
|
})
|
||||||
@@ -178,6 +179,34 @@ describe('AwsApiGatewayAdapter', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('adds a method to an existing resource without creating a duplicate path', async () => {
|
||||||
|
const apiCommands: unknown[] = []
|
||||||
|
const adapter = new AwsApiGatewayAdapter(
|
||||||
|
fakeClient((command) => {
|
||||||
|
apiCommands.push(command)
|
||||||
|
if (command instanceof GetResourcesCommand) {
|
||||||
|
return {items: [{id: 'resource456', path: '/hello'}, {id: 'root123', path: '/'}]}
|
||||||
|
}
|
||||||
|
if (command instanceof CreateDeploymentCommand) return {id: 'deployment789'}
|
||||||
|
return {}
|
||||||
|
}),
|
||||||
|
fakeLambda((command) => command instanceof GetFunctionCommand
|
||||||
|
? {Configuration: {FunctionArn: 'arn:aws:lambda:us-east-1:000000000000:function:hello'}}
|
||||||
|
: {}),
|
||||||
|
'000000000000',
|
||||||
|
'us-east-1',
|
||||||
|
)
|
||||||
|
|
||||||
|
await adapter.createApiGatewayLambdaRoute('a1b2c3', {
|
||||||
|
path: '/hello', method: 'POST', stage: 'dev', functionName: 'hello',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(apiCommands.some((command) => command instanceof CreateResourceCommand)).toBeFalse()
|
||||||
|
expect((apiCommands.find((command) => command instanceof PutMethodCommand) as PutMethodCommand).input).toMatchObject({
|
||||||
|
resourceId: 'resource456', httpMethod: 'POST',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test('tests a deployed route and preserves its HTTP response', async () => {
|
test('tests a deployed route and preserves its HTTP response', async () => {
|
||||||
const originalFetch = globalThis.fetch
|
const originalFetch = globalThis.fetch
|
||||||
const requests: Request[] = []
|
const requests: Request[] = []
|
||||||
@@ -258,6 +287,27 @@ describe('AwsApiGatewayAdapter', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('deletes one method and redeploys its stage', async () => {
|
||||||
|
const commands: unknown[] = []
|
||||||
|
const lambdaCommands: unknown[] = []
|
||||||
|
const adapter = new AwsApiGatewayAdapter(fakeClient((command) => {
|
||||||
|
commands.push(command)
|
||||||
|
if (command instanceof GetMethodCommand) {
|
||||||
|
return {methodIntegration: {uri: 'arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:000000000000:function:hello/invocations'}}
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
}), fakeLambda((command) => {
|
||||||
|
lambdaCommands.push(command)
|
||||||
|
return {}
|
||||||
|
}))
|
||||||
|
|
||||||
|
await adapter.deleteApiGatewayRoute('a1b2c3', {resourceId: 'resource456', path: '/hello', method: 'POST', stage: 'dev'})
|
||||||
|
|
||||||
|
expect((commands[1] as DeleteMethodCommand).input).toMatchObject({restApiId: 'a1b2c3', resourceId: 'resource456', httpMethod: 'POST'})
|
||||||
|
expect((commands[2] as CreateDeploymentCommand).input).toMatchObject({restApiId: 'a1b2c3', stageName: 'dev'})
|
||||||
|
expect((lambdaCommands[0] as RemovePermissionCommand).input).toMatchObject({FunctionName: 'hello', StatementId: 'apigateway-a1b2c3-dev-POST-hello'})
|
||||||
|
})
|
||||||
|
|
||||||
test('returns the AWS API Gateway schema', () => {
|
test('returns the AWS API Gateway schema', () => {
|
||||||
const adapter = new AwsApiGatewayAdapter(fakeClient(() => ({})))
|
const adapter = new AwsApiGatewayAdapter(fakeClient(() => ({})))
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
CreateDeploymentCommand,
|
CreateDeploymentCommand,
|
||||||
CreateResourceCommand,
|
CreateResourceCommand,
|
||||||
CreateRestApiCommand,
|
CreateRestApiCommand,
|
||||||
|
DeleteMethodCommand,
|
||||||
DeleteRestApiCommand,
|
DeleteRestApiCommand,
|
||||||
GetRestApiCommand,
|
GetRestApiCommand,
|
||||||
GetRestApisCommand,
|
GetRestApisCommand,
|
||||||
@@ -13,12 +14,12 @@ import {
|
|||||||
type APIGatewayClient,
|
type APIGatewayClient,
|
||||||
type RestApi,
|
type RestApi,
|
||||||
} from '@aws-sdk/client-api-gateway'
|
} from '@aws-sdk/client-api-gateway'
|
||||||
import {AddPermissionCommand, GetFunctionCommand, type LambdaClient} from '@aws-sdk/client-lambda'
|
import {AddPermissionCommand, GetFunctionCommand, RemovePermissionCommand, type LambdaClient} from '@aws-sdk/client-lambda'
|
||||||
import {apiGateway as defaultApiGateway, defaultAccountId, lambda as defaultLambda} from '../aws'
|
import {apiGateway as defaultApiGateway, defaultAccountId, lambda as defaultLambda} from '../aws'
|
||||||
import {awsApiGatewaySchema} from '../cloud-spi/apigatewaySchema'
|
import {awsApiGatewaySchema} from '../cloud-spi/apigatewaySchema'
|
||||||
import {ValidationError} from '../cloud-spi/errors'
|
import {ValidationError} from '../cloud-spi/errors'
|
||||||
import {awsEndpoint} from '../service/runtimeProbe'
|
import {awsEndpoint} from '../service/runtimeProbe'
|
||||||
import type {ApiGatewayLambdaRouteInput, ApiGatewayLambdaRouteResult, ApiGatewayRouteOverview, ApiGatewayRoutesResult, ApiGatewayTestRouteInput, ApiGatewayTestRouteResult, CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types'
|
import type {ApiGatewayDeleteRouteInput, ApiGatewayLambdaRouteInput, ApiGatewayLambdaRouteResult, ApiGatewayRouteOverview, ApiGatewayRoutesResult, ApiGatewayTestRouteInput, ApiGatewayTestRouteResult, CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types'
|
||||||
|
|
||||||
export class AwsApiGatewayAdapter implements CloudServiceAdapter {
|
export class AwsApiGatewayAdapter implements CloudServiceAdapter {
|
||||||
readonly cloud = 'aws' as const
|
readonly cloud = 'aws' as const
|
||||||
@@ -84,12 +85,7 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter {
|
|||||||
throw new ValidationError('method must be GET, POST, PUT, or DELETE')
|
throw new ValidationError('method must be GET, POST, PUT, or DELETE')
|
||||||
}
|
}
|
||||||
|
|
||||||
const root = await this.rootResource(apiId)
|
const resource = await this.resourceForPath(apiId, `/${pathPart}`) ?? await this.createTopLevelResource(apiId, pathPart)
|
||||||
const resource = await this.apiGateway.send(new CreateResourceCommand({
|
|
||||||
restApiId: apiId,
|
|
||||||
parentId: root.id,
|
|
||||||
pathPart,
|
|
||||||
}))
|
|
||||||
const resourceId = resource.id
|
const resourceId = resource.id
|
||||||
if (!resourceId) throw new ValidationError('API Gateway did not return a resource id')
|
if (!resourceId) throw new ValidationError('API Gateway did not return a resource id')
|
||||||
|
|
||||||
@@ -112,13 +108,23 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter {
|
|||||||
uri: `arn:aws:apigateway:${this.region}:lambda:path/2015-03-31/functions/${functionArn}/invocations`,
|
uri: `arn:aws:apigateway:${this.region}:lambda:path/2015-03-31/functions/${functionArn}/invocations`,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
await this.lambda.send(new AddPermissionCommand({
|
const permission = {
|
||||||
FunctionName: functionName,
|
FunctionName: functionName,
|
||||||
StatementId: `apigateway-${apiId}-${stage}-${method}-${pathPart}`,
|
StatementId: apiGatewayPermissionStatementId(apiId, stage, method, `/${pathPart}`),
|
||||||
Action: 'lambda:InvokeFunction',
|
Action: 'lambda:InvokeFunction',
|
||||||
Principal: 'apigateway.amazonaws.com',
|
Principal: 'apigateway.amazonaws.com',
|
||||||
SourceArn: `arn:aws:execute-api:${this.region}:${this.accountId}:${apiId}/${stage}/${method}/${pathPart}`,
|
SourceArn: `arn:aws:execute-api:${this.region}:${this.accountId}:${apiId}/${stage}/${method}/${pathPart}`,
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await this.lambda.send(new AddPermissionCommand(permission))
|
||||||
|
} catch (error) {
|
||||||
|
if (!hasHttpStatus(error, 409)) throw error
|
||||||
|
await this.lambda.send(new RemovePermissionCommand({
|
||||||
|
FunctionName: functionName,
|
||||||
|
StatementId: permission.StatementId,
|
||||||
}))
|
}))
|
||||||
|
await this.lambda.send(new AddPermissionCommand(permission))
|
||||||
|
}
|
||||||
|
|
||||||
const deployment = await this.apiGateway.send(new CreateDeploymentCommand({
|
const deployment = await this.apiGateway.send(new CreateDeploymentCommand({
|
||||||
restApiId: apiId,
|
restApiId: apiId,
|
||||||
@@ -205,6 +211,40 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async deleteApiGatewayRoute(apiId: string, input: ApiGatewayDeleteRouteInput): Promise<void> {
|
||||||
|
const method = input.method.toUpperCase()
|
||||||
|
const stage = stageName(input.stage)
|
||||||
|
if (!['GET', 'POST', 'PUT', 'DELETE'].includes(method)) throw new ValidationError('method must be GET, POST, PUT, or DELETE')
|
||||||
|
if (!stringValue(input.resourceId)) throw new ValidationError('resourceId is required')
|
||||||
|
|
||||||
|
const route = await this.apiGateway.send(new GetMethodCommand({
|
||||||
|
restApiId: apiId,
|
||||||
|
resourceId: input.resourceId,
|
||||||
|
httpMethod: method,
|
||||||
|
}))
|
||||||
|
await this.apiGateway.send(new DeleteMethodCommand({
|
||||||
|
restApiId: apiId,
|
||||||
|
resourceId: input.resourceId,
|
||||||
|
httpMethod: method,
|
||||||
|
}))
|
||||||
|
const functionName = lambdaFunctionName(route.methodIntegration?.uri)
|
||||||
|
if (functionName) {
|
||||||
|
try {
|
||||||
|
await this.lambda.send(new RemovePermissionCommand({
|
||||||
|
FunctionName: functionName,
|
||||||
|
StatementId: apiGatewayPermissionStatementId(apiId, stage, method, input.path),
|
||||||
|
}))
|
||||||
|
} catch (error) {
|
||||||
|
if (!hasHttpStatus(error, 404)) throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await this.apiGateway.send(new CreateDeploymentCommand({
|
||||||
|
restApiId: apiId,
|
||||||
|
stageName: stage,
|
||||||
|
description: `Delete ${method} method`,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
private async rootResource(apiId: string) {
|
private async rootResource(apiId: string) {
|
||||||
let position: string | undefined
|
let position: string | undefined
|
||||||
do {
|
do {
|
||||||
@@ -215,6 +255,26 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter {
|
|||||||
} while (position)
|
} while (position)
|
||||||
throw new ValidationError('API Gateway root resource was not found')
|
throw new ValidationError('API Gateway root resource was not found')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async resourceForPath(apiId: string, path: string) {
|
||||||
|
let position: string | undefined
|
||||||
|
do {
|
||||||
|
const result = await this.apiGateway.send(new GetResourcesCommand({restApiId: apiId, position}))
|
||||||
|
const resource = result.items?.find((item) => item.path === path)
|
||||||
|
if (resource?.id) return resource
|
||||||
|
position = result.position
|
||||||
|
} while (position)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createTopLevelResource(apiId: string, pathPart: string) {
|
||||||
|
const root = await this.rootResource(apiId)
|
||||||
|
return this.apiGateway.send(new CreateResourceCommand({
|
||||||
|
restApiId: apiId,
|
||||||
|
parentId: root.id,
|
||||||
|
pathPart,
|
||||||
|
}))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function routePathPart(path: string): string {
|
function routePathPart(path: string): string {
|
||||||
@@ -252,6 +312,10 @@ function lambdaFunctionName(uri?: string): string | null {
|
|||||||
return match?.[1] ?? null
|
return match?.[1] ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function apiGatewayPermissionStatementId(apiId: string, stage: string, method: string, path: string): string {
|
||||||
|
return `apigateway-${apiId}-${stage}-${method}-${routePathPart(path)}`
|
||||||
|
}
|
||||||
|
|
||||||
function toResource(api: RestApi): CloudResource {
|
function toResource(api: RestApi): CloudResource {
|
||||||
const id = api.id ?? ''
|
const id = api.id ?? ''
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,12 +1,28 @@
|
|||||||
import {describe, expect, test} from 'bun:test'
|
import {describe, expect, test} from 'bun:test'
|
||||||
import {
|
import {
|
||||||
|
AddPermissionCommand,
|
||||||
CreateFunctionCommand,
|
CreateFunctionCommand,
|
||||||
|
CreateEventSourceMappingCommand,
|
||||||
DeleteFunctionCommand,
|
DeleteFunctionCommand,
|
||||||
|
DeleteEventSourceMappingCommand,
|
||||||
GetFunctionCommand,
|
GetFunctionCommand,
|
||||||
InvokeCommand,
|
InvokeCommand,
|
||||||
type LambdaClient,
|
type LambdaClient,
|
||||||
ListFunctionsCommand,
|
ListFunctionsCommand,
|
||||||
|
ListEventSourceMappingsCommand,
|
||||||
|
RemovePermissionCommand,
|
||||||
|
UpdateFunctionCodeCommand,
|
||||||
|
UpdateFunctionConfigurationCommand,
|
||||||
} from '@aws-sdk/client-lambda'
|
} from '@aws-sdk/client-lambda'
|
||||||
|
import {
|
||||||
|
DeleteRuleCommand,
|
||||||
|
ListRulesCommand,
|
||||||
|
ListTargetsByRuleCommand,
|
||||||
|
PutRuleCommand,
|
||||||
|
PutTargetsCommand,
|
||||||
|
RemoveTargetsCommand,
|
||||||
|
type EventBridgeClient,
|
||||||
|
} from '@aws-sdk/client-eventbridge'
|
||||||
import {AwsServerlessAdapter} from './AwsServerlessAdapter'
|
import {AwsServerlessAdapter} from './AwsServerlessAdapter'
|
||||||
import {ValidationError} from '../cloud-spi/errors'
|
import {ValidationError} from '../cloud-spi/errors'
|
||||||
|
|
||||||
@@ -24,6 +40,17 @@ function stubLambda(handler: (command: object) => SendResult | Promise<SendResul
|
|||||||
return {client, sent}
|
return {client, sent}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stubEventBridge(handler: (command: object) => SendResult | Promise<SendResult>) {
|
||||||
|
const sent: object[] = []
|
||||||
|
const client = {
|
||||||
|
async send(command: object) {
|
||||||
|
sent.push(command)
|
||||||
|
return handler(command)
|
||||||
|
},
|
||||||
|
} as unknown as EventBridgeClient
|
||||||
|
return {client, sent}
|
||||||
|
}
|
||||||
|
|
||||||
const listPayload = {
|
const listPayload = {
|
||||||
Functions: [
|
Functions: [
|
||||||
{
|
{
|
||||||
@@ -214,6 +241,28 @@ describe('AwsServerlessAdapter', () => {
|
|||||||
expect((sent[0] as DeleteFunctionCommand).input.FunctionName).toBe('hello')
|
expect((sent[0] as DeleteFunctionCommand).input.FunctionName).toBe('hello')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('updates inline source and Lambda configuration', async () => {
|
||||||
|
const {client, sent} = stubLambda((command) => {
|
||||||
|
if (command instanceof GetFunctionCommand) {
|
||||||
|
return {Configuration: {FunctionName: 'hello', Runtime: 'nodejs22.x', Handler: 'index.handler'}}
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
})
|
||||||
|
const adapter = new AwsServerlessAdapter(client)
|
||||||
|
|
||||||
|
await adapter.update('hello', {values: {
|
||||||
|
code: 'exports.handler = async () => ({statusCode: 201, body: "created"})',
|
||||||
|
handler: 'index.handler', runtime: 'nodejs22.x', memorySize: '256', timeout: '10',
|
||||||
|
environment: JSON.stringify([{key: 'APP_GREETING', value: 'Hello from Floci'}]),
|
||||||
|
}})
|
||||||
|
|
||||||
|
expect(sent[1]).toBeInstanceOf(UpdateFunctionCodeCommand)
|
||||||
|
expect((sent[2] as UpdateFunctionConfigurationCommand).input).toMatchObject({
|
||||||
|
FunctionName: 'hello', Runtime: 'nodejs22.x', Handler: 'index.handler', MemorySize: 256, Timeout: 10,
|
||||||
|
Environment: {Variables: {APP_GREETING: 'Hello from Floci'}},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('invoke', () => {
|
describe('invoke', () => {
|
||||||
test('returns the decoded payload, status and duration', async () => {
|
test('returns the decoded payload, status and duration', async () => {
|
||||||
const {client, sent} = stubLambda(() => ({
|
const {client, sent} = stubLambda(() => ({
|
||||||
@@ -272,4 +321,133 @@ describe('AwsServerlessAdapter', () => {
|
|||||||
expect(result.payload).toBe('')
|
expect(result.payload).toBe('')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('EventBridge scheduled triggers', () => {
|
||||||
|
test('creates a scheduled rule, Lambda target, and invocation permission', async () => {
|
||||||
|
const {client: lambda, sent: lambdaSent} = stubLambda((command) => {
|
||||||
|
if (command instanceof GetFunctionCommand) {
|
||||||
|
return {Configuration: {FunctionArn: 'arn:aws:lambda:us-east-1:000000000000:function:hello'}}
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
})
|
||||||
|
const {client: eventBridge, sent: eventBridgeSent} = stubEventBridge((command) => {
|
||||||
|
if (command instanceof PutRuleCommand) return {RuleArn: 'arn:aws:events:us-east-1:000000000000:rule/hourly-hello'}
|
||||||
|
return {}
|
||||||
|
})
|
||||||
|
|
||||||
|
const trigger = await new AwsServerlessAdapter(lambda, eventBridge).createEventBridgeLambdaTrigger({
|
||||||
|
ruleName: 'hourly-hello', scheduleExpression: 'rate(1 hour)', functionName: 'hello',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect((eventBridgeSent[0] as PutRuleCommand).input).toMatchObject({Name: 'hourly-hello', ScheduleExpression: 'rate(1 hour)', State: 'ENABLED'})
|
||||||
|
expect((eventBridgeSent[1] as PutTargetsCommand).input).toMatchObject({
|
||||||
|
Rule: 'hourly-hello',
|
||||||
|
Targets: [{Id: 'floci-ui-eventbridge-hello', Arn: 'arn:aws:lambda:us-east-1:000000000000:function:hello'}],
|
||||||
|
})
|
||||||
|
expect((lambdaSent[1] as AddPermissionCommand).input).toMatchObject({
|
||||||
|
FunctionName: 'hello', StatementId: 'floci-ui-eventbridge-hourly-hello',
|
||||||
|
Principal: 'events.amazonaws.com', SourceArn: 'arn:aws:events:us-east-1:000000000000:rule/hourly-hello',
|
||||||
|
})
|
||||||
|
expect(trigger.scheduleExpression).toBe('rate(1 hour)')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('lists scheduled rules targeting the requested Lambda', async () => {
|
||||||
|
const {client: lambda} = stubLambda(() => ({}))
|
||||||
|
const {client: eventBridge, sent} = stubEventBridge((command) => {
|
||||||
|
if (command instanceof ListRulesCommand) {
|
||||||
|
return {Rules: [
|
||||||
|
{Name: 'hourly-hello', Arn: 'arn:aws:events:us-east-1:000000000000:rule/hourly-hello', ScheduleExpression: 'rate(1 hour)', State: 'ENABLED'},
|
||||||
|
{Name: 'daily-other', Arn: 'arn:aws:events:us-east-1:000000000000:rule/daily-other', ScheduleExpression: 'rate(1 day)'},
|
||||||
|
]}
|
||||||
|
}
|
||||||
|
if (command instanceof ListTargetsByRuleCommand && command.input.Rule === 'hourly-hello') {
|
||||||
|
return {Targets: [{Id: 'hello', Arn: 'arn:aws:lambda:us-east-1:000000000000:function:hello'}]}
|
||||||
|
}
|
||||||
|
return {Targets: [{Id: 'other', Arn: 'arn:aws:lambda:us-east-1:000000000000:function:other'}]}
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(new AwsServerlessAdapter(lambda, eventBridge).listEventBridgeLambdaTriggers('hello')).resolves.toEqual([expect.objectContaining({ruleName: 'hourly-hello', functionName: 'hello'})])
|
||||||
|
expect(sent.filter((command) => command instanceof ListTargetsByRuleCommand)).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('removes targets and permissions before deleting the rule', async () => {
|
||||||
|
const {client: lambda, sent: lambdaSent} = stubLambda(() => ({}))
|
||||||
|
const {client: eventBridge, sent: eventBridgeSent} = stubEventBridge((command) => {
|
||||||
|
if (command instanceof ListTargetsByRuleCommand) {
|
||||||
|
return {Targets: [{Id: 'hello', Arn: 'arn:aws:lambda:us-east-1:000000000000:function:hello'}]}
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
})
|
||||||
|
|
||||||
|
await new AwsServerlessAdapter(lambda, eventBridge).deleteEventBridgeLambdaTrigger('hello', 'hourly-hello')
|
||||||
|
|
||||||
|
expect(eventBridgeSent[0]).toBeInstanceOf(ListTargetsByRuleCommand)
|
||||||
|
expect((eventBridgeSent[1] as RemoveTargetsCommand).input).toMatchObject({Rule: 'hourly-hello', Ids: ['hello']})
|
||||||
|
expect(lambdaSent[0]).toBeInstanceOf(RemovePermissionCommand)
|
||||||
|
expect((eventBridgeSent[2] as DeleteRuleCommand).input.Name).toBe('hourly-hello')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('SQS event source mappings', () => {
|
||||||
|
const queueArn = 'arn:aws:sqs:us-east-1:000000000000:orders'
|
||||||
|
|
||||||
|
test('creates an SQS mapping with the Lambda AWS SDK command', async () => {
|
||||||
|
const {client, sent} = stubLambda(() => ({
|
||||||
|
UUID: 'mapping-1',
|
||||||
|
FunctionArn: 'arn:aws:lambda:us-east-1:000000000000:function:hello',
|
||||||
|
EventSourceArn: queueArn,
|
||||||
|
BatchSize: 10,
|
||||||
|
State: 'Creating',
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mapping = await new AwsServerlessAdapter(client).createSqsLambdaEventSourceMapping({
|
||||||
|
functionName: 'hello', queueArn, batchSize: 10,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(sent[0]).toBeInstanceOf(CreateEventSourceMappingCommand)
|
||||||
|
expect((sent[0] as CreateEventSourceMappingCommand).input).toEqual({
|
||||||
|
FunctionName: 'hello', EventSourceArn: queueArn, BatchSize: 10,
|
||||||
|
})
|
||||||
|
expect(mapping).toMatchObject({uuid: 'mapping-1', functionName: 'hello', queueArn, batchSize: 10})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects an SQS batch size outside the AWS range', async () => {
|
||||||
|
const {client} = stubLambda(() => ({}))
|
||||||
|
await expect(new AwsServerlessAdapter(client).createSqsLambdaEventSourceMapping({
|
||||||
|
functionName: 'hello', queueArn, batchSize: 11,
|
||||||
|
})).rejects.toThrow(new ValidationError('batchSize must be an integer between 1 and 10'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('lists only SQS mappings for the requested Lambda across pages', async () => {
|
||||||
|
const {client, sent} = stubLambda((command) => {
|
||||||
|
if (command instanceof ListEventSourceMappingsCommand && !command.input.Marker) {
|
||||||
|
return {
|
||||||
|
EventSourceMappings: [
|
||||||
|
{UUID: 'sqs-mapping', EventSourceArn: queueArn, FunctionArn: 'arn:aws:lambda:us-east-1:000000000000:function:hello'},
|
||||||
|
{UUID: 'stream-mapping', EventSourceArn: 'arn:aws:kinesis:us-east-1:000000000000:stream/orders'},
|
||||||
|
],
|
||||||
|
NextMarker: 'page-2',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {EventSourceMappings: [{UUID: 'sqs-mapping-2', EventSourceArn: 'arn:aws:sqs:us-east-1:000000000000:payments'}]}
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(new AwsServerlessAdapter(client).listSqsLambdaEventSourceMappings('hello')).resolves.toEqual([
|
||||||
|
expect.objectContaining({uuid: 'sqs-mapping', queueArn, functionName: 'hello'}),
|
||||||
|
expect.objectContaining({uuid: 'sqs-mapping-2', queueArn: 'arn:aws:sqs:us-east-1:000000000000:payments'}),
|
||||||
|
])
|
||||||
|
expect(sent).toHaveLength(2)
|
||||||
|
expect(sent[0]).toBeInstanceOf(ListEventSourceMappingsCommand)
|
||||||
|
expect((sent[0] as ListEventSourceMappingsCommand).input.FunctionName).toBe('hello')
|
||||||
|
expect((sent[1] as ListEventSourceMappingsCommand).input.Marker).toBe('page-2')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('deletes an SQS mapping by its AWS UUID', async () => {
|
||||||
|
const {client, sent} = stubLambda(() => ({}))
|
||||||
|
await new AwsServerlessAdapter(client).deleteSqsLambdaEventSourceMapping('hello', 'mapping-1')
|
||||||
|
|
||||||
|
expect(sent[0]).toBeInstanceOf(DeleteEventSourceMappingCommand)
|
||||||
|
expect((sent[0] as DeleteEventSourceMappingCommand).input).toEqual({UUID: 'mapping-1'})
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,31 +1,55 @@
|
|||||||
import {ValidationError} from '../cloud-spi/errors'
|
import {ValidationError} from '../cloud-spi/errors'
|
||||||
import {
|
import {
|
||||||
CreateFunctionCommand,
|
CreateFunctionCommand,
|
||||||
|
CreateEventSourceMappingCommand,
|
||||||
|
AddPermissionCommand,
|
||||||
DeleteFunctionCommand,
|
DeleteFunctionCommand,
|
||||||
|
DeleteEventSourceMappingCommand,
|
||||||
GetFunctionCommand,
|
GetFunctionCommand,
|
||||||
InvokeCommand,
|
InvokeCommand,
|
||||||
type InvokeCommandOutput,
|
type InvokeCommandOutput,
|
||||||
ListFunctionsCommand,
|
ListFunctionsCommand,
|
||||||
|
ListEventSourceMappingsCommand,
|
||||||
|
RemovePermissionCommand,
|
||||||
|
UpdateFunctionCodeCommand,
|
||||||
|
UpdateFunctionConfigurationCommand,
|
||||||
type LambdaClient,
|
type LambdaClient,
|
||||||
} from "@aws-sdk/client-lambda";
|
} from "@aws-sdk/client-lambda";
|
||||||
|
import {
|
||||||
|
DeleteRuleCommand,
|
||||||
|
ListRulesCommand,
|
||||||
|
ListTargetsByRuleCommand,
|
||||||
|
PutRuleCommand,
|
||||||
|
PutTargetsCommand,
|
||||||
|
RemoveTargetsCommand,
|
||||||
|
type EventBridgeClient,
|
||||||
|
} from '@aws-sdk/client-eventbridge'
|
||||||
import { awsServerlessSchema } from "../cloud-spi/serverlessSchema";
|
import { awsServerlessSchema } from "../cloud-spi/serverlessSchema";
|
||||||
import type {
|
import type {
|
||||||
CloudResource,
|
CloudResource,
|
||||||
CloudServiceAdapter,
|
CloudServiceAdapter,
|
||||||
CreateResourceInput,
|
CreateResourceInput,
|
||||||
ResourceQuery,
|
ResourceQuery,
|
||||||
|
EventBridgeLambdaTriggerInput,
|
||||||
|
EventBridgeLambdaTriggerOverview,
|
||||||
|
SqsLambdaEventSourceMappingInput,
|
||||||
|
SqsLambdaEventSourceMappingOverview,
|
||||||
ServerlessInvokeResult,
|
ServerlessInvokeResult,
|
||||||
ServiceSchema,
|
ServiceSchema,
|
||||||
|
UpdateResourceInput,
|
||||||
} from "../cloud-spi/types";
|
} from "../cloud-spi/types";
|
||||||
import { lambda as defaultLambda } from "../aws";
|
import { eventbridge as defaultEventBridge, lambda as defaultLambda } from "../aws";
|
||||||
import { createZipArchive, handlerFileName } from "./zipArchive";
|
import { createZipArchive, handlerFileName, readStoredZipEntry } from "./zipArchive";
|
||||||
import {lambdaStarterCode} from '../cloud-spi/lambdaStarterCode'
|
import {lambdaStarterCode} from '../cloud-spi/lambdaStarterCode'
|
||||||
|
|
||||||
export class AwsServerlessAdapter implements CloudServiceAdapter {
|
export class AwsServerlessAdapter implements CloudServiceAdapter {
|
||||||
readonly cloud = "aws" as const;
|
readonly cloud = "aws" as const;
|
||||||
readonly service = "serverless" as const;
|
readonly service = "serverless" as const;
|
||||||
|
|
||||||
constructor(private readonly lambda: LambdaClient = defaultLambda) {}
|
constructor(
|
||||||
|
private readonly lambda: LambdaClient = defaultLambda,
|
||||||
|
private readonly eventBridge: EventBridgeClient = defaultEventBridge,
|
||||||
|
) {}
|
||||||
|
|
||||||
schema(): ServiceSchema {
|
schema(): ServiceSchema {
|
||||||
return awsServerlessSchema();
|
return awsServerlessSchema();
|
||||||
@@ -66,6 +90,7 @@ export class AwsServerlessAdapter implements CloudServiceAdapter {
|
|||||||
const config = res.Configuration;
|
const config = res.Configuration;
|
||||||
|
|
||||||
if (!config) return null;
|
if (!config) return null;
|
||||||
|
const source = await deployedHandlerSource(res.Code?.Location, config.Handler, config.Runtime)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: config.FunctionName ?? config.FunctionArn ?? id,
|
id: config.FunctionName ?? config.FunctionArn ?? id,
|
||||||
@@ -90,6 +115,7 @@ export class AwsServerlessAdapter implements CloudServiceAdapter {
|
|||||||
version: config.Version,
|
version: config.Version,
|
||||||
codeSha256: config.CodeSha256,
|
codeSha256: config.CodeSha256,
|
||||||
environment: config.Environment?.Variables,
|
environment: config.Environment?.Variables,
|
||||||
|
...(source === null ? {} : {code: source}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -176,6 +202,43 @@ export class AwsServerlessAdapter implements CloudServiceAdapter {
|
|||||||
await this.lambda.send(new DeleteFunctionCommand({ FunctionName: id }));
|
await this.lambda.send(new DeleteFunctionCommand({ FunctionName: id }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async update(id: string, input: UpdateResourceInput): Promise<CloudResource> {
|
||||||
|
const values = input.values;
|
||||||
|
const source = stringValue(values.code);
|
||||||
|
const zipFile = stringValue(values.zipFile);
|
||||||
|
|
||||||
|
if (source || zipFile) {
|
||||||
|
const current = await this.lambda.send(new GetFunctionCommand({FunctionName: id}));
|
||||||
|
const handler = stringValue(values.handler) || current.Configuration?.Handler;
|
||||||
|
const runtime = stringValue(values.runtime) || current.Configuration?.Runtime;
|
||||||
|
if (!handler || !runtime) throw new ValidationError('Lambda handler and runtime are required to package source code');
|
||||||
|
|
||||||
|
await this.lambda.send(new UpdateFunctionCodeCommand({
|
||||||
|
FunctionName: id,
|
||||||
|
ZipFile: zipFile
|
||||||
|
? decodeBase64Zip(zipFile)
|
||||||
|
: createZipArchive([{name: handlerFileName(handler, runtime), content: source}]),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const configuration = {
|
||||||
|
FunctionName: id,
|
||||||
|
...(values.runtime === undefined ? {} : {Runtime: stringValue(values.runtime) as never}),
|
||||||
|
...(values.handler === undefined ? {} : {Handler: stringValue(values.handler)}),
|
||||||
|
...(values.description === undefined ? {} : {Description: stringValue(values.description)}),
|
||||||
|
...(values.environment === undefined ? {} : {Environment: {Variables: environmentVariables(values.environment)}}),
|
||||||
|
...(values.memorySize === undefined ? {} : {MemorySize: positiveNumber(values.memorySize, 'memorySize')}),
|
||||||
|
...(values.timeout === undefined ? {} : {Timeout: positiveNumber(values.timeout, 'timeout')}),
|
||||||
|
};
|
||||||
|
if (Object.keys(configuration).length > 1) {
|
||||||
|
await this.lambda.send(new UpdateFunctionConfigurationCommand(configuration));
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await this.get(id);
|
||||||
|
if (!updated) throw new ValidationError(`Lambda function ${id} was not found after update`);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
async invoke(id: string, payload: string): Promise<ServerlessInvokeResult> {
|
async invoke(id: string, payload: string): Promise<ServerlessInvokeResult> {
|
||||||
const startedAt = performance.now();
|
const startedAt = performance.now();
|
||||||
const res = await this.lambda.send(
|
const res = await this.lambda.send(
|
||||||
@@ -196,6 +259,157 @@ export class AwsServerlessAdapter implements CloudServiceAdapter {
|
|||||||
executionDuration,
|
executionDuration,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createEventBridgeLambdaTrigger(input: EventBridgeLambdaTriggerInput): Promise<EventBridgeLambdaTriggerOverview> {
|
||||||
|
const ruleName = stringValue(input.ruleName)
|
||||||
|
const scheduleExpression = stringValue(input.scheduleExpression)
|
||||||
|
const functionName = stringValue(input.functionName)
|
||||||
|
if (!ruleName) throw new ValidationError('ruleName is required')
|
||||||
|
if (!scheduleExpression) throw new ValidationError('scheduleExpression is required')
|
||||||
|
if (!functionName) throw new ValidationError('functionName is required')
|
||||||
|
|
||||||
|
const functionResult = await this.lambda.send(new GetFunctionCommand({FunctionName: functionName}))
|
||||||
|
const functionArn = functionResult.Configuration?.FunctionArn
|
||||||
|
if (!functionArn) throw new ValidationError(`Lambda function ${functionName} did not return an ARN`)
|
||||||
|
|
||||||
|
const rule = await this.eventBridge.send(new PutRuleCommand({
|
||||||
|
Name: ruleName,
|
||||||
|
ScheduleExpression: scheduleExpression,
|
||||||
|
State: 'ENABLED',
|
||||||
|
}))
|
||||||
|
if (!rule.RuleArn) throw new ValidationError(`EventBridge rule ${ruleName} did not return an ARN`)
|
||||||
|
|
||||||
|
const targetId = eventBridgeTargetId(functionName)
|
||||||
|
await this.eventBridge.send(new PutTargetsCommand({
|
||||||
|
Rule: ruleName,
|
||||||
|
Targets: [{Id: targetId, Arn: functionArn}],
|
||||||
|
}))
|
||||||
|
await this.ensureEventBridgePermission(functionName, ruleName, rule.RuleArn)
|
||||||
|
return {
|
||||||
|
id: ruleName,
|
||||||
|
ruleName,
|
||||||
|
ruleArn: rule.RuleArn,
|
||||||
|
functionName,
|
||||||
|
functionArn,
|
||||||
|
scheduleExpression,
|
||||||
|
state: 'ENABLED',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async listEventBridgeLambdaTriggers(functionName: string): Promise<EventBridgeLambdaTriggerOverview[]> {
|
||||||
|
const triggers: EventBridgeLambdaTriggerOverview[] = []
|
||||||
|
let nextToken: string | undefined
|
||||||
|
do {
|
||||||
|
const rules = await this.eventBridge.send(new ListRulesCommand({NextToken: nextToken}))
|
||||||
|
for (const rule of rules.Rules ?? []) {
|
||||||
|
if (!rule.Name || !rule.Arn || !rule.ScheduleExpression) continue
|
||||||
|
const targets = await this.listEventBridgeTargets(rule.Name)
|
||||||
|
for (const target of targets) {
|
||||||
|
if (!target.Arn || lambdaFunctionName(target.Arn) !== functionName) continue
|
||||||
|
triggers.push({
|
||||||
|
id: rule.Name,
|
||||||
|
ruleName: rule.Name,
|
||||||
|
ruleArn: rule.Arn,
|
||||||
|
functionName,
|
||||||
|
functionArn: target.Arn,
|
||||||
|
scheduleExpression: rule.ScheduleExpression,
|
||||||
|
state: rule.State ?? null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nextToken = rules.NextToken
|
||||||
|
} while (nextToken)
|
||||||
|
return triggers.sort((left, right) => left.ruleName.localeCompare(right.ruleName))
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteEventBridgeLambdaTrigger(functionName: string, triggerId: string): Promise<void> {
|
||||||
|
const ruleName = stringValue(triggerId)
|
||||||
|
if (!ruleName) throw new ValidationError('triggerId is required')
|
||||||
|
const targets = await this.listEventBridgeTargets(ruleName)
|
||||||
|
const matchingTargets = targets.filter((target) => target.Arn && lambdaFunctionName(target.Arn) === functionName)
|
||||||
|
const targetIds = matchingTargets.map((target) => target.Id).filter((id): id is string => Boolean(id))
|
||||||
|
if (targetIds.length > 0) {
|
||||||
|
await this.eventBridge.send(new RemoveTargetsCommand({Rule: ruleName, Ids: targetIds}))
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await this.lambda.send(new RemovePermissionCommand({
|
||||||
|
FunctionName: functionName,
|
||||||
|
StatementId: eventBridgePermissionStatementId(ruleName),
|
||||||
|
}))
|
||||||
|
} catch (error) {
|
||||||
|
if (!hasHttpStatus(error, 404)) throw error
|
||||||
|
}
|
||||||
|
if (targets.length === matchingTargets.length) {
|
||||||
|
await this.eventBridge.send(new DeleteRuleCommand({Name: ruleName}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createSqsLambdaEventSourceMapping(input: SqsLambdaEventSourceMappingInput): Promise<SqsLambdaEventSourceMappingOverview> {
|
||||||
|
const functionName = stringValue(input.functionName)
|
||||||
|
const queueArn = stringValue(input.queueArn)
|
||||||
|
if (!functionName) throw new ValidationError('functionName is required')
|
||||||
|
if (!queueArn) throw new ValidationError('queueArn is required')
|
||||||
|
const batchSize = input.batchSize === undefined ? undefined : sqsBatchSize(input.batchSize)
|
||||||
|
const mapping = await this.lambda.send(new CreateEventSourceMappingCommand({
|
||||||
|
FunctionName: functionName,
|
||||||
|
EventSourceArn: queueArn,
|
||||||
|
...(batchSize === undefined ? {} : {BatchSize: batchSize}),
|
||||||
|
}))
|
||||||
|
return sqsEventSourceMappingOverview(mapping, functionName)
|
||||||
|
}
|
||||||
|
|
||||||
|
async listSqsLambdaEventSourceMappings(functionName: string): Promise<SqsLambdaEventSourceMappingOverview[]> {
|
||||||
|
const normalizedFunctionName = stringValue(functionName)
|
||||||
|
if (!normalizedFunctionName) throw new ValidationError('functionName is required')
|
||||||
|
const mappings: SqsLambdaEventSourceMappingOverview[] = []
|
||||||
|
let marker: string | undefined
|
||||||
|
do {
|
||||||
|
const result = await this.lambda.send(new ListEventSourceMappingsCommand({
|
||||||
|
FunctionName: normalizedFunctionName,
|
||||||
|
Marker: marker,
|
||||||
|
}))
|
||||||
|
for (const mapping of result.EventSourceMappings ?? []) {
|
||||||
|
if (mapping.EventSourceArn?.includes(':sqs:')) {
|
||||||
|
mappings.push(sqsEventSourceMappingOverview(mapping, normalizedFunctionName))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
marker = result.NextMarker
|
||||||
|
} while (marker)
|
||||||
|
return mappings.sort((left, right) => left.uuid.localeCompare(right.uuid))
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteSqsLambdaEventSourceMapping(functionName: string, mappingId: string): Promise<void> {
|
||||||
|
if (!stringValue(functionName)) throw new ValidationError('functionName is required')
|
||||||
|
const uuid = stringValue(mappingId)
|
||||||
|
if (!uuid) throw new ValidationError('mappingId is required')
|
||||||
|
await this.lambda.send(new DeleteEventSourceMappingCommand({UUID: uuid}))
|
||||||
|
}
|
||||||
|
|
||||||
|
private async listEventBridgeTargets(ruleName: string) {
|
||||||
|
const targets = []
|
||||||
|
let nextToken: string | undefined
|
||||||
|
do {
|
||||||
|
const result = await this.eventBridge.send(new ListTargetsByRuleCommand({Rule: ruleName, NextToken: nextToken}))
|
||||||
|
targets.push(...(result.Targets ?? []))
|
||||||
|
nextToken = result.NextToken
|
||||||
|
} while (nextToken)
|
||||||
|
return targets
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureEventBridgePermission(functionName: string, ruleName: string, ruleArn: string): Promise<void> {
|
||||||
|
const permission = {
|
||||||
|
FunctionName: functionName,
|
||||||
|
StatementId: eventBridgePermissionStatementId(ruleName),
|
||||||
|
Action: 'lambda:InvokeFunction',
|
||||||
|
Principal: 'events.amazonaws.com',
|
||||||
|
SourceArn: ruleArn,
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await this.lambda.send(new AddPermissionCommand(permission))
|
||||||
|
} catch (error) {
|
||||||
|
if (!hasHttpStatus(error, 409)) throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function decodeBase64Zip(value: string): Uint8Array {
|
function decodeBase64Zip(value: string): Uint8Array {
|
||||||
@@ -213,6 +427,99 @@ function positiveNumberOrDefault(value: unknown, defaultValue: number): number {
|
|||||||
return Number.isFinite(number) && number > 0 ? number : defaultValue
|
return Number.isFinite(number) && number > 0 ? number : defaultValue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function positiveNumber(value: unknown, field: string): number {
|
||||||
|
const number = Number(value)
|
||||||
|
if (!Number.isFinite(number) || number <= 0) throw new ValidationError(`${field} must be a positive number`)
|
||||||
|
return number
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringValue(value: unknown): string {
|
||||||
|
return typeof value === 'string' ? value.trim() : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function lambdaFunctionName(arn: string): string | null {
|
||||||
|
const match = arn.match(/:function:([^:/]+)/)
|
||||||
|
return match?.[1] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventBridgeTargetId(functionName: string): string {
|
||||||
|
return `floci-ui-eventbridge-${functionName}`.replace(/[^A-Za-z0-9_.-]/g, '-')
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventBridgePermissionStatementId(ruleName: string): string {
|
||||||
|
return `floci-ui-eventbridge-${ruleName}`.replace(/[^A-Za-z0-9_.-]/g, '-').slice(0, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sqsBatchSize(value: number): number {
|
||||||
|
if (!Number.isInteger(value) || value < 1 || value > 10) {
|
||||||
|
throw new ValidationError('batchSize must be an integer between 1 and 10')
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
function sqsEventSourceMappingOverview(
|
||||||
|
mapping: {UUID?: string; FunctionArn?: string; EventSourceArn?: string; BatchSize?: number; State?: string; StateTransitionReason?: string},
|
||||||
|
functionName: string,
|
||||||
|
): SqsLambdaEventSourceMappingOverview {
|
||||||
|
const uuid = mapping.UUID
|
||||||
|
const queueArn = mapping.EventSourceArn
|
||||||
|
if (!uuid || !queueArn) throw new ValidationError('Lambda event source mapping did not return a UUID and SQS queue ARN')
|
||||||
|
return {
|
||||||
|
id: uuid,
|
||||||
|
uuid,
|
||||||
|
functionName,
|
||||||
|
functionArn: mapping.FunctionArn ?? null,
|
||||||
|
queueArn,
|
||||||
|
batchSize: mapping.BatchSize ?? null,
|
||||||
|
state: mapping.State ?? null,
|
||||||
|
stateTransitionReason: mapping.StateTransitionReason ?? null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deployedHandlerSource(location: string | undefined, handler: string | undefined, runtime: string | undefined): Promise<string | null> {
|
||||||
|
if (!location || !handler || !runtime) return null
|
||||||
|
try {
|
||||||
|
const response = await globalThis.fetch(location)
|
||||||
|
if (!response.ok) return null
|
||||||
|
return readStoredZipEntry(new Uint8Array(await response.arrayBuffer()), handlerFileName(handler, runtime))
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function environmentVariables(value: unknown): Record<string, string> {
|
||||||
|
let variables: unknown = value
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
try {
|
||||||
|
variables = JSON.parse(value)
|
||||||
|
} catch {
|
||||||
|
throw new ValidationError('environment must be a JSON object of string values')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!variables || typeof variables !== 'object' || Array.isArray(variables)) {
|
||||||
|
if (!Array.isArray(variables)) throw new ValidationError('environment must be a JSON object of string values')
|
||||||
|
const entries = variables.map((entry) => {
|
||||||
|
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
||||||
|
throw new ValidationError('environment rows must have non-empty keys and string values')
|
||||||
|
}
|
||||||
|
const row = entry as {key?: unknown; value?: unknown}
|
||||||
|
if (typeof row.key !== 'string' || !row.key || typeof row.value !== 'string') {
|
||||||
|
throw new ValidationError('environment rows must have non-empty keys and string values')
|
||||||
|
}
|
||||||
|
return [row.key, row.value] as const
|
||||||
|
})
|
||||||
|
if (new Set(entries.map(([key]) => key)).size !== entries.length) {
|
||||||
|
throw new ValidationError('environment variable names must be unique')
|
||||||
|
}
|
||||||
|
return Object.fromEntries(entries)
|
||||||
|
}
|
||||||
|
const entries = Object.entries(variables)
|
||||||
|
if (entries.some(([key, variable]) => !key || typeof variable !== 'string')) {
|
||||||
|
throw new ValidationError('environment must be a JSON object of non-empty keys and string values')
|
||||||
|
}
|
||||||
|
return Object.fromEntries(entries) as Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
function decodePayload(payload: InvokeCommandOutput["Payload"]): string {
|
function decodePayload(payload: InvokeCommandOutput["Payload"]): string {
|
||||||
if (!payload) return "";
|
if (!payload) return "";
|
||||||
return new TextDecoder().decode(payload);
|
return new TextDecoder().decode(payload);
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import {describe, expect, test} from 'bun:test'
|
import {describe, expect, test} from 'bun:test'
|
||||||
import {
|
import {
|
||||||
|
GetBucketNotificationConfigurationCommand,
|
||||||
GetBucketTaggingCommand,
|
GetBucketTaggingCommand,
|
||||||
ListBucketsCommand,
|
ListBucketsCommand,
|
||||||
|
PutBucketNotificationConfigurationCommand,
|
||||||
type S3Client,
|
type S3Client,
|
||||||
} from '@aws-sdk/client-s3'
|
} from '@aws-sdk/client-s3'
|
||||||
|
import {AddPermissionCommand, GetFunctionCommand, RemovePermissionCommand, type LambdaClient} from '@aws-sdk/client-lambda'
|
||||||
import {AwsStorageAdapter} from './AwsStorageAdapter'
|
import {AwsStorageAdapter} from './AwsStorageAdapter'
|
||||||
|
|
||||||
function fakeS3(overrides: Partial<{
|
function fakeS3(overrides: Partial<{
|
||||||
@@ -100,4 +103,90 @@ describe('AwsStorageAdapter', () => {
|
|||||||
expect(result.map((r) => r.name).sort()).toEqual(['prod-assets', 'prod-logs'])
|
expect(result.map((r) => r.name).sort()).toEqual(['prod-assets', 'prod-logs'])
|
||||||
expect(requestedBuckets.sort()).toEqual(['prod-assets', 'prod-logs'])
|
expect(requestedBuckets.sort()).toEqual(['prod-assets', 'prod-logs'])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('adds an S3 Lambda trigger without replacing other bucket notifications', async () => {
|
||||||
|
const sent: unknown[] = []
|
||||||
|
const s3 = {
|
||||||
|
send: async (command: unknown) => {
|
||||||
|
sent.push(command)
|
||||||
|
if (command instanceof GetBucketNotificationConfigurationCommand) {
|
||||||
|
return {
|
||||||
|
QueueConfigurations: [{Id: 'queue-trigger', QueueArn: 'arn:aws:sqs:us-east-1:000000000000:jobs', Events: ['s3:ObjectCreated:*']}],
|
||||||
|
EventBridgeConfiguration: {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (command instanceof PutBucketNotificationConfigurationCommand) return {}
|
||||||
|
throw new Error(`Unexpected command: ${command?.constructor?.name}`)
|
||||||
|
},
|
||||||
|
} as unknown as S3Client
|
||||||
|
const lambda = fakeLambda()
|
||||||
|
const adapter = new AwsStorageAdapter(s3, lambda.client, '000000000000')
|
||||||
|
|
||||||
|
await adapter.createS3LambdaTrigger('uploads', {
|
||||||
|
functionName: 'thumbnailer',
|
||||||
|
events: ['s3:ObjectCreated:*'],
|
||||||
|
prefix: 'images/',
|
||||||
|
suffix: '.jpg',
|
||||||
|
})
|
||||||
|
|
||||||
|
const put = sent.find((command) => command instanceof PutBucketNotificationConfigurationCommand) as PutBucketNotificationConfigurationCommand
|
||||||
|
expect(put.input.NotificationConfiguration?.QueueConfigurations).toHaveLength(1)
|
||||||
|
expect(put.input.NotificationConfiguration?.EventBridgeConfiguration).toEqual({})
|
||||||
|
expect(put.input.NotificationConfiguration?.LambdaFunctionConfigurations).toEqual([{
|
||||||
|
Id: 'floci-ui-s3-thumbnailer-s3-ObjectCreated---images---jpg',
|
||||||
|
LambdaFunctionArn: 'arn:aws:lambda:us-east-1:000000000000:function:thumbnailer',
|
||||||
|
Events: ['s3:ObjectCreated:*'],
|
||||||
|
Filter: {Key: {FilterRules: [{Name: 'prefix', Value: 'images/'}, {Name: 'suffix', Value: '.jpg'}]}},
|
||||||
|
}])
|
||||||
|
const permission = lambda.sent.find((command) => command instanceof AddPermissionCommand) as AddPermissionCommand
|
||||||
|
expect(permission.input).toMatchObject({
|
||||||
|
FunctionName: 'thumbnailer',
|
||||||
|
Principal: 's3.amazonaws.com',
|
||||||
|
SourceArn: 'arn:aws:s3:::uploads',
|
||||||
|
SourceAccount: '000000000000',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('removes the S3 permission only after the final bucket trigger for a Lambda is deleted', async () => {
|
||||||
|
const sent: unknown[] = []
|
||||||
|
const s3 = {
|
||||||
|
send: async (command: unknown) => {
|
||||||
|
sent.push(command)
|
||||||
|
if (command instanceof GetBucketNotificationConfigurationCommand) {
|
||||||
|
return {LambdaFunctionConfigurations: [{
|
||||||
|
Id: 'trigger-a',
|
||||||
|
LambdaFunctionArn: 'arn:aws:lambda:us-east-1:000000000000:function:thumbnailer',
|
||||||
|
Events: ['s3:ObjectCreated:*'],
|
||||||
|
}]}
|
||||||
|
}
|
||||||
|
if (command instanceof PutBucketNotificationConfigurationCommand) return {}
|
||||||
|
throw new Error(`Unexpected command: ${command?.constructor?.name}`)
|
||||||
|
},
|
||||||
|
} as unknown as S3Client
|
||||||
|
const lambda = fakeLambda()
|
||||||
|
const adapter = new AwsStorageAdapter(s3, lambda.client)
|
||||||
|
|
||||||
|
await adapter.deleteS3LambdaTrigger('uploads', 'trigger-a')
|
||||||
|
|
||||||
|
const put = sent.find((command) => command instanceof PutBucketNotificationConfigurationCommand) as PutBucketNotificationConfigurationCommand
|
||||||
|
expect(put.input.NotificationConfiguration?.LambdaFunctionConfigurations).toEqual([])
|
||||||
|
expect(lambda.sent.some((command) => command instanceof RemovePermissionCommand)).toBe(true)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function fakeLambda(): {client: LambdaClient; sent: unknown[]} {
|
||||||
|
const sent: unknown[] = []
|
||||||
|
return {
|
||||||
|
sent,
|
||||||
|
client: {
|
||||||
|
send: async (command: unknown) => {
|
||||||
|
sent.push(command)
|
||||||
|
if (command instanceof GetFunctionCommand) {
|
||||||
|
return {Configuration: {FunctionArn: 'arn:aws:lambda:us-east-1:000000000000:function:thumbnailer'}}
|
||||||
|
}
|
||||||
|
if (command instanceof AddPermissionCommand || command instanceof RemovePermissionCommand) return {}
|
||||||
|
throw new Error(`Unexpected command: ${command?.constructor?.name}`)
|
||||||
|
},
|
||||||
|
} as unknown as LambdaClient,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,14 +5,18 @@ import {
|
|||||||
CreateBucketCommand,
|
CreateBucketCommand,
|
||||||
DeleteBucketCommand,
|
DeleteBucketCommand,
|
||||||
DeleteObjectCommand,
|
DeleteObjectCommand,
|
||||||
|
type Event,
|
||||||
GetBucketTaggingCommand,
|
GetBucketTaggingCommand,
|
||||||
|
GetBucketNotificationConfigurationCommand,
|
||||||
GetObjectCommand,
|
GetObjectCommand,
|
||||||
ListBucketsCommand,
|
ListBucketsCommand,
|
||||||
ListObjectsV2Command,
|
ListObjectsV2Command,
|
||||||
PutObjectCommand,
|
PutObjectCommand,
|
||||||
|
PutBucketNotificationConfigurationCommand,
|
||||||
type S3Client,
|
type S3Client,
|
||||||
} from '@aws-sdk/client-s3'
|
} from '@aws-sdk/client-s3'
|
||||||
import {s3 as defaultS3} from '../aws'
|
import {AddPermissionCommand, GetFunctionCommand, RemovePermissionCommand, type LambdaClient} from '@aws-sdk/client-lambda'
|
||||||
|
import {defaultAccountId, lambda as defaultLambda, s3 as defaultS3} from '../aws'
|
||||||
import {awsStorageSchema} from '../cloud-spi/storageSchema'
|
import {awsStorageSchema} from '../cloud-spi/storageSchema'
|
||||||
import type {
|
import type {
|
||||||
CloudResource,
|
CloudResource,
|
||||||
@@ -22,13 +26,19 @@ import type {
|
|||||||
ServiceSchema,
|
ServiceSchema,
|
||||||
StorageObjectDownload,
|
StorageObjectDownload,
|
||||||
StorageObjectList,
|
StorageObjectList,
|
||||||
|
S3LambdaTriggerInput,
|
||||||
|
S3LambdaTriggerOverview,
|
||||||
} from '../cloud-spi/types'
|
} from '../cloud-spi/types'
|
||||||
|
|
||||||
export class AwsStorageAdapter implements CloudServiceAdapter {
|
export class AwsStorageAdapter implements CloudServiceAdapter {
|
||||||
readonly cloud = 'aws' as const
|
readonly cloud = 'aws' as const
|
||||||
readonly service = 'storage' as const
|
readonly service = 'storage' as const
|
||||||
|
|
||||||
constructor(private readonly s3: S3Client = defaultS3) {}
|
constructor(
|
||||||
|
private readonly s3: S3Client = defaultS3,
|
||||||
|
private readonly lambda: LambdaClient = defaultLambda,
|
||||||
|
private readonly accountId = defaultAccountId(),
|
||||||
|
) {}
|
||||||
|
|
||||||
schema(): ServiceSchema {
|
schema(): ServiceSchema {
|
||||||
return awsStorageSchema()
|
return awsStorageSchema()
|
||||||
@@ -109,6 +119,86 @@ export class AwsStorageAdapter implements CloudServiceAdapter {
|
|||||||
await this.s3.send(new DeleteBucketCommand({Bucket: id}))
|
await this.s3.send(new DeleteBucketCommand({Bucket: id}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createS3LambdaTrigger(bucketName: string, input: S3LambdaTriggerInput): Promise<S3LambdaTriggerOverview> {
|
||||||
|
const functionName = stringValue(input.functionName)
|
||||||
|
const events = input.events.filter((event): event is Event => S3_OBJECT_CREATED_EVENTS.has(event as Event))
|
||||||
|
if (!functionName) throw new ValidationError('functionName is required')
|
||||||
|
if (events.length === 0) throw new ValidationError('Select at least one S3 object-created event')
|
||||||
|
|
||||||
|
const functionResult = await this.lambda.send(new GetFunctionCommand({FunctionName: functionName}))
|
||||||
|
const functionArn = functionResult.Configuration?.FunctionArn
|
||||||
|
if (!functionArn) throw new ValidationError(`Lambda function ${functionName} did not return an ARN`)
|
||||||
|
|
||||||
|
const notification = await this.s3.send(new GetBucketNotificationConfigurationCommand({Bucket: bucketName}))
|
||||||
|
const id = s3TriggerId(functionName, events, input.prefix, input.suffix)
|
||||||
|
const configurations = notification.LambdaFunctionConfigurations ?? []
|
||||||
|
if (configurations.some((configuration) => configuration.Id === id)) {
|
||||||
|
throw new ValidationError('This S3 trigger already exists')
|
||||||
|
}
|
||||||
|
const filterRules = [
|
||||||
|
...(stringValue(input.prefix) ? [{Name: 'prefix' as const, Value: stringValue(input.prefix)}] : []),
|
||||||
|
...(stringValue(input.suffix) ? [{Name: 'suffix' as const, Value: stringValue(input.suffix)}] : []),
|
||||||
|
]
|
||||||
|
await this.ensureS3Permission(functionName, bucketName)
|
||||||
|
await this.s3.send(new PutBucketNotificationConfigurationCommand({
|
||||||
|
Bucket: bucketName,
|
||||||
|
NotificationConfiguration: {
|
||||||
|
QueueConfigurations: notification.QueueConfigurations,
|
||||||
|
TopicConfigurations: notification.TopicConfigurations,
|
||||||
|
LambdaFunctionConfigurations: [...configurations, {
|
||||||
|
Id: id,
|
||||||
|
LambdaFunctionArn: functionArn,
|
||||||
|
Events: events,
|
||||||
|
...(filterRules.length > 0 ? {Filter: {Key: {FilterRules: filterRules}}} : {}),
|
||||||
|
}],
|
||||||
|
EventBridgeConfiguration: notification.EventBridgeConfiguration,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
return {id, functionName, functionArn, events, prefix: stringValue(input.prefix) || null, suffix: stringValue(input.suffix) || null}
|
||||||
|
}
|
||||||
|
|
||||||
|
async listS3LambdaTriggers(bucketName: string): Promise<S3LambdaTriggerOverview[]> {
|
||||||
|
const notification = await this.s3.send(new GetBucketNotificationConfigurationCommand({Bucket: bucketName}))
|
||||||
|
return (notification.LambdaFunctionConfigurations ?? []).flatMap((configuration) => {
|
||||||
|
if (!configuration.Id || !configuration.LambdaFunctionArn) return []
|
||||||
|
return [{
|
||||||
|
id: configuration.Id,
|
||||||
|
functionName: lambdaFunctionName(configuration.LambdaFunctionArn),
|
||||||
|
functionArn: configuration.LambdaFunctionArn,
|
||||||
|
events: configuration.Events ?? [],
|
||||||
|
prefix: filterValue(configuration.Filter?.Key?.FilterRules, 'prefix'),
|
||||||
|
suffix: filterValue(configuration.Filter?.Key?.FilterRules, 'suffix'),
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteS3LambdaTrigger(bucketName: string, triggerId: string): Promise<void> {
|
||||||
|
const notification = await this.s3.send(new GetBucketNotificationConfigurationCommand({Bucket: bucketName}))
|
||||||
|
const removed = (notification.LambdaFunctionConfigurations ?? []).find((configuration) => configuration.Id === triggerId)
|
||||||
|
if (!removed) throw new ValidationError('S3 Lambda trigger was not found')
|
||||||
|
const configurations = (notification.LambdaFunctionConfigurations ?? []).filter((configuration) => configuration.Id !== triggerId)
|
||||||
|
await this.s3.send(new PutBucketNotificationConfigurationCommand({
|
||||||
|
Bucket: bucketName,
|
||||||
|
NotificationConfiguration: {
|
||||||
|
QueueConfigurations: notification.QueueConfigurations,
|
||||||
|
TopicConfigurations: notification.TopicConfigurations,
|
||||||
|
LambdaFunctionConfigurations: configurations,
|
||||||
|
EventBridgeConfiguration: notification.EventBridgeConfiguration,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
const functionName = removed.LambdaFunctionArn ? lambdaFunctionName(removed.LambdaFunctionArn) : ''
|
||||||
|
if (functionName && !configurations.some((configuration) => configuration.LambdaFunctionArn === removed.LambdaFunctionArn)) {
|
||||||
|
try {
|
||||||
|
await this.lambda.send(new RemovePermissionCommand({
|
||||||
|
FunctionName: functionName,
|
||||||
|
StatementId: s3PermissionStatementId(bucketName, functionName),
|
||||||
|
}))
|
||||||
|
} catch (error) {
|
||||||
|
if (!hasHttpStatus(error, 404)) throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async listObjects(resourceId: string, prefix = ''): Promise<StorageObjectList> {
|
async listObjects(resourceId: string, prefix = ''): Promise<StorageObjectList> {
|
||||||
const res = await this.s3.send(new ListObjectsV2Command({
|
const res = await this.s3.send(new ListObjectsV2Command({
|
||||||
Bucket: resourceId,
|
Bucket: resourceId,
|
||||||
@@ -178,12 +268,36 @@ export class AwsStorageAdapter implements CloudServiceAdapter {
|
|||||||
CopySource: `${srcResourceId}/${srcKey}`,
|
CopySource: `${srcResourceId}/${srcKey}`,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async ensureS3Permission(functionName: string, bucketName: string): Promise<void> {
|
||||||
|
const permission = {
|
||||||
|
FunctionName: functionName,
|
||||||
|
StatementId: s3PermissionStatementId(bucketName, functionName),
|
||||||
|
Action: 'lambda:InvokeFunction',
|
||||||
|
Principal: 's3.amazonaws.com',
|
||||||
|
SourceArn: `arn:aws:s3:::${bucketName}`,
|
||||||
|
SourceAccount: this.accountId,
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await this.lambda.send(new AddPermissionCommand(permission))
|
||||||
|
} catch (error) {
|
||||||
|
if (!hasHttpStatus(error, 409)) throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function stringValue(value: unknown): string {
|
function stringValue(value: unknown): string {
|
||||||
return typeof value === 'string' ? value.trim() : ''
|
return typeof value === 'string' ? value.trim() : ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const S3_OBJECT_CREATED_EVENTS = new Set<Event>([
|
||||||
|
's3:ObjectCreated:*',
|
||||||
|
's3:ObjectCreated:Put',
|
||||||
|
's3:ObjectCreated:Post',
|
||||||
|
's3:ObjectCreated:Copy',
|
||||||
|
's3:ObjectCreated:CompleteMultipartUpload',
|
||||||
|
])
|
||||||
|
|
||||||
function filterBucketsBySearch(buckets: Bucket[], search?: string): Bucket[] {
|
function filterBucketsBySearch(buckets: Bucket[], search?: string): Bucket[] {
|
||||||
const normalized = search?.trim().toLowerCase()
|
const normalized = search?.trim().toLowerCase()
|
||||||
if (!normalized) return buckets
|
if (!normalized) return buckets
|
||||||
@@ -198,3 +312,26 @@ function objectName(key: string, prefix: string): string {
|
|||||||
function isValidS3BucketName(value: string): boolean {
|
function isValidS3BucketName(value: string): boolean {
|
||||||
return /^(?!\d+\.\d+\.\d+\.\d+$)(?!.*\.\.)(?!.*\.-)(?!.*-\.)(?!.*--x-s3$)(?!.*-s3alias$)[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(value)
|
return /^(?!\d+\.\d+\.\d+\.\d+$)(?!.*\.\.)(?!.*\.-)(?!.*-\.)(?!.*--x-s3$)(?!.*-s3alias$)[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function s3TriggerId(functionName: string, events: string[], prefix?: string, suffix?: string): string {
|
||||||
|
return `floci-ui-s3-${[functionName, ...[...events].sort(), stringValue(prefix), stringValue(suffix)]
|
||||||
|
.join('-').replace(/[^A-Za-z0-9_-]/g, '-').slice(0, 180)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function s3PermissionStatementId(bucketName: string, functionName: string): string {
|
||||||
|
return `floci-ui-s3-${bucketName}-${functionName}`.replace(/[^A-Za-z0-9_-]/g, '-').slice(0, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
function lambdaFunctionName(functionArn: string): string {
|
||||||
|
return functionArn.split(':').pop() ?? functionArn
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterValue(rules: Array<{Name?: string; Value?: string}> | undefined, name: string): string | null {
|
||||||
|
return rules?.find((rule) => rule.Name === name)?.Value ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasHttpStatus(error: unknown, statusCode: number): boolean {
|
||||||
|
return typeof error === 'object' && error !== null
|
||||||
|
&& '$metadata' in error
|
||||||
|
&& (error as {$metadata?: {httpStatusCode?: number}}).$metadata?.httpStatusCode === statusCode
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import {describe, expect, test} from 'bun:test'
|
|||||||
import {execFile} from 'node:child_process'
|
import {execFile} from 'node:child_process'
|
||||||
import {rm, writeFile} from 'node:fs/promises'
|
import {rm, writeFile} from 'node:fs/promises'
|
||||||
import {tmpdir} from 'node:os'
|
import {tmpdir} from 'node:os'
|
||||||
import {createZipArchive, crc32, handlerFileName} from './zipArchive'
|
import {createZipArchive, crc32, handlerFileName, readStoredZipEntry} from './zipArchive'
|
||||||
|
|
||||||
describe('crc32', () => {
|
describe('crc32', () => {
|
||||||
// Reference values from the standard CRC-32 (IEEE 802.3) test vectors.
|
// Reference values from the standard CRC-32 (IEEE 802.3) test vectors.
|
||||||
@@ -15,6 +15,12 @@ describe('crc32', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('createZipArchive', () => {
|
describe('createZipArchive', () => {
|
||||||
|
test('reads a stored source file from an archive', () => {
|
||||||
|
const archive = createZipArchive([{name: 'index.js', content: 'exports.handler = async () => ({})'}])
|
||||||
|
|
||||||
|
expect(readStoredZipEntry(archive, 'index.js')).toBe('exports.handler = async () => ({})')
|
||||||
|
expect(readStoredZipEntry(archive, 'missing.js')).toBeNull()
|
||||||
|
})
|
||||||
test('writes the ZIP magic number and end-of-central-directory record', () => {
|
test('writes the ZIP magic number and end-of-central-directory record', () => {
|
||||||
const archive = createZipArchive([{name: 'index.js', content: 'module.exports = 1'}])
|
const archive = createZipArchive([{name: 'index.js', content: 'module.exports = 1'}])
|
||||||
const view = new DataView(archive.buffer, archive.byteOffset, archive.byteLength)
|
const view = new DataView(archive.buffer, archive.byteOffset, archive.byteLength)
|
||||||
|
|||||||
@@ -109,6 +109,28 @@ export function createZipArchive(entries: ZipEntry[]): Uint8Array {
|
|||||||
return concat([...localParts, ...centralParts, endRecord])
|
return concat([...localParts, ...centralParts, endRecord])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Reads one uncompressed file from the simple ZIP packages created by this console. */
|
||||||
|
export function readStoredZipEntry(archive: Uint8Array, name: string): string | null {
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
let offset = 0
|
||||||
|
while (offset + 30 <= archive.length && new DataView(archive.buffer, archive.byteOffset + offset).getUint32(0, true) === 0x04034b50) {
|
||||||
|
const view = new DataView(archive.buffer, archive.byteOffset + offset)
|
||||||
|
const compression = view.getUint16(8, true)
|
||||||
|
const size = view.getUint32(22, true)
|
||||||
|
const nameLength = view.getUint16(26, true)
|
||||||
|
const extraLength = view.getUint16(28, true)
|
||||||
|
const fileName = decoder.decode(archive.slice(offset + 30, offset + 30 + nameLength))
|
||||||
|
const dataOffset = offset + 30 + nameLength + extraLength
|
||||||
|
if (dataOffset + size > archive.length) return null
|
||||||
|
if (fileName === name) {
|
||||||
|
if (compression !== 0) return null
|
||||||
|
return decoder.decode(archive.slice(dataOffset, dataOffset + size))
|
||||||
|
}
|
||||||
|
offset = dataOffset + size
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
function concat(parts: Uint8Array[]): Uint8Array {
|
function concat(parts: Uint8Array[]): Uint8Array {
|
||||||
const total = parts.reduce((sum, part) => sum + part.length, 0)
|
const total = parts.reduce((sum, part) => sum + part.length, 0)
|
||||||
const out = new Uint8Array(total)
|
const out = new Uint8Array(total)
|
||||||
|
|||||||
@@ -122,7 +122,24 @@ export function awsServerlessSchema(): ServiceSchema {
|
|||||||
span: true,
|
span: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
actions: ['list', 'create', 'inspect', 'delete'],
|
actions: ['list', 'create', 'update', 'inspect', 'delete'],
|
||||||
|
updateFields: [
|
||||||
|
{name: 'runtime', label: 'Runtime', type: 'select', required: true, valuePath: 'metadata.runtime', options: [
|
||||||
|
{label: 'Node.js 22.x', value: 'nodejs22.x'}, {label: 'Node.js 20.x', value: 'nodejs20.x'},
|
||||||
|
{label: 'Node.js 18.x', value: 'nodejs18.x'}, {label: 'Python 3.12', value: 'python3.12'},
|
||||||
|
{label: 'Python 3.11', value: 'python3.11'},
|
||||||
|
]},
|
||||||
|
{name: 'handler', label: 'Handler', type: 'text', required: true, valuePath: 'metadata.handler'},
|
||||||
|
{name: 'memorySize', label: 'Memory Size', type: 'text', required: false, valuePath: 'metadata.memorySize'},
|
||||||
|
{name: 'timeout', label: 'Timeout', type: 'text', required: false, valuePath: 'metadata.timeout'},
|
||||||
|
{name: 'description', label: 'Description', type: 'text', required: false, valuePath: 'metadata.description'},
|
||||||
|
{name: 'environment', label: 'Environment Variables', type: 'key-value', required: false, valuePath: 'metadata.environment', span: true,
|
||||||
|
description: 'Available to the function as process.env in Node.js or os.environ in Python.'},
|
||||||
|
{name: 'code', label: 'Handler Source', type: 'textarea', required: false, valuePath: 'metadata.code', span: true,
|
||||||
|
description: 'Current handler source. Editing and saving replaces this handler file in the deployment package.'},
|
||||||
|
{name: 'zipFile', label: 'Replacement ZIP Deployment Package', type: 'file', required: false,
|
||||||
|
accept: '.zip,application/zip', span: true, description: 'Uploads this ZIP instead of replacement source.'},
|
||||||
|
],
|
||||||
filters: serverlessFilters,
|
filters: serverlessFilters,
|
||||||
columns: serverlessColumns,
|
columns: serverlessColumns,
|
||||||
capabilities: {
|
capabilities: {
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export interface CloudServiceStatus {
|
|||||||
errorCode: string | null
|
errorCode: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FieldType = 'text' | 'textarea' | 'password' | 'select' | 'file'
|
export type FieldType = 'text' | 'textarea' | 'password' | 'select' | 'file' | 'key-value'
|
||||||
|
|
||||||
export interface FieldSchema {
|
export interface FieldSchema {
|
||||||
name: string
|
name: string
|
||||||
@@ -491,6 +491,62 @@ export interface ApiGatewayRoutesResult {
|
|||||||
stages: string[]
|
stages: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ApiGatewayDeleteRouteInput {
|
||||||
|
resourceId: string
|
||||||
|
path: string
|
||||||
|
method: string
|
||||||
|
stage: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface S3LambdaTriggerInput {
|
||||||
|
functionName: string
|
||||||
|
events: string[]
|
||||||
|
prefix?: string
|
||||||
|
suffix?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface S3LambdaTriggerOverview {
|
||||||
|
id: string
|
||||||
|
functionName: string
|
||||||
|
functionArn: string
|
||||||
|
events: string[]
|
||||||
|
prefix: string | null
|
||||||
|
suffix: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventBridgeLambdaTriggerInput {
|
||||||
|
ruleName: string
|
||||||
|
scheduleExpression: string
|
||||||
|
functionName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventBridgeLambdaTriggerOverview {
|
||||||
|
id: string
|
||||||
|
ruleName: string
|
||||||
|
ruleArn: string
|
||||||
|
functionName: string
|
||||||
|
functionArn: string
|
||||||
|
scheduleExpression: string
|
||||||
|
state: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SqsLambdaEventSourceMappingInput {
|
||||||
|
functionName: string
|
||||||
|
queueArn: string
|
||||||
|
batchSize?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SqsLambdaEventSourceMappingOverview {
|
||||||
|
id: string
|
||||||
|
uuid: string
|
||||||
|
functionName: string
|
||||||
|
functionArn: string | null
|
||||||
|
queueArn: string
|
||||||
|
batchSize: number | null
|
||||||
|
state: string | null
|
||||||
|
stateTransitionReason: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export type KmsEncryptionAlgorithm =
|
export type KmsEncryptionAlgorithm =
|
||||||
| 'SYMMETRIC_DEFAULT'
|
| 'SYMMETRIC_DEFAULT'
|
||||||
| 'RSAES_OAEP_SHA_1'
|
| 'RSAES_OAEP_SHA_1'
|
||||||
@@ -555,6 +611,16 @@ export interface CloudServiceAdapter {
|
|||||||
createApiGatewayLambdaRoute?(apiId: string, input: ApiGatewayLambdaRouteInput): Promise<ApiGatewayLambdaRouteResult>
|
createApiGatewayLambdaRoute?(apiId: string, input: ApiGatewayLambdaRouteInput): Promise<ApiGatewayLambdaRouteResult>
|
||||||
testApiGatewayRoute?(apiId: string, input: ApiGatewayTestRouteInput): Promise<ApiGatewayTestRouteResult>
|
testApiGatewayRoute?(apiId: string, input: ApiGatewayTestRouteInput): Promise<ApiGatewayTestRouteResult>
|
||||||
listApiGatewayRoutes?(apiId: string): Promise<ApiGatewayRoutesResult>
|
listApiGatewayRoutes?(apiId: string): Promise<ApiGatewayRoutesResult>
|
||||||
|
deleteApiGatewayRoute?(apiId: string, input: ApiGatewayDeleteRouteInput): Promise<void>
|
||||||
|
createS3LambdaTrigger?(bucketName: string, input: S3LambdaTriggerInput): Promise<S3LambdaTriggerOverview>
|
||||||
|
listS3LambdaTriggers?(bucketName: string): Promise<S3LambdaTriggerOverview[]>
|
||||||
|
deleteS3LambdaTrigger?(bucketName: string, triggerId: string): Promise<void>
|
||||||
|
createEventBridgeLambdaTrigger?(input: EventBridgeLambdaTriggerInput): Promise<EventBridgeLambdaTriggerOverview>
|
||||||
|
listEventBridgeLambdaTriggers?(functionName: string): Promise<EventBridgeLambdaTriggerOverview[]>
|
||||||
|
deleteEventBridgeLambdaTrigger?(functionName: string, triggerId: string): Promise<void>
|
||||||
|
createSqsLambdaEventSourceMapping?(input: SqsLambdaEventSourceMappingInput): Promise<SqsLambdaEventSourceMappingOverview>
|
||||||
|
listSqsLambdaEventSourceMappings?(functionName: string): Promise<SqsLambdaEventSourceMappingOverview[]>
|
||||||
|
deleteSqsLambdaEventSourceMapping?(functionName: string, mappingId: string): Promise<void>
|
||||||
encrypt?(id: string, input: KmsEncryptInput): Promise<KmsEncryptResult>
|
encrypt?(id: string, input: KmsEncryptInput): Promise<KmsEncryptResult>
|
||||||
decrypt?(id: string, input: KmsDecryptInput): Promise<KmsDecryptResult>
|
decrypt?(id: string, input: KmsDecryptInput): Promise<KmsDecryptResult>
|
||||||
// Lifecycle verbs. Optional because most categories have no notion of them;
|
// Lifecycle verbs. Optional because most categories have no notion of them;
|
||||||
|
|||||||
@@ -55,13 +55,13 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap
|
|||||||
const ec2Service = createEc2Service(clients.ec2)
|
const ec2Service = createEc2Service(clients.ec2)
|
||||||
|
|
||||||
return new CloudAdapterRegistry([
|
return new CloudAdapterRegistry([
|
||||||
new AwsStorageAdapter(clients.s3),
|
new AwsStorageAdapter(clients.s3, clients.lambda, resolveAccountId(accountId)),
|
||||||
new AwsDynamoDbAdapter(clients.dynamodb),
|
new AwsDynamoDbAdapter(clients.dynamodb),
|
||||||
new AwsEksAdapter(createEksService(clients.eks)),
|
new AwsEksAdapter(createEksService(clients.eks)),
|
||||||
new AwsDatabaseAdapter(createRdsService(clients.rds), clients.rds),
|
new AwsDatabaseAdapter(createRdsService(clients.rds), clients.rds),
|
||||||
new AwsComputeAdapter(ec2Service),
|
new AwsComputeAdapter(ec2Service),
|
||||||
new AwsNetworkingAdapter(ec2Service),
|
new AwsNetworkingAdapter(ec2Service),
|
||||||
new AwsServerlessAdapter(clients.lambda),
|
new AwsServerlessAdapter(clients.lambda, clients.eventbridge),
|
||||||
new AwsLogsAdapter(clients.logs),
|
new AwsLogsAdapter(clients.logs),
|
||||||
new AwsParameterStoreAdapter(clients.ssm),
|
new AwsParameterStoreAdapter(clients.ssm),
|
||||||
new AwsKmsAdapter(clients.kms),
|
new AwsKmsAdapter(clients.kms),
|
||||||
|
|||||||
@@ -529,6 +529,20 @@ export function createCloudRoutes(injectedService?: CloudProxyService) {
|
|||||||
return withRuntime(c, async () => c.json(await svc(c).listApiGatewayRoutes(cloud, c.req.param('id'))))
|
return withRuntime(c, async () => c.json(await svc(c).listApiGatewayRoutes(cloud, c.req.param('id'))))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
app.delete('/:cloud/services/apigateway/resources/:id/routes/:resourceId/:method', 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).deleteApiGatewayRoute(cloud, c.req.param('id'), {
|
||||||
|
resourceId: c.req.param('resourceId'),
|
||||||
|
path: c.req.query('path') ?? '',
|
||||||
|
method: c.req.param('method'),
|
||||||
|
stage: c.req.query('stage') ?? '',
|
||||||
|
})
|
||||||
|
return c.json({ok: true})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
app.post('/:cloud/services/apigateway/resources/:id/routes/test', async (c) => {
|
app.post('/:cloud/services/apigateway/resources/:id/routes/test', async (c) => {
|
||||||
const cloud = c.req.param('cloud') as CloudProvider
|
const cloud = c.req.param('cloud') as CloudProvider
|
||||||
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
|
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
|
||||||
@@ -547,6 +561,94 @@ export function createCloudRoutes(injectedService?: CloudProxyService) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
app.get('/:cloud/services/storage/resources/:id/lambda-triggers', 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).listS3LambdaTriggers(cloud, c.req.param('id'))))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/:cloud/services/storage/resources/:id/lambda-triggers', 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 trigger = await svc(c).createS3LambdaTrigger(cloud, c.req.param('id'), {
|
||||||
|
functionName: typeof input.functionName === 'string' ? input.functionName : '',
|
||||||
|
events: Array.isArray(input.events) ? input.events.filter((event): event is string => typeof event === 'string') : [],
|
||||||
|
prefix: typeof input.prefix === 'string' ? input.prefix : undefined,
|
||||||
|
suffix: typeof input.suffix === 'string' ? input.suffix : undefined,
|
||||||
|
})
|
||||||
|
return c.json(trigger, 201)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete('/:cloud/services/storage/resources/:id/lambda-triggers/:triggerId', 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).deleteS3LambdaTrigger(cloud, c.req.param('id'), c.req.param('triggerId'))
|
||||||
|
return c.json({ok: true})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/:cloud/services/serverless/resources/:id/eventbridge-triggers', 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).listEventBridgeLambdaTriggers(cloud, c.req.param('id'))))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/:cloud/services/serverless/resources/:id/eventbridge-triggers', 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 trigger = await svc(c).createEventBridgeLambdaTrigger(cloud, {
|
||||||
|
ruleName: typeof input.ruleName === 'string' ? input.ruleName : '',
|
||||||
|
scheduleExpression: typeof input.scheduleExpression === 'string' ? input.scheduleExpression : '',
|
||||||
|
functionName: c.req.param('id'),
|
||||||
|
})
|
||||||
|
return c.json(trigger, 201)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete('/:cloud/services/serverless/resources/:id/eventbridge-triggers/:triggerId', 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).deleteEventBridgeLambdaTrigger(cloud, c.req.param('id'), c.req.param('triggerId'))
|
||||||
|
return c.json({ok: true})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/:cloud/services/serverless/resources/:id/sqs-event-source-mappings', 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).listSqsLambdaEventSourceMappings(cloud, c.req.param('id'))))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/:cloud/services/serverless/resources/:id/sqs-event-source-mappings', 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 trigger = await svc(c).createSqsLambdaEventSourceMapping(cloud, {
|
||||||
|
functionName: c.req.param('id'),
|
||||||
|
queueArn: typeof input.queueArn === 'string' ? input.queueArn : '',
|
||||||
|
batchSize: typeof input.batchSize === 'number' ? input.batchSize : undefined,
|
||||||
|
})
|
||||||
|
return c.json(trigger, 201)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete('/:cloud/services/serverless/resources/:id/sqs-event-source-mappings/:mappingId', 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).deleteSqsLambdaEventSourceMapping(cloud, c.req.param('id'), c.req.param('mappingId'))
|
||||||
|
return c.json({ok: true})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
app.get('/:cloud/services/:service/resources/:id/collections', async (c) => {
|
app.get('/:cloud/services/:service/resources/:id/collections', async (c) => {
|
||||||
const target = childTarget(c)
|
const target = childTarget(c)
|
||||||
if (!target) return c.json({error: 'Unknown cloud or service'}, 404)
|
if (!target) return c.json({error: 'Unknown cloud or service'}, 404)
|
||||||
|
|||||||
@@ -25,6 +25,13 @@ import type {
|
|||||||
ApiGatewayTestRouteInput,
|
ApiGatewayTestRouteInput,
|
||||||
ApiGatewayTestRouteResult,
|
ApiGatewayTestRouteResult,
|
||||||
ApiGatewayRoutesResult,
|
ApiGatewayRoutesResult,
|
||||||
|
ApiGatewayDeleteRouteInput,
|
||||||
|
S3LambdaTriggerInput,
|
||||||
|
S3LambdaTriggerOverview,
|
||||||
|
EventBridgeLambdaTriggerInput,
|
||||||
|
EventBridgeLambdaTriggerOverview,
|
||||||
|
SqsLambdaEventSourceMappingInput,
|
||||||
|
SqsLambdaEventSourceMappingOverview,
|
||||||
DatabaseSnapshot,
|
DatabaseSnapshot,
|
||||||
KubernetesFargateProfile,
|
KubernetesFargateProfile,
|
||||||
KubernetesNodegroup,
|
KubernetesNodegroup,
|
||||||
@@ -261,6 +268,66 @@ export class CloudProxyService {
|
|||||||
return adapter.listApiGatewayRoutes(apiId)
|
return adapter.listApiGatewayRoutes(apiId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async deleteApiGatewayRoute(cloud: CloudProvider, apiId: string, input: ApiGatewayDeleteRouteInput): Promise<void> {
|
||||||
|
const adapter = this.requireAdapter(cloud, 'apigateway')
|
||||||
|
if (!adapter.deleteApiGatewayRoute) throw new NotSupportedError(`API Gateway route deletion is not supported for ${cloud}`)
|
||||||
|
await adapter.deleteApiGatewayRoute(apiId, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
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}`)
|
||||||
|
return adapter.createS3LambdaTrigger(bucketName, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
async listS3LambdaTriggers(cloud: CloudProvider, bucketName: string): Promise<S3LambdaTriggerOverview[]> {
|
||||||
|
const adapter = this.requireAdapter(cloud, 'storage')
|
||||||
|
if (!adapter.listS3LambdaTriggers) throw new NotSupportedError(`S3 Lambda triggers are not supported for ${cloud}`)
|
||||||
|
return adapter.listS3LambdaTriggers(bucketName)
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteS3LambdaTrigger(cloud: CloudProvider, bucketName: string, triggerId: string): Promise<void> {
|
||||||
|
const adapter = this.requireAdapter(cloud, 'storage')
|
||||||
|
if (!adapter.deleteS3LambdaTrigger) throw new NotSupportedError(`S3 Lambda triggers are not supported for ${cloud}`)
|
||||||
|
await adapter.deleteS3LambdaTrigger(bucketName, triggerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async createEventBridgeLambdaTrigger(cloud: CloudProvider, input: EventBridgeLambdaTriggerInput): Promise<EventBridgeLambdaTriggerOverview> {
|
||||||
|
const adapter = this.requireAdapter(cloud, 'serverless')
|
||||||
|
if (!adapter.createEventBridgeLambdaTrigger) throw new NotSupportedError(`EventBridge Lambda triggers are not supported for ${cloud}`)
|
||||||
|
return adapter.createEventBridgeLambdaTrigger(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
async listEventBridgeLambdaTriggers(cloud: CloudProvider, functionName: string): Promise<EventBridgeLambdaTriggerOverview[]> {
|
||||||
|
const adapter = this.requireAdapter(cloud, 'serverless')
|
||||||
|
if (!adapter.listEventBridgeLambdaTriggers) throw new NotSupportedError(`EventBridge Lambda triggers are not supported for ${cloud}`)
|
||||||
|
return adapter.listEventBridgeLambdaTriggers(functionName)
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteEventBridgeLambdaTrigger(cloud: CloudProvider, functionName: string, triggerId: string): Promise<void> {
|
||||||
|
const adapter = this.requireAdapter(cloud, 'serverless')
|
||||||
|
if (!adapter.deleteEventBridgeLambdaTrigger) throw new NotSupportedError(`EventBridge Lambda triggers are not supported for ${cloud}`)
|
||||||
|
await adapter.deleteEventBridgeLambdaTrigger(functionName, triggerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async createSqsLambdaEventSourceMapping(cloud: CloudProvider, input: SqsLambdaEventSourceMappingInput): Promise<SqsLambdaEventSourceMappingOverview> {
|
||||||
|
const adapter = this.requireAdapter(cloud, 'serverless')
|
||||||
|
if (!adapter.createSqsLambdaEventSourceMapping) throw new NotSupportedError(`SQS Lambda event source mappings are not supported for ${cloud}`)
|
||||||
|
return adapter.createSqsLambdaEventSourceMapping(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
async listSqsLambdaEventSourceMappings(cloud: CloudProvider, functionName: string): Promise<SqsLambdaEventSourceMappingOverview[]> {
|
||||||
|
const adapter = this.requireAdapter(cloud, 'serverless')
|
||||||
|
if (!adapter.listSqsLambdaEventSourceMappings) throw new NotSupportedError(`SQS Lambda event source mappings are not supported for ${cloud}`)
|
||||||
|
return adapter.listSqsLambdaEventSourceMappings(functionName)
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteSqsLambdaEventSourceMapping(cloud: CloudProvider, functionName: string, mappingId: string): Promise<void> {
|
||||||
|
const adapter = this.requireAdapter(cloud, 'serverless')
|
||||||
|
if (!adapter.deleteSqsLambdaEventSourceMapping) throw new NotSupportedError(`SQS Lambda event source mappings are not supported for ${cloud}`)
|
||||||
|
await adapter.deleteSqsLambdaEventSourceMapping(functionName, mappingId)
|
||||||
|
}
|
||||||
|
|
||||||
async updateResource(cloud: CloudProvider, service: CloudServiceType, id: string, input: UpdateResourceInput): Promise<CloudResource> {
|
async updateResource(cloud: CloudProvider, service: CloudServiceType, id: string, input: UpdateResourceInput): Promise<CloudResource> {
|
||||||
const adapter = this.requireAdapter(cloud, service)
|
const adapter = this.requireAdapter(cloud, service)
|
||||||
if (!adapter.update) throw new NotSupportedError(`Resource updates are not supported for ${cloud}/${service}`)
|
if (!adapter.update) throw new NotSupportedError(`Resource updates are not supported for ${cloud}/${service}`)
|
||||||
|
|||||||
@@ -26,7 +26,17 @@ export const apiEndpointKeys = {
|
|||||||
decrypt: "clouds.services.resources.decrypt",
|
decrypt: "clouds.services.resources.decrypt",
|
||||||
createApiGatewayRoute: "clouds.services.resources.apigateway.route.create",
|
createApiGatewayRoute: "clouds.services.resources.apigateway.route.create",
|
||||||
listApiGatewayRoutes: "clouds.services.resources.apigateway.routes.list",
|
listApiGatewayRoutes: "clouds.services.resources.apigateway.routes.list",
|
||||||
|
deleteApiGatewayRoute: "clouds.services.resources.apigateway.route.delete",
|
||||||
testApiGatewayRoute: "clouds.services.resources.apigateway.route.test",
|
testApiGatewayRoute: "clouds.services.resources.apigateway.route.test",
|
||||||
|
createS3LambdaTrigger: "clouds.services.resources.storage.lambda-trigger.create",
|
||||||
|
listS3LambdaTriggers: "clouds.services.resources.storage.lambda-triggers.list",
|
||||||
|
deleteS3LambdaTrigger: "clouds.services.resources.storage.lambda-trigger.delete",
|
||||||
|
createEventBridgeLambdaTrigger: "clouds.services.resources.serverless.eventbridge-trigger.create",
|
||||||
|
listEventBridgeLambdaTriggers: "clouds.services.resources.serverless.eventbridge-triggers.list",
|
||||||
|
deleteEventBridgeLambdaTrigger: "clouds.services.resources.serverless.eventbridge-trigger.delete",
|
||||||
|
createSqsLambdaEventSourceMapping: "clouds.services.resources.serverless.sqs-event-source-mapping.create",
|
||||||
|
listSqsLambdaEventSourceMappings: "clouds.services.resources.serverless.sqs-event-source-mappings.list",
|
||||||
|
deleteSqsLambdaEventSourceMapping: "clouds.services.resources.serverless.sqs-event-source-mapping.delete",
|
||||||
},
|
},
|
||||||
storage: {
|
storage: {
|
||||||
objects: {
|
objects: {
|
||||||
@@ -310,6 +320,14 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
|||||||
telemetry: { service: "apigateway" },
|
telemetry: { service: "apigateway" },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
apiEndpointKeys.clouds.resources.deleteApiGatewayRoute,
|
||||||
|
{
|
||||||
|
path: "/clouds/:cloud/services/apigateway/resources/:id/routes/:resourceId/:method",
|
||||||
|
method: "DELETE",
|
||||||
|
telemetry: { service: "apigateway" },
|
||||||
|
},
|
||||||
|
],
|
||||||
[
|
[
|
||||||
apiEndpointKeys.clouds.resources.testApiGatewayRoute,
|
apiEndpointKeys.clouds.resources.testApiGatewayRoute,
|
||||||
{
|
{
|
||||||
@@ -318,6 +336,78 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
|||||||
telemetry: { service: "apigateway" },
|
telemetry: { service: "apigateway" },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
apiEndpointKeys.clouds.resources.createS3LambdaTrigger,
|
||||||
|
{
|
||||||
|
path: "/clouds/:cloud/services/storage/resources/:id/lambda-triggers",
|
||||||
|
method: "POST",
|
||||||
|
telemetry: { service: "storage" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
apiEndpointKeys.clouds.resources.listS3LambdaTriggers,
|
||||||
|
{
|
||||||
|
path: "/clouds/:cloud/services/storage/resources/:id/lambda-triggers",
|
||||||
|
method: "GET",
|
||||||
|
telemetry: { service: "storage" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
apiEndpointKeys.clouds.resources.deleteS3LambdaTrigger,
|
||||||
|
{
|
||||||
|
path: "/clouds/:cloud/services/storage/resources/:id/lambda-triggers/:triggerId",
|
||||||
|
method: "DELETE",
|
||||||
|
telemetry: { service: "storage" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
apiEndpointKeys.clouds.resources.createEventBridgeLambdaTrigger,
|
||||||
|
{
|
||||||
|
path: "/clouds/:cloud/services/serverless/resources/:id/eventbridge-triggers",
|
||||||
|
method: "POST",
|
||||||
|
telemetry: { service: "serverless" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
apiEndpointKeys.clouds.resources.listEventBridgeLambdaTriggers,
|
||||||
|
{
|
||||||
|
path: "/clouds/:cloud/services/serverless/resources/:id/eventbridge-triggers",
|
||||||
|
method: "GET",
|
||||||
|
telemetry: { service: "serverless" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
apiEndpointKeys.clouds.resources.deleteEventBridgeLambdaTrigger,
|
||||||
|
{
|
||||||
|
path: "/clouds/:cloud/services/serverless/resources/:id/eventbridge-triggers/:triggerId",
|
||||||
|
method: "DELETE",
|
||||||
|
telemetry: { service: "serverless" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
apiEndpointKeys.clouds.resources.createSqsLambdaEventSourceMapping,
|
||||||
|
{
|
||||||
|
path: "/clouds/:cloud/services/serverless/resources/:id/sqs-event-source-mappings",
|
||||||
|
method: "POST",
|
||||||
|
telemetry: { service: "serverless" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
apiEndpointKeys.clouds.resources.listSqsLambdaEventSourceMappings,
|
||||||
|
{
|
||||||
|
path: "/clouds/:cloud/services/serverless/resources/:id/sqs-event-source-mappings",
|
||||||
|
method: "GET",
|
||||||
|
telemetry: { service: "serverless" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
apiEndpointKeys.clouds.resources.deleteSqsLambdaEventSourceMapping,
|
||||||
|
{
|
||||||
|
path: "/clouds/:cloud/services/serverless/resources/:id/sqs-event-source-mappings/:mappingId",
|
||||||
|
method: "DELETE",
|
||||||
|
telemetry: { service: "serverless" },
|
||||||
|
},
|
||||||
|
],
|
||||||
[
|
[
|
||||||
apiEndpointKeys.clouds.resources.get,
|
apiEndpointKeys.clouds.resources.get,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -245,6 +245,60 @@ export interface ApiGatewayRoutesResult {
|
|||||||
stages: string[];
|
stages: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DeleteApiGatewayRouteInput {
|
||||||
|
resourceId: string;
|
||||||
|
path: string;
|
||||||
|
method: string;
|
||||||
|
stage: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateS3LambdaTriggerInput {
|
||||||
|
functionName: string;
|
||||||
|
events: string[];
|
||||||
|
prefix?: string;
|
||||||
|
suffix?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface S3LambdaTriggerOverview {
|
||||||
|
id: string;
|
||||||
|
functionName: string;
|
||||||
|
functionArn: string;
|
||||||
|
events: string[];
|
||||||
|
prefix: string | null;
|
||||||
|
suffix: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateEventBridgeLambdaTriggerInput {
|
||||||
|
ruleName: string;
|
||||||
|
scheduleExpression: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventBridgeLambdaTriggerOverview {
|
||||||
|
id: string;
|
||||||
|
ruleName: string;
|
||||||
|
ruleArn: string;
|
||||||
|
functionName: string;
|
||||||
|
functionArn: string;
|
||||||
|
scheduleExpression: string;
|
||||||
|
state: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateSqsLambdaEventSourceMappingInput {
|
||||||
|
queueArn: string;
|
||||||
|
batchSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SqsLambdaEventSourceMappingOverview {
|
||||||
|
id: string;
|
||||||
|
uuid: string;
|
||||||
|
functionName: string;
|
||||||
|
functionArn: string | null;
|
||||||
|
queueArn: string;
|
||||||
|
batchSize: number | null;
|
||||||
|
state: string | null;
|
||||||
|
stateTransitionReason: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function createApiGatewayLambdaRoute(
|
export async function createApiGatewayLambdaRoute(
|
||||||
cloud: CloudProvider,
|
cloud: CloudProvider,
|
||||||
apiId: string,
|
apiId: string,
|
||||||
@@ -272,6 +326,19 @@ export async function listApiGatewayRoutes(
|
|||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function deleteApiGatewayRoute(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
apiId: string,
|
||||||
|
input: DeleteApiGatewayRouteInput,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<void> {
|
||||||
|
await apiClient.call<void>(
|
||||||
|
apiEndpointKeys.clouds.resources.deleteApiGatewayRoute,
|
||||||
|
requestOptions(cloud, "apigateway", {signal, params: {stage: input.stage, path: input.path}}),
|
||||||
|
{cloud, id: apiId, resourceId: input.resourceId, method: input.method},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function testApiGatewayRoute(
|
export async function testApiGatewayRoute(
|
||||||
cloud: CloudProvider,
|
cloud: CloudProvider,
|
||||||
apiId: string,
|
apiId: string,
|
||||||
@@ -286,6 +353,126 @@ export async function testApiGatewayRoute(
|
|||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createS3LambdaTrigger(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
bucketName: string,
|
||||||
|
input: CreateS3LambdaTriggerInput,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<S3LambdaTriggerOverview> {
|
||||||
|
const res = await apiClient.call<S3LambdaTriggerOverview, CreateS3LambdaTriggerInput>(
|
||||||
|
apiEndpointKeys.clouds.resources.createS3LambdaTrigger,
|
||||||
|
requestOptions(cloud, "storage", {signal, body: input}),
|
||||||
|
{cloud, id: bucketName},
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listS3LambdaTriggers(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
bucketName: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<S3LambdaTriggerOverview[]> {
|
||||||
|
const res = await apiClient.call<S3LambdaTriggerOverview[]>(
|
||||||
|
apiEndpointKeys.clouds.resources.listS3LambdaTriggers,
|
||||||
|
requestOptions(cloud, "storage", {signal}),
|
||||||
|
{cloud, id: bucketName},
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteS3LambdaTrigger(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
bucketName: string,
|
||||||
|
triggerId: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<void> {
|
||||||
|
await apiClient.call<void>(
|
||||||
|
apiEndpointKeys.clouds.resources.deleteS3LambdaTrigger,
|
||||||
|
requestOptions(cloud, "storage", {signal}),
|
||||||
|
{cloud, id: bucketName, triggerId},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createEventBridgeLambdaTrigger(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
functionName: string,
|
||||||
|
input: CreateEventBridgeLambdaTriggerInput,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<EventBridgeLambdaTriggerOverview> {
|
||||||
|
const res = await apiClient.call<EventBridgeLambdaTriggerOverview, CreateEventBridgeLambdaTriggerInput>(
|
||||||
|
apiEndpointKeys.clouds.resources.createEventBridgeLambdaTrigger,
|
||||||
|
requestOptions(cloud, "serverless", {signal, body: input}),
|
||||||
|
{cloud, id: functionName},
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listEventBridgeLambdaTriggers(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
functionName: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<EventBridgeLambdaTriggerOverview[]> {
|
||||||
|
const res = await apiClient.call<EventBridgeLambdaTriggerOverview[]>(
|
||||||
|
apiEndpointKeys.clouds.resources.listEventBridgeLambdaTriggers,
|
||||||
|
requestOptions(cloud, "serverless", {signal}),
|
||||||
|
{cloud, id: functionName},
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteEventBridgeLambdaTrigger(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
functionName: string,
|
||||||
|
triggerId: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<void> {
|
||||||
|
await apiClient.call<void>(
|
||||||
|
apiEndpointKeys.clouds.resources.deleteEventBridgeLambdaTrigger,
|
||||||
|
requestOptions(cloud, "serverless", {signal}),
|
||||||
|
{cloud, id: functionName, triggerId},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSqsLambdaEventSourceMapping(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
functionName: string,
|
||||||
|
input: CreateSqsLambdaEventSourceMappingInput,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<SqsLambdaEventSourceMappingOverview> {
|
||||||
|
const res = await apiClient.call<SqsLambdaEventSourceMappingOverview, CreateSqsLambdaEventSourceMappingInput>(
|
||||||
|
apiEndpointKeys.clouds.resources.createSqsLambdaEventSourceMapping,
|
||||||
|
requestOptions(cloud, "serverless", {signal, body: input}),
|
||||||
|
{cloud, id: functionName},
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listSqsLambdaEventSourceMappings(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
functionName: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<SqsLambdaEventSourceMappingOverview[]> {
|
||||||
|
const res = await apiClient.call<SqsLambdaEventSourceMappingOverview[]>(
|
||||||
|
apiEndpointKeys.clouds.resources.listSqsLambdaEventSourceMappings,
|
||||||
|
requestOptions(cloud, "serverless", {signal}),
|
||||||
|
{cloud, id: functionName},
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSqsLambdaEventSourceMapping(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
functionName: string,
|
||||||
|
mappingId: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<void> {
|
||||||
|
await apiClient.call<void>(
|
||||||
|
apiEndpointKeys.clouds.resources.deleteSqsLambdaEventSourceMapping,
|
||||||
|
requestOptions(cloud, "serverless", {signal}),
|
||||||
|
{cloud, id: functionName, mappingId},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export type KmsEncryptionAlgorithm =
|
export type KmsEncryptionAlgorithm =
|
||||||
| "SYMMETRIC_DEFAULT"
|
| "SYMMETRIC_DEFAULT"
|
||||||
| "RSAES_OAEP_SHA_1"
|
| "RSAES_OAEP_SHA_1"
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import type {CloudResource} from '@/types/resource'
|
|||||||
|
|
||||||
const apiMocks = vi.hoisted(() => ({
|
const apiMocks = vi.hoisted(() => ({
|
||||||
createApiGatewayLambdaRoute: vi.fn(),
|
createApiGatewayLambdaRoute: vi.fn(),
|
||||||
|
deleteApiGatewayRoute: vi.fn(),
|
||||||
listApiGatewayRoutes: vi.fn(),
|
listApiGatewayRoutes: vi.fn(),
|
||||||
listCloudResources: vi.fn(),
|
listCloudResources: vi.fn(),
|
||||||
|
queryLogs: vi.fn(),
|
||||||
testApiGatewayRoute: vi.fn(),
|
testApiGatewayRoute: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -124,3 +126,21 @@ test('selects a deployed route for testing', async () => {
|
|||||||
expect(screen.getAllByRole('combobox')[0]).toHaveValue('POST')
|
expect(screen.getAllByRole('combobox')[0]).toHaveValue('POST')
|
||||||
expect(screen.getAllByRole('combobox')[1]).toHaveValue('orders-handler')
|
expect(screen.getAllByRole('combobox')[1]).toHaveValue('orders-handler')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('confirms before deleting a deployed route', async () => {
|
||||||
|
apiMocks.listCloudResources.mockResolvedValue([lambda])
|
||||||
|
apiMocks.listApiGatewayRoutes.mockResolvedValue({
|
||||||
|
routes: [{resourceId: 'resource-123', path: '/orders', method: 'POST', functionName: 'orders-handler'}], stages: ['dev'],
|
||||||
|
})
|
||||||
|
apiMocks.deleteApiGatewayRoute.mockResolvedValue(undefined)
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||||
|
render(<QueryClientProvider client={queryClient}><ApiGatewayRoutePanel cloud="aws" resource={api} runtimeReachable/></QueryClientProvider>)
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', {name: 'Delete POST /orders'}))
|
||||||
|
expect(screen.getByText('Delete POST?')).toBeInTheDocument()
|
||||||
|
await user.click(screen.getByRole('button', {name: 'Delete'}))
|
||||||
|
await waitFor(() => expect(apiMocks.deleteApiGatewayRoute).toHaveBeenCalledWith('aws', 'api-123', {
|
||||||
|
resourceId: 'resource-123', path: '/orders', method: 'POST', stage: 'dev',
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import {useEffect, useState} from 'react'
|
import {useEffect, useState} from 'react'
|
||||||
import {ArrowRight, Braces, Link2, ListTree, Loader2, Rocket, Send, Waypoints, X} from 'lucide-react'
|
import {ArrowRight, Braces, Copy, Link2, ListTree, Loader2, Rocket, Send, Trash2, Waypoints, X} from 'lucide-react'
|
||||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||||
import {createApiGatewayLambdaRoute, listApiGatewayRoutes, listCloudResources, testApiGatewayRoute} from '@/api/cloudProxyClient'
|
import {createApiGatewayLambdaRoute, deleteApiGatewayRoute, listApiGatewayRoutes, listCloudResources, testApiGatewayRoute} from '@/api/cloudProxyClient'
|
||||||
import type {CloudProvider} from '@/types/cloud'
|
import type {CloudProvider} from '@/types/cloud'
|
||||||
import type {CloudResource} from '@/types/resource'
|
import type {CloudResource} from '@/types/resource'
|
||||||
|
import {LogsQueryPanel} from './LogsQueryPanel'
|
||||||
|
|
||||||
interface ApiGatewayRoutePanelProps {
|
interface ApiGatewayRoutePanelProps {
|
||||||
cloud: CloudProvider
|
cloud: CloudProvider
|
||||||
@@ -18,7 +19,11 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat
|
|||||||
const [functionName, setFunctionName] = useState('')
|
const [functionName, setFunctionName] = useState('')
|
||||||
const [testBody, setTestBody] = useState('{}')
|
const [testBody, setTestBody] = useState('{}')
|
||||||
const [testHeaders, setTestHeaders] = useState('{\n "content-type": "application/json"\n}')
|
const [testHeaders, setTestHeaders] = useState('{\n "content-type": "application/json"\n}')
|
||||||
|
const [testQuery, setTestQuery] = useState('')
|
||||||
|
const [curlCopied, setCurlCopied] = useState(false)
|
||||||
const [bodyFormatError, setBodyFormatError] = useState<string | null>(null)
|
const [bodyFormatError, setBodyFormatError] = useState<string | null>(null)
|
||||||
|
const [logsRefreshKey, setLogsRefreshKey] = useState(0)
|
||||||
|
const [deleteConfirmation, setDeleteConfirmation] = useState<string | null>(null)
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const functionsQuery = useQuery({
|
const functionsQuery = useQuery({
|
||||||
queryKey: ['cloud-resources', cloud, 'serverless'],
|
queryKey: ['cloud-resources', cloud, 'serverless'],
|
||||||
@@ -38,7 +43,11 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat
|
|||||||
setFunctionName('')
|
setFunctionName('')
|
||||||
setTestBody('{}')
|
setTestBody('{}')
|
||||||
setTestHeaders('{\n "content-type": "application/json"\n}')
|
setTestHeaders('{\n "content-type": "application/json"\n}')
|
||||||
|
setTestQuery('')
|
||||||
|
setCurlCopied(false)
|
||||||
setBodyFormatError(null)
|
setBodyFormatError(null)
|
||||||
|
setLogsRefreshKey(0)
|
||||||
|
setDeleteConfirmation(null)
|
||||||
}, [resource?.id])
|
}, [resource?.id])
|
||||||
|
|
||||||
const routeMutation = useMutation({
|
const routeMutation = useMutation({
|
||||||
@@ -52,8 +61,17 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat
|
|||||||
stage,
|
stage,
|
||||||
body: testBody,
|
body: testBody,
|
||||||
headers: parseHeaders(testHeaders),
|
headers: parseHeaders(testHeaders),
|
||||||
query: '',
|
query: testQuery,
|
||||||
}),
|
}),
|
||||||
|
onSuccess: () => setLogsRefreshKey((value) => value + 1),
|
||||||
|
})
|
||||||
|
const deleteRouteMutation = useMutation({
|
||||||
|
mutationFn: ({resourceId, path, method}: {resourceId: string; path: string; method: string}) => deleteApiGatewayRoute(cloud, resource!.id, {resourceId, path, method, stage}),
|
||||||
|
onSuccess: () => {
|
||||||
|
setDeleteConfirmation(null)
|
||||||
|
testMutation.reset()
|
||||||
|
queryClient.invalidateQueries({queryKey: ['api-gateway-routes', cloud, resource!.id]})
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!resource || resource.service !== 'apigateway') {
|
if (!resource || resource.service !== 'apigateway') {
|
||||||
@@ -69,6 +87,8 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat
|
|||||||
|
|
||||||
const functions = functionsQuery.data?.filter((item) => item.type === 'lambda') ?? []
|
const functions = functionsQuery.data?.filter((item) => item.type === 'lambda') ?? []
|
||||||
const canCreate = cloud === 'aws' && runtimeReachable && Boolean(functionName) && !routeMutation.isPending
|
const canCreate = cloud === 'aws' && runtimeReachable && Boolean(functionName) && !routeMutation.isPending
|
||||||
|
const existingPath = routesQuery.data?.routes.some((route) => route.path === path) ?? false
|
||||||
|
const curlCommand = buildCurlCommand(resource.id, stage, path, method, testHeaders, testQuery, testBody)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="table-panel">
|
<section className="table-panel">
|
||||||
@@ -78,6 +98,14 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat
|
|||||||
<h3><Link2 size={15}/> Connect Lambda</h3>
|
<h3><Link2 size={15}/> Connect Lambda</h3>
|
||||||
<p className="muted compact-text">Creates and deploys one Lambda proxy route to the selected REST API.</p>
|
<p className="muted compact-text">Creates and deploys one Lambda proxy route to the selected REST API.</p>
|
||||||
</div>
|
</div>
|
||||||
|
{testMutation.data && functionName && (
|
||||||
|
<LogsQueryPanel
|
||||||
|
cloud={cloud}
|
||||||
|
logGroupName={`/aws/lambda/${functionName}`}
|
||||||
|
runtimeReachable={runtimeReachable}
|
||||||
|
autoRunKey={logsRefreshKey}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="resource-create-inline">
|
<div className="resource-create-inline">
|
||||||
<form className="inspector-action-form apigateway-route-form" onSubmit={(event) => {
|
<form className="inspector-action-form apigateway-route-form" onSubmit={(event) => {
|
||||||
@@ -87,7 +115,7 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat
|
|||||||
<label className="dynamic-field">
|
<label className="dynamic-field">
|
||||||
<span>Route path</span>
|
<span>Route path</span>
|
||||||
<input className="input" value={path} onChange={(event) => setPath(event.target.value)} placeholder="/orders" required/>
|
<input className="input" value={path} onChange={(event) => setPath(event.target.value)} placeholder="/orders" required/>
|
||||||
<small>One path segment, for example <code>/qrcode</code>.</small>
|
<small>{existingPath ? `Adds ${method} to the existing ${path} resource.` : <>One path segment, for example <code>/qrcode</code>.</>}</small>
|
||||||
</label>
|
</label>
|
||||||
<label className="dynamic-field">
|
<label className="dynamic-field">
|
||||||
<span>Method</span>
|
<span>Method</span>
|
||||||
@@ -137,7 +165,9 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat
|
|||||||
<div className="apigateway-route-list">
|
<div className="apigateway-route-list">
|
||||||
{routesQuery.data.routes.map((route) => {
|
{routesQuery.data.routes.map((route) => {
|
||||||
const selected = route.path === path && route.method === method
|
const selected = route.path === path && route.method === method
|
||||||
return <button className={`apigateway-route-item${selected ? ' selected' : ''}`} type="button" key={`${route.resourceId}-${route.method}`} onClick={() => {
|
const key = `${route.resourceId}-${route.method}`
|
||||||
|
return <div className={`apigateway-route-item${selected ? ' selected' : ''}`} key={key}>
|
||||||
|
<button className="apigateway-route-select" type="button" onClick={() => {
|
||||||
setPath(route.path)
|
setPath(route.path)
|
||||||
setMethod(route.method)
|
setMethod(route.method)
|
||||||
setFunctionName(route.functionName ?? '')
|
setFunctionName(route.functionName ?? '')
|
||||||
@@ -148,6 +178,12 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat
|
|||||||
<code>{route.path}</code>
|
<code>{route.path}</code>
|
||||||
<small>{route.functionName ?? 'No Lambda integration'}</small>
|
<small>{route.functionName ?? 'No Lambda integration'}</small>
|
||||||
</button>
|
</button>
|
||||||
|
{deleteConfirmation === key ? <div className="apigateway-route-confirm">
|
||||||
|
<span>Delete {route.method}?</span>
|
||||||
|
<button className="button danger compact" type="button" disabled={deleteRouteMutation.isPending} onClick={() => deleteRouteMutation.mutate(route)}>Delete</button>
|
||||||
|
<button className="button compact" type="button" disabled={deleteRouteMutation.isPending} onClick={() => setDeleteConfirmation(null)}>Cancel</button>
|
||||||
|
</div> : <button className="icon-btn" type="button" aria-label={`Delete ${route.method} ${route.path}`} onClick={() => setDeleteConfirmation(key)}><Trash2 size={13}/></button>}
|
||||||
|
</div>
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -190,6 +226,11 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat
|
|||||||
<span>Request headers (JSON)</span>
|
<span>Request headers (JSON)</span>
|
||||||
<textarea className="textarea code-textarea small" value={testHeaders} onChange={(event) => setTestHeaders(event.target.value)} spellCheck={false}/>
|
<textarea className="textarea code-textarea small" value={testHeaders} onChange={(event) => setTestHeaders(event.target.value)} spellCheck={false}/>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="dynamic-field">
|
||||||
|
<span>Query parameters</span>
|
||||||
|
<input className="input" value={testQuery} onChange={(event) => setTestQuery(event.target.value)} placeholder="page=1&limit=20"/>
|
||||||
|
<small>URL-encoded query string.</small>
|
||||||
|
</label>
|
||||||
<div className="dynamic-field apigateway-test-body">
|
<div className="dynamic-field apigateway-test-body">
|
||||||
<div className="apigateway-test-body-label">
|
<div className="apigateway-test-body-label">
|
||||||
<span>Request body</span>
|
<span>Request body</span>
|
||||||
@@ -225,6 +266,13 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat
|
|||||||
{testMutation.isPending ? <Loader2 size={13}/> : <Send size={13}/>}
|
{testMutation.isPending ? <Loader2 size={13}/> : <Send size={13}/>}
|
||||||
{testMutation.isPending ? 'Sending' : 'Send test request'}
|
{testMutation.isPending ? 'Sending' : 'Send test request'}
|
||||||
</button>
|
</button>
|
||||||
|
<button className="button" type="button" onClick={() => {
|
||||||
|
void navigator.clipboard.writeText(curlCommand)
|
||||||
|
setCurlCopied(true)
|
||||||
|
window.setTimeout(() => setCurlCopied(false), 1200)
|
||||||
|
}}>
|
||||||
|
<Copy size={13}/>{curlCopied ? 'Copied' : 'Copy cURL'}
|
||||||
|
</button>
|
||||||
{testMutation.isError && <p className="error-text compact-text">{testMutation.error instanceof Error ? testMutation.error.message : 'Unable to test API route.'}</p>}
|
{testMutation.isError && <p className="error-text compact-text">{testMutation.error instanceof Error ? testMutation.error.message : 'Unable to test API route.'}</p>}
|
||||||
</form>
|
</form>
|
||||||
<div className="apigateway-test-response">
|
<div className="apigateway-test-response">
|
||||||
@@ -271,3 +319,16 @@ function formatResponseBody(body: string): string {
|
|||||||
return body || '(empty response body)'
|
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 {
|
||||||
|
headers = parseHeaders(headersText)
|
||||||
|
} catch {
|
||||||
|
// Keep cURL available while the user is editing invalid headers.
|
||||||
|
}
|
||||||
|
const url = `http://localhost:4566/execute-api/${apiId}/${stage}${path}${query ? `?${query}` : ''}`
|
||||||
|
const args = [`curl -X ${method}`, `'${url}'`, ...Object.entries(headers).map(([name, value]) => `-H '${name}: ${value.replace(/'/g, "'\\''")}'`)]
|
||||||
|
if (!['GET', 'HEAD', 'OPTIONS'].includes(method) && body) args.push(`--data '${body.replace(/'/g, "'\\''")}'`)
|
||||||
|
return args.join(' ')
|
||||||
|
}
|
||||||
|
|||||||
@@ -48,3 +48,16 @@ test('replaces Hello World source when the Lambda runtime changes', async () =>
|
|||||||
|
|
||||||
expect(screen.getByLabelText('Hello World Source')).toHaveValue('def handler(event, context):\n return {}')
|
expect(screen.getByLabelText('Hello World Source')).toHaveValue('def handler(event, context):\n return {}')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('edits key-value fields as variable rows', async () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<DynamicFormRenderer schema={{...schema, fields: [{name: 'environment', label: 'Environment Variables', type: 'key-value', required: false}]}} isSubmitting={false} onSubmit={onSubmit}/>)
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', {name: 'Add variable'}))
|
||||||
|
await user.type(screen.getByLabelText('Environment Variables key 1'), 'APP_GREETING')
|
||||||
|
await user.type(screen.getByLabelText('Environment Variables value 1'), 'Hello')
|
||||||
|
await user.click(screen.getByRole('button', {name: 'Create'}))
|
||||||
|
|
||||||
|
expect(onSubmit).toHaveBeenCalledWith({environment: '[{"key":"APP_GREETING","value":"Hello"}]'})
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {FormEvent, useState} from 'react'
|
import {FormEvent, useRef, useState} from 'react'
|
||||||
import {Plus} from 'lucide-react'
|
import {Plus, Trash2} from 'lucide-react'
|
||||||
import type {FieldSchema, ServiceSchema} from '@/types/schema'
|
import type {FieldSchema, ServiceSchema} from '@/types/schema'
|
||||||
|
|
||||||
interface DynamicFormRendererProps {
|
interface DynamicFormRendererProps {
|
||||||
@@ -146,6 +146,10 @@ function FieldInput({field, required, maxLength, value, invalid, messageId, onCh
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (field.type === 'key-value') {
|
||||||
|
return <KeyValueInput field={field} value={value} onChange={onChange}/>
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
type={field.type === 'password' ? 'password' : 'text'}
|
type={field.type === 'password' ? 'password' : 'text'}
|
||||||
@@ -163,6 +167,35 @@ function FieldInput({field, required, maxLength, value, invalid, messageId, onCh
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function KeyValueInput({field, value, onChange}: {field: FieldSchema; value: string; onChange: (value: string) => void}) {
|
||||||
|
const [entries, setEntries] = useState(() => keyValueEntries(value))
|
||||||
|
const entriesRef = useRef(entries)
|
||||||
|
|
||||||
|
function commit(next: Array<{key: string; value: string}>) {
|
||||||
|
entriesRef.current = next
|
||||||
|
setEntries(next)
|
||||||
|
onChange(JSON.stringify(next))
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateEntry(index: number, property: 'key' | 'value', nextValue: string) {
|
||||||
|
const next = entriesRef.current.map((entry, entryIndex) => entryIndex === index ? {...entry, [property]: nextValue} : entry)
|
||||||
|
commit(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="key-value-editor">
|
||||||
|
{entries.map((entry, index) => (
|
||||||
|
<div className="key-value-row" key={index}>
|
||||||
|
<input className="input" value={entry.key} placeholder="Variable name" aria-label={`${field.label} key ${index + 1}`} onChange={(event) => updateEntry(index, 'key', event.target.value)}/>
|
||||||
|
<input className="input" value={entry.value} placeholder="Value" aria-label={`${field.label} value ${index + 1}`} onChange={(event) => updateEntry(index, 'value', event.target.value)}/>
|
||||||
|
<button className="icon-btn" type="button" aria-label={`Remove ${entry.key || 'environment variable'}`} onClick={() => commit(entriesRef.current.filter((_, entryIndex) => entryIndex !== index))}><Trash2 size={13}/></button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button className="button compact" type="button" aria-label="Add variable" onClick={() => commit([...entriesRef.current, {key: '', value: ''}])}><Plus size={13}/> Add variable</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function readFileAsBase64(file: File): Promise<string> {
|
function readFileAsBase64(file: File): Promise<string> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const reader = new FileReader()
|
const reader = new FileReader()
|
||||||
@@ -187,13 +220,27 @@ function getInitialFormValues(
|
|||||||
if (initialValues) {
|
if (initialValues) {
|
||||||
for (const [key, val] of Object.entries(initialValues)) {
|
for (const [key, val] of Object.entries(initialValues)) {
|
||||||
if (val !== undefined && val !== null) {
|
if (val !== undefined && val !== null) {
|
||||||
defaults[key] = String(val)
|
const field = fields.find((entry) => entry.name === key)
|
||||||
|
defaults[key] = field?.type === 'key-value' && typeof val === 'object'
|
||||||
|
? JSON.stringify(Object.entries(val as Record<string, unknown>).map(([entryKey, entryValue]) => ({key: entryKey, value: String(entryValue)})))
|
||||||
|
: String(val)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return defaults
|
return defaults
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function keyValueEntries(value: string): Array<{key: string; value: string}> {
|
||||||
|
if (!value) return []
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(value)
|
||||||
|
if (!Array.isArray(parsed)) return []
|
||||||
|
return parsed.filter((entry): entry is {key: string; value: string} => Boolean(entry) && typeof entry === 'object' && typeof entry.key === 'string' && typeof entry.value === 'string')
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function defaultValues(fields: FieldSchema[]): Record<string, string> {
|
function defaultValues(fields: FieldSchema[]): Record<string, string> {
|
||||||
const values = Object.fromEntries(fields.map((field) => [field.name, field.defaultValue ?? '']))
|
const values = Object.fromEntries(fields.map((field) => [field.name, field.defaultValue ?? '']))
|
||||||
for (const field of fields) {
|
for (const field of fields) {
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import {expect, test} from 'vitest'
|
||||||
|
import {bucketDeleteWarning} from '@/lib/bucketDeletion'
|
||||||
|
|
||||||
|
test('warns before deleting a bucket that contains folder prefixes', () => {
|
||||||
|
expect(bucketDeleteWarning({
|
||||||
|
prefix: '',
|
||||||
|
objects: [{key: 'orders/', name: 'orders', type: 'folder', size: null, lastModified: null, metadata: {}}],
|
||||||
|
})).toBe('This bucket contains objects or folders. Delete them before deleting the bucket.')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('allows deleting an empty bucket', () => {
|
||||||
|
expect(bucketDeleteWarning({prefix: '', objects: []})).toBeNull()
|
||||||
|
})
|
||||||
@@ -4,10 +4,16 @@ import { ChevronDown, ChevronUp, Info, Loader2, Plus, RefreshCw, Trash2, X } fro
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
createCloudResource,
|
createCloudResource,
|
||||||
|
createApiGatewayLambdaRoute,
|
||||||
|
createEventBridgeLambdaTrigger,
|
||||||
|
createS3LambdaTrigger,
|
||||||
|
createSqsLambdaEventSourceMapping,
|
||||||
clearEmailInbox,
|
clearEmailInbox,
|
||||||
deleteCloudResource,
|
deleteCloudResource,
|
||||||
|
getCloudResource,
|
||||||
getServiceSchema,
|
getServiceSchema,
|
||||||
listCloudResources,
|
listCloudResources,
|
||||||
|
listStorageObjects,
|
||||||
updateCloudResource,
|
updateCloudResource,
|
||||||
} from "@/api/cloudProxyClient";
|
} from "@/api/cloudProxyClient";
|
||||||
import { DynamicFormRenderer } from "@/components/DynamicFormRenderer";
|
import { DynamicFormRenderer } from "@/components/DynamicFormRenderer";
|
||||||
@@ -39,6 +45,12 @@ import { CreateRdsInstanceForm } from "@/components/CreateRdsInstanceForm";
|
|||||||
import { AppConfigPanel } from "@/components/AppConfigPanel";
|
import { AppConfigPanel } from "@/components/AppConfigPanel";
|
||||||
import { KmsCryptoPanel } from "@/components/KmsCryptoPanel";
|
import { KmsCryptoPanel } from "@/components/KmsCryptoPanel";
|
||||||
import { ApiGatewayRoutePanel } from "@/components/ApiGatewayRoutePanel";
|
import { ApiGatewayRoutePanel } from "@/components/ApiGatewayRoutePanel";
|
||||||
|
import { S3LambdaTriggerPanel } from "@/components/S3LambdaTriggerPanel";
|
||||||
|
import { ScheduledLambdaTriggerPanel } from "@/components/ScheduledLambdaTriggerPanel";
|
||||||
|
import { SqsLambdaTriggerPanel } from "@/components/SqsLambdaTriggerPanel";
|
||||||
|
import { LambdaInvocationHistoryPanel } from "@/components/LambdaInvocationHistoryPanel";
|
||||||
|
import { LambdaCreatePanel } from "@/components/LambdaCreatePanel";
|
||||||
|
import { bucketDeleteWarning } from "@/lib/bucketDeletion";
|
||||||
|
|
||||||
interface DynamicResourceViewProps {
|
interface DynamicResourceViewProps {
|
||||||
cloud: CloudProvider;
|
cloud: CloudProvider;
|
||||||
@@ -73,6 +85,7 @@ export function DynamicResourceView({
|
|||||||
const [selectedItems, setSelectedItems] = useState<CloudResource[]>([]);
|
const [selectedItems, setSelectedItems] = useState<CloudResource[]>([]);
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState(false);
|
const [deleteConfirm, setDeleteConfirm] = useState(false);
|
||||||
const [bulkDeleteError, setBulkDeleteError] = useState<string | null>(null);
|
const [bulkDeleteError, setBulkDeleteError] = useState<string | null>(null);
|
||||||
|
const [deleteWarning, setDeleteWarning] = useState<string | null>(null);
|
||||||
|
|
||||||
|
|
||||||
const handleDatabaseTabChange = (tab: "instances" | "snapshots") => {
|
const handleDatabaseTabChange = (tab: "instances" | "snapshots") => {
|
||||||
@@ -138,8 +151,94 @@ export function DynamicResourceView({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const createMut = useMutation({
|
const createMut = useMutation({
|
||||||
mutationFn: (values: Record<string, unknown>) =>
|
mutationFn: async (values: Record<string, unknown>) => {
|
||||||
createCloudResource(cloud, service, values),
|
const blueprintBucketName = typeof values.blueprintBucketName === "string" ? values.blueprintBucketName.trim() : "";
|
||||||
|
const blueprintApiName = typeof values.blueprintApiName === "string" ? values.blueprintApiName.trim() : "";
|
||||||
|
const blueprintRuleName = typeof values.blueprintRuleName === "string" ? values.blueprintRuleName.trim() : "";
|
||||||
|
const blueprintScheduleExpression = typeof values.blueprintScheduleExpression === "string" ? values.blueprintScheduleExpression.trim() : "";
|
||||||
|
const blueprintQueueName = typeof values.blueprintQueueName === "string" ? values.blueprintQueueName.trim() : "";
|
||||||
|
const blueprintSqsBatchSize = typeof values.blueprintSqsBatchSize === "number" ? values.blueprintSqsBatchSize : undefined;
|
||||||
|
const lambdaValues = {...values};
|
||||||
|
delete lambdaValues.blueprintBucketName;
|
||||||
|
delete lambdaValues.blueprintApiName;
|
||||||
|
delete lambdaValues.blueprintApiPath;
|
||||||
|
delete lambdaValues.blueprintApiMethod;
|
||||||
|
delete lambdaValues.blueprintApiStage;
|
||||||
|
delete lambdaValues.blueprintRuleName;
|
||||||
|
delete lambdaValues.blueprintScheduleExpression;
|
||||||
|
delete lambdaValues.blueprintQueueName;
|
||||||
|
delete lambdaValues.blueprintSqsBatchSize;
|
||||||
|
const resource = await createCloudResource(cloud, service, lambdaValues);
|
||||||
|
if (blueprintBucketName) {
|
||||||
|
let bucketCreated = false;
|
||||||
|
try {
|
||||||
|
await createCloudResource(cloud, "storage", {bucketName: blueprintBucketName});
|
||||||
|
bucketCreated = true;
|
||||||
|
await createS3LambdaTrigger(cloud, blueprintBucketName, {
|
||||||
|
functionName: resource.name,
|
||||||
|
events: ["s3:ObjectCreated:*"],
|
||||||
|
});
|
||||||
|
return resource;
|
||||||
|
} catch (error) {
|
||||||
|
if (bucketCreated) await deleteCloudResource(cloud, "storage", blueprintBucketName);
|
||||||
|
await deleteCloudResource(cloud, service, resource.id);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (blueprintRuleName) {
|
||||||
|
try {
|
||||||
|
await createEventBridgeLambdaTrigger(cloud, resource.name, {
|
||||||
|
ruleName: blueprintRuleName,
|
||||||
|
scheduleExpression: blueprintScheduleExpression,
|
||||||
|
});
|
||||||
|
return resource;
|
||||||
|
} catch (error) {
|
||||||
|
await deleteCloudResource(cloud, service, resource.id);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (blueprintQueueName) {
|
||||||
|
let queueId: string | undefined;
|
||||||
|
try {
|
||||||
|
const queue = await createCloudResource(cloud, "messaging", {queueName: blueprintQueueName});
|
||||||
|
queueId = queue.id;
|
||||||
|
const queueArn = queue.metadata.arn;
|
||||||
|
if (typeof queueArn !== "string" || !queueArn) {
|
||||||
|
throw new Error("Created SQS queue did not return an ARN.");
|
||||||
|
}
|
||||||
|
await createSqsLambdaEventSourceMapping(cloud, resource.name, {
|
||||||
|
queueArn,
|
||||||
|
batchSize: blueprintSqsBatchSize,
|
||||||
|
});
|
||||||
|
return resource;
|
||||||
|
} catch (error) {
|
||||||
|
const rollbacks = [deleteCloudResource(cloud, service, resource.id)];
|
||||||
|
if (queueId) rollbacks.push(deleteCloudResource(cloud, "messaging", queueId));
|
||||||
|
await Promise.allSettled(rollbacks);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!blueprintApiName) return resource;
|
||||||
|
|
||||||
|
let apiId: string | undefined;
|
||||||
|
try {
|
||||||
|
const api = await createCloudResource(cloud, "apigateway", {name: blueprintApiName});
|
||||||
|
apiId = api.id;
|
||||||
|
const route = await createApiGatewayLambdaRoute(cloud, api.id, {
|
||||||
|
functionName: resource.name,
|
||||||
|
path: String(values.blueprintApiPath ?? ""),
|
||||||
|
method: String(values.blueprintApiMethod ?? "GET"),
|
||||||
|
stage: String(values.blueprintApiStage ?? "dev"),
|
||||||
|
});
|
||||||
|
return {...resource, metadata: {...resource.metadata, apiGatewayId: api.id, apiGatewayInvokeUrl: route.invokeUrl}};
|
||||||
|
} catch (error) {
|
||||||
|
if (apiId) await deleteCloudResource(cloud, "apigateway", apiId);
|
||||||
|
await deleteCloudResource(cloud, service, resource.id);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
onSuccess: (resource) => {
|
onSuccess: (resource) => {
|
||||||
setSelected(resource);
|
setSelected(resource);
|
||||||
setCreateOpen(false);
|
setCreateOpen(false);
|
||||||
@@ -150,15 +249,25 @@ export function DynamicResourceView({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const deleteMut = useMutation({
|
const deleteMut = useMutation({
|
||||||
mutationFn: (resource: CloudResource) =>
|
mutationFn: async (resource: CloudResource) => {
|
||||||
deleteCloudResource(cloud, service, resource.id),
|
if (cloud === "aws" && service === "storage") {
|
||||||
|
const contents = await listStorageObjects(cloud, resource.id);
|
||||||
|
const warning = bucketDeleteWarning(contents);
|
||||||
|
if (warning) throw new Error(warning);
|
||||||
|
}
|
||||||
|
await deleteCloudResource(cloud, service, resource.id);
|
||||||
|
},
|
||||||
onSuccess: (_, resource) => {
|
onSuccess: (_, resource) => {
|
||||||
|
setDeleteWarning(null);
|
||||||
if (selected?.id === resource.id) setSelected(undefined);
|
if (selected?.id === resource.id) setSelected(undefined);
|
||||||
setSelectedItems((prev) => prev.filter((item) => item.id !== resource.id));
|
setSelectedItems((prev) => prev.filter((item) => item.id !== resource.id));
|
||||||
void qc.invalidateQueries({
|
void qc.invalidateQueries({
|
||||||
queryKey: ["cloud-resources", cloud, service],
|
queryKey: ["cloud-resources", cloud, service],
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
setDeleteWarning(error instanceof Error ? error.message : "Unable to delete resource.");
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const deleteMultipleMut = useMutation({
|
const deleteMultipleMut = useMutation({
|
||||||
@@ -234,6 +343,13 @@ export function DynamicResourceView({
|
|||||||
}
|
}
|
||||||
}, [successToast]);
|
}, [successToast]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (deleteWarning) {
|
||||||
|
const timer = setTimeout(() => setDeleteWarning(null), 5000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [deleteWarning]);
|
||||||
|
|
||||||
const updateMut = useMutation({
|
const updateMut = useMutation({
|
||||||
mutationFn: ({ id, values }: { id: string; values: Record<string, unknown> }) =>
|
mutationFn: ({ id, values }: { id: string; values: Record<string, unknown> }) =>
|
||||||
updateCloudResource(cloud, service, id, values),
|
updateCloudResource(cloud, service, id, values),
|
||||||
@@ -280,8 +396,9 @@ export function DynamicResourceView({
|
|||||||
if (!trimmed && !field.required) {
|
if (!trimmed && !field.required) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const initialStr =
|
const initialStr = field.type === 'key-value' && initialVal && typeof initialVal === 'object'
|
||||||
initialVal !== undefined && initialVal !== null ? String(initialVal).trim() : "";
|
? JSON.stringify(Object.entries(initialVal).map(([key, value]) => ({key, value: String(value)})))
|
||||||
|
: initialVal !== undefined && initialVal !== null ? String(initialVal).trim() : "";
|
||||||
if (trimmed !== initialStr) {
|
if (trimmed !== initialStr) {
|
||||||
diff[field.name] = trimmed;
|
diff[field.name] = trimmed;
|
||||||
}
|
}
|
||||||
@@ -623,6 +740,14 @@ export function DynamicResourceView({
|
|||||||
}}
|
}}
|
||||||
onCancel={() => setCreateOpen(false)}
|
onCancel={() => setCreateOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
) : service === "serverless" && cloud === "aws" ? (
|
||||||
|
<LambdaCreatePanel
|
||||||
|
schema={schema}
|
||||||
|
isSubmitting={createMut.isPending}
|
||||||
|
submitError={createMut.error instanceof Error ? createMut.error.message : null}
|
||||||
|
onCancel={() => setCreateOpen(false)}
|
||||||
|
onSubmit={(values) => createMut.mutate(values)}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<DynamicFormRenderer
|
<DynamicFormRenderer
|
||||||
key={`${cloud}:${service}:create`}
|
key={`${cloud}:${service}:create`}
|
||||||
@@ -658,9 +783,14 @@ export function DynamicResourceView({
|
|||||||
schema.updateFields &&
|
schema.updateFields &&
|
||||||
schema.updateFields.length > 0 &&
|
schema.updateFields.length > 0 &&
|
||||||
schema.actions.includes("update")
|
schema.actions.includes("update")
|
||||||
? (resource) => {
|
? async (resource) => {
|
||||||
setEditingResource(resource);
|
try {
|
||||||
|
const detailedResource = await getCloudResource(cloud, service, resource.id);
|
||||||
|
setEditingResource(detailedResource);
|
||||||
setUpdateError(null);
|
setUpdateError(null);
|
||||||
|
} catch (error) {
|
||||||
|
setUpdateError(error instanceof Error ? error.message : "Unable to load resource configuration");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
onDelete: (resource) => deleteMut.mutate(resource),
|
onDelete: (resource) => deleteMut.mutate(resource),
|
||||||
@@ -709,11 +839,31 @@ export function DynamicResourceView({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{service === "serverless" && (
|
{service === "serverless" && (
|
||||||
|
<>
|
||||||
<ServerlessInvokePanel
|
<ServerlessInvokePanel
|
||||||
cloud={cloud}
|
cloud={cloud}
|
||||||
resource={activeSelected}
|
resource={activeSelected}
|
||||||
runtimeReachable={canUseRuntime}
|
runtimeReachable={canUseRuntime}
|
||||||
/>
|
/>
|
||||||
|
<S3LambdaTriggerPanel
|
||||||
|
cloud={cloud}
|
||||||
|
resource={activeSelected}
|
||||||
|
runtimeReachable={canUseRuntime}
|
||||||
|
/>
|
||||||
|
<ScheduledLambdaTriggerPanel
|
||||||
|
cloud={cloud}
|
||||||
|
resource={activeSelected}
|
||||||
|
runtimeReachable={canUseRuntime}
|
||||||
|
/>
|
||||||
|
<SqsLambdaTriggerPanel
|
||||||
|
cloud={cloud}
|
||||||
|
resource={activeSelected}
|
||||||
|
runtimeReachable={canUseRuntime}
|
||||||
|
/>
|
||||||
|
{cloud === "aws" && activeSelected?.type === "lambda" && (
|
||||||
|
<LambdaInvocationHistoryPanel cloud={cloud} functionName={activeSelected.name} runtimeReachable={canUseRuntime}/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{service === "apigateway" && (
|
{service === "apigateway" && (
|
||||||
<ApiGatewayRoutePanel
|
<ApiGatewayRoutePanel
|
||||||
@@ -803,6 +953,35 @@ export function DynamicResourceView({
|
|||||||
{successToast}
|
{successToast}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{deleteWarning && (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
bottom: 24,
|
||||||
|
right: 24,
|
||||||
|
background: "var(--status-error, #dc2626)",
|
||||||
|
color: "#fff",
|
||||||
|
padding: "10px 18px",
|
||||||
|
borderRadius: 6,
|
||||||
|
boxShadow: "0 4px 16px rgba(0,0,0,0.3)",
|
||||||
|
zIndex: 1000,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{deleteWarning}</span>
|
||||||
|
<button
|
||||||
|
className="icon-btn"
|
||||||
|
type="button"
|
||||||
|
aria-label="Dismiss warning"
|
||||||
|
onClick={() => setDeleteWarning(null)}
|
||||||
|
style={{ marginLeft: 10, color: "inherit" }}
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import {render, screen} from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import {expect, test, vi} from 'vitest'
|
||||||
|
import {LambdaCreatePanel} from './LambdaCreatePanel'
|
||||||
|
import type {ServiceSchema} from '@/types/schema'
|
||||||
|
|
||||||
|
const schema: ServiceSchema = {
|
||||||
|
cloud: 'aws', service: 'serverless', displayName: 'AWS Lambda', actions: ['create'], columns: [], filters: [], capabilities: {resourceActions: []},
|
||||||
|
fields: [
|
||||||
|
{name: 'functionName', label: 'Function Name', type: 'text', required: true},
|
||||||
|
{name: 'runtime', label: 'Runtime', type: 'select', required: true, options: [{label: 'Node.js 22.x', value: 'nodejs22.x'}]},
|
||||||
|
{name: 'handler', label: 'Handler', type: 'text', required: true},
|
||||||
|
{name: 'role', label: 'Execution Role ARN', type: 'text', required: true},
|
||||||
|
{name: 'description', label: 'Description', type: 'text', required: false},
|
||||||
|
{name: 'code', label: 'Hello World Source', type: 'textarea', required: false},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPanel(onSubmit = vi.fn()) {
|
||||||
|
render(<LambdaCreatePanel schema={schema} isSubmitting={false} submitError={null} onCancel={vi.fn()} onSubmit={onSubmit}/>)
|
||||||
|
return onSubmit
|
||||||
|
}
|
||||||
|
|
||||||
|
test('offers scratch and blueprint Lambda creation paths', () => {
|
||||||
|
renderPanel()
|
||||||
|
expect(screen.getByRole('button', {name: /start from scratch/i})).toBeVisible()
|
||||||
|
expect(screen.getByRole('button', {name: /^blueprints/i})).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('prefills the reviewed S3 upload processor blueprint', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderPanel()
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', {name: /^blueprints/i}))
|
||||||
|
expect(screen.getByText('Lambda Blueprints')).toBeVisible()
|
||||||
|
await user.click(screen.getByRole('button', {name: /s3 upload processor/i}))
|
||||||
|
|
||||||
|
expect(screen.getByText('Blueprint: S3 upload processor')).toBeVisible()
|
||||||
|
expect(screen.getByDisplayValue('Processes S3 object-created events.')).toBeVisible()
|
||||||
|
expect(screen.getByDisplayValue(/record\.s3\.bucket\.name/)).toBeVisible()
|
||||||
|
expect(screen.getByText(/create lambda, create bucket/i)).toBeVisible()
|
||||||
|
expect(screen.getByLabelText(/s3 bucket name/i)).toBeRequired()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keeps the blank creation form available', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderPanel()
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', {name: /start from scratch/i}))
|
||||||
|
|
||||||
|
expect(screen.getByText('Start from scratch')).toBeVisible()
|
||||||
|
expect(screen.getByLabelText(/function name/i)).toHaveValue('')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('prefills the REST Lambda API blueprint and its deployment plan', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderPanel()
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', {name: /^blueprints/i}))
|
||||||
|
await user.click(screen.getByRole('button', {name: /rest lambda api/i}))
|
||||||
|
|
||||||
|
expect(screen.getByText('Blueprint: REST Lambda API')).toBeVisible()
|
||||||
|
expect(screen.getByDisplayValue(/event\.httpMethod/)).toBeVisible()
|
||||||
|
expect(screen.getByLabelText(/api name/i)).toBeVisible()
|
||||||
|
expect(screen.getByText(/grant api gateway permission/i)).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('prefills the scheduled Lambda blueprint and its EventBridge schedule', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderPanel()
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', {name: /^blueprints/i}))
|
||||||
|
await user.click(screen.getByRole('button', {name: /scheduled lambda/i}))
|
||||||
|
|
||||||
|
expect(screen.getByText('Blueprint: Scheduled Lambda')).toBeVisible()
|
||||||
|
expect(screen.getByDisplayValue(/scheduled event received/i)).toBeVisible()
|
||||||
|
expect(screen.getByLabelText(/rule name/i)).toBeVisible()
|
||||||
|
expect(screen.getByLabelText(/schedule expression/i)).toHaveValue('rate(5 minutes)')
|
||||||
|
expect(screen.getByText(/create eventbridge rule/i)).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('prefills the SQS worker blueprint and submits queue mapping settings', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const onSubmit = renderPanel()
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', {name: /^blueprints/i}))
|
||||||
|
await user.click(screen.getByRole('button', {name: /sqs worker/i}))
|
||||||
|
|
||||||
|
expect(screen.getByText('Blueprint: SQS Worker')).toBeVisible()
|
||||||
|
expect(screen.getByDisplayValue(/event\.Records/)).toBeVisible()
|
||||||
|
await user.type(screen.getByLabelText(/sqs queue name/i), 'orders-queue')
|
||||||
|
await user.clear(screen.getByLabelText(/batch size/i))
|
||||||
|
await user.type(screen.getByLabelText(/batch size/i), '5')
|
||||||
|
await user.type(screen.getByLabelText(/function name/i), 'orders-worker')
|
||||||
|
await user.type(screen.getByLabelText(/execution role arn/i), 'arn:aws:iam::000000000000:role/lambda')
|
||||||
|
await user.click(screen.getByRole('button', {name: 'Create Lambda'}))
|
||||||
|
|
||||||
|
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
blueprintQueueName: 'orders-queue', blueprintSqsBatchSize: 5,
|
||||||
|
code: expect.stringContaining('event.Records'),
|
||||||
|
}))
|
||||||
|
})
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import {useState} from 'react'
|
||||||
|
import {Braces, Boxes} from 'lucide-react'
|
||||||
|
import {DynamicFormRenderer} from './DynamicFormRenderer'
|
||||||
|
import type {ServiceSchema} from '@/types/schema'
|
||||||
|
|
||||||
|
interface LambdaCreatePanelProps {
|
||||||
|
schema: ServiceSchema
|
||||||
|
isSubmitting: boolean
|
||||||
|
submitError: string | null
|
||||||
|
onCancel: () => void
|
||||||
|
onSubmit: (values: Record<string, unknown>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LambdaCreatePanel({schema, isSubmitting, submitError, onCancel, onSubmit}: LambdaCreatePanelProps) {
|
||||||
|
const [mode, setMode] = useState<'choose' | 'catalog' | 'scratch' | 's3' | 'api' | 'scheduled' | 'sqs'>('choose')
|
||||||
|
const [bucketName, setBucketName] = useState('')
|
||||||
|
const [apiName, setApiName] = useState('')
|
||||||
|
const [apiPath, setApiPath] = useState('/hello')
|
||||||
|
const [apiMethod, setApiMethod] = useState('GET')
|
||||||
|
const [apiStage, setApiStage] = useState('dev')
|
||||||
|
const [ruleName, setRuleName] = useState('')
|
||||||
|
const [scheduleExpression, setScheduleExpression] = useState('rate(5 minutes)')
|
||||||
|
const [queueName, setQueueName] = useState('')
|
||||||
|
const [batchSize, setBatchSize] = useState('10')
|
||||||
|
const [blueprintError, setBlueprintError] = useState<string | null>(null)
|
||||||
|
if (mode === 'choose') return <div className="resource-create-inline">
|
||||||
|
<p className="eyebrow">Create Lambda</p><h3>How do you want to start?</h3>
|
||||||
|
<div className="apigateway-request-flow">
|
||||||
|
<button className="apigateway-flow-step" type="button" onClick={() => setMode('scratch')}><Braces size={18}/><div><strong>Start from scratch</strong><small>Choose runtime, handler, source, and settings.</small></div></button>
|
||||||
|
<button className="apigateway-flow-step" type="button" onClick={() => setMode('catalog')}><Boxes size={18}/><div><strong>Blueprints</strong><small>Choose a reviewed multi-resource starter.</small></div></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
if (mode === 'catalog') return <div className="resource-create-inline">
|
||||||
|
<div className="dynamic-stage-header"><div><p className="eyebrow">Lambda Blueprints</p><h3>Choose a starting point</h3><p className="muted compact-text">Each blueprint displays its source, resources, permissions, and deployment plan before it creates anything.</p></div><button className="button" type="button" onClick={() => setMode('choose')}>Back</button></div>
|
||||||
|
<div className="apigateway-request-flow">
|
||||||
|
<button className="apigateway-flow-step" type="button" onClick={() => setMode('s3')}><Boxes size={18}/><div><strong>S3 upload processor</strong><small>Create a bucket and invoke Lambda for new objects.</small></div></button>
|
||||||
|
<button className="apigateway-flow-step" type="button" onClick={() => setMode('api')}><Braces size={18}/><div><strong>REST Lambda API</strong><small>Create and deploy an API Gateway Lambda proxy route.</small></div></button>
|
||||||
|
<button className="apigateway-flow-step" type="button" onClick={() => setMode('scheduled')}><Boxes size={18}/><div><strong>Scheduled Lambda</strong><small>Invoke Lambda on an EventBridge schedule.</small></div></button>
|
||||||
|
<button className="apigateway-flow-step" type="button" onClick={() => setMode('sqs')}><Boxes size={18}/><div><strong>SQS Worker</strong><small>Create a queue and process messages in Lambda batches.</small></div></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
const blueprint = mode === 's3' ? {
|
||||||
|
runtime: 'nodejs22.x', handler: 'index.handler', description: 'Processes S3 object-created events.',
|
||||||
|
code: S3_UPLOAD_PROCESSOR_SOURCE,
|
||||||
|
} : mode === 'api' ? {
|
||||||
|
runtime: 'nodejs22.x', handler: 'index.handler', description: 'Handles API Gateway Lambda proxy requests.',
|
||||||
|
code: REST_LAMBDA_API_SOURCE,
|
||||||
|
} : mode === 'scheduled' ? {
|
||||||
|
runtime: 'nodejs22.x', handler: 'index.handler', description: 'Runs on an EventBridge schedule.',
|
||||||
|
code: SCHEDULED_LAMBDA_SOURCE,
|
||||||
|
} : mode === 'sqs' ? {
|
||||||
|
runtime: 'nodejs22.x', handler: 'index.handler', description: 'Processes messages from an SQS queue.',
|
||||||
|
code: SQS_WORKER_SOURCE,
|
||||||
|
} : {}
|
||||||
|
return <div className="resource-create-inline">
|
||||||
|
<div className="dynamic-stage-header"><div><p className="eyebrow">{mode === 's3' ? 'Blueprint: S3 upload processor' : mode === 'api' ? 'Blueprint: REST Lambda API' : mode === 'scheduled' ? 'Blueprint: Scheduled Lambda' : mode === 'sqs' ? 'Blueprint: SQS Worker' : 'Start from scratch'}</p><p className="muted compact-text">{mode === 's3' ? 'Review the Lambda, bucket, trigger, and permission before creating them.' : mode === 'api' ? 'Review the Lambda, REST API, route, deployment, and permission before creating them.' : mode === 'scheduled' ? 'Review the Lambda and EventBridge schedule before creating them.' : mode === 'sqs' ? 'Review the Lambda, SQS queue, and event source mapping before creating them.' : 'Create a Lambda with your own source and configuration.'}</p></div><button className="button" type="button" disabled={isSubmitting} onClick={() => setMode('choose')}>Back</button></div>
|
||||||
|
{mode === 's3' && <label className="dynamic-field"><span>S3 Bucket Name<em className="field-required">*</em></span><input className="input" value={bucketName} required disabled={isSubmitting} onChange={(event) => { setBucketName(event.target.value); setBlueprintError(null) }} placeholder="my-upload-bucket"/><small>Creates this bucket, then invokes the Lambda for every object-created event.</small></label>}
|
||||||
|
{mode === 's3' && <div className="apigateway-route-explorer"><p className="eyebrow">Creation plan</p><p className="muted compact-text">Create Lambda, create bucket <code>{bucketName || 'your-bucket'}</code>, grant S3 permission to invoke the Lambda, then add an <code>s3:ObjectCreated:*</code> trigger.</p></div>}
|
||||||
|
{mode === 'api' && <div className="dynamic-form">
|
||||||
|
<label className="dynamic-field"><span>API Name<em className="field-required">*</em></span><input className="input" value={apiName} disabled={isSubmitting} onChange={(event) => setApiName(event.target.value)} placeholder="orders-api"/><small>Creates an API Gateway REST API.</small></label>
|
||||||
|
<label className="dynamic-field"><span>Route Path<em className="field-required">*</em></span><input className="input" value={apiPath} disabled={isSubmitting} onChange={(event) => setApiPath(event.target.value)} placeholder="/orders"/><small>One top-level Lambda proxy path.</small></label>
|
||||||
|
<label className="dynamic-field"><span>Method</span><select className="input" value={apiMethod} disabled={isSubmitting} onChange={(event) => setApiMethod(event.target.value)}>{['GET', 'POST', 'PUT', 'DELETE'].map((value) => <option key={value} value={value}>{value}</option>)}</select></label>
|
||||||
|
<label className="dynamic-field"><span>Stage<em className="field-required">*</em></span><input className="input" value={apiStage} disabled={isSubmitting} onChange={(event) => setApiStage(event.target.value)} placeholder="dev"/></label>
|
||||||
|
</div>}
|
||||||
|
{mode === 'api' && <div className="apigateway-route-explorer"><p className="eyebrow">Creation plan</p><p className="muted compact-text">Create Lambda, create REST API <code>{apiName || 'your-api'}</code>, add <code>{apiMethod} {apiPath || '/route'}</code>, grant API Gateway permission, and deploy stage <code>{apiStage || 'dev'}</code>.</p></div>}
|
||||||
|
{mode === 'scheduled' && <div className="dynamic-form">
|
||||||
|
<label className="dynamic-field"><span>Rule Name<em className="field-required">*</em></span><input className="input" value={ruleName} disabled={isSubmitting} onChange={(event) => setRuleName(event.target.value)} placeholder="hourly-report"/><small>Creates this EventBridge rule.</small></label>
|
||||||
|
<label className="dynamic-field"><span>Schedule Expression<em className="field-required">*</em></span><input className="input" value={scheduleExpression} disabled={isSubmitting} onChange={(event) => setScheduleExpression(event.target.value)} placeholder="rate(5 minutes)"/><small>Use a rate or cron expression.</small></label>
|
||||||
|
</div>}
|
||||||
|
{mode === 'scheduled' && <div className="apigateway-route-explorer"><p className="eyebrow">Creation plan</p><p className="muted compact-text">Create Lambda, create EventBridge rule <code>{ruleName || 'your-rule'}</code>, then invoke it on <code>{scheduleExpression || 'your schedule'}</code>.</p></div>}
|
||||||
|
{mode === 'sqs' && <div className="dynamic-form">
|
||||||
|
<label className="dynamic-field"><span>SQS Queue Name<em className="field-required">*</em></span><input className="input" value={queueName} required disabled={isSubmitting} onChange={(event) => { setQueueName(event.target.value); setBlueprintError(null) }} placeholder="orders-queue"/><small>Creates this SQS queue.</small></label>
|
||||||
|
<label className="dynamic-field"><span>Batch Size<em className="field-required">*</em></span><input className="input" type="number" min="1" max="10" value={batchSize} required disabled={isSubmitting} onChange={(event) => { setBatchSize(event.target.value); setBlueprintError(null) }}/><small>Messages sent to Lambda per invocation, from 1 to 10.</small></label>
|
||||||
|
</div>}
|
||||||
|
{mode === 'sqs' && <div className="apigateway-route-explorer"><p className="eyebrow">Creation plan</p><p className="muted compact-text">Create Lambda, create SQS queue <code>{queueName || 'your-queue'}</code>, then create an event source mapping with a batch size of <code>{batchSize || '10'}</code>.</p></div>}
|
||||||
|
{blueprintError && <p className="form-error" role="alert">{blueprintError}</p>}
|
||||||
|
<DynamicFormRenderer schema={schema} initialValues={blueprint} isSubmitting={isSubmitting} submitLabel={mode === 's3' ? 'Create blueprint' : 'Create Lambda'} pendingLabel="Creating Lambda" submitError={submitError} onCancel={onCancel} onSubmit={(values) => {
|
||||||
|
if (mode === 's3' && !bucketName.trim()) {
|
||||||
|
setBlueprintError('S3 bucket name is required.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (mode === 'api' && (!apiName.trim() || !apiPath.trim() || !apiStage.trim())) {
|
||||||
|
setBlueprintError('API name, route path, and stage are required.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (mode === 'scheduled' && (!ruleName.trim() || !scheduleExpression.trim())) {
|
||||||
|
setBlueprintError('Rule name and schedule expression are required.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const parsedBatchSize = Number(batchSize)
|
||||||
|
if (mode === 'sqs' && (!queueName.trim() || !Number.isInteger(parsedBatchSize) || parsedBatchSize < 1 || parsedBatchSize > 10)) {
|
||||||
|
setBlueprintError('SQS queue name and a batch size from 1 to 10 are required.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onSubmit(mode === 's3' ? {...values, blueprintBucketName: bucketName} : mode === 'api' ? {...values, blueprintApiName: apiName, blueprintApiPath: apiPath, blueprintApiMethod: apiMethod, blueprintApiStage: apiStage} : mode === 'scheduled' ? {...values, blueprintRuleName: ruleName, blueprintScheduleExpression: scheduleExpression} : mode === 'sqs' ? {...values, blueprintQueueName: queueName, blueprintSqsBatchSize: parsedBatchSize} : values)
|
||||||
|
}}/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
const S3_UPLOAD_PROCESSOR_SOURCE = `exports.handler = async (event) => {
|
||||||
|
for (const record of event.Records ?? []) {
|
||||||
|
console.log("S3 object created:", {
|
||||||
|
bucket: record.s3.bucket.name,
|
||||||
|
key: record.s3.object.key,
|
||||||
|
event: record.eventName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { statusCode: 200, body: JSON.stringify({message: "Processed S3 event"}) };
|
||||||
|
};`
|
||||||
|
|
||||||
|
const REST_LAMBDA_API_SOURCE = `exports.handler = async (event) => {
|
||||||
|
return {
|
||||||
|
statusCode: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
message: "Hello from Lambda",
|
||||||
|
method: event.httpMethod,
|
||||||
|
path: event.path,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
};`
|
||||||
|
|
||||||
|
const SCHEDULED_LAMBDA_SOURCE = `exports.handler = async (event) => {
|
||||||
|
console.log("Scheduled event received:", {
|
||||||
|
id: event.id,
|
||||||
|
time: event.time,
|
||||||
|
source: event.source,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { statusCode: 200, body: JSON.stringify({message: "Scheduled task completed"}) };
|
||||||
|
};`
|
||||||
|
|
||||||
|
const SQS_WORKER_SOURCE = `exports.handler = async (event) => {
|
||||||
|
for (const record of event.Records ?? []) {
|
||||||
|
console.log("SQS message received:", {
|
||||||
|
messageId: record.messageId,
|
||||||
|
body: record.body,
|
||||||
|
attributes: record.attributes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { statusCode: 200, body: JSON.stringify({message: "Processed SQS batch"}) };
|
||||||
|
};`
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import {Activity, RefreshCw, Timer, TriangleAlert} from 'lucide-react'
|
||||||
|
import {useQuery} from '@tanstack/react-query'
|
||||||
|
import {queryLogs} from '@/api/cloudProxyClient'
|
||||||
|
import type {CloudProvider} from '@/types/cloud'
|
||||||
|
|
||||||
|
interface LambdaInvocationHistoryPanelProps {
|
||||||
|
cloud: CloudProvider
|
||||||
|
functionName: string
|
||||||
|
runtimeReachable: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InvocationRecord {
|
||||||
|
requestId: string
|
||||||
|
timestamp: string
|
||||||
|
durationMs: number
|
||||||
|
errorType: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const HISTORY_QUERY = 'fields @timestamp, @message\n| filter @message like /REPORT RequestId:/\n| sort @timestamp desc\n| limit 20'
|
||||||
|
|
||||||
|
export function LambdaInvocationHistoryPanel({cloud, functionName, runtimeReachable}: LambdaInvocationHistoryPanelProps) {
|
||||||
|
const history = useQuery({
|
||||||
|
queryKey: ['lambda-invocation-history', cloud, functionName],
|
||||||
|
queryFn: async () => {
|
||||||
|
const endTime = Math.floor(Date.now() / 1000)
|
||||||
|
try {
|
||||||
|
const result = await queryLogs(cloud, `/aws/lambda/${functionName}`, {
|
||||||
|
queryString: HISTORY_QUERY,
|
||||||
|
startTime: endTime - 24 * 60 * 60,
|
||||||
|
endTime,
|
||||||
|
})
|
||||||
|
return result.rows.map(toInvocationRecord).filter((record): record is InvocationRecord => record !== null)
|
||||||
|
} catch (error) {
|
||||||
|
if (isMissingLogGroup(error)) return []
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled: cloud === 'aws' && runtimeReachable,
|
||||||
|
})
|
||||||
|
const records = history.data ?? []
|
||||||
|
const errors = records.filter((record) => record.errorType !== null).length
|
||||||
|
const averageDuration = records.length === 0 ? null : records.reduce((sum, record) => sum + record.durationMs, 0) / records.length
|
||||||
|
|
||||||
|
return <section className="table-panel">
|
||||||
|
<div className="dynamic-stage-header">
|
||||||
|
<div><p className="eyebrow">Observability</p><h3><Activity size={15}/> Invocation history</h3><p className="muted compact-text">Real Lambda platform records from the last 24 hours.</p></div>
|
||||||
|
<button className="button" type="button" disabled={history.isFetching} onClick={() => history.refetch()}><RefreshCw size={13}/>{history.isFetching ? 'Refreshing' : 'Refresh'}</button>
|
||||||
|
</div>
|
||||||
|
<div className="apigateway-request-flow">
|
||||||
|
<div className="apigateway-flow-step"><Timer size={16}/><div><strong>{averageDuration === null ? 'No duration' : `${averageDuration.toFixed(1)} ms`}</strong><small>Average duration</small></div></div>
|
||||||
|
<div className="apigateway-flow-step"><TriangleAlert size={16}/><div><strong>{errors}</strong><small>Function errors</small></div></div>
|
||||||
|
<div className="apigateway-flow-step"><Activity size={16}/><div><strong>{records.length}</strong><small>Recorded invocations</small></div></div>
|
||||||
|
</div>
|
||||||
|
{history.isError && <p className="error-text compact-text">{history.error instanceof Error ? history.error.message : 'Unable to load invocation history.'}</p>}
|
||||||
|
{!history.isLoading && records.length === 0 && <p className="muted compact-text">No Lambda platform records yet. Invoke the function or trigger it from S3, then refresh.</p>}
|
||||||
|
{records.length > 0 && <table className="table"><thead><tr><th>Time</th><th>Request ID</th><th>Duration</th><th>Outcome</th></tr></thead><tbody>{records.map((record) => <tr key={record.requestId}><td>{record.timestamp}</td><td><code>{record.requestId}</code></td><td>{record.durationMs.toFixed(2)} ms</td><td>{record.errorType ? <span className="error-text">{record.errorType}</span> : 'Success'}</td></tr>)}</tbody></table>}
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
function toInvocationRecord(row: Record<string, string>): InvocationRecord | null {
|
||||||
|
const message = row['@message'] ?? ''
|
||||||
|
const match = /REPORT RequestId: (\S+) Duration: ([\d.]+) ms.*?(?: Error Type: (.+))?$/.exec(message)
|
||||||
|
if (!match) return null
|
||||||
|
return {requestId: match[1], timestamp: row['@timestamp'] ?? '', durationMs: Number(match[2]), errorType: match[3] ?? null}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMissingLogGroup(error: unknown): boolean {
|
||||||
|
return error instanceof Error && /log group does not exist|resource_not_found/i.test(error.message)
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ interface LogsQueryPanelProps {
|
|||||||
cloud: CloudProvider
|
cloud: CloudProvider
|
||||||
logGroupName?: string
|
logGroupName?: string
|
||||||
runtimeReachable: boolean
|
runtimeReachable: boolean
|
||||||
|
autoRunKey?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -35,19 +36,24 @@ interface LogsQueryPanelProps {
|
|||||||
* implement (stats/visualize, pattern analysis, live tail) — this repo's own
|
* implement (stats/visualize, pattern analysis, live tail) — this repo's own
|
||||||
* rule against decorative, unwired controls.
|
* rule against decorative, unwired controls.
|
||||||
*/
|
*/
|
||||||
export function LogsQueryPanel({cloud, logGroupName, runtimeReachable}: LogsQueryPanelProps) {
|
export function LogsQueryPanel({cloud, logGroupName, runtimeReachable, autoRunKey = 0}: LogsQueryPanelProps) {
|
||||||
const [queryText, setQueryText] = useState(DEFAULT_QUERY)
|
const [queryText, setQueryText] = useState(DEFAULT_QUERY)
|
||||||
const [rangeMinutes, setRangeMinutes] = useState(RANGE_OPTIONS[1].minutes)
|
const [rangeMinutes, setRangeMinutes] = useState(RANGE_OPTIONS[1].minutes)
|
||||||
const [expanded, setExpanded] = useState(false)
|
const [expanded, setExpanded] = useState(false)
|
||||||
|
|
||||||
const queryMut = useMutation({
|
const queryMut = useMutation({
|
||||||
mutationFn: () => {
|
mutationFn: async () => {
|
||||||
const endTime = Math.floor(Date.now() / 1000)
|
const endTime = Math.floor(Date.now() / 1000)
|
||||||
return queryLogs(cloud, logGroupName ?? '', {
|
try {
|
||||||
|
return await queryLogs(cloud, logGroupName ?? '', {
|
||||||
queryString: queryText,
|
queryString: queryText,
|
||||||
startTime: endTime - rangeMinutes * 60,
|
startTime: endTime - rangeMinutes * 60,
|
||||||
endTime,
|
endTime,
|
||||||
})
|
})
|
||||||
|
} catch (error) {
|
||||||
|
if (isMissingLogGroup(error)) return {queryId: '', status: 'Complete', rows: []}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const {mutate: runQuery, reset: resetQuery} = queryMut
|
const {mutate: runQuery, reset: resetQuery} = queryMut
|
||||||
@@ -62,6 +68,10 @@ export function LogsQueryPanel({cloud, logGroupName, runtimeReachable}: LogsQuer
|
|||||||
setExpanded(false)
|
setExpanded(false)
|
||||||
}, [logGroupName, resetQuery])
|
}, [logGroupName, resetQuery])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoRunKey > 0 && logGroupName && runtimeReachable) runQuery()
|
||||||
|
}, [autoRunKey, logGroupName, runQuery, runtimeReachable])
|
||||||
|
|
||||||
if (!logGroupName) return null
|
if (!logGroupName) return null
|
||||||
|
|
||||||
function submit() {
|
function submit() {
|
||||||
@@ -150,6 +160,10 @@ function macShortcut(): string {
|
|||||||
return typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform) ? '⌘' : 'Ctrl'
|
return typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform) ? '⌘' : 'Ctrl'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isMissingLogGroup(error: unknown): boolean {
|
||||||
|
return error instanceof Error && /log group does not exist|resource_not_found/i.test(error.message)
|
||||||
|
}
|
||||||
|
|
||||||
const STILL_RUNNING_STATUSES = new Set(['Running', 'Scheduled'])
|
const STILL_RUNNING_STATUSES = new Set(['Running', 'Scheduled'])
|
||||||
|
|
||||||
function LogsQueryResults({result, expanded}: {result?: LogsInsightsQueryResult; expanded: boolean}) {
|
function LogsQueryResults({result, expanded}: {result?: LogsInsightsQueryResult; expanded: boolean}) {
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ export function ResourceTable({
|
|||||||
)}
|
)}
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<button
|
<button
|
||||||
className="icon-btn"
|
className={schema.cloud === 'aws' && schema.service === 'serverless' ? 'button compact lambda-edit-button' : 'icon-btn'}
|
||||||
type="button"
|
type="button"
|
||||||
title={`Edit ${resource.name}`}
|
title={`Edit ${resource.name}`}
|
||||||
aria-label={`Edit ${resource.name}`}
|
aria-label={`Edit ${resource.name}`}
|
||||||
@@ -130,6 +130,7 @@ export function ResourceTable({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Pencil size={13} />
|
<Pencil size={13} />
|
||||||
|
{schema.cloud === 'aws' && schema.service === 'serverless' && 'Edit Lambda'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{canDelete && (
|
{canDelete && (
|
||||||
@@ -187,4 +188,3 @@ export function ResourceTable({
|
|||||||
</table>
|
</table>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import {useEffect, useState} from 'react'
|
||||||
|
import {HardDrive, Loader2, Plus, Trash2, Zap} from 'lucide-react'
|
||||||
|
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||||
|
import {createS3LambdaTrigger, deleteS3LambdaTrigger, listCloudResources, listS3LambdaTriggers, type S3LambdaTriggerOverview} from '@/api/cloudProxyClient'
|
||||||
|
import type {CloudProvider} from '@/types/cloud'
|
||||||
|
import type {CloudResource} from '@/types/resource'
|
||||||
|
import {LogsQueryPanel} from './LogsQueryPanel'
|
||||||
|
|
||||||
|
interface S3LambdaTriggerPanelProps {
|
||||||
|
cloud: CloudProvider
|
||||||
|
resource?: CloudResource
|
||||||
|
runtimeReachable: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TriggerWithBucket extends S3LambdaTriggerOverview {
|
||||||
|
bucketName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function S3LambdaTriggerPanel({cloud, resource, runtimeReachable}: S3LambdaTriggerPanelProps) {
|
||||||
|
const [bucketName, setBucketName] = useState('')
|
||||||
|
const [eventName, setEventName] = useState('s3:ObjectCreated:*')
|
||||||
|
const [prefix, setPrefix] = useState('')
|
||||||
|
const [suffix, setSuffix] = useState('')
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<TriggerWithBucket | null>(null)
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const bucketsQuery = useQuery({
|
||||||
|
queryKey: ['cloud-resources', cloud, 'storage'],
|
||||||
|
queryFn: ({signal}) => listCloudResources(cloud, 'storage', undefined, signal),
|
||||||
|
enabled: cloud === 'aws' && Boolean(resource) && runtimeReachable,
|
||||||
|
})
|
||||||
|
const triggersQuery = useQuery({
|
||||||
|
queryKey: ['s3-lambda-triggers', cloud, resource?.id],
|
||||||
|
queryFn: async ({signal}): Promise<TriggerWithBucket[]> => {
|
||||||
|
const buckets = await listCloudResources(cloud, 'storage', undefined, signal)
|
||||||
|
const results = await Promise.all(buckets.map(async (bucket) => {
|
||||||
|
const triggers = await listS3LambdaTriggers(cloud, bucket.id, signal)
|
||||||
|
return triggers.filter((trigger) => trigger.functionName === resource!.name).map((trigger) => ({...trigger, bucketName: bucket.id}))
|
||||||
|
}))
|
||||||
|
return results.flat()
|
||||||
|
},
|
||||||
|
enabled: cloud === 'aws' && Boolean(resource) && runtimeReachable,
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setBucketName('')
|
||||||
|
setPrefix('')
|
||||||
|
setSuffix('')
|
||||||
|
setDeleteTarget(null)
|
||||||
|
}, [resource?.id])
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: () => createS3LambdaTrigger(cloud, bucketName, {functionName: resource!.name, events: [eventName], prefix, suffix}),
|
||||||
|
onSuccess: () => {
|
||||||
|
setPrefix('')
|
||||||
|
setSuffix('')
|
||||||
|
queryClient.invalidateQueries({queryKey: ['s3-lambda-triggers', cloud, resource?.id]})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (trigger: TriggerWithBucket) => deleteS3LambdaTrigger(cloud, trigger.bucketName, trigger.id),
|
||||||
|
onSuccess: () => {
|
||||||
|
setDeleteTarget(null)
|
||||||
|
queryClient.invalidateQueries({queryKey: ['s3-lambda-triggers', cloud, resource?.id]})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!resource || resource.service !== 'serverless' || resource.type !== 'lambda') return null
|
||||||
|
const buckets = bucketsQuery.data?.filter((bucket) => bucket.type === 'bucket') ?? []
|
||||||
|
const canCreate = Boolean(bucketName) && !createMutation.isPending
|
||||||
|
|
||||||
|
return <section className="table-panel">
|
||||||
|
<div className="dynamic-stage-header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Event Source</p>
|
||||||
|
<h3><Zap size={15}/> Add trigger</h3>
|
||||||
|
<p className="muted compact-text">Invoke <code>{resource.name}</code> when an S3 object is created.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="resource-create-inline">
|
||||||
|
<form className="inspector-action-form apigateway-route-form" onSubmit={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (canCreate) createMutation.mutate()
|
||||||
|
}}>
|
||||||
|
<label className="dynamic-field">
|
||||||
|
<span>S3 bucket</span>
|
||||||
|
<select className="input" value={bucketName} onChange={(event) => setBucketName(event.target.value)} required>
|
||||||
|
<option value="">Select a bucket</option>
|
||||||
|
{buckets.map((bucket) => <option key={bucket.id} value={bucket.id}>{bucket.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<small>{bucketsQuery.isLoading ? 'Loading buckets...' : buckets.length === 0 ? 'Create an S3 bucket before adding a trigger.' : 'The bucket that sends object-created events.'}</small>
|
||||||
|
</label>
|
||||||
|
<label className="dynamic-field">
|
||||||
|
<span>Event type</span>
|
||||||
|
<select className="input" value={eventName} onChange={(event) => setEventName(event.target.value)}>
|
||||||
|
<option value="s3:ObjectCreated:*">All object created events</option>
|
||||||
|
<option value="s3:ObjectCreated:Put">Object uploaded (PUT)</option>
|
||||||
|
<option value="s3:ObjectCreated:Post">Object uploaded (POST)</option>
|
||||||
|
<option value="s3:ObjectCreated:Copy">Object copied</option>
|
||||||
|
<option value="s3:ObjectCreated:CompleteMultipartUpload">Multipart upload completed</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="dynamic-field">
|
||||||
|
<span>Prefix filter</span>
|
||||||
|
<input className="input" value={prefix} onChange={(event) => setPrefix(event.target.value)} placeholder="uploads/"/>
|
||||||
|
<small>Optional object-key prefix.</small>
|
||||||
|
</label>
|
||||||
|
<label className="dynamic-field">
|
||||||
|
<span>Suffix filter</span>
|
||||||
|
<input className="input" value={suffix} onChange={(event) => setSuffix(event.target.value)} placeholder=".json"/>
|
||||||
|
<small>Optional object-key suffix.</small>
|
||||||
|
</label>
|
||||||
|
<button className="button primary apigateway-route-submit" type="submit" disabled={!canCreate}>
|
||||||
|
{createMutation.isPending ? <Loader2 size={13}/> : <Plus size={13}/>}
|
||||||
|
{createMutation.isPending ? 'Adding trigger' : 'Add trigger'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{createMutation.isError && <p className="error-text compact-text">{createMutation.error instanceof Error ? createMutation.error.message : 'Unable to add S3 trigger.'}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="apigateway-route-explorer">
|
||||||
|
<div className="dynamic-stage-header"><div><p className="eyebrow">Configured Triggers</p><h3><HardDrive size={15}/> S3 object created</h3></div></div>
|
||||||
|
{triggersQuery.isLoading && <p className="muted compact-text">Loading S3 triggers...</p>}
|
||||||
|
{triggersQuery.isError && <p className="error-text compact-text">{triggersQuery.error instanceof Error ? triggersQuery.error.message : 'Unable to load S3 triggers.'}</p>}
|
||||||
|
{triggersQuery.data?.length === 0 && <p className="muted compact-text">No S3 triggers configured for this Lambda.</p>}
|
||||||
|
{triggersQuery.data?.map((trigger) => <div className="apigateway-route-item" key={`${trigger.bucketName}-${trigger.id}`}>
|
||||||
|
<div className="apigateway-route-select"><span className="apigateway-route-method">S3</span><code>{trigger.bucketName}</code><small>{trigger.events.join(', ')}{trigger.prefix ? ` · prefix: ${trigger.prefix}` : ''}{trigger.suffix ? ` · suffix: ${trigger.suffix}` : ''}</small></div>
|
||||||
|
{deleteTarget?.id === trigger.id && deleteTarget.bucketName === trigger.bucketName ? <div className="apigateway-route-confirm"><span>Remove trigger?</span><button className="button danger compact" type="button" disabled={deleteMutation.isPending} onClick={() => deleteMutation.mutate(trigger)}>Remove</button><button className="button compact" type="button" disabled={deleteMutation.isPending} onClick={() => setDeleteTarget(null)}>Cancel</button></div> : <button className="icon-btn" type="button" aria-label={`Remove S3 trigger from ${trigger.bucketName}`} onClick={() => setDeleteTarget(trigger)}><Trash2 size={13}/></button>}
|
||||||
|
</div>)}
|
||||||
|
</div>
|
||||||
|
{triggersQuery.data && triggersQuery.data.length > 0 && (
|
||||||
|
<div className="apigateway-route-explorer">
|
||||||
|
<div className="dynamic-stage-header"><div><p className="eyebrow">Recent Activity</p><h3><Zap size={15}/> Lambda logs</h3><p className="muted compact-text">Inspect the function logs generated by S3-triggered invocations.</p></div></div>
|
||||||
|
<LogsQueryPanel cloud={cloud} logGroupName={`/aws/lambda/${resource.name}`} runtimeReachable={runtimeReachable} autoRunKey={triggersQuery.data.length}/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
||||||
|
import {render, screen, waitFor} from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import {expect, test, vi} from 'vitest'
|
||||||
|
import {ScheduledLambdaTriggerPanel} from './ScheduledLambdaTriggerPanel'
|
||||||
|
import type {CloudResource} from '@/types/resource'
|
||||||
|
|
||||||
|
const apiMocks = vi.hoisted(() => ({
|
||||||
|
createEventBridgeLambdaTrigger: vi.fn(),
|
||||||
|
deleteEventBridgeLambdaTrigger: vi.fn(),
|
||||||
|
listEventBridgeLambdaTriggers: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api/cloudProxyClient', () => apiMocks)
|
||||||
|
|
||||||
|
const lambda: CloudResource = {
|
||||||
|
id: 'reporter', name: 'reporter', cloud: 'aws', service: 'serverless', type: 'lambda',
|
||||||
|
region: null, createdAt: null, metadata: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPanel() {
|
||||||
|
const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||||
|
render(<QueryClientProvider client={queryClient}><ScheduledLambdaTriggerPanel cloud="aws" resource={lambda} runtimeReachable/></QueryClientProvider>)
|
||||||
|
}
|
||||||
|
|
||||||
|
test('creates a scheduled EventBridge Lambda trigger', async () => {
|
||||||
|
apiMocks.listEventBridgeLambdaTriggers.mockResolvedValue([])
|
||||||
|
apiMocks.createEventBridgeLambdaTrigger.mockResolvedValue({id: 'hourly-report', ruleName: 'hourly-report', scheduleExpression: 'rate(1 hour)'})
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderPanel()
|
||||||
|
|
||||||
|
await user.type(screen.getByLabelText(/rule name/i), 'hourly-report')
|
||||||
|
await user.clear(screen.getByLabelText(/schedule expression/i))
|
||||||
|
await user.type(screen.getByLabelText(/schedule expression/i), 'rate(1 hour)')
|
||||||
|
await user.click(screen.getByRole('button', {name: 'Add schedule'}))
|
||||||
|
|
||||||
|
await waitFor(() => expect(apiMocks.createEventBridgeLambdaTrigger).toHaveBeenCalledWith('aws', 'reporter', {
|
||||||
|
ruleName: 'hourly-report', scheduleExpression: 'rate(1 hour)',
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('confirms before deleting a scheduled trigger', async () => {
|
||||||
|
apiMocks.listEventBridgeLambdaTriggers.mockResolvedValue([{id: 'hourly-report', ruleName: 'hourly-report', scheduleExpression: 'rate(1 hour)', state: 'ENABLED'}])
|
||||||
|
apiMocks.deleteEventBridgeLambdaTrigger.mockResolvedValue(undefined)
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderPanel()
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', {name: 'Remove schedule hourly-report'}))
|
||||||
|
expect(screen.getByText('Remove schedule?')).toBeVisible()
|
||||||
|
await user.click(screen.getByRole('button', {name: 'Remove'}))
|
||||||
|
|
||||||
|
await waitFor(() => expect(apiMocks.deleteEventBridgeLambdaTrigger).toHaveBeenCalledWith('aws', 'reporter', 'hourly-report'))
|
||||||
|
})
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import {useEffect, useState} from 'react'
|
||||||
|
import {CalendarClock, Loader2, Plus, Trash2, Zap} from 'lucide-react'
|
||||||
|
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||||
|
import {createEventBridgeLambdaTrigger, deleteEventBridgeLambdaTrigger, listEventBridgeLambdaTriggers, type EventBridgeLambdaTriggerOverview} from '@/api/cloudProxyClient'
|
||||||
|
import type {CloudProvider} from '@/types/cloud'
|
||||||
|
import type {CloudResource} from '@/types/resource'
|
||||||
|
|
||||||
|
interface ScheduledLambdaTriggerPanelProps {
|
||||||
|
cloud: CloudProvider
|
||||||
|
resource?: CloudResource
|
||||||
|
runtimeReachable: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScheduledLambdaTriggerPanel({cloud, resource, runtimeReachable}: ScheduledLambdaTriggerPanelProps) {
|
||||||
|
const [ruleName, setRuleName] = useState('')
|
||||||
|
const [scheduleExpression, setScheduleExpression] = useState('rate(5 minutes)')
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<EventBridgeLambdaTriggerOverview | null>(null)
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const triggersQuery = useQuery({
|
||||||
|
queryKey: ['eventbridge-lambda-triggers', cloud, resource?.id],
|
||||||
|
queryFn: ({signal}) => listEventBridgeLambdaTriggers(cloud, resource!.name, signal),
|
||||||
|
enabled: cloud === 'aws' && Boolean(resource) && runtimeReachable,
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setRuleName('')
|
||||||
|
setScheduleExpression('rate(5 minutes)')
|
||||||
|
setDeleteTarget(null)
|
||||||
|
}, [resource?.id])
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: () => createEventBridgeLambdaTrigger(cloud, resource!.name, {ruleName, scheduleExpression}),
|
||||||
|
onSuccess: () => {
|
||||||
|
setRuleName('')
|
||||||
|
queryClient.invalidateQueries({queryKey: ['eventbridge-lambda-triggers', cloud, resource?.id]})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (trigger: EventBridgeLambdaTriggerOverview) => deleteEventBridgeLambdaTrigger(cloud, resource!.name, trigger.id),
|
||||||
|
onSuccess: () => {
|
||||||
|
setDeleteTarget(null)
|
||||||
|
queryClient.invalidateQueries({queryKey: ['eventbridge-lambda-triggers', cloud, resource?.id]})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!resource || resource.service !== 'serverless' || resource.type !== 'lambda') return null
|
||||||
|
const canCreate = Boolean(ruleName.trim() && scheduleExpression.trim()) && !createMutation.isPending
|
||||||
|
|
||||||
|
return <section className="table-panel">
|
||||||
|
<div className="dynamic-stage-header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Schedule</p>
|
||||||
|
<h3><CalendarClock size={15}/> Add scheduled trigger</h3>
|
||||||
|
<p className="muted compact-text">Invoke <code>{resource.name}</code> on an EventBridge schedule.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="resource-create-inline">
|
||||||
|
<form className="inspector-action-form apigateway-route-form" onSubmit={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (canCreate) createMutation.mutate()
|
||||||
|
}}>
|
||||||
|
<label className="dynamic-field">
|
||||||
|
<span>Rule name</span>
|
||||||
|
<input className="input" value={ruleName} onChange={(event) => setRuleName(event.target.value)} placeholder="hourly-report" required/>
|
||||||
|
<small>A unique EventBridge rule name.</small>
|
||||||
|
</label>
|
||||||
|
<label className="dynamic-field">
|
||||||
|
<span>Schedule expression</span>
|
||||||
|
<input className="input" value={scheduleExpression} onChange={(event) => setScheduleExpression(event.target.value)} placeholder="rate(5 minutes)" required/>
|
||||||
|
<small>Use a rate or cron expression, for example <code>rate(1 hour)</code>.</small>
|
||||||
|
</label>
|
||||||
|
<button className="button primary apigateway-route-submit" type="submit" disabled={!canCreate}>
|
||||||
|
{createMutation.isPending ? <Loader2 size={13}/> : <Plus size={13}/>}
|
||||||
|
{createMutation.isPending ? 'Adding schedule' : 'Add schedule'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{createMutation.isError && <p className="error-text compact-text">{createMutation.error instanceof Error ? createMutation.error.message : 'Unable to add scheduled trigger.'}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="apigateway-route-explorer">
|
||||||
|
<div className="dynamic-stage-header"><div><p className="eyebrow">Configured Schedules</p><h3><Zap size={15}/> EventBridge rules</h3></div></div>
|
||||||
|
{triggersQuery.isLoading && <p className="muted compact-text">Loading scheduled triggers...</p>}
|
||||||
|
{triggersQuery.isError && <p className="error-text compact-text">{triggersQuery.error instanceof Error ? triggersQuery.error.message : 'Unable to load scheduled triggers.'}</p>}
|
||||||
|
{triggersQuery.data?.length === 0 && <p className="muted compact-text">No scheduled triggers configured for this Lambda.</p>}
|
||||||
|
{triggersQuery.data?.map((trigger) => <div className="apigateway-route-item" key={trigger.id}>
|
||||||
|
<div className="apigateway-route-select"><span className="apigateway-route-method">RULE</span><code>{trigger.ruleName}</code><small>{trigger.scheduleExpression}{trigger.state ? ` · ${trigger.state}` : ''}</small></div>
|
||||||
|
{deleteTarget?.id === trigger.id ? <div className="apigateway-route-confirm"><span>Remove schedule?</span><button className="button danger compact" type="button" disabled={deleteMutation.isPending} onClick={() => deleteMutation.mutate(trigger)}>Remove</button><button className="button compact" type="button" disabled={deleteMutation.isPending} onClick={() => setDeleteTarget(null)}>Cancel</button></div> : <button className="icon-btn" type="button" aria-label={`Remove schedule ${trigger.ruleName}`} onClick={() => setDeleteTarget(trigger)}><Trash2 size={13}/></button>}
|
||||||
|
</div>)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import {invokeCloudResource} from "@/api/cloudProxyClient";
|
|||||||
import type {ServerlessInvokeResult} from "@/api/cloudProxyClient";
|
import type {ServerlessInvokeResult} from "@/api/cloudProxyClient";
|
||||||
import type {CloudProvider} from "@/types/cloud";
|
import type {CloudProvider} from "@/types/cloud";
|
||||||
import type {CloudResource} from "@/types/resource";
|
import type {CloudResource} from "@/types/resource";
|
||||||
|
import {LogsQueryPanel} from "./LogsQueryPanel";
|
||||||
|
|
||||||
interface ServerlessInvokePanelProps {
|
interface ServerlessInvokePanelProps {
|
||||||
cloud: CloudProvider;
|
cloud: CloudProvider;
|
||||||
@@ -22,6 +23,7 @@ export function ServerlessInvokePanel({
|
|||||||
const [showLog, setShowLog] = useState(false);
|
const [showLog, setShowLog] = useState(false);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const [validationError, setValidationError] = useState<string | null>(null);
|
const [validationError, setValidationError] = useState<string | null>(null);
|
||||||
|
const [logsRefreshKey, setLogsRefreshKey] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPayload("{\n \n}");
|
setPayload("{\n \n}");
|
||||||
@@ -29,6 +31,7 @@ export function ServerlessInvokePanel({
|
|||||||
setShowLog(false);
|
setShowLog(false);
|
||||||
setCopied(false);
|
setCopied(false);
|
||||||
setValidationError(null);
|
setValidationError(null);
|
||||||
|
setLogsRefreshKey(0);
|
||||||
}, [resource?.id]);
|
}, [resource?.id]);
|
||||||
|
|
||||||
const isSupportedResource =
|
const isSupportedResource =
|
||||||
@@ -54,6 +57,7 @@ export function ServerlessInvokePanel({
|
|||||||
setInvokeResult(result);
|
setInvokeResult(result);
|
||||||
setShowLog(false);
|
setShowLog(false);
|
||||||
setCopied(false);
|
setCopied(false);
|
||||||
|
setLogsRefreshKey((value) => value + 1);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -221,6 +225,14 @@ const clearPayload = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{cloud === "aws" && invokeResult && (
|
||||||
|
<LogsQueryPanel
|
||||||
|
cloud={cloud}
|
||||||
|
logGroupName={`/aws/lambda/${resource.name}`}
|
||||||
|
runtimeReachable={runtimeReachable}
|
||||||
|
autoRunKey={logsRefreshKey}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
||||||
|
import {render, screen, waitFor} from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import {expect, test, vi} from 'vitest'
|
||||||
|
import {SqsLambdaTriggerPanel} from './SqsLambdaTriggerPanel'
|
||||||
|
import type {CloudResource} from '@/types/resource'
|
||||||
|
|
||||||
|
const apiMocks = vi.hoisted(() => ({
|
||||||
|
createSqsLambdaEventSourceMapping: vi.fn(),
|
||||||
|
deleteSqsLambdaEventSourceMapping: vi.fn(),
|
||||||
|
listCloudResources: vi.fn(),
|
||||||
|
listSqsLambdaEventSourceMappings: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api/cloudProxyClient', () => apiMocks)
|
||||||
|
|
||||||
|
const lambda: CloudResource = {
|
||||||
|
id: 'worker', name: 'worker', cloud: 'aws', service: 'serverless', type: 'lambda',
|
||||||
|
region: null, createdAt: null, metadata: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
const queue: CloudResource = {
|
||||||
|
id: 'orders', name: 'orders', cloud: 'aws', service: 'messaging', type: 'queue',
|
||||||
|
region: null, createdAt: null, metadata: {arn: 'arn:aws:sqs:us-east-1:000000000000:orders'},
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPanel() {
|
||||||
|
const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||||
|
render(<QueryClientProvider client={queryClient}><SqsLambdaTriggerPanel cloud="aws" resource={lambda} runtimeReachable/></QueryClientProvider>)
|
||||||
|
}
|
||||||
|
|
||||||
|
test('creates an SQS Lambda event source mapping from the selected queue ARN', async () => {
|
||||||
|
apiMocks.listCloudResources.mockResolvedValue([queue])
|
||||||
|
apiMocks.listSqsLambdaEventSourceMappings.mockResolvedValue([])
|
||||||
|
apiMocks.createSqsLambdaEventSourceMapping.mockResolvedValue({id: 'mapping-1'})
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderPanel()
|
||||||
|
|
||||||
|
await screen.findByRole('option', {name: 'orders'})
|
||||||
|
await user.selectOptions(screen.getByLabelText(/sqs queue/i), 'arn:aws:sqs:us-east-1:000000000000:orders')
|
||||||
|
await user.clear(screen.getByLabelText(/batch size/i))
|
||||||
|
await user.type(screen.getByLabelText(/batch size/i), '5')
|
||||||
|
await user.click(screen.getByRole('button', {name: 'Add trigger'}))
|
||||||
|
|
||||||
|
await waitFor(() => expect(apiMocks.createSqsLambdaEventSourceMapping).toHaveBeenCalledWith('aws', 'worker', {
|
||||||
|
queueArn: 'arn:aws:sqs:us-east-1:000000000000:orders', batchSize: 5,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('confirms before deleting an SQS Lambda event source mapping', async () => {
|
||||||
|
apiMocks.listCloudResources.mockResolvedValue([queue])
|
||||||
|
apiMocks.listSqsLambdaEventSourceMappings.mockResolvedValue([{
|
||||||
|
id: 'mapping-1', queueArn: queue.metadata.arn, batchSize: 10, state: 'Enabled',
|
||||||
|
}])
|
||||||
|
apiMocks.deleteSqsLambdaEventSourceMapping.mockResolvedValue(undefined)
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderPanel()
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', {name: /remove sqs trigger from/i}))
|
||||||
|
expect(screen.getByText('Remove trigger?')).toBeVisible()
|
||||||
|
await user.click(screen.getByRole('button', {name: 'Remove'}))
|
||||||
|
|
||||||
|
await waitFor(() => expect(apiMocks.deleteSqsLambdaEventSourceMapping).toHaveBeenCalledWith('aws', 'worker', 'mapping-1'))
|
||||||
|
})
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import {useEffect, useState} from 'react'
|
||||||
|
import {Loader2, MessageSquare, Plus, Trash2, Zap} from 'lucide-react'
|
||||||
|
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||||
|
import {createSqsLambdaEventSourceMapping, deleteSqsLambdaEventSourceMapping, listCloudResources, listSqsLambdaEventSourceMappings, type SqsLambdaEventSourceMappingOverview} from '@/api/cloudProxyClient'
|
||||||
|
import type {CloudProvider} from '@/types/cloud'
|
||||||
|
import type {CloudResource} from '@/types/resource'
|
||||||
|
|
||||||
|
interface SqsLambdaTriggerPanelProps {
|
||||||
|
cloud: CloudProvider
|
||||||
|
resource?: CloudResource
|
||||||
|
runtimeReachable: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SqsLambdaTriggerPanel({cloud, resource, runtimeReachable}: SqsLambdaTriggerPanelProps) {
|
||||||
|
const [queueArn, setQueueArn] = useState('')
|
||||||
|
const [batchSize, setBatchSize] = useState('10')
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<SqsLambdaEventSourceMappingOverview | null>(null)
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const queuesQuery = useQuery({
|
||||||
|
queryKey: ['cloud-resources', cloud, 'messaging'],
|
||||||
|
queryFn: ({signal}) => listCloudResources(cloud, 'messaging', undefined, signal),
|
||||||
|
enabled: cloud === 'aws' && Boolean(resource) && runtimeReachable,
|
||||||
|
})
|
||||||
|
const mappingsQuery = useQuery({
|
||||||
|
queryKey: ['sqs-lambda-event-source-mappings', cloud, resource?.id],
|
||||||
|
queryFn: ({signal}) => listSqsLambdaEventSourceMappings(cloud, resource!.name, signal),
|
||||||
|
enabled: cloud === 'aws' && Boolean(resource) && runtimeReachable,
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setQueueArn('')
|
||||||
|
setBatchSize('10')
|
||||||
|
setDeleteTarget(null)
|
||||||
|
}, [resource?.id])
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: () => createSqsLambdaEventSourceMapping(cloud, resource!.name, {queueArn, batchSize: Number(batchSize)}),
|
||||||
|
onSuccess: () => {
|
||||||
|
setQueueArn('')
|
||||||
|
queryClient.invalidateQueries({queryKey: ['sqs-lambda-event-source-mappings', cloud, resource?.id]})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (mapping: SqsLambdaEventSourceMappingOverview) => deleteSqsLambdaEventSourceMapping(cloud, resource!.name, mapping.id),
|
||||||
|
onSuccess: () => {
|
||||||
|
setDeleteTarget(null)
|
||||||
|
queryClient.invalidateQueries({queryKey: ['sqs-lambda-event-source-mappings', cloud, resource?.id]})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!resource || resource.service !== 'serverless' || resource.type !== 'lambda') return null
|
||||||
|
const queues = (queuesQuery.data ?? []).filter((queue) => (queue.type === 'queue' || queue.type === 'fifo-queue') && typeof queue.metadata.arn === 'string')
|
||||||
|
const parsedBatchSize = Number(batchSize)
|
||||||
|
const canCreate = Boolean(queueArn) && Number.isInteger(parsedBatchSize) && parsedBatchSize >= 1 && parsedBatchSize <= 10 && !createMutation.isPending
|
||||||
|
|
||||||
|
return <section className="table-panel">
|
||||||
|
<div className="dynamic-stage-header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Event Source</p>
|
||||||
|
<h3><Zap size={15}/> Add SQS trigger</h3>
|
||||||
|
<p className="muted compact-text">Poll an SQS queue and invoke <code>{resource.name}</code> for each batch.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="resource-create-inline">
|
||||||
|
<form className="inspector-action-form apigateway-route-form" onSubmit={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (canCreate) createMutation.mutate()
|
||||||
|
}}>
|
||||||
|
<label className="dynamic-field">
|
||||||
|
<span>SQS queue</span>
|
||||||
|
<select className="input" value={queueArn} onChange={(event) => setQueueArn(event.target.value)} required>
|
||||||
|
<option value="">Select a queue</option>
|
||||||
|
{queues.map((queue) => <option key={queue.id} value={String(queue.metadata.arn)}>{queue.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<small>{queuesQuery.isLoading ? 'Loading queues...' : queues.length === 0 ? 'Create an SQS queue before adding a trigger.' : 'The queue Lambda polls for messages.'}</small>
|
||||||
|
</label>
|
||||||
|
<label className="dynamic-field">
|
||||||
|
<span>Batch size</span>
|
||||||
|
<input className="input" type="number" min="1" max="10" value={batchSize} onChange={(event) => setBatchSize(event.target.value)} required/>
|
||||||
|
<small>Messages sent to Lambda per invocation, from 1 to 10.</small>
|
||||||
|
</label>
|
||||||
|
<button className="button primary apigateway-route-submit" type="submit" disabled={!canCreate}>
|
||||||
|
{createMutation.isPending ? <Loader2 size={13}/> : <Plus size={13}/>} {createMutation.isPending ? 'Adding trigger' : 'Add trigger'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{createMutation.isError && <p className="error-text compact-text">{createMutation.error instanceof Error ? createMutation.error.message : 'Unable to add SQS trigger.'}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="apigateway-route-explorer">
|
||||||
|
<div className="dynamic-stage-header"><div><p className="eyebrow">Configured Triggers</p><h3><MessageSquare size={15}/> SQS event source mappings</h3></div></div>
|
||||||
|
{mappingsQuery.isLoading && <p className="muted compact-text">Loading SQS triggers...</p>}
|
||||||
|
{mappingsQuery.isError && <p className="error-text compact-text">{mappingsQuery.error instanceof Error ? mappingsQuery.error.message : 'Unable to load SQS triggers.'}</p>}
|
||||||
|
{mappingsQuery.data?.length === 0 && <p className="muted compact-text">No SQS triggers configured for this Lambda.</p>}
|
||||||
|
{mappingsQuery.data?.map((mapping) => <div className="apigateway-route-item" key={mapping.id}>
|
||||||
|
<div className="apigateway-route-select"><span className="apigateway-route-method">SQS</span><code>{mapping.queueArn}</code><small>Batch size: {mapping.batchSize ?? 'default'}{mapping.state ? ` · ${mapping.state}` : ''}</small></div>
|
||||||
|
{deleteTarget?.id === mapping.id ? <div className="apigateway-route-confirm"><span>Remove trigger?</span><button className="button danger compact" type="button" disabled={deleteMutation.isPending} onClick={() => deleteMutation.mutate(mapping)}>Remove</button><button className="button compact" type="button" disabled={deleteMutation.isPending} onClick={() => setDeleteTarget(null)}>Cancel</button></div> : <button className="icon-btn" type="button" aria-label={`Remove SQS trigger from ${mapping.queueArn}`} onClick={() => setDeleteTarget(mapping)}><Trash2 size={13}/></button>}
|
||||||
|
</div>)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
||||||
|
import {render, screen, waitFor} from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import {expect, test, vi} from 'vitest'
|
||||||
|
import {StorageObjectBrowser} from './StorageObjectBrowser'
|
||||||
|
import type {CloudResource} from '@/types/resource'
|
||||||
|
|
||||||
|
const apiMocks = vi.hoisted(() => ({
|
||||||
|
copyStorageObject: vi.fn(),
|
||||||
|
createCloudResource: vi.fn(),
|
||||||
|
deleteStorageObject: vi.fn(),
|
||||||
|
listCloudResources: vi.fn(),
|
||||||
|
listStorageObjects: vi.fn(),
|
||||||
|
storageObjectDownloadUrl: vi.fn(),
|
||||||
|
uploadStorageObject: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api/cloudProxyClient', () => apiMocks)
|
||||||
|
|
||||||
|
const bucket: CloudResource = {
|
||||||
|
id: 'incoming-orders', name: 'incoming-orders', cloud: 'aws', service: 'storage', type: 'bucket',
|
||||||
|
region: null, createdAt: null, metadata: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
test('deletes a folder and all nested objects', async () => {
|
||||||
|
apiMocks.listStorageObjects.mockImplementation((_cloud: string, _bucket: string, prefix: string) => {
|
||||||
|
if (prefix === 'orders/') {
|
||||||
|
return Promise.resolve({prefix, objects: [{
|
||||||
|
key: 'orders/order.json', name: 'order.json', type: 'object', size: 10, lastModified: null, metadata: {},
|
||||||
|
}]})
|
||||||
|
}
|
||||||
|
return Promise.resolve({prefix, objects: [{
|
||||||
|
key: 'orders/', name: 'orders', type: 'folder', size: null, lastModified: null, metadata: {},
|
||||||
|
}]})
|
||||||
|
})
|
||||||
|
apiMocks.deleteStorageObject.mockResolvedValue(undefined)
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||||
|
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<StorageObjectBrowser cloud="aws" resource={bucket} runtimeReachable capabilities={['delete']} onSelectObject={() => {}}/>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', {name: 'Delete orders folder'}))
|
||||||
|
await user.click(screen.getByRole('button', {name: 'Delete folder'}))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(apiMocks.deleteStorageObject).toHaveBeenCalledWith('aws', 'incoming-orders', 'orders/order.json')
|
||||||
|
expect(apiMocks.deleteStorageObject).toHaveBeenCalledWith('aws', 'incoming-orders', 'orders/')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('requires the bucket name before emptying all folder contents', async () => {
|
||||||
|
apiMocks.listStorageObjects.mockImplementation((_cloud: string, _bucket: string, prefix: string) => {
|
||||||
|
if (prefix === 'orders/') {
|
||||||
|
return Promise.resolve({prefix, objects: [{
|
||||||
|
key: 'orders/order.json', name: 'order.json', type: 'object', size: 10, lastModified: null, metadata: {},
|
||||||
|
}]})
|
||||||
|
}
|
||||||
|
return Promise.resolve({prefix, objects: [{
|
||||||
|
key: 'orders/', name: 'orders', type: 'folder', size: null, lastModified: null, metadata: {},
|
||||||
|
}]})
|
||||||
|
})
|
||||||
|
apiMocks.deleteStorageObject.mockResolvedValue(undefined)
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||||
|
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<StorageObjectBrowser cloud="aws" resource={bucket} runtimeReachable capabilities={['delete']} onSelectObject={() => {}}/>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', {name: 'Empty bucket'}))
|
||||||
|
expect(screen.getAllByRole('button', {name: /^Empty bucket$/})[1]).toBeDisabled()
|
||||||
|
await user.type(screen.getByPlaceholderText('incoming-orders'), 'incoming-orders')
|
||||||
|
await user.click(screen.getAllByRole('button', {name: /^Empty bucket$/})[1])
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(apiMocks.deleteStorageObject).toHaveBeenCalledWith('aws', 'incoming-orders', 'orders/order.json')
|
||||||
|
expect(apiMocks.deleteStorageObject).toHaveBeenCalledWith('aws', 'incoming-orders', 'orders/')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shows an object extension instead of a generic object type', async () => {
|
||||||
|
apiMocks.listStorageObjects.mockResolvedValue({prefix: '', objects: [{
|
||||||
|
key: 'orders/order.json', name: 'order.json', type: 'object', size: 10, lastModified: null, metadata: {},
|
||||||
|
}]})
|
||||||
|
const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||||
|
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<StorageObjectBrowser cloud="aws" resource={bucket} runtimeReachable capabilities={['delete']} onSelectObject={() => {}}/>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(await screen.findByText('JSON file')).toBeInTheDocument()
|
||||||
|
})
|
||||||
@@ -35,11 +35,16 @@ export function StorageObjectBrowser({cloud, resource, capabilities = [], runtim
|
|||||||
const [folderName, setFolderName] = useState('')
|
const [folderName, setFolderName] = useState('')
|
||||||
const [createFolderOpen, setCreateFolderOpen] = useState(false)
|
const [createFolderOpen, setCreateFolderOpen] = useState(false)
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null)
|
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null)
|
||||||
|
const [deleteFolderConfirm, setDeleteFolderConfirm] = useState<string | null>(null)
|
||||||
|
const [emptyBucketConfirm, setEmptyBucketConfirm] = useState(false)
|
||||||
|
const [emptyBucketText, setEmptyBucketText] = useState('')
|
||||||
const [copyObject, setCopyObject] = useState<StorageObject | null>(null)
|
const [copyObject, setCopyObject] = useState<StorageObject | null>(null)
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set())
|
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set())
|
||||||
const [bulkDeleting, setBulkDeleting] = useState(false)
|
const [bulkDeleting, setBulkDeleting] = useState(false)
|
||||||
const [previewObject, setPreviewObject] = useState<StorageObject | null>(null)
|
const [previewObject, setPreviewObject] = useState<StorageObject | null>(null)
|
||||||
|
const [folderUpload, setFolderUpload] = useState<{completed: number; total: number; failed: number} | null>(null)
|
||||||
|
const [folderUploadNotice, setFolderUploadNotice] = useState<string | null>(null)
|
||||||
|
|
||||||
const resolvedCapabilities = withRuntimeState(normalizeCapabilities(capabilities), runtimeReachable)
|
const resolvedCapabilities = withRuntimeState(normalizeCapabilities(capabilities), runtimeReachable)
|
||||||
const uploadCapability = capabilityFor(resolvedCapabilities, 'upload')
|
const uploadCapability = capabilityFor(resolvedCapabilities, 'upload')
|
||||||
@@ -69,10 +74,15 @@ export function StorageObjectBrowser({cloud, resource, capabilities = [], runtim
|
|||||||
setFolderName('')
|
setFolderName('')
|
||||||
setCreateFolderOpen(false)
|
setCreateFolderOpen(false)
|
||||||
setDeleteConfirm(null)
|
setDeleteConfirm(null)
|
||||||
|
setDeleteFolderConfirm(null)
|
||||||
|
setEmptyBucketConfirm(false)
|
||||||
|
setEmptyBucketText('')
|
||||||
setCopyObject(null)
|
setCopyObject(null)
|
||||||
setSearch('')
|
setSearch('')
|
||||||
setSelectedKeys(new Set())
|
setSelectedKeys(new Set())
|
||||||
setPreviewObject(null)
|
setPreviewObject(null)
|
||||||
|
setFolderUpload(null)
|
||||||
|
setFolderUploadNotice(null)
|
||||||
onSelectObject(undefined)
|
onSelectObject(undefined)
|
||||||
}, [resource?.id, onSelectObject])
|
}, [resource?.id, onSelectObject])
|
||||||
|
|
||||||
@@ -91,6 +101,27 @@ export function StorageObjectBrowser({cloud, resource, capabilities = [], runtim
|
|||||||
onSuccess: () => qc.invalidateQueries({queryKey: ['storage-objects', cloud, resource?.id]}),
|
onSuccess: () => qc.invalidateQueries({queryKey: ['storage-objects', cloud, resource?.id]}),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const folderUploadMut = useMutation({
|
||||||
|
mutationFn: async (files: File[]) => {
|
||||||
|
if (!resource) return
|
||||||
|
const basePrefix = uploadPrefix || prefix
|
||||||
|
setFolderUpload({completed: 0, total: files.length, failed: 0})
|
||||||
|
let completed = 0
|
||||||
|
let failed = 0
|
||||||
|
for (const file of files) {
|
||||||
|
const relativePath = (file as File & {webkitRelativePath?: string}).webkitRelativePath || file.name
|
||||||
|
try {
|
||||||
|
await uploadStorageObject(cloud, resource.id, `${basePrefix}${relativePath}`, file)
|
||||||
|
completed++
|
||||||
|
} catch {
|
||||||
|
failed++
|
||||||
|
}
|
||||||
|
setFolderUpload({completed, total: files.length, failed})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: () => qc.invalidateQueries({queryKey: ['storage-objects', cloud, resource?.id]}),
|
||||||
|
})
|
||||||
|
|
||||||
const createFolderMut = useMutation({
|
const createFolderMut = useMutation({
|
||||||
mutationFn: async (name: string) => {
|
mutationFn: async (name: string) => {
|
||||||
if (!resource) return
|
if (!resource) return
|
||||||
@@ -115,6 +146,31 @@ export function StorageObjectBrowser({cloud, resource, capabilities = [], runtim
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const deleteFolderMut = useMutation({
|
||||||
|
mutationFn: async (folder: StorageObject) => {
|
||||||
|
if (!resource) return
|
||||||
|
await deleteFolderContents(cloud, resource.id, folder.key)
|
||||||
|
},
|
||||||
|
onSuccess: (_, folder) => {
|
||||||
|
if (selectedObjectKey?.startsWith(folder.key)) onSelectObject(undefined)
|
||||||
|
setDeleteFolderConfirm(null)
|
||||||
|
void qc.invalidateQueries({queryKey: ['storage-objects', cloud, resource?.id]})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const emptyBucketMut = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (!resource) return
|
||||||
|
await emptyBucket(cloud, resource.id)
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
setEmptyBucketConfirm(false)
|
||||||
|
setEmptyBucketText('')
|
||||||
|
onSelectObject(undefined)
|
||||||
|
void qc.invalidateQueries({queryKey: ['storage-objects', cloud, resource?.id]})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const moveCopyMut = useMutation({
|
const moveCopyMut = useMutation({
|
||||||
mutationFn: async ({srcKey, destKey, destResourceId, mode}: {
|
mutationFn: async ({srcKey, destKey, destResourceId, mode}: {
|
||||||
srcKey: string; destKey: string; destResourceId?: string; mode: 'move' | 'copy'
|
srcKey: string; destKey: string; destResourceId?: string; mode: 'move' | 'copy'
|
||||||
@@ -137,6 +193,7 @@ export function StorageObjectBrowser({cloud, resource, capabilities = [], runtim
|
|||||||
setSearch('')
|
setSearch('')
|
||||||
setSelectedKeys(new Set())
|
setSelectedKeys(new Set())
|
||||||
setDeleteConfirm(null)
|
setDeleteConfirm(null)
|
||||||
|
setDeleteFolderConfirm(null)
|
||||||
setPreviewObject(null)
|
setPreviewObject(null)
|
||||||
onSelectObject(undefined)
|
onSelectObject(undefined)
|
||||||
}
|
}
|
||||||
@@ -181,7 +238,7 @@ export function StorageObjectBrowser({cloud, resource, capabilities = [], runtim
|
|||||||
const allSelected = filteredFiles.length > 0 && filteredFiles.every((f) => selectedKeys.has(f.key))
|
const allSelected = filteredFiles.length > 0 && filteredFiles.every((f) => selectedKeys.has(f.key))
|
||||||
const someSelected = !allSelected && filteredFiles.some((f) => selectedKeys.has(f.key))
|
const someSelected = !allSelected && filteredFiles.some((f) => selectedKeys.has(f.key))
|
||||||
|
|
||||||
const error = query.error ?? uploadMut.error ?? createFolderMut.error ?? deleteMut.error ?? moveCopyMut.error
|
const error = query.error ?? uploadMut.error ?? createFolderMut.error ?? deleteMut.error ?? deleteFolderMut.error ?? emptyBucketMut.error ?? moveCopyMut.error
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="object-browser">
|
<section className="object-browser">
|
||||||
@@ -224,6 +281,12 @@ export function StorageObjectBrowser({cloud, resource, capabilities = [], runtim
|
|||||||
Back
|
Back
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{cloud === 'aws' && (
|
||||||
|
<button className="button danger" type="button" disabled={emptyBucketMut.isPending} onClick={() => setEmptyBucketConfirm(true)}>
|
||||||
|
<Trash2 size={14}/>
|
||||||
|
Empty bucket
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{createFolderCapability && (
|
{createFolderCapability && (
|
||||||
<button className="button" type="button" disabled={!canCreateFolder} title={createFolderCapability.reason} onClick={() => setCreateFolderOpen((open) => !open)}>
|
<button className="button" type="button" disabled={!canCreateFolder} title={createFolderCapability.reason} onClick={() => setCreateFolderOpen((open) => !open)}>
|
||||||
<Folder size={14}/>
|
<Folder size={14}/>
|
||||||
@@ -267,6 +330,42 @@ export function StorageObjectBrowser({cloud, resource, capabilities = [], runtim
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
{emptyBucketConfirm && (
|
||||||
|
<form className="object-create-folder" onSubmit={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (emptyBucketText === resource.name) emptyBucketMut.mutate()
|
||||||
|
}}>
|
||||||
|
<label>
|
||||||
|
<span>Type <code>{resource.name}</code> to permanently delete every object and folder in this bucket.</span>
|
||||||
|
<input className="input" value={emptyBucketText} onChange={(event) => setEmptyBucketText(event.target.value)} placeholder={resource.name}/>
|
||||||
|
</label>
|
||||||
|
<button className="button danger" type="submit" disabled={emptyBucketMut.isPending || emptyBucketText !== resource.name}>
|
||||||
|
<Trash2 size={14}/>
|
||||||
|
{emptyBucketMut.isPending ? 'Emptying bucket' : 'Empty bucket'}
|
||||||
|
</button>
|
||||||
|
<button className="button" type="button" disabled={emptyBucketMut.isPending} onClick={() => {
|
||||||
|
setEmptyBucketConfirm(false)
|
||||||
|
setEmptyBucketText('')
|
||||||
|
}}>Cancel</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
{folderUpload && <p className={folderUpload.failed > 0 ? 'error-text compact-text' : 'muted compact-text'}>
|
||||||
|
Folder upload: {folderUpload.completed} of {folderUpload.total} files uploaded{folderUpload.failed > 0 ? `, ${folderUpload.failed} failed` : ''}.
|
||||||
|
</p>}
|
||||||
|
{folderUploadNotice && <p className="error-text compact-text">{folderUploadNotice}</p>}
|
||||||
|
{canUpload && <div className="object-upload-dropzone" onDragOver={(event) => event.preventDefault()} onDrop={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
void droppedFiles(event.dataTransfer).then((files) => {
|
||||||
|
if (files.length > 0) {
|
||||||
|
setFolderUploadNotice(null)
|
||||||
|
folderUploadMut.mutate(files)
|
||||||
|
} else {
|
||||||
|
setFolderUploadNotice('Drop files or a folder from your desktop to upload them.')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}}>
|
||||||
|
<Folder size={18}/><strong>Drop files or folders here</strong><span>Folder paths are preserved under the current upload prefix.</span>
|
||||||
|
</div>}
|
||||||
|
|
||||||
{/* ── Error ── */}
|
{/* ── Error ── */}
|
||||||
{error && (
|
{error && (
|
||||||
@@ -348,10 +447,25 @@ export function StorageObjectBrowser({cloud, resource, capabilities = [], runtim
|
|||||||
<ChevronRight size={12}/>
|
<ChevronRight size={12}/>
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>{object.type}</td>
|
<td>Folder</td>
|
||||||
<td>—</td>
|
<td>—</td>
|
||||||
<td>{object.lastModified ?? '—'}</td>
|
<td>{object.lastModified ?? '—'}</td>
|
||||||
<td/>
|
<td className="table-actions">
|
||||||
|
{canDelete && deleteFolderConfirm === object.key ? (
|
||||||
|
<span className="object-folder-confirm">
|
||||||
|
<button className="button danger compact" disabled={deleteFolderMut.isPending} onClick={() => deleteFolderMut.mutate(object)}>
|
||||||
|
Delete folder
|
||||||
|
</button>
|
||||||
|
<button className="button compact" disabled={deleteFolderMut.isPending} onClick={() => setDeleteFolderConfirm(null)}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
) : deleteCapability && (
|
||||||
|
<button className="icon-btn danger" disabled={!canDelete} title={deleteCapability.reason ?? `Delete ${object.name} and all contents`} aria-label={`Delete ${object.name} folder`} onClick={() => setDeleteFolderConfirm(object.key)}>
|
||||||
|
<Trash2 size={13}/>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{filteredFiles.map((object) => {
|
{filteredFiles.map((object) => {
|
||||||
@@ -389,7 +503,7 @@ export function StorageObjectBrowser({cloud, resource, capabilities = [], runtim
|
|||||||
{object.name}
|
{object.name}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>{object.type}</td>
|
<td>{objectTypeLabel(object.name)}</td>
|
||||||
<td>{object.size === null ? '—' : formatBytes(object.size)}</td>
|
<td>{object.size === null ? '—' : formatBytes(object.size)}</td>
|
||||||
<td>{object.lastModified ?? '—'}</td>
|
<td>{object.lastModified ?? '—'}</td>
|
||||||
<td className="table-actions">
|
<td className="table-actions">
|
||||||
@@ -639,3 +753,59 @@ function parentPrefix(prefix: string): string {
|
|||||||
return segments.length ? `${segments.join('/')}/` : ''
|
return segments.length ? `${segments.join('/')}/` : ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function objectTypeLabel(name: string): string {
|
||||||
|
const extension = name.split('.').pop()?.trim().toUpperCase()
|
||||||
|
return extension && extension !== name.toUpperCase() ? `${extension} file` : 'File'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DroppedEntry {
|
||||||
|
isFile: boolean
|
||||||
|
isDirectory: boolean
|
||||||
|
name: string
|
||||||
|
file(callback: (file: File) => void, errorCallback?: (error: DOMException) => void): void
|
||||||
|
createReader(): {readEntries(callback: (entries: DroppedEntry[]) => void, errorCallback?: (error: DOMException) => void): void}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function droppedFiles(dataTransfer: DataTransfer): Promise<File[]> {
|
||||||
|
const items = Array.from(dataTransfer.items)
|
||||||
|
const entries = items.map((item) => (item as DataTransferItem & {webkitGetAsEntry?: () => unknown}).webkitGetAsEntry?.() as DroppedEntry | null | undefined).filter((entry): entry is DroppedEntry => entry !== null && entry !== undefined)
|
||||||
|
if (entries.length === 0) return Array.from(dataTransfer.files)
|
||||||
|
const files: File[] = []
|
||||||
|
for (const entry of entries) files.push(...await filesInEntry(entry))
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
|
||||||
|
async function filesInEntry(entry: DroppedEntry, prefix = ''): Promise<File[]> {
|
||||||
|
const files: File[] = []
|
||||||
|
const path = `${prefix}${entry.name}`
|
||||||
|
if (entry.isFile) {
|
||||||
|
const file = await new Promise<File>((resolve, reject) => entry.file(resolve, reject))
|
||||||
|
Object.defineProperty(file, 'webkitRelativePath', {value: path})
|
||||||
|
return [file]
|
||||||
|
}
|
||||||
|
if (!entry.isDirectory) return files
|
||||||
|
const reader = entry.createReader()
|
||||||
|
let entries: DroppedEntry[]
|
||||||
|
do {
|
||||||
|
entries = await new Promise<DroppedEntry[]>((resolve, reject) => reader.readEntries(resolve, reject))
|
||||||
|
for (const child of entries) files.push(...await filesInEntry(child, `${path}/`))
|
||||||
|
} while (entries.length > 0)
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteFolderContents(cloud: CloudProvider, bucketName: string, prefix: string): Promise<void> {
|
||||||
|
const listing = await listStorageObjects(cloud, bucketName, prefix)
|
||||||
|
const folders = listing.objects.filter((object) => object.type === 'folder')
|
||||||
|
const objects = listing.objects.filter((object) => object.type === 'object')
|
||||||
|
await Promise.all(folders.map((folder) => deleteFolderContents(cloud, bucketName, folder.key)))
|
||||||
|
await Promise.all(objects.map((object) => deleteStorageObject(cloud, bucketName, object.key)))
|
||||||
|
await deleteStorageObject(cloud, bucketName, prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function emptyBucket(cloud: CloudProvider, bucketName: string): Promise<void> {
|
||||||
|
const listing = await listStorageObjects(cloud, bucketName, '')
|
||||||
|
const folders = listing.objects.filter((object) => object.type === 'folder')
|
||||||
|
const objects = listing.objects.filter((object) => object.type === 'object')
|
||||||
|
await Promise.all(folders.map((folder) => deleteFolderContents(cloud, bucketName, folder.key)))
|
||||||
|
await Promise.all(objects.map((object) => deleteStorageObject(cloud, bucketName, object.key)))
|
||||||
|
}
|
||||||
|
|||||||
@@ -1436,6 +1436,18 @@ pre,
|
|||||||
color: var(--status-error);
|
color: var(--status-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.key-value-editor {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-value-row {
|
||||||
|
align-items: center;
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
.dynamic-field--span {
|
.dynamic-field--span {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
@@ -2460,6 +2472,36 @@ pre,
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.object-upload-dropzone {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
margin: 12px 0;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: 1px dashed var(--border, #4b5563);
|
||||||
|
border-radius: 7px;
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.object-upload-dropzone strong {
|
||||||
|
color: var(--text-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.object-upload-dropzone span {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lambda-edit-button {
|
||||||
|
background: color-mix(in srgb, var(--accent) 18%, transparent);
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 65%, var(--border));
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lambda-edit-button:hover {
|
||||||
|
background: color-mix(in srgb, var(--accent) 28%, transparent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
.table-confirm-actions {
|
.table-confirm-actions {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -2582,13 +2624,30 @@ pre,
|
|||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 7px 9px;
|
padding: 7px 9px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.apigateway-route-select {
|
||||||
|
align-items: center;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apigateway-route-confirm {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
.apigateway-route-item:hover,
|
.apigateway-route-item:hover,
|
||||||
.apigateway-route-item.selected {
|
.apigateway-route-item.selected {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type {StorageObjectList} from '@/types/resource'
|
||||||
|
|
||||||
|
export function bucketDeleteWarning(contents: StorageObjectList): string | null {
|
||||||
|
return contents.objects.length > 0
|
||||||
|
? 'This bucket contains objects or folders. Delete them before deleting the bucket.'
|
||||||
|
: null
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type {CloudProvider, CloudServiceType} from './cloud'
|
import type {CloudProvider, CloudServiceType} from './cloud'
|
||||||
|
|
||||||
export type FieldType = 'text' | 'textarea' | 'password' | 'select' | 'file'
|
export type FieldType = 'text' | 'textarea' | 'password' | 'select' | 'file' | 'key-value'
|
||||||
export type ActionSchema = 'list' | 'create' | 'update' | 'delete' | 'inspect'
|
export type ActionSchema = 'list' | 'create' | 'update' | 'delete' | 'inspect'
|
||||||
// Mirrors packages/api/src/cloud-spi/types.ts. Lifecycle verbs can be advertised
|
// Mirrors packages/api/src/cloud-spi/types.ts. Lifecycle verbs can be advertised
|
||||||
// in a capability block even though they are not table-level controls.
|
// in a capability block even though they are not table-level controls.
|
||||||
|
|||||||
Reference in New Issue
Block a user