diff --git a/packages/api/src/adapter-aws/AwsApiGatewayAdapter.test.ts b/packages/api/src/adapter-aws/AwsApiGatewayAdapter.test.ts index 9ea4e13..283f541 100644 --- a/packages/api/src/adapter-aws/AwsApiGatewayAdapter.test.ts +++ b/packages/api/src/adapter-aws/AwsApiGatewayAdapter.test.ts @@ -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(() => ({}))) diff --git a/packages/api/src/adapter-aws/AwsApiGatewayAdapter.ts b/packages/api/src/adapter-aws/AwsApiGatewayAdapter.ts index dc3abf9..8376d10 100644 --- a/packages/api/src/adapter-aws/AwsApiGatewayAdapter.ts +++ b/packages/api/src/adapter-aws/AwsApiGatewayAdapter.ts @@ -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 { + 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 { diff --git a/packages/api/src/adapter-aws/AwsServerlessAdapter.test.ts b/packages/api/src/adapter-aws/AwsServerlessAdapter.test.ts index 150a7cf..d30ddba 100644 --- a/packages/api/src/adapter-aws/AwsServerlessAdapter.test.ts +++ b/packages/api/src/adapter-aws/AwsServerlessAdapter.test.ts @@ -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 SendResult | Promise) { + 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'}) + }) + }) }) diff --git a/packages/api/src/adapter-aws/AwsServerlessAdapter.ts b/packages/api/src/adapter-aws/AwsServerlessAdapter.ts index e02da32..1f28b9d 100644 --- a/packages/api/src/adapter-aws/AwsServerlessAdapter.ts +++ b/packages/api/src/adapter-aws/AwsServerlessAdapter.ts @@ -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 { + 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 { 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 +} + function decodePayload(payload: InvokeCommandOutput["Payload"]): string { if (!payload) return ""; return new TextDecoder().decode(payload); diff --git a/packages/api/src/adapter-aws/AwsStorageAdapter.test.ts b/packages/api/src/adapter-aws/AwsStorageAdapter.test.ts index 0926915..85e73e7 100644 --- a/packages/api/src/adapter-aws/AwsStorageAdapter.test.ts +++ b/packages/api/src/adapter-aws/AwsStorageAdapter.test.ts @@ -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, + } +} diff --git a/packages/api/src/adapter-aws/AwsStorageAdapter.ts b/packages/api/src/adapter-aws/AwsStorageAdapter.ts index ba72961..31c1ad9 100644 --- a/packages/api/src/adapter-aws/AwsStorageAdapter.ts +++ b/packages/api/src/adapter-aws/AwsStorageAdapter.ts @@ -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 { + 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 { + 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 { + 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 { 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 { + 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([ + '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 +} diff --git a/packages/api/src/adapter-aws/zipArchive.test.ts b/packages/api/src/adapter-aws/zipArchive.test.ts index 3bccb44..f7141a1 100644 --- a/packages/api/src/adapter-aws/zipArchive.test.ts +++ b/packages/api/src/adapter-aws/zipArchive.test.ts @@ -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) diff --git a/packages/api/src/adapter-aws/zipArchive.ts b/packages/api/src/adapter-aws/zipArchive.ts index 8f9779e..b9a6600 100644 --- a/packages/api/src/adapter-aws/zipArchive.ts +++ b/packages/api/src/adapter-aws/zipArchive.ts @@ -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) diff --git a/packages/api/src/cloud-spi/serverlessSchema.ts b/packages/api/src/cloud-spi/serverlessSchema.ts index fe4feaf..05fb432 100644 --- a/packages/api/src/cloud-spi/serverlessSchema.ts +++ b/packages/api/src/cloud-spi/serverlessSchema.ts @@ -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: { diff --git a/packages/api/src/cloud-spi/types.ts b/packages/api/src/cloud-spi/types.ts index 6c309b2..8607e19 100644 --- a/packages/api/src/cloud-spi/types.ts +++ b/packages/api/src/cloud-spi/types.ts @@ -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 testApiGatewayRoute?(apiId: string, input: ApiGatewayTestRouteInput): Promise listApiGatewayRoutes?(apiId: string): Promise + deleteApiGatewayRoute?(apiId: string, input: ApiGatewayDeleteRouteInput): Promise + createS3LambdaTrigger?(bucketName: string, input: S3LambdaTriggerInput): Promise + listS3LambdaTriggers?(bucketName: string): Promise + deleteS3LambdaTrigger?(bucketName: string, triggerId: string): Promise + createEventBridgeLambdaTrigger?(input: EventBridgeLambdaTriggerInput): Promise + listEventBridgeLambdaTriggers?(functionName: string): Promise + deleteEventBridgeLambdaTrigger?(functionName: string, triggerId: string): Promise + createSqsLambdaEventSourceMapping?(input: SqsLambdaEventSourceMappingInput): Promise + listSqsLambdaEventSourceMappings?(functionName: string): Promise + deleteSqsLambdaEventSourceMapping?(functionName: string, mappingId: string): Promise encrypt?(id: string, input: KmsEncryptInput): Promise decrypt?(id: string, input: KmsDecryptInput): Promise // Lifecycle verbs. Optional because most categories have no notion of them; diff --git a/packages/api/src/cloudProxy.ts b/packages/api/src/cloudProxy.ts index ab74908..a709d0a 100644 --- a/packages/api/src/cloudProxy.ts +++ b/packages/api/src/cloudProxy.ts @@ -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), diff --git a/packages/api/src/routes/clouds.ts b/packages/api/src/routes/clouds.ts index 1d181b9..ce07a77 100644 --- a/packages/api/src/routes/clouds.ts +++ b/packages/api/src/routes/clouds.ts @@ -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>() + 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>() + 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>() + 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) diff --git a/packages/api/src/service/CloudProxyService.ts b/packages/api/src/service/CloudProxyService.ts index beaecd4..bb3e1f4 100644 --- a/packages/api/src/service/CloudProxyService.ts +++ b/packages/api/src/service/CloudProxyService.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { const adapter = this.requireAdapter(cloud, service) if (!adapter.update) throw new NotSupportedError(`Resource updates are not supported for ${cloud}/${service}`) diff --git a/packages/frontend/src/api/api.ts b/packages/frontend/src/api/api.ts index 1330087..9823c24 100644 --- a/packages/frontend/src/api/api.ts +++ b/packages/frontend/src/api/api.ts @@ -26,7 +26,17 @@ export const apiEndpointKeys = { decrypt: "clouds.services.resources.decrypt", createApiGatewayRoute: "clouds.services.resources.apigateway.route.create", listApiGatewayRoutes: "clouds.services.resources.apigateway.routes.list", - testApiGatewayRoute: "clouds.services.resources.apigateway.route.test", + deleteApiGatewayRoute: "clouds.services.resources.apigateway.route.delete", + testApiGatewayRoute: "clouds.services.resources.apigateway.route.test", + createS3LambdaTrigger: "clouds.services.resources.storage.lambda-trigger.create", + listS3LambdaTriggers: "clouds.services.resources.storage.lambda-triggers.list", + deleteS3LambdaTrigger: "clouds.services.resources.storage.lambda-trigger.delete", + createEventBridgeLambdaTrigger: "clouds.services.resources.serverless.eventbridge-trigger.create", + listEventBridgeLambdaTriggers: "clouds.services.resources.serverless.eventbridge-triggers.list", + deleteEventBridgeLambdaTrigger: "clouds.services.resources.serverless.eventbridge-trigger.delete", + createSqsLambdaEventSourceMapping: "clouds.services.resources.serverless.sqs-event-source-mapping.create", + listSqsLambdaEventSourceMappings: "clouds.services.resources.serverless.sqs-event-source-mappings.list", + deleteSqsLambdaEventSourceMapping: "clouds.services.resources.serverless.sqs-event-source-mapping.delete", }, storage: { objects: { @@ -310,6 +320,14 @@ export const endpointRegistry: EndpointRegistry = new Map([ telemetry: { service: "apigateway" }, }, ], + [ + apiEndpointKeys.clouds.resources.deleteApiGatewayRoute, + { + path: "/clouds/:cloud/services/apigateway/resources/:id/routes/:resourceId/:method", + method: "DELETE", + telemetry: { service: "apigateway" }, + }, + ], [ apiEndpointKeys.clouds.resources.testApiGatewayRoute, { @@ -318,6 +336,78 @@ export const endpointRegistry: EndpointRegistry = new Map([ telemetry: { service: "apigateway" }, }, ], + [ + apiEndpointKeys.clouds.resources.createS3LambdaTrigger, + { + path: "/clouds/:cloud/services/storage/resources/:id/lambda-triggers", + method: "POST", + telemetry: { service: "storage" }, + }, + ], + [ + apiEndpointKeys.clouds.resources.listS3LambdaTriggers, + { + path: "/clouds/:cloud/services/storage/resources/:id/lambda-triggers", + method: "GET", + telemetry: { service: "storage" }, + }, + ], + [ + apiEndpointKeys.clouds.resources.deleteS3LambdaTrigger, + { + path: "/clouds/:cloud/services/storage/resources/:id/lambda-triggers/:triggerId", + method: "DELETE", + telemetry: { service: "storage" }, + }, + ], + [ + apiEndpointKeys.clouds.resources.createEventBridgeLambdaTrigger, + { + path: "/clouds/:cloud/services/serverless/resources/:id/eventbridge-triggers", + method: "POST", + telemetry: { service: "serverless" }, + }, + ], + [ + apiEndpointKeys.clouds.resources.listEventBridgeLambdaTriggers, + { + path: "/clouds/:cloud/services/serverless/resources/:id/eventbridge-triggers", + method: "GET", + telemetry: { service: "serverless" }, + }, + ], + [ + apiEndpointKeys.clouds.resources.deleteEventBridgeLambdaTrigger, + { + path: "/clouds/:cloud/services/serverless/resources/:id/eventbridge-triggers/:triggerId", + method: "DELETE", + telemetry: { service: "serverless" }, + }, + ], + [ + apiEndpointKeys.clouds.resources.createSqsLambdaEventSourceMapping, + { + path: "/clouds/:cloud/services/serverless/resources/:id/sqs-event-source-mappings", + method: "POST", + telemetry: { service: "serverless" }, + }, + ], + [ + apiEndpointKeys.clouds.resources.listSqsLambdaEventSourceMappings, + { + path: "/clouds/:cloud/services/serverless/resources/:id/sqs-event-source-mappings", + method: "GET", + telemetry: { service: "serverless" }, + }, + ], + [ + apiEndpointKeys.clouds.resources.deleteSqsLambdaEventSourceMapping, + { + path: "/clouds/:cloud/services/serverless/resources/:id/sqs-event-source-mappings/:mappingId", + method: "DELETE", + telemetry: { service: "serverless" }, + }, + ], [ apiEndpointKeys.clouds.resources.get, { diff --git a/packages/frontend/src/api/cloudProxyClient.ts b/packages/frontend/src/api/cloudProxyClient.ts index 11367dc..d2cd720 100644 --- a/packages/frontend/src/api/cloudProxyClient.ts +++ b/packages/frontend/src/api/cloudProxyClient.ts @@ -245,6 +245,60 @@ export interface ApiGatewayRoutesResult { stages: string[]; } +export interface DeleteApiGatewayRouteInput { + resourceId: string; + path: string; + method: string; + stage: string; +} + +export interface CreateS3LambdaTriggerInput { + functionName: string; + events: string[]; + prefix?: string; + suffix?: string; +} + +export interface S3LambdaTriggerOverview { + id: string; + functionName: string; + functionArn: string; + events: string[]; + prefix: string | null; + suffix: string | null; +} + +export interface CreateEventBridgeLambdaTriggerInput { + ruleName: string; + scheduleExpression: string; +} + +export interface EventBridgeLambdaTriggerOverview { + id: string; + ruleName: string; + ruleArn: string; + functionName: string; + functionArn: string; + scheduleExpression: string; + state: string | null; +} + +export interface CreateSqsLambdaEventSourceMappingInput { + queueArn: string; + batchSize?: number; +} + +export interface SqsLambdaEventSourceMappingOverview { + id: string; + uuid: string; + functionName: string; + functionArn: string | null; + queueArn: string; + batchSize: number | null; + state: string | null; + stateTransitionReason: string | null; +} + export async function createApiGatewayLambdaRoute( cloud: CloudProvider, apiId: string, @@ -272,6 +326,19 @@ export async function listApiGatewayRoutes( return res.data; } +export async function deleteApiGatewayRoute( + cloud: CloudProvider, + apiId: string, + input: DeleteApiGatewayRouteInput, + signal?: AbortSignal, +): Promise { + await apiClient.call( + apiEndpointKeys.clouds.resources.deleteApiGatewayRoute, + requestOptions(cloud, "apigateway", {signal, params: {stage: input.stage, path: input.path}}), + {cloud, id: apiId, resourceId: input.resourceId, method: input.method}, + ); +} + export async function testApiGatewayRoute( cloud: CloudProvider, apiId: string, @@ -286,6 +353,126 @@ export async function testApiGatewayRoute( return res.data; } +export async function createS3LambdaTrigger( + cloud: CloudProvider, + bucketName: string, + input: CreateS3LambdaTriggerInput, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.resources.createS3LambdaTrigger, + requestOptions(cloud, "storage", {signal, body: input}), + {cloud, id: bucketName}, + ); + return res.data; +} + +export async function listS3LambdaTriggers( + cloud: CloudProvider, + bucketName: string, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.resources.listS3LambdaTriggers, + requestOptions(cloud, "storage", {signal}), + {cloud, id: bucketName}, + ); + return res.data; +} + +export async function deleteS3LambdaTrigger( + cloud: CloudProvider, + bucketName: string, + triggerId: string, + signal?: AbortSignal, +): Promise { + await apiClient.call( + apiEndpointKeys.clouds.resources.deleteS3LambdaTrigger, + requestOptions(cloud, "storage", {signal}), + {cloud, id: bucketName, triggerId}, + ); +} + +export async function createEventBridgeLambdaTrigger( + cloud: CloudProvider, + functionName: string, + input: CreateEventBridgeLambdaTriggerInput, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.resources.createEventBridgeLambdaTrigger, + requestOptions(cloud, "serverless", {signal, body: input}), + {cloud, id: functionName}, + ); + return res.data; +} + +export async function listEventBridgeLambdaTriggers( + cloud: CloudProvider, + functionName: string, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.resources.listEventBridgeLambdaTriggers, + requestOptions(cloud, "serverless", {signal}), + {cloud, id: functionName}, + ); + return res.data; +} + +export async function deleteEventBridgeLambdaTrigger( + cloud: CloudProvider, + functionName: string, + triggerId: string, + signal?: AbortSignal, +): Promise { + await apiClient.call( + apiEndpointKeys.clouds.resources.deleteEventBridgeLambdaTrigger, + requestOptions(cloud, "serverless", {signal}), + {cloud, id: functionName, triggerId}, + ); +} + +export async function createSqsLambdaEventSourceMapping( + cloud: CloudProvider, + functionName: string, + input: CreateSqsLambdaEventSourceMappingInput, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.resources.createSqsLambdaEventSourceMapping, + requestOptions(cloud, "serverless", {signal, body: input}), + {cloud, id: functionName}, + ); + return res.data; +} + +export async function listSqsLambdaEventSourceMappings( + cloud: CloudProvider, + functionName: string, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.resources.listSqsLambdaEventSourceMappings, + requestOptions(cloud, "serverless", {signal}), + {cloud, id: functionName}, + ); + return res.data; +} + +export async function deleteSqsLambdaEventSourceMapping( + cloud: CloudProvider, + functionName: string, + mappingId: string, + signal?: AbortSignal, +): Promise { + await apiClient.call( + apiEndpointKeys.clouds.resources.deleteSqsLambdaEventSourceMapping, + requestOptions(cloud, "serverless", {signal}), + {cloud, id: functionName, mappingId}, + ); +} + export type KmsEncryptionAlgorithm = | "SYMMETRIC_DEFAULT" | "RSAES_OAEP_SHA_1" diff --git a/packages/frontend/src/components/ApiGatewayRoutePanel.test.tsx b/packages/frontend/src/components/ApiGatewayRoutePanel.test.tsx index 96028b0..855cb4e 100644 --- a/packages/frontend/src/components/ApiGatewayRoutePanel.test.tsx +++ b/packages/frontend/src/components/ApiGatewayRoutePanel.test.tsx @@ -7,8 +7,10 @@ import type {CloudResource} from '@/types/resource' const apiMocks = vi.hoisted(() => ({ createApiGatewayLambdaRoute: vi.fn(), + deleteApiGatewayRoute: vi.fn(), listApiGatewayRoutes: vi.fn(), listCloudResources: vi.fn(), + queryLogs: vi.fn(), testApiGatewayRoute: vi.fn(), })) @@ -124,3 +126,21 @@ test('selects a deployed route for testing', async () => { expect(screen.getAllByRole('combobox')[0]).toHaveValue('POST') expect(screen.getAllByRole('combobox')[1]).toHaveValue('orders-handler') }) + +test('confirms before deleting a deployed route', async () => { + apiMocks.listCloudResources.mockResolvedValue([lambda]) + apiMocks.listApiGatewayRoutes.mockResolvedValue({ + routes: [{resourceId: 'resource-123', path: '/orders', method: 'POST', functionName: 'orders-handler'}], stages: ['dev'], + }) + apiMocks.deleteApiGatewayRoute.mockResolvedValue(undefined) + const user = userEvent.setup() + const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}}) + render() + + await user.click(await screen.findByRole('button', {name: 'Delete POST /orders'})) + expect(screen.getByText('Delete POST?')).toBeInTheDocument() + await user.click(screen.getByRole('button', {name: 'Delete'})) + await waitFor(() => expect(apiMocks.deleteApiGatewayRoute).toHaveBeenCalledWith('aws', 'api-123', { + resourceId: 'resource-123', path: '/orders', method: 'POST', stage: 'dev', + })) +}) diff --git a/packages/frontend/src/components/ApiGatewayRoutePanel.tsx b/packages/frontend/src/components/ApiGatewayRoutePanel.tsx index cbdd102..384c415 100644 --- a/packages/frontend/src/components/ApiGatewayRoutePanel.tsx +++ b/packages/frontend/src/components/ApiGatewayRoutePanel.tsx @@ -1,9 +1,10 @@ import {useEffect, useState} from 'react' -import {ArrowRight, Braces, Link2, ListTree, Loader2, Rocket, Send, Waypoints, X} from 'lucide-react' +import {ArrowRight, Braces, Copy, Link2, ListTree, Loader2, Rocket, Send, Trash2, Waypoints, X} from 'lucide-react' import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' -import {createApiGatewayLambdaRoute, listApiGatewayRoutes, listCloudResources, testApiGatewayRoute} from '@/api/cloudProxyClient' +import {createApiGatewayLambdaRoute, deleteApiGatewayRoute, listApiGatewayRoutes, listCloudResources, testApiGatewayRoute} from '@/api/cloudProxyClient' import type {CloudProvider} from '@/types/cloud' import type {CloudResource} from '@/types/resource' +import {LogsQueryPanel} from './LogsQueryPanel' interface ApiGatewayRoutePanelProps { cloud: CloudProvider @@ -18,7 +19,11 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat const [functionName, setFunctionName] = useState('') const [testBody, setTestBody] = useState('{}') const [testHeaders, setTestHeaders] = useState('{\n "content-type": "application/json"\n}') + const [testQuery, setTestQuery] = useState('') + const [curlCopied, setCurlCopied] = useState(false) const [bodyFormatError, setBodyFormatError] = useState(null) + const [logsRefreshKey, setLogsRefreshKey] = useState(0) + const [deleteConfirmation, setDeleteConfirmation] = useState(null) const queryClient = useQueryClient() const functionsQuery = useQuery({ queryKey: ['cloud-resources', cloud, 'serverless'], @@ -38,7 +43,11 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat setFunctionName('') setTestBody('{}') setTestHeaders('{\n "content-type": "application/json"\n}') + setTestQuery('') + setCurlCopied(false) setBodyFormatError(null) + setLogsRefreshKey(0) + setDeleteConfirmation(null) }, [resource?.id]) const routeMutation = useMutation({ @@ -52,8 +61,17 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat stage, body: testBody, headers: parseHeaders(testHeaders), - query: '', + query: testQuery, }), + onSuccess: () => setLogsRefreshKey((value) => value + 1), + }) + const deleteRouteMutation = useMutation({ + mutationFn: ({resourceId, path, method}: {resourceId: string; path: string; method: string}) => deleteApiGatewayRoute(cloud, resource!.id, {resourceId, path, method, stage}), + onSuccess: () => { + setDeleteConfirmation(null) + testMutation.reset() + queryClient.invalidateQueries({queryKey: ['api-gateway-routes', cloud, resource!.id]}) + }, }) if (!resource || resource.service !== 'apigateway') { @@ -69,6 +87,8 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat const functions = functionsQuery.data?.filter((item) => item.type === 'lambda') ?? [] const canCreate = cloud === 'aws' && runtimeReachable && Boolean(functionName) && !routeMutation.isPending + const existingPath = routesQuery.data?.routes.some((route) => route.path === path) ?? false + const curlCommand = buildCurlCommand(resource.id, stage, path, method, testHeaders, testQuery, testBody) return (
@@ -78,6 +98,14 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat

Connect Lambda

Creates and deploys one Lambda proxy route to the selected REST API.

+ {testMutation.data && functionName && ( + + )}
{ @@ -87,7 +115,7 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat
)} @@ -190,6 +226,11 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat Request headers (JSON)