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