feat: add SQS + EventBridge
This commit is contained in:
@@ -3,6 +3,7 @@ import {
|
||||
CreateDeploymentCommand,
|
||||
CreateResourceCommand,
|
||||
CreateRestApiCommand,
|
||||
DeleteMethodCommand,
|
||||
DeleteRestApiCommand,
|
||||
GetRestApiCommand,
|
||||
GetRestApisCommand,
|
||||
@@ -13,7 +14,7 @@ import {
|
||||
PutMethodCommand,
|
||||
type APIGatewayClient,
|
||||
} 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'
|
||||
|
||||
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([
|
||||
'GetResourcesCommand', 'CreateResourceCommand', 'PutMethodCommand',
|
||||
'GetResourcesCommand', 'GetResourcesCommand', 'CreateResourceCommand', 'PutMethodCommand',
|
||||
'PutIntegrationCommand', 'CreateDeploymentCommand',
|
||||
])
|
||||
expect((apiCommands[1] as CreateResourceCommand).input).toMatchObject({
|
||||
expect((apiCommands[2] as CreateResourceCommand).input).toMatchObject({
|
||||
restApiId: 'a1b2c3', parentId: 'root123', pathPart: 'hello',
|
||||
})
|
||||
expect((apiCommands[2] as PutMethodCommand).input).toMatchObject({
|
||||
expect((apiCommands[3] as PutMethodCommand).input).toMatchObject({
|
||||
httpMethod: 'GET', authorizationType: 'NONE',
|
||||
})
|
||||
expect((apiCommands[3] as PutIntegrationCommand).input).toMatchObject({
|
||||
expect((apiCommands[4] as PutIntegrationCommand).input).toMatchObject({
|
||||
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',
|
||||
})
|
||||
@@ -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 () => {
|
||||
const originalFetch = globalThis.fetch
|
||||
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', () => {
|
||||
const adapter = new AwsApiGatewayAdapter(fakeClient(() => ({})))
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
CreateDeploymentCommand,
|
||||
CreateResourceCommand,
|
||||
CreateRestApiCommand,
|
||||
DeleteMethodCommand,
|
||||
DeleteRestApiCommand,
|
||||
GetRestApiCommand,
|
||||
GetRestApisCommand,
|
||||
@@ -13,12 +14,12 @@ import {
|
||||
type APIGatewayClient,
|
||||
type RestApi,
|
||||
} 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 {awsApiGatewaySchema} from '../cloud-spi/apigatewaySchema'
|
||||
import {ValidationError} from '../cloud-spi/errors'
|
||||
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 {
|
||||
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')
|
||||
}
|
||||
|
||||
const root = await this.rootResource(apiId)
|
||||
const resource = await this.apiGateway.send(new CreateResourceCommand({
|
||||
restApiId: apiId,
|
||||
parentId: root.id,
|
||||
pathPart,
|
||||
}))
|
||||
const resource = await this.resourceForPath(apiId, `/${pathPart}`) ?? await this.createTopLevelResource(apiId, pathPart)
|
||||
const resourceId = 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`,
|
||||
}))
|
||||
|
||||
await this.lambda.send(new AddPermissionCommand({
|
||||
const permission = {
|
||||
FunctionName: functionName,
|
||||
StatementId: `apigateway-${apiId}-${stage}-${method}-${pathPart}`,
|
||||
StatementId: apiGatewayPermissionStatementId(apiId, stage, method, `/${pathPart}`),
|
||||
Action: 'lambda:InvokeFunction',
|
||||
Principal: 'apigateway.amazonaws.com',
|
||||
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({
|
||||
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) {
|
||||
let position: string | undefined
|
||||
do {
|
||||
@@ -215,6 +255,26 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter {
|
||||
} while (position)
|
||||
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 {
|
||||
@@ -252,6 +312,10 @@ function lambdaFunctionName(uri?: string): string | 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 {
|
||||
const id = api.id ?? ''
|
||||
return {
|
||||
|
||||
@@ -1,12 +1,28 @@
|
||||
import {describe, expect, test} from 'bun:test'
|
||||
import {
|
||||
AddPermissionCommand,
|
||||
CreateFunctionCommand,
|
||||
CreateEventSourceMappingCommand,
|
||||
DeleteFunctionCommand,
|
||||
DeleteEventSourceMappingCommand,
|
||||
GetFunctionCommand,
|
||||
InvokeCommand,
|
||||
type LambdaClient,
|
||||
ListFunctionsCommand,
|
||||
ListEventSourceMappingsCommand,
|
||||
RemovePermissionCommand,
|
||||
UpdateFunctionCodeCommand,
|
||||
UpdateFunctionConfigurationCommand,
|
||||
} from '@aws-sdk/client-lambda'
|
||||
import {
|
||||
DeleteRuleCommand,
|
||||
ListRulesCommand,
|
||||
ListTargetsByRuleCommand,
|
||||
PutRuleCommand,
|
||||
PutTargetsCommand,
|
||||
RemoveTargetsCommand,
|
||||
type EventBridgeClient,
|
||||
} from '@aws-sdk/client-eventbridge'
|
||||
import {AwsServerlessAdapter} from './AwsServerlessAdapter'
|
||||
import {ValidationError} from '../cloud-spi/errors'
|
||||
|
||||
@@ -24,6 +40,17 @@ function stubLambda(handler: (command: object) => SendResult | Promise<SendResul
|
||||
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 = {
|
||||
Functions: [
|
||||
{
|
||||
@@ -214,6 +241,28 @@ describe('AwsServerlessAdapter', () => {
|
||||
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', () => {
|
||||
test('returns the decoded payload, status and duration', async () => {
|
||||
const {client, sent} = stubLambda(() => ({
|
||||
@@ -272,4 +321,133 @@ describe('AwsServerlessAdapter', () => {
|
||||
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 {
|
||||
CreateFunctionCommand,
|
||||
CreateEventSourceMappingCommand,
|
||||
AddPermissionCommand,
|
||||
DeleteFunctionCommand,
|
||||
DeleteEventSourceMappingCommand,
|
||||
GetFunctionCommand,
|
||||
InvokeCommand,
|
||||
type InvokeCommandOutput,
|
||||
ListFunctionsCommand,
|
||||
ListEventSourceMappingsCommand,
|
||||
RemovePermissionCommand,
|
||||
UpdateFunctionCodeCommand,
|
||||
UpdateFunctionConfigurationCommand,
|
||||
type LambdaClient,
|
||||
} 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 type {
|
||||
CloudResource,
|
||||
CloudServiceAdapter,
|
||||
CreateResourceInput,
|
||||
ResourceQuery,
|
||||
EventBridgeLambdaTriggerInput,
|
||||
EventBridgeLambdaTriggerOverview,
|
||||
SqsLambdaEventSourceMappingInput,
|
||||
SqsLambdaEventSourceMappingOverview,
|
||||
ServerlessInvokeResult,
|
||||
ServiceSchema,
|
||||
UpdateResourceInput,
|
||||
} from "../cloud-spi/types";
|
||||
import { lambda as defaultLambda } from "../aws";
|
||||
import { createZipArchive, handlerFileName } from "./zipArchive";
|
||||
import { eventbridge as defaultEventBridge, lambda as defaultLambda } from "../aws";
|
||||
import { createZipArchive, handlerFileName, readStoredZipEntry } from "./zipArchive";
|
||||
import {lambdaStarterCode} from '../cloud-spi/lambdaStarterCode'
|
||||
|
||||
export class AwsServerlessAdapter implements CloudServiceAdapter {
|
||||
readonly cloud = "aws" 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 {
|
||||
return awsServerlessSchema();
|
||||
@@ -66,6 +90,7 @@ export class AwsServerlessAdapter implements CloudServiceAdapter {
|
||||
const config = res.Configuration;
|
||||
|
||||
if (!config) return null;
|
||||
const source = await deployedHandlerSource(res.Code?.Location, config.Handler, config.Runtime)
|
||||
|
||||
return {
|
||||
id: config.FunctionName ?? config.FunctionArn ?? id,
|
||||
@@ -89,7 +114,8 @@ export class AwsServerlessAdapter implements CloudServiceAdapter {
|
||||
role: config.Role,
|
||||
version: config.Version,
|
||||
codeSha256: config.CodeSha256,
|
||||
environment: config.Environment?.Variables,
|
||||
environment: config.Environment?.Variables,
|
||||
...(source === null ? {} : {code: source}),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -176,6 +202,43 @@ export class AwsServerlessAdapter implements CloudServiceAdapter {
|
||||
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> {
|
||||
const startedAt = performance.now();
|
||||
const res = await this.lambda.send(
|
||||
@@ -196,6 +259,157 @@ export class AwsServerlessAdapter implements CloudServiceAdapter {
|
||||
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 {
|
||||
@@ -213,6 +427,99 @@ function positiveNumberOrDefault(value: unknown, defaultValue: number): number {
|
||||
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 {
|
||||
if (!payload) return "";
|
||||
return new TextDecoder().decode(payload);
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import {describe, expect, test} from 'bun:test'
|
||||
import {
|
||||
GetBucketNotificationConfigurationCommand,
|
||||
GetBucketTaggingCommand,
|
||||
ListBucketsCommand,
|
||||
PutBucketNotificationConfigurationCommand,
|
||||
type S3Client,
|
||||
} from '@aws-sdk/client-s3'
|
||||
import {AddPermissionCommand, GetFunctionCommand, RemovePermissionCommand, type LambdaClient} from '@aws-sdk/client-lambda'
|
||||
import {AwsStorageAdapter} from './AwsStorageAdapter'
|
||||
|
||||
function fakeS3(overrides: Partial<{
|
||||
@@ -100,4 +103,90 @@ describe('AwsStorageAdapter', () => {
|
||||
expect(result.map((r) => r.name).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,
|
||||
DeleteBucketCommand,
|
||||
DeleteObjectCommand,
|
||||
type Event,
|
||||
GetBucketTaggingCommand,
|
||||
GetBucketNotificationConfigurationCommand,
|
||||
GetObjectCommand,
|
||||
ListBucketsCommand,
|
||||
ListObjectsV2Command,
|
||||
PutObjectCommand,
|
||||
PutBucketNotificationConfigurationCommand,
|
||||
type S3Client,
|
||||
} 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 type {
|
||||
CloudResource,
|
||||
@@ -22,13 +26,19 @@ import type {
|
||||
ServiceSchema,
|
||||
StorageObjectDownload,
|
||||
StorageObjectList,
|
||||
S3LambdaTriggerInput,
|
||||
S3LambdaTriggerOverview,
|
||||
} from '../cloud-spi/types'
|
||||
|
||||
export class AwsStorageAdapter implements CloudServiceAdapter {
|
||||
readonly cloud = 'aws' 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 {
|
||||
return awsStorageSchema()
|
||||
@@ -109,6 +119,86 @@ export class AwsStorageAdapter implements CloudServiceAdapter {
|
||||
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> {
|
||||
const res = await this.s3.send(new ListObjectsV2Command({
|
||||
Bucket: resourceId,
|
||||
@@ -178,12 +268,36 @@ export class AwsStorageAdapter implements CloudServiceAdapter {
|
||||
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 {
|
||||
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[] {
|
||||
const normalized = search?.trim().toLowerCase()
|
||||
if (!normalized) return buckets
|
||||
@@ -198,3 +312,26 @@ function objectName(key: string, prefix: string): string {
|
||||
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)
|
||||
}
|
||||
|
||||
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 {rm, writeFile} from 'node:fs/promises'
|
||||
import {tmpdir} from 'node:os'
|
||||
import {createZipArchive, crc32, handlerFileName} from './zipArchive'
|
||||
import {createZipArchive, crc32, handlerFileName, readStoredZipEntry} from './zipArchive'
|
||||
|
||||
describe('crc32', () => {
|
||||
// Reference values from the standard CRC-32 (IEEE 802.3) test vectors.
|
||||
@@ -15,6 +15,12 @@ describe('crc32', () => {
|
||||
})
|
||||
|
||||
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', () => {
|
||||
const archive = createZipArchive([{name: 'index.js', content: 'module.exports = 1'}])
|
||||
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])
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
const total = parts.reduce((sum, part) => sum + part.length, 0)
|
||||
const out = new Uint8Array(total)
|
||||
|
||||
Reference in New Issue
Block a user