feat: add SQS + EventBridge

This commit is contained in:
Zakaria
2026-09-24 22:34:04 -04:00
parent 9bbe80b5f2
commit 1b129e9dcf
37 changed files with 2902 additions and 66 deletions
@@ -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)
+18 -1
View File
@@ -122,7 +122,24 @@ export function awsServerlessSchema(): ServiceSchema {
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,
columns: serverlessColumns,
capabilities: {
+67 -1
View File
@@ -71,7 +71,7 @@ export interface CloudServiceStatus {
errorCode: string | null
}
export type FieldType = 'text' | 'textarea' | 'password' | 'select' | 'file'
export type FieldType = 'text' | 'textarea' | 'password' | 'select' | 'file' | 'key-value'
export interface FieldSchema {
name: string
@@ -491,6 +491,62 @@ export interface ApiGatewayRoutesResult {
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 =
| 'SYMMETRIC_DEFAULT'
| 'RSAES_OAEP_SHA_1'
@@ -555,6 +611,16 @@ export interface CloudServiceAdapter {
createApiGatewayLambdaRoute?(apiId: string, input: ApiGatewayLambdaRouteInput): Promise<ApiGatewayLambdaRouteResult>
testApiGatewayRoute?(apiId: string, input: ApiGatewayTestRouteInput): Promise<ApiGatewayTestRouteResult>
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>
decrypt?(id: string, input: KmsDecryptInput): Promise<KmsDecryptResult>
// Lifecycle verbs. Optional because most categories have no notion of them;
+2 -2
View File
@@ -55,13 +55,13 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap
const ec2Service = createEc2Service(clients.ec2)
return new CloudAdapterRegistry([
new AwsStorageAdapter(clients.s3),
new AwsStorageAdapter(clients.s3, clients.lambda, resolveAccountId(accountId)),
new AwsDynamoDbAdapter(clients.dynamodb),
new AwsEksAdapter(createEksService(clients.eks)),
new AwsDatabaseAdapter(createRdsService(clients.rds), clients.rds),
new AwsComputeAdapter(ec2Service),
new AwsNetworkingAdapter(ec2Service),
new AwsServerlessAdapter(clients.lambda),
new AwsServerlessAdapter(clients.lambda, clients.eventbridge),
new AwsLogsAdapter(clients.logs),
new AwsParameterStoreAdapter(clients.ssm),
new AwsKmsAdapter(clients.kms),
+102
View File
@@ -529,6 +529,20 @@ export function createCloudRoutes(injectedService?: CloudProxyService) {
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) => {
const cloud = c.req.param('cloud') as CloudProvider
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) => {
const target = childTarget(c)
if (!target) return c.json({error: 'Unknown cloud or service'}, 404)
@@ -25,6 +25,13 @@ import type {
ApiGatewayTestRouteInput,
ApiGatewayTestRouteResult,
ApiGatewayRoutesResult,
ApiGatewayDeleteRouteInput,
S3LambdaTriggerInput,
S3LambdaTriggerOverview,
EventBridgeLambdaTriggerInput,
EventBridgeLambdaTriggerOverview,
SqsLambdaEventSourceMappingInput,
SqsLambdaEventSourceMappingOverview,
DatabaseSnapshot,
KubernetesFargateProfile,
KubernetesNodegroup,
@@ -261,6 +268,66 @@ export class CloudProxyService {
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> {
const adapter = this.requireAdapter(cloud, service)
if (!adapter.update) throw new NotSupportedError(`Resource updates are not supported for ${cloud}/${service}`)