feat: add SQS + EventBridge

This commit is contained in:
Zakaria
2026-09-24 22:34:04 -04:00
parent 9bbe80b5f2
commit 1b129e9dcf
37 changed files with 2902 additions and 66 deletions
@@ -3,6 +3,7 @@ import {
CreateDeploymentCommand,
CreateResourceCommand,
CreateRestApiCommand,
DeleteMethodCommand,
DeleteRestApiCommand,
GetRestApiCommand,
GetRestApisCommand,
@@ -13,7 +14,7 @@ import {
PutMethodCommand,
type APIGatewayClient,
} from '@aws-sdk/client-api-gateway'
import {AddPermissionCommand, GetFunctionCommand, type LambdaClient} from '@aws-sdk/client-lambda'
import {AddPermissionCommand, GetFunctionCommand, RemovePermissionCommand, type LambdaClient} from '@aws-sdk/client-lambda'
import {AwsApiGatewayAdapter} from './AwsApiGatewayAdapter'
function fakeClient(handler: (command: unknown) => unknown): APIGatewayClient {
@@ -155,16 +156,16 @@ describe('AwsApiGatewayAdapter', () => {
})
expect(apiCommands.map((command) => (command as {constructor: {name: string}}).constructor.name)).toEqual([
'GetResourcesCommand', 'CreateResourceCommand', 'PutMethodCommand',
'GetResourcesCommand', 'GetResourcesCommand', 'CreateResourceCommand', 'PutMethodCommand',
'PutIntegrationCommand', 'CreateDeploymentCommand',
])
expect((apiCommands[1] as CreateResourceCommand).input).toMatchObject({
expect((apiCommands[2] as CreateResourceCommand).input).toMatchObject({
restApiId: 'a1b2c3', parentId: 'root123', pathPart: 'hello',
})
expect((apiCommands[2] as PutMethodCommand).input).toMatchObject({
expect((apiCommands[3] as PutMethodCommand).input).toMatchObject({
httpMethod: 'GET', authorizationType: 'NONE',
})
expect((apiCommands[3] as PutIntegrationCommand).input).toMatchObject({
expect((apiCommands[4] as PutIntegrationCommand).input).toMatchObject({
type: 'AWS_PROXY', integrationHttpMethod: 'POST',
uri: 'arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:000000000000:function:hello/invocations',
})
@@ -178,6 +179,34 @@ describe('AwsApiGatewayAdapter', () => {
})
})
test('adds a method to an existing resource without creating a duplicate path', async () => {
const apiCommands: unknown[] = []
const adapter = new AwsApiGatewayAdapter(
fakeClient((command) => {
apiCommands.push(command)
if (command instanceof GetResourcesCommand) {
return {items: [{id: 'resource456', path: '/hello'}, {id: 'root123', path: '/'}]}
}
if (command instanceof CreateDeploymentCommand) return {id: 'deployment789'}
return {}
}),
fakeLambda((command) => command instanceof GetFunctionCommand
? {Configuration: {FunctionArn: 'arn:aws:lambda:us-east-1:000000000000:function:hello'}}
: {}),
'000000000000',
'us-east-1',
)
await adapter.createApiGatewayLambdaRoute('a1b2c3', {
path: '/hello', method: 'POST', stage: 'dev', functionName: 'hello',
})
expect(apiCommands.some((command) => command instanceof CreateResourceCommand)).toBeFalse()
expect((apiCommands.find((command) => command instanceof PutMethodCommand) as PutMethodCommand).input).toMatchObject({
resourceId: 'resource456', httpMethod: 'POST',
})
})
test('tests a deployed route and preserves its HTTP response', async () => {
const originalFetch = globalThis.fetch
const requests: Request[] = []
@@ -258,6 +287,27 @@ describe('AwsApiGatewayAdapter', () => {
})
})
test('deletes one method and redeploys its stage', async () => {
const commands: unknown[] = []
const lambdaCommands: unknown[] = []
const adapter = new AwsApiGatewayAdapter(fakeClient((command) => {
commands.push(command)
if (command instanceof GetMethodCommand) {
return {methodIntegration: {uri: 'arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:000000000000:function:hello/invocations'}}
}
return {}
}), fakeLambda((command) => {
lambdaCommands.push(command)
return {}
}))
await adapter.deleteApiGatewayRoute('a1b2c3', {resourceId: 'resource456', path: '/hello', method: 'POST', stage: 'dev'})
expect((commands[1] as DeleteMethodCommand).input).toMatchObject({restApiId: 'a1b2c3', resourceId: 'resource456', httpMethod: 'POST'})
expect((commands[2] as CreateDeploymentCommand).input).toMatchObject({restApiId: 'a1b2c3', stageName: 'dev'})
expect((lambdaCommands[0] as RemovePermissionCommand).input).toMatchObject({FunctionName: 'hello', StatementId: 'apigateway-a1b2c3-dev-POST-hello'})
})
test('returns the AWS API Gateway schema', () => {
const adapter = new AwsApiGatewayAdapter(fakeClient(() => ({})))
@@ -2,6 +2,7 @@ import {
CreateDeploymentCommand,
CreateResourceCommand,
CreateRestApiCommand,
DeleteMethodCommand,
DeleteRestApiCommand,
GetRestApiCommand,
GetRestApisCommand,
@@ -13,12 +14,12 @@ import {
type APIGatewayClient,
type RestApi,
} from '@aws-sdk/client-api-gateway'
import {AddPermissionCommand, GetFunctionCommand, type LambdaClient} from '@aws-sdk/client-lambda'
import {AddPermissionCommand, GetFunctionCommand, RemovePermissionCommand, type LambdaClient} from '@aws-sdk/client-lambda'
import {apiGateway as defaultApiGateway, defaultAccountId, lambda as defaultLambda} from '../aws'
import {awsApiGatewaySchema} from '../cloud-spi/apigatewaySchema'
import {ValidationError} from '../cloud-spi/errors'
import {awsEndpoint} from '../service/runtimeProbe'
import type {ApiGatewayLambdaRouteInput, ApiGatewayLambdaRouteResult, ApiGatewayRouteOverview, ApiGatewayRoutesResult, ApiGatewayTestRouteInput, ApiGatewayTestRouteResult, CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types'
import type {ApiGatewayDeleteRouteInput, ApiGatewayLambdaRouteInput, ApiGatewayLambdaRouteResult, ApiGatewayRouteOverview, ApiGatewayRoutesResult, ApiGatewayTestRouteInput, ApiGatewayTestRouteResult, CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types'
export class AwsApiGatewayAdapter implements CloudServiceAdapter {
readonly cloud = 'aws' as const
@@ -84,12 +85,7 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter {
throw new ValidationError('method must be GET, POST, PUT, or DELETE')
}
const root = await this.rootResource(apiId)
const resource = await this.apiGateway.send(new CreateResourceCommand({
restApiId: apiId,
parentId: root.id,
pathPart,
}))
const resource = await this.resourceForPath(apiId, `/${pathPart}`) ?? await this.createTopLevelResource(apiId, pathPart)
const resourceId = resource.id
if (!resourceId) throw new ValidationError('API Gateway did not return a resource id')
@@ -112,13 +108,23 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter {
uri: `arn:aws:apigateway:${this.region}:lambda:path/2015-03-31/functions/${functionArn}/invocations`,
}))
await this.lambda.send(new AddPermissionCommand({
const permission = {
FunctionName: functionName,
StatementId: `apigateway-${apiId}-${stage}-${method}-${pathPart}`,
StatementId: apiGatewayPermissionStatementId(apiId, stage, method, `/${pathPart}`),
Action: 'lambda:InvokeFunction',
Principal: 'apigateway.amazonaws.com',
SourceArn: `arn:aws:execute-api:${this.region}:${this.accountId}:${apiId}/${stage}/${method}/${pathPart}`,
}))
}
try {
await this.lambda.send(new AddPermissionCommand(permission))
} catch (error) {
if (!hasHttpStatus(error, 409)) throw error
await this.lambda.send(new RemovePermissionCommand({
FunctionName: functionName,
StatementId: permission.StatementId,
}))
await this.lambda.send(new AddPermissionCommand(permission))
}
const deployment = await this.apiGateway.send(new CreateDeploymentCommand({
restApiId: apiId,
@@ -205,6 +211,40 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter {
}
}
async deleteApiGatewayRoute(apiId: string, input: ApiGatewayDeleteRouteInput): Promise<void> {
const method = input.method.toUpperCase()
const stage = stageName(input.stage)
if (!['GET', 'POST', 'PUT', 'DELETE'].includes(method)) throw new ValidationError('method must be GET, POST, PUT, or DELETE')
if (!stringValue(input.resourceId)) throw new ValidationError('resourceId is required')
const route = await this.apiGateway.send(new GetMethodCommand({
restApiId: apiId,
resourceId: input.resourceId,
httpMethod: method,
}))
await this.apiGateway.send(new DeleteMethodCommand({
restApiId: apiId,
resourceId: input.resourceId,
httpMethod: method,
}))
const functionName = lambdaFunctionName(route.methodIntegration?.uri)
if (functionName) {
try {
await this.lambda.send(new RemovePermissionCommand({
FunctionName: functionName,
StatementId: apiGatewayPermissionStatementId(apiId, stage, method, input.path),
}))
} catch (error) {
if (!hasHttpStatus(error, 404)) throw error
}
}
await this.apiGateway.send(new CreateDeploymentCommand({
restApiId: apiId,
stageName: stage,
description: `Delete ${method} method`,
}))
}
private async rootResource(apiId: string) {
let position: string | undefined
do {
@@ -215,6 +255,26 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter {
} while (position)
throw new ValidationError('API Gateway root resource was not found')
}
private async resourceForPath(apiId: string, path: string) {
let position: string | undefined
do {
const result = await this.apiGateway.send(new GetResourcesCommand({restApiId: apiId, position}))
const resource = result.items?.find((item) => item.path === path)
if (resource?.id) return resource
position = result.position
} while (position)
return undefined
}
private async createTopLevelResource(apiId: string, pathPart: string) {
const root = await this.rootResource(apiId)
return this.apiGateway.send(new CreateResourceCommand({
restApiId: apiId,
parentId: root.id,
pathPart,
}))
}
}
function routePathPart(path: string): string {
@@ -252,6 +312,10 @@ function lambdaFunctionName(uri?: string): string | null {
return match?.[1] ?? null
}
function apiGatewayPermissionStatementId(apiId: string, stage: string, method: string, path: string): string {
return `apigateway-${apiId}-${stage}-${method}-${routePathPart(path)}`
}
function toResource(api: RestApi): CloudResource {
const id = api.id ?? ''
return {
@@ -1,12 +1,28 @@
import {describe, expect, test} from 'bun:test'
import {
AddPermissionCommand,
CreateFunctionCommand,
CreateEventSourceMappingCommand,
DeleteFunctionCommand,
DeleteEventSourceMappingCommand,
GetFunctionCommand,
InvokeCommand,
type LambdaClient,
ListFunctionsCommand,
ListEventSourceMappingsCommand,
RemovePermissionCommand,
UpdateFunctionCodeCommand,
UpdateFunctionConfigurationCommand,
} from '@aws-sdk/client-lambda'
import {
DeleteRuleCommand,
ListRulesCommand,
ListTargetsByRuleCommand,
PutRuleCommand,
PutTargetsCommand,
RemoveTargetsCommand,
type EventBridgeClient,
} from '@aws-sdk/client-eventbridge'
import {AwsServerlessAdapter} from './AwsServerlessAdapter'
import {ValidationError} from '../cloud-spi/errors'
@@ -24,6 +40,17 @@ function stubLambda(handler: (command: object) => SendResult | Promise<SendResul
return {client, sent}
}
function stubEventBridge(handler: (command: object) => SendResult | Promise<SendResult>) {
const sent: object[] = []
const client = {
async send(command: object) {
sent.push(command)
return handler(command)
},
} as unknown as EventBridgeClient
return {client, sent}
}
const listPayload = {
Functions: [
{
@@ -214,6 +241,28 @@ describe('AwsServerlessAdapter', () => {
expect((sent[0] as DeleteFunctionCommand).input.FunctionName).toBe('hello')
})
test('updates inline source and Lambda configuration', async () => {
const {client, sent} = stubLambda((command) => {
if (command instanceof GetFunctionCommand) {
return {Configuration: {FunctionName: 'hello', Runtime: 'nodejs22.x', Handler: 'index.handler'}}
}
return {}
})
const adapter = new AwsServerlessAdapter(client)
await adapter.update('hello', {values: {
code: 'exports.handler = async () => ({statusCode: 201, body: "created"})',
handler: 'index.handler', runtime: 'nodejs22.x', memorySize: '256', timeout: '10',
environment: JSON.stringify([{key: 'APP_GREETING', value: 'Hello from Floci'}]),
}})
expect(sent[1]).toBeInstanceOf(UpdateFunctionCodeCommand)
expect((sent[2] as UpdateFunctionConfigurationCommand).input).toMatchObject({
FunctionName: 'hello', Runtime: 'nodejs22.x', Handler: 'index.handler', MemorySize: 256, Timeout: 10,
Environment: {Variables: {APP_GREETING: 'Hello from Floci'}},
})
})
describe('invoke', () => {
test('returns the decoded payload, status and duration', async () => {
const {client, sent} = stubLambda(() => ({
@@ -272,4 +321,133 @@ describe('AwsServerlessAdapter', () => {
expect(result.payload).toBe('')
})
})
describe('EventBridge scheduled triggers', () => {
test('creates a scheduled rule, Lambda target, and invocation permission', async () => {
const {client: lambda, sent: lambdaSent} = stubLambda((command) => {
if (command instanceof GetFunctionCommand) {
return {Configuration: {FunctionArn: 'arn:aws:lambda:us-east-1:000000000000:function:hello'}}
}
return {}
})
const {client: eventBridge, sent: eventBridgeSent} = stubEventBridge((command) => {
if (command instanceof PutRuleCommand) return {RuleArn: 'arn:aws:events:us-east-1:000000000000:rule/hourly-hello'}
return {}
})
const trigger = await new AwsServerlessAdapter(lambda, eventBridge).createEventBridgeLambdaTrigger({
ruleName: 'hourly-hello', scheduleExpression: 'rate(1 hour)', functionName: 'hello',
})
expect((eventBridgeSent[0] as PutRuleCommand).input).toMatchObject({Name: 'hourly-hello', ScheduleExpression: 'rate(1 hour)', State: 'ENABLED'})
expect((eventBridgeSent[1] as PutTargetsCommand).input).toMatchObject({
Rule: 'hourly-hello',
Targets: [{Id: 'floci-ui-eventbridge-hello', Arn: 'arn:aws:lambda:us-east-1:000000000000:function:hello'}],
})
expect((lambdaSent[1] as AddPermissionCommand).input).toMatchObject({
FunctionName: 'hello', StatementId: 'floci-ui-eventbridge-hourly-hello',
Principal: 'events.amazonaws.com', SourceArn: 'arn:aws:events:us-east-1:000000000000:rule/hourly-hello',
})
expect(trigger.scheduleExpression).toBe('rate(1 hour)')
})
test('lists scheduled rules targeting the requested Lambda', async () => {
const {client: lambda} = stubLambda(() => ({}))
const {client: eventBridge, sent} = stubEventBridge((command) => {
if (command instanceof ListRulesCommand) {
return {Rules: [
{Name: 'hourly-hello', Arn: 'arn:aws:events:us-east-1:000000000000:rule/hourly-hello', ScheduleExpression: 'rate(1 hour)', State: 'ENABLED'},
{Name: 'daily-other', Arn: 'arn:aws:events:us-east-1:000000000000:rule/daily-other', ScheduleExpression: 'rate(1 day)'},
]}
}
if (command instanceof ListTargetsByRuleCommand && command.input.Rule === 'hourly-hello') {
return {Targets: [{Id: 'hello', Arn: 'arn:aws:lambda:us-east-1:000000000000:function:hello'}]}
}
return {Targets: [{Id: 'other', Arn: 'arn:aws:lambda:us-east-1:000000000000:function:other'}]}
})
await expect(new AwsServerlessAdapter(lambda, eventBridge).listEventBridgeLambdaTriggers('hello')).resolves.toEqual([expect.objectContaining({ruleName: 'hourly-hello', functionName: 'hello'})])
expect(sent.filter((command) => command instanceof ListTargetsByRuleCommand)).toHaveLength(2)
})
test('removes targets and permissions before deleting the rule', async () => {
const {client: lambda, sent: lambdaSent} = stubLambda(() => ({}))
const {client: eventBridge, sent: eventBridgeSent} = stubEventBridge((command) => {
if (command instanceof ListTargetsByRuleCommand) {
return {Targets: [{Id: 'hello', Arn: 'arn:aws:lambda:us-east-1:000000000000:function:hello'}]}
}
return {}
})
await new AwsServerlessAdapter(lambda, eventBridge).deleteEventBridgeLambdaTrigger('hello', 'hourly-hello')
expect(eventBridgeSent[0]).toBeInstanceOf(ListTargetsByRuleCommand)
expect((eventBridgeSent[1] as RemoveTargetsCommand).input).toMatchObject({Rule: 'hourly-hello', Ids: ['hello']})
expect(lambdaSent[0]).toBeInstanceOf(RemovePermissionCommand)
expect((eventBridgeSent[2] as DeleteRuleCommand).input.Name).toBe('hourly-hello')
})
})
describe('SQS event source mappings', () => {
const queueArn = 'arn:aws:sqs:us-east-1:000000000000:orders'
test('creates an SQS mapping with the Lambda AWS SDK command', async () => {
const {client, sent} = stubLambda(() => ({
UUID: 'mapping-1',
FunctionArn: 'arn:aws:lambda:us-east-1:000000000000:function:hello',
EventSourceArn: queueArn,
BatchSize: 10,
State: 'Creating',
}))
const mapping = await new AwsServerlessAdapter(client).createSqsLambdaEventSourceMapping({
functionName: 'hello', queueArn, batchSize: 10,
})
expect(sent[0]).toBeInstanceOf(CreateEventSourceMappingCommand)
expect((sent[0] as CreateEventSourceMappingCommand).input).toEqual({
FunctionName: 'hello', EventSourceArn: queueArn, BatchSize: 10,
})
expect(mapping).toMatchObject({uuid: 'mapping-1', functionName: 'hello', queueArn, batchSize: 10})
})
test('rejects an SQS batch size outside the AWS range', async () => {
const {client} = stubLambda(() => ({}))
await expect(new AwsServerlessAdapter(client).createSqsLambdaEventSourceMapping({
functionName: 'hello', queueArn, batchSize: 11,
})).rejects.toThrow(new ValidationError('batchSize must be an integer between 1 and 10'))
})
test('lists only SQS mappings for the requested Lambda across pages', async () => {
const {client, sent} = stubLambda((command) => {
if (command instanceof ListEventSourceMappingsCommand && !command.input.Marker) {
return {
EventSourceMappings: [
{UUID: 'sqs-mapping', EventSourceArn: queueArn, FunctionArn: 'arn:aws:lambda:us-east-1:000000000000:function:hello'},
{UUID: 'stream-mapping', EventSourceArn: 'arn:aws:kinesis:us-east-1:000000000000:stream/orders'},
],
NextMarker: 'page-2',
}
}
return {EventSourceMappings: [{UUID: 'sqs-mapping-2', EventSourceArn: 'arn:aws:sqs:us-east-1:000000000000:payments'}]}
})
await expect(new AwsServerlessAdapter(client).listSqsLambdaEventSourceMappings('hello')).resolves.toEqual([
expect.objectContaining({uuid: 'sqs-mapping', queueArn, functionName: 'hello'}),
expect.objectContaining({uuid: 'sqs-mapping-2', queueArn: 'arn:aws:sqs:us-east-1:000000000000:payments'}),
])
expect(sent).toHaveLength(2)
expect(sent[0]).toBeInstanceOf(ListEventSourceMappingsCommand)
expect((sent[0] as ListEventSourceMappingsCommand).input.FunctionName).toBe('hello')
expect((sent[1] as ListEventSourceMappingsCommand).input.Marker).toBe('page-2')
})
test('deletes an SQS mapping by its AWS UUID', async () => {
const {client, sent} = stubLambda(() => ({}))
await new AwsServerlessAdapter(client).deleteSqsLambdaEventSourceMapping('hello', 'mapping-1')
expect(sent[0]).toBeInstanceOf(DeleteEventSourceMappingCommand)
expect((sent[0] as DeleteEventSourceMappingCommand).input).toEqual({UUID: 'mapping-1'})
})
})
})
@@ -1,31 +1,55 @@
import {ValidationError} from '../cloud-spi/errors'
import {
CreateFunctionCommand,
CreateEventSourceMappingCommand,
AddPermissionCommand,
DeleteFunctionCommand,
DeleteEventSourceMappingCommand,
GetFunctionCommand,
InvokeCommand,
type InvokeCommandOutput,
ListFunctionsCommand,
ListEventSourceMappingsCommand,
RemovePermissionCommand,
UpdateFunctionCodeCommand,
UpdateFunctionConfigurationCommand,
type LambdaClient,
} from "@aws-sdk/client-lambda";
import {
DeleteRuleCommand,
ListRulesCommand,
ListTargetsByRuleCommand,
PutRuleCommand,
PutTargetsCommand,
RemoveTargetsCommand,
type EventBridgeClient,
} from '@aws-sdk/client-eventbridge'
import { awsServerlessSchema } from "../cloud-spi/serverlessSchema";
import type {
CloudResource,
CloudServiceAdapter,
CreateResourceInput,
ResourceQuery,
EventBridgeLambdaTriggerInput,
EventBridgeLambdaTriggerOverview,
SqsLambdaEventSourceMappingInput,
SqsLambdaEventSourceMappingOverview,
ServerlessInvokeResult,
ServiceSchema,
UpdateResourceInput,
} from "../cloud-spi/types";
import { lambda as defaultLambda } from "../aws";
import { createZipArchive, handlerFileName } from "./zipArchive";
import { eventbridge as defaultEventBridge, lambda as defaultLambda } from "../aws";
import { createZipArchive, handlerFileName, readStoredZipEntry } from "./zipArchive";
import {lambdaStarterCode} from '../cloud-spi/lambdaStarterCode'
export class AwsServerlessAdapter implements CloudServiceAdapter {
readonly cloud = "aws" as const;
readonly service = "serverless" as const;
constructor(private readonly lambda: LambdaClient = defaultLambda) {}
constructor(
private readonly lambda: LambdaClient = defaultLambda,
private readonly eventBridge: EventBridgeClient = defaultEventBridge,
) {}
schema(): ServiceSchema {
return awsServerlessSchema();
@@ -66,6 +90,7 @@ export class AwsServerlessAdapter implements CloudServiceAdapter {
const config = res.Configuration;
if (!config) return null;
const source = await deployedHandlerSource(res.Code?.Location, config.Handler, config.Runtime)
return {
id: config.FunctionName ?? config.FunctionArn ?? id,
@@ -89,7 +114,8 @@ export class AwsServerlessAdapter implements CloudServiceAdapter {
role: config.Role,
version: config.Version,
codeSha256: config.CodeSha256,
environment: config.Environment?.Variables,
environment: config.Environment?.Variables,
...(source === null ? {} : {code: source}),
},
};
} catch (error) {
@@ -176,6 +202,43 @@ export class AwsServerlessAdapter implements CloudServiceAdapter {
await this.lambda.send(new DeleteFunctionCommand({ FunctionName: id }));
}
async update(id: string, input: UpdateResourceInput): Promise<CloudResource> {
const values = input.values;
const source = stringValue(values.code);
const zipFile = stringValue(values.zipFile);
if (source || zipFile) {
const current = await this.lambda.send(new GetFunctionCommand({FunctionName: id}));
const handler = stringValue(values.handler) || current.Configuration?.Handler;
const runtime = stringValue(values.runtime) || current.Configuration?.Runtime;
if (!handler || !runtime) throw new ValidationError('Lambda handler and runtime are required to package source code');
await this.lambda.send(new UpdateFunctionCodeCommand({
FunctionName: id,
ZipFile: zipFile
? decodeBase64Zip(zipFile)
: createZipArchive([{name: handlerFileName(handler, runtime), content: source}]),
}));
}
const configuration = {
FunctionName: id,
...(values.runtime === undefined ? {} : {Runtime: stringValue(values.runtime) as never}),
...(values.handler === undefined ? {} : {Handler: stringValue(values.handler)}),
...(values.description === undefined ? {} : {Description: stringValue(values.description)}),
...(values.environment === undefined ? {} : {Environment: {Variables: environmentVariables(values.environment)}}),
...(values.memorySize === undefined ? {} : {MemorySize: positiveNumber(values.memorySize, 'memorySize')}),
...(values.timeout === undefined ? {} : {Timeout: positiveNumber(values.timeout, 'timeout')}),
};
if (Object.keys(configuration).length > 1) {
await this.lambda.send(new UpdateFunctionConfigurationCommand(configuration));
}
const updated = await this.get(id);
if (!updated) throw new ValidationError(`Lambda function ${id} was not found after update`);
return updated;
}
async invoke(id: string, payload: string): Promise<ServerlessInvokeResult> {
const startedAt = performance.now();
const res = await this.lambda.send(
@@ -196,6 +259,157 @@ export class AwsServerlessAdapter implements CloudServiceAdapter {
executionDuration,
};
}
async createEventBridgeLambdaTrigger(input: EventBridgeLambdaTriggerInput): Promise<EventBridgeLambdaTriggerOverview> {
const ruleName = stringValue(input.ruleName)
const scheduleExpression = stringValue(input.scheduleExpression)
const functionName = stringValue(input.functionName)
if (!ruleName) throw new ValidationError('ruleName is required')
if (!scheduleExpression) throw new ValidationError('scheduleExpression is required')
if (!functionName) throw new ValidationError('functionName is required')
const functionResult = await this.lambda.send(new GetFunctionCommand({FunctionName: functionName}))
const functionArn = functionResult.Configuration?.FunctionArn
if (!functionArn) throw new ValidationError(`Lambda function ${functionName} did not return an ARN`)
const rule = await this.eventBridge.send(new PutRuleCommand({
Name: ruleName,
ScheduleExpression: scheduleExpression,
State: 'ENABLED',
}))
if (!rule.RuleArn) throw new ValidationError(`EventBridge rule ${ruleName} did not return an ARN`)
const targetId = eventBridgeTargetId(functionName)
await this.eventBridge.send(new PutTargetsCommand({
Rule: ruleName,
Targets: [{Id: targetId, Arn: functionArn}],
}))
await this.ensureEventBridgePermission(functionName, ruleName, rule.RuleArn)
return {
id: ruleName,
ruleName,
ruleArn: rule.RuleArn,
functionName,
functionArn,
scheduleExpression,
state: 'ENABLED',
}
}
async listEventBridgeLambdaTriggers(functionName: string): Promise<EventBridgeLambdaTriggerOverview[]> {
const triggers: EventBridgeLambdaTriggerOverview[] = []
let nextToken: string | undefined
do {
const rules = await this.eventBridge.send(new ListRulesCommand({NextToken: nextToken}))
for (const rule of rules.Rules ?? []) {
if (!rule.Name || !rule.Arn || !rule.ScheduleExpression) continue
const targets = await this.listEventBridgeTargets(rule.Name)
for (const target of targets) {
if (!target.Arn || lambdaFunctionName(target.Arn) !== functionName) continue
triggers.push({
id: rule.Name,
ruleName: rule.Name,
ruleArn: rule.Arn,
functionName,
functionArn: target.Arn,
scheduleExpression: rule.ScheduleExpression,
state: rule.State ?? null,
})
}
}
nextToken = rules.NextToken
} while (nextToken)
return triggers.sort((left, right) => left.ruleName.localeCompare(right.ruleName))
}
async deleteEventBridgeLambdaTrigger(functionName: string, triggerId: string): Promise<void> {
const ruleName = stringValue(triggerId)
if (!ruleName) throw new ValidationError('triggerId is required')
const targets = await this.listEventBridgeTargets(ruleName)
const matchingTargets = targets.filter((target) => target.Arn && lambdaFunctionName(target.Arn) === functionName)
const targetIds = matchingTargets.map((target) => target.Id).filter((id): id is string => Boolean(id))
if (targetIds.length > 0) {
await this.eventBridge.send(new RemoveTargetsCommand({Rule: ruleName, Ids: targetIds}))
}
try {
await this.lambda.send(new RemovePermissionCommand({
FunctionName: functionName,
StatementId: eventBridgePermissionStatementId(ruleName),
}))
} catch (error) {
if (!hasHttpStatus(error, 404)) throw error
}
if (targets.length === matchingTargets.length) {
await this.eventBridge.send(new DeleteRuleCommand({Name: ruleName}))
}
}
async createSqsLambdaEventSourceMapping(input: SqsLambdaEventSourceMappingInput): Promise<SqsLambdaEventSourceMappingOverview> {
const functionName = stringValue(input.functionName)
const queueArn = stringValue(input.queueArn)
if (!functionName) throw new ValidationError('functionName is required')
if (!queueArn) throw new ValidationError('queueArn is required')
const batchSize = input.batchSize === undefined ? undefined : sqsBatchSize(input.batchSize)
const mapping = await this.lambda.send(new CreateEventSourceMappingCommand({
FunctionName: functionName,
EventSourceArn: queueArn,
...(batchSize === undefined ? {} : {BatchSize: batchSize}),
}))
return sqsEventSourceMappingOverview(mapping, functionName)
}
async listSqsLambdaEventSourceMappings(functionName: string): Promise<SqsLambdaEventSourceMappingOverview[]> {
const normalizedFunctionName = stringValue(functionName)
if (!normalizedFunctionName) throw new ValidationError('functionName is required')
const mappings: SqsLambdaEventSourceMappingOverview[] = []
let marker: string | undefined
do {
const result = await this.lambda.send(new ListEventSourceMappingsCommand({
FunctionName: normalizedFunctionName,
Marker: marker,
}))
for (const mapping of result.EventSourceMappings ?? []) {
if (mapping.EventSourceArn?.includes(':sqs:')) {
mappings.push(sqsEventSourceMappingOverview(mapping, normalizedFunctionName))
}
}
marker = result.NextMarker
} while (marker)
return mappings.sort((left, right) => left.uuid.localeCompare(right.uuid))
}
async deleteSqsLambdaEventSourceMapping(functionName: string, mappingId: string): Promise<void> {
if (!stringValue(functionName)) throw new ValidationError('functionName is required')
const uuid = stringValue(mappingId)
if (!uuid) throw new ValidationError('mappingId is required')
await this.lambda.send(new DeleteEventSourceMappingCommand({UUID: uuid}))
}
private async listEventBridgeTargets(ruleName: string) {
const targets = []
let nextToken: string | undefined
do {
const result = await this.eventBridge.send(new ListTargetsByRuleCommand({Rule: ruleName, NextToken: nextToken}))
targets.push(...(result.Targets ?? []))
nextToken = result.NextToken
} while (nextToken)
return targets
}
private async ensureEventBridgePermission(functionName: string, ruleName: string, ruleArn: string): Promise<void> {
const permission = {
FunctionName: functionName,
StatementId: eventBridgePermissionStatementId(ruleName),
Action: 'lambda:InvokeFunction',
Principal: 'events.amazonaws.com',
SourceArn: ruleArn,
}
try {
await this.lambda.send(new AddPermissionCommand(permission))
} catch (error) {
if (!hasHttpStatus(error, 409)) throw error
}
}
}
function decodeBase64Zip(value: string): Uint8Array {
@@ -213,6 +427,99 @@ function positiveNumberOrDefault(value: unknown, defaultValue: number): number {
return Number.isFinite(number) && number > 0 ? number : defaultValue
}
function positiveNumber(value: unknown, field: string): number {
const number = Number(value)
if (!Number.isFinite(number) || number <= 0) throw new ValidationError(`${field} must be a positive number`)
return number
}
function stringValue(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
function lambdaFunctionName(arn: string): string | null {
const match = arn.match(/:function:([^:/]+)/)
return match?.[1] ?? null
}
function eventBridgeTargetId(functionName: string): string {
return `floci-ui-eventbridge-${functionName}`.replace(/[^A-Za-z0-9_.-]/g, '-')
}
function eventBridgePermissionStatementId(ruleName: string): string {
return `floci-ui-eventbridge-${ruleName}`.replace(/[^A-Za-z0-9_.-]/g, '-').slice(0, 100)
}
function sqsBatchSize(value: number): number {
if (!Number.isInteger(value) || value < 1 || value > 10) {
throw new ValidationError('batchSize must be an integer between 1 and 10')
}
return value
}
function sqsEventSourceMappingOverview(
mapping: {UUID?: string; FunctionArn?: string; EventSourceArn?: string; BatchSize?: number; State?: string; StateTransitionReason?: string},
functionName: string,
): SqsLambdaEventSourceMappingOverview {
const uuid = mapping.UUID
const queueArn = mapping.EventSourceArn
if (!uuid || !queueArn) throw new ValidationError('Lambda event source mapping did not return a UUID and SQS queue ARN')
return {
id: uuid,
uuid,
functionName,
functionArn: mapping.FunctionArn ?? null,
queueArn,
batchSize: mapping.BatchSize ?? null,
state: mapping.State ?? null,
stateTransitionReason: mapping.StateTransitionReason ?? null,
}
}
async function deployedHandlerSource(location: string | undefined, handler: string | undefined, runtime: string | undefined): Promise<string | null> {
if (!location || !handler || !runtime) return null
try {
const response = await globalThis.fetch(location)
if (!response.ok) return null
return readStoredZipEntry(new Uint8Array(await response.arrayBuffer()), handlerFileName(handler, runtime))
} catch {
return null
}
}
function environmentVariables(value: unknown): Record<string, string> {
let variables: unknown = value
if (typeof value === 'string') {
try {
variables = JSON.parse(value)
} catch {
throw new ValidationError('environment must be a JSON object of string values')
}
}
if (!variables || typeof variables !== 'object' || Array.isArray(variables)) {
if (!Array.isArray(variables)) throw new ValidationError('environment must be a JSON object of string values')
const entries = variables.map((entry) => {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
throw new ValidationError('environment rows must have non-empty keys and string values')
}
const row = entry as {key?: unknown; value?: unknown}
if (typeof row.key !== 'string' || !row.key || typeof row.value !== 'string') {
throw new ValidationError('environment rows must have non-empty keys and string values')
}
return [row.key, row.value] as const
})
if (new Set(entries.map(([key]) => key)).size !== entries.length) {
throw new ValidationError('environment variable names must be unique')
}
return Object.fromEntries(entries)
}
const entries = Object.entries(variables)
if (entries.some(([key, variable]) => !key || typeof variable !== 'string')) {
throw new ValidationError('environment must be a JSON object of non-empty keys and string values')
}
return Object.fromEntries(entries) as Record<string, string>
}
function decodePayload(payload: InvokeCommandOutput["Payload"]): string {
if (!payload) return "";
return new TextDecoder().decode(payload);
@@ -1,9 +1,12 @@
import {describe, expect, test} from 'bun:test'
import {
GetBucketNotificationConfigurationCommand,
GetBucketTaggingCommand,
ListBucketsCommand,
PutBucketNotificationConfigurationCommand,
type S3Client,
} from '@aws-sdk/client-s3'
import {AddPermissionCommand, GetFunctionCommand, RemovePermissionCommand, type LambdaClient} from '@aws-sdk/client-lambda'
import {AwsStorageAdapter} from './AwsStorageAdapter'
function fakeS3(overrides: Partial<{
@@ -100,4 +103,90 @@ describe('AwsStorageAdapter', () => {
expect(result.map((r) => r.name).sort()).toEqual(['prod-assets', 'prod-logs'])
expect(requestedBuckets.sort()).toEqual(['prod-assets', 'prod-logs'])
})
test('adds an S3 Lambda trigger without replacing other bucket notifications', async () => {
const sent: unknown[] = []
const s3 = {
send: async (command: unknown) => {
sent.push(command)
if (command instanceof GetBucketNotificationConfigurationCommand) {
return {
QueueConfigurations: [{Id: 'queue-trigger', QueueArn: 'arn:aws:sqs:us-east-1:000000000000:jobs', Events: ['s3:ObjectCreated:*']}],
EventBridgeConfiguration: {},
}
}
if (command instanceof PutBucketNotificationConfigurationCommand) return {}
throw new Error(`Unexpected command: ${command?.constructor?.name}`)
},
} as unknown as S3Client
const lambda = fakeLambda()
const adapter = new AwsStorageAdapter(s3, lambda.client, '000000000000')
await adapter.createS3LambdaTrigger('uploads', {
functionName: 'thumbnailer',
events: ['s3:ObjectCreated:*'],
prefix: 'images/',
suffix: '.jpg',
})
const put = sent.find((command) => command instanceof PutBucketNotificationConfigurationCommand) as PutBucketNotificationConfigurationCommand
expect(put.input.NotificationConfiguration?.QueueConfigurations).toHaveLength(1)
expect(put.input.NotificationConfiguration?.EventBridgeConfiguration).toEqual({})
expect(put.input.NotificationConfiguration?.LambdaFunctionConfigurations).toEqual([{
Id: 'floci-ui-s3-thumbnailer-s3-ObjectCreated---images---jpg',
LambdaFunctionArn: 'arn:aws:lambda:us-east-1:000000000000:function:thumbnailer',
Events: ['s3:ObjectCreated:*'],
Filter: {Key: {FilterRules: [{Name: 'prefix', Value: 'images/'}, {Name: 'suffix', Value: '.jpg'}]}},
}])
const permission = lambda.sent.find((command) => command instanceof AddPermissionCommand) as AddPermissionCommand
expect(permission.input).toMatchObject({
FunctionName: 'thumbnailer',
Principal: 's3.amazonaws.com',
SourceArn: 'arn:aws:s3:::uploads',
SourceAccount: '000000000000',
})
})
test('removes the S3 permission only after the final bucket trigger for a Lambda is deleted', async () => {
const sent: unknown[] = []
const s3 = {
send: async (command: unknown) => {
sent.push(command)
if (command instanceof GetBucketNotificationConfigurationCommand) {
return {LambdaFunctionConfigurations: [{
Id: 'trigger-a',
LambdaFunctionArn: 'arn:aws:lambda:us-east-1:000000000000:function:thumbnailer',
Events: ['s3:ObjectCreated:*'],
}]}
}
if (command instanceof PutBucketNotificationConfigurationCommand) return {}
throw new Error(`Unexpected command: ${command?.constructor?.name}`)
},
} as unknown as S3Client
const lambda = fakeLambda()
const adapter = new AwsStorageAdapter(s3, lambda.client)
await adapter.deleteS3LambdaTrigger('uploads', 'trigger-a')
const put = sent.find((command) => command instanceof PutBucketNotificationConfigurationCommand) as PutBucketNotificationConfigurationCommand
expect(put.input.NotificationConfiguration?.LambdaFunctionConfigurations).toEqual([])
expect(lambda.sent.some((command) => command instanceof RemovePermissionCommand)).toBe(true)
})
})
function fakeLambda(): {client: LambdaClient; sent: unknown[]} {
const sent: unknown[] = []
return {
sent,
client: {
send: async (command: unknown) => {
sent.push(command)
if (command instanceof GetFunctionCommand) {
return {Configuration: {FunctionArn: 'arn:aws:lambda:us-east-1:000000000000:function:thumbnailer'}}
}
if (command instanceof AddPermissionCommand || command instanceof RemovePermissionCommand) return {}
throw new Error(`Unexpected command: ${command?.constructor?.name}`)
},
} as unknown as LambdaClient,
}
}
@@ -5,14 +5,18 @@ import {
CreateBucketCommand,
DeleteBucketCommand,
DeleteObjectCommand,
type Event,
GetBucketTaggingCommand,
GetBucketNotificationConfigurationCommand,
GetObjectCommand,
ListBucketsCommand,
ListObjectsV2Command,
PutObjectCommand,
PutBucketNotificationConfigurationCommand,
type S3Client,
} from '@aws-sdk/client-s3'
import {s3 as defaultS3} from '../aws'
import {AddPermissionCommand, GetFunctionCommand, RemovePermissionCommand, type LambdaClient} from '@aws-sdk/client-lambda'
import {defaultAccountId, lambda as defaultLambda, s3 as defaultS3} from '../aws'
import {awsStorageSchema} from '../cloud-spi/storageSchema'
import type {
CloudResource,
@@ -22,13 +26,19 @@ import type {
ServiceSchema,
StorageObjectDownload,
StorageObjectList,
S3LambdaTriggerInput,
S3LambdaTriggerOverview,
} from '../cloud-spi/types'
export class AwsStorageAdapter implements CloudServiceAdapter {
readonly cloud = 'aws' as const
readonly service = 'storage' as const
constructor(private readonly s3: S3Client = defaultS3) {}
constructor(
private readonly s3: S3Client = defaultS3,
private readonly lambda: LambdaClient = defaultLambda,
private readonly accountId = defaultAccountId(),
) {}
schema(): ServiceSchema {
return awsStorageSchema()
@@ -109,6 +119,86 @@ export class AwsStorageAdapter implements CloudServiceAdapter {
await this.s3.send(new DeleteBucketCommand({Bucket: id}))
}
async createS3LambdaTrigger(bucketName: string, input: S3LambdaTriggerInput): Promise<S3LambdaTriggerOverview> {
const functionName = stringValue(input.functionName)
const events = input.events.filter((event): event is Event => S3_OBJECT_CREATED_EVENTS.has(event as Event))
if (!functionName) throw new ValidationError('functionName is required')
if (events.length === 0) throw new ValidationError('Select at least one S3 object-created event')
const functionResult = await this.lambda.send(new GetFunctionCommand({FunctionName: functionName}))
const functionArn = functionResult.Configuration?.FunctionArn
if (!functionArn) throw new ValidationError(`Lambda function ${functionName} did not return an ARN`)
const notification = await this.s3.send(new GetBucketNotificationConfigurationCommand({Bucket: bucketName}))
const id = s3TriggerId(functionName, events, input.prefix, input.suffix)
const configurations = notification.LambdaFunctionConfigurations ?? []
if (configurations.some((configuration) => configuration.Id === id)) {
throw new ValidationError('This S3 trigger already exists')
}
const filterRules = [
...(stringValue(input.prefix) ? [{Name: 'prefix' as const, Value: stringValue(input.prefix)}] : []),
...(stringValue(input.suffix) ? [{Name: 'suffix' as const, Value: stringValue(input.suffix)}] : []),
]
await this.ensureS3Permission(functionName, bucketName)
await this.s3.send(new PutBucketNotificationConfigurationCommand({
Bucket: bucketName,
NotificationConfiguration: {
QueueConfigurations: notification.QueueConfigurations,
TopicConfigurations: notification.TopicConfigurations,
LambdaFunctionConfigurations: [...configurations, {
Id: id,
LambdaFunctionArn: functionArn,
Events: events,
...(filterRules.length > 0 ? {Filter: {Key: {FilterRules: filterRules}}} : {}),
}],
EventBridgeConfiguration: notification.EventBridgeConfiguration,
},
}))
return {id, functionName, functionArn, events, prefix: stringValue(input.prefix) || null, suffix: stringValue(input.suffix) || null}
}
async listS3LambdaTriggers(bucketName: string): Promise<S3LambdaTriggerOverview[]> {
const notification = await this.s3.send(new GetBucketNotificationConfigurationCommand({Bucket: bucketName}))
return (notification.LambdaFunctionConfigurations ?? []).flatMap((configuration) => {
if (!configuration.Id || !configuration.LambdaFunctionArn) return []
return [{
id: configuration.Id,
functionName: lambdaFunctionName(configuration.LambdaFunctionArn),
functionArn: configuration.LambdaFunctionArn,
events: configuration.Events ?? [],
prefix: filterValue(configuration.Filter?.Key?.FilterRules, 'prefix'),
suffix: filterValue(configuration.Filter?.Key?.FilterRules, 'suffix'),
}]
})
}
async deleteS3LambdaTrigger(bucketName: string, triggerId: string): Promise<void> {
const notification = await this.s3.send(new GetBucketNotificationConfigurationCommand({Bucket: bucketName}))
const removed = (notification.LambdaFunctionConfigurations ?? []).find((configuration) => configuration.Id === triggerId)
if (!removed) throw new ValidationError('S3 Lambda trigger was not found')
const configurations = (notification.LambdaFunctionConfigurations ?? []).filter((configuration) => configuration.Id !== triggerId)
await this.s3.send(new PutBucketNotificationConfigurationCommand({
Bucket: bucketName,
NotificationConfiguration: {
QueueConfigurations: notification.QueueConfigurations,
TopicConfigurations: notification.TopicConfigurations,
LambdaFunctionConfigurations: configurations,
EventBridgeConfiguration: notification.EventBridgeConfiguration,
},
}))
const functionName = removed.LambdaFunctionArn ? lambdaFunctionName(removed.LambdaFunctionArn) : ''
if (functionName && !configurations.some((configuration) => configuration.LambdaFunctionArn === removed.LambdaFunctionArn)) {
try {
await this.lambda.send(new RemovePermissionCommand({
FunctionName: functionName,
StatementId: s3PermissionStatementId(bucketName, functionName),
}))
} catch (error) {
if (!hasHttpStatus(error, 404)) throw error
}
}
}
async listObjects(resourceId: string, prefix = ''): Promise<StorageObjectList> {
const res = await this.s3.send(new ListObjectsV2Command({
Bucket: resourceId,
@@ -178,12 +268,36 @@ export class AwsStorageAdapter implements CloudServiceAdapter {
CopySource: `${srcResourceId}/${srcKey}`,
}))
}
private async ensureS3Permission(functionName: string, bucketName: string): Promise<void> {
const permission = {
FunctionName: functionName,
StatementId: s3PermissionStatementId(bucketName, functionName),
Action: 'lambda:InvokeFunction',
Principal: 's3.amazonaws.com',
SourceArn: `arn:aws:s3:::${bucketName}`,
SourceAccount: this.accountId,
}
try {
await this.lambda.send(new AddPermissionCommand(permission))
} catch (error) {
if (!hasHttpStatus(error, 409)) throw error
}
}
}
function stringValue(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
const S3_OBJECT_CREATED_EVENTS = new Set<Event>([
's3:ObjectCreated:*',
's3:ObjectCreated:Put',
's3:ObjectCreated:Post',
's3:ObjectCreated:Copy',
's3:ObjectCreated:CompleteMultipartUpload',
])
function filterBucketsBySearch(buckets: Bucket[], search?: string): Bucket[] {
const normalized = search?.trim().toLowerCase()
if (!normalized) return buckets
@@ -198,3 +312,26 @@ function objectName(key: string, prefix: string): string {
function isValidS3BucketName(value: string): boolean {
return /^(?!\d+\.\d+\.\d+\.\d+$)(?!.*\.\.)(?!.*\.-)(?!.*-\.)(?!.*--x-s3$)(?!.*-s3alias$)[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(value)
}
function s3TriggerId(functionName: string, events: string[], prefix?: string, suffix?: string): string {
return `floci-ui-s3-${[functionName, ...[...events].sort(), stringValue(prefix), stringValue(suffix)]
.join('-').replace(/[^A-Za-z0-9_-]/g, '-').slice(0, 180)}`
}
function s3PermissionStatementId(bucketName: string, functionName: string): string {
return `floci-ui-s3-${bucketName}-${functionName}`.replace(/[^A-Za-z0-9_-]/g, '-').slice(0, 100)
}
function lambdaFunctionName(functionArn: string): string {
return functionArn.split(':').pop() ?? functionArn
}
function filterValue(rules: Array<{Name?: string; Value?: string}> | undefined, name: string): string | null {
return rules?.find((rule) => rule.Name === name)?.Value ?? null
}
function hasHttpStatus(error: unknown, statusCode: number): boolean {
return typeof error === 'object' && error !== null
&& '$metadata' in error
&& (error as {$metadata?: {httpStatusCode?: number}}).$metadata?.httpStatusCode === statusCode
}
@@ -2,7 +2,7 @@ import {describe, expect, test} from 'bun:test'
import {execFile} from 'node:child_process'
import {rm, writeFile} from 'node:fs/promises'
import {tmpdir} from 'node:os'
import {createZipArchive, crc32, handlerFileName} from './zipArchive'
import {createZipArchive, crc32, handlerFileName, readStoredZipEntry} from './zipArchive'
describe('crc32', () => {
// Reference values from the standard CRC-32 (IEEE 802.3) test vectors.
@@ -15,6 +15,12 @@ describe('crc32', () => {
})
describe('createZipArchive', () => {
test('reads a stored source file from an archive', () => {
const archive = createZipArchive([{name: 'index.js', content: 'exports.handler = async () => ({})'}])
expect(readStoredZipEntry(archive, 'index.js')).toBe('exports.handler = async () => ({})')
expect(readStoredZipEntry(archive, 'missing.js')).toBeNull()
})
test('writes the ZIP magic number and end-of-central-directory record', () => {
const archive = createZipArchive([{name: 'index.js', content: 'module.exports = 1'}])
const view = new DataView(archive.buffer, archive.byteOffset, archive.byteLength)
@@ -109,6 +109,28 @@ export function createZipArchive(entries: ZipEntry[]): Uint8Array {
return concat([...localParts, ...centralParts, endRecord])
}
/** Reads one uncompressed file from the simple ZIP packages created by this console. */
export function readStoredZipEntry(archive: Uint8Array, name: string): string | null {
const decoder = new TextDecoder()
let offset = 0
while (offset + 30 <= archive.length && new DataView(archive.buffer, archive.byteOffset + offset).getUint32(0, true) === 0x04034b50) {
const view = new DataView(archive.buffer, archive.byteOffset + offset)
const compression = view.getUint16(8, true)
const size = view.getUint32(22, true)
const nameLength = view.getUint16(26, true)
const extraLength = view.getUint16(28, true)
const fileName = decoder.decode(archive.slice(offset + 30, offset + 30 + nameLength))
const dataOffset = offset + 30 + nameLength + extraLength
if (dataOffset + size > archive.length) return null
if (fileName === name) {
if (compression !== 0) return null
return decoder.decode(archive.slice(dataOffset, dataOffset + size))
}
offset = dataOffset + size
}
return null
}
function concat(parts: Uint8Array[]): Uint8Array {
const total = parts.reduce((sum, part) => sum + part.length, 0)
const out = new Uint8Array(total)
+18 -1
View File
@@ -122,7 +122,24 @@ export function awsServerlessSchema(): ServiceSchema {
span: true,
},
],
actions: ['list', 'create', 'inspect', 'delete'],
actions: ['list', 'create', 'update', 'inspect', 'delete'],
updateFields: [
{name: 'runtime', label: 'Runtime', type: 'select', required: true, valuePath: 'metadata.runtime', options: [
{label: 'Node.js 22.x', value: 'nodejs22.x'}, {label: 'Node.js 20.x', value: 'nodejs20.x'},
{label: 'Node.js 18.x', value: 'nodejs18.x'}, {label: 'Python 3.12', value: 'python3.12'},
{label: 'Python 3.11', value: 'python3.11'},
]},
{name: 'handler', label: 'Handler', type: 'text', required: true, valuePath: 'metadata.handler'},
{name: 'memorySize', label: 'Memory Size', type: 'text', required: false, valuePath: 'metadata.memorySize'},
{name: 'timeout', label: 'Timeout', type: 'text', required: false, valuePath: 'metadata.timeout'},
{name: 'description', label: 'Description', type: 'text', required: false, valuePath: 'metadata.description'},
{name: 'environment', label: 'Environment Variables', type: 'key-value', required: false, valuePath: 'metadata.environment', span: true,
description: 'Available to the function as process.env in Node.js or os.environ in Python.'},
{name: 'code', label: 'Handler Source', type: 'textarea', required: false, valuePath: 'metadata.code', span: true,
description: 'Current handler source. Editing and saving replaces this handler file in the deployment package.'},
{name: 'zipFile', label: 'Replacement ZIP Deployment Package', type: 'file', required: false,
accept: '.zip,application/zip', span: true, description: 'Uploads this ZIP instead of replacement source.'},
],
filters: serverlessFilters,
columns: serverlessColumns,
capabilities: {
+67 -1
View File
@@ -71,7 +71,7 @@ export interface CloudServiceStatus {
errorCode: string | null
}
export type FieldType = 'text' | 'textarea' | 'password' | 'select' | 'file'
export type FieldType = 'text' | 'textarea' | 'password' | 'select' | 'file' | 'key-value'
export interface FieldSchema {
name: string
@@ -491,6 +491,62 @@ export interface ApiGatewayRoutesResult {
stages: string[]
}
export interface ApiGatewayDeleteRouteInput {
resourceId: string
path: string
method: string
stage: string
}
export interface S3LambdaTriggerInput {
functionName: string
events: string[]
prefix?: string
suffix?: string
}
export interface S3LambdaTriggerOverview {
id: string
functionName: string
functionArn: string
events: string[]
prefix: string | null
suffix: string | null
}
export interface EventBridgeLambdaTriggerInput {
ruleName: string
scheduleExpression: string
functionName: string
}
export interface EventBridgeLambdaTriggerOverview {
id: string
ruleName: string
ruleArn: string
functionName: string
functionArn: string
scheduleExpression: string
state: string | null
}
export interface SqsLambdaEventSourceMappingInput {
functionName: string
queueArn: string
batchSize?: number
}
export interface SqsLambdaEventSourceMappingOverview {
id: string
uuid: string
functionName: string
functionArn: string | null
queueArn: string
batchSize: number | null
state: string | null
stateTransitionReason: string | null
}
export type KmsEncryptionAlgorithm =
| 'SYMMETRIC_DEFAULT'
| 'RSAES_OAEP_SHA_1'
@@ -555,6 +611,16 @@ export interface CloudServiceAdapter {
createApiGatewayLambdaRoute?(apiId: string, input: ApiGatewayLambdaRouteInput): Promise<ApiGatewayLambdaRouteResult>
testApiGatewayRoute?(apiId: string, input: ApiGatewayTestRouteInput): Promise<ApiGatewayTestRouteResult>
listApiGatewayRoutes?(apiId: string): Promise<ApiGatewayRoutesResult>
deleteApiGatewayRoute?(apiId: string, input: ApiGatewayDeleteRouteInput): Promise<void>
createS3LambdaTrigger?(bucketName: string, input: S3LambdaTriggerInput): Promise<S3LambdaTriggerOverview>
listS3LambdaTriggers?(bucketName: string): Promise<S3LambdaTriggerOverview[]>
deleteS3LambdaTrigger?(bucketName: string, triggerId: string): Promise<void>
createEventBridgeLambdaTrigger?(input: EventBridgeLambdaTriggerInput): Promise<EventBridgeLambdaTriggerOverview>
listEventBridgeLambdaTriggers?(functionName: string): Promise<EventBridgeLambdaTriggerOverview[]>
deleteEventBridgeLambdaTrigger?(functionName: string, triggerId: string): Promise<void>
createSqsLambdaEventSourceMapping?(input: SqsLambdaEventSourceMappingInput): Promise<SqsLambdaEventSourceMappingOverview>
listSqsLambdaEventSourceMappings?(functionName: string): Promise<SqsLambdaEventSourceMappingOverview[]>
deleteSqsLambdaEventSourceMapping?(functionName: string, mappingId: string): Promise<void>
encrypt?(id: string, input: KmsEncryptInput): Promise<KmsEncryptResult>
decrypt?(id: string, input: KmsDecryptInput): Promise<KmsDecryptResult>
// Lifecycle verbs. Optional because most categories have no notion of them;
+2 -2
View File
@@ -55,13 +55,13 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap
const ec2Service = createEc2Service(clients.ec2)
return new CloudAdapterRegistry([
new AwsStorageAdapter(clients.s3),
new AwsStorageAdapter(clients.s3, clients.lambda, resolveAccountId(accountId)),
new AwsDynamoDbAdapter(clients.dynamodb),
new AwsEksAdapter(createEksService(clients.eks)),
new AwsDatabaseAdapter(createRdsService(clients.rds), clients.rds),
new AwsComputeAdapter(ec2Service),
new AwsNetworkingAdapter(ec2Service),
new AwsServerlessAdapter(clients.lambda),
new AwsServerlessAdapter(clients.lambda, clients.eventbridge),
new AwsLogsAdapter(clients.logs),
new AwsParameterStoreAdapter(clients.ssm),
new AwsKmsAdapter(clients.kms),
+102
View File
@@ -529,6 +529,20 @@ export function createCloudRoutes(injectedService?: CloudProxyService) {
return withRuntime(c, async () => c.json(await svc(c).listApiGatewayRoutes(cloud, c.req.param('id'))))
})
app.delete('/:cloud/services/apigateway/resources/:id/routes/:resourceId/:method', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => {
await svc(c).deleteApiGatewayRoute(cloud, c.req.param('id'), {
resourceId: c.req.param('resourceId'),
path: c.req.query('path') ?? '',
method: c.req.param('method'),
stage: c.req.query('stage') ?? '',
})
return c.json({ok: true})
})
})
app.post('/:cloud/services/apigateway/resources/:id/routes/test', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
@@ -547,6 +561,94 @@ export function createCloudRoutes(injectedService?: CloudProxyService) {
})
})
app.get('/:cloud/services/storage/resources/:id/lambda-triggers', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => c.json(await svc(c).listS3LambdaTriggers(cloud, c.req.param('id'))))
})
app.post('/:cloud/services/storage/resources/:id/lambda-triggers', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => {
const input = await c.req.json<Record<string, unknown>>()
const trigger = await svc(c).createS3LambdaTrigger(cloud, c.req.param('id'), {
functionName: typeof input.functionName === 'string' ? input.functionName : '',
events: Array.isArray(input.events) ? input.events.filter((event): event is string => typeof event === 'string') : [],
prefix: typeof input.prefix === 'string' ? input.prefix : undefined,
suffix: typeof input.suffix === 'string' ? input.suffix : undefined,
})
return c.json(trigger, 201)
})
})
app.delete('/:cloud/services/storage/resources/:id/lambda-triggers/:triggerId', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => {
await svc(c).deleteS3LambdaTrigger(cloud, c.req.param('id'), c.req.param('triggerId'))
return c.json({ok: true})
})
})
app.get('/:cloud/services/serverless/resources/:id/eventbridge-triggers', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => c.json(await svc(c).listEventBridgeLambdaTriggers(cloud, c.req.param('id'))))
})
app.post('/:cloud/services/serverless/resources/:id/eventbridge-triggers', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => {
const input = await c.req.json<Record<string, unknown>>()
const trigger = await svc(c).createEventBridgeLambdaTrigger(cloud, {
ruleName: typeof input.ruleName === 'string' ? input.ruleName : '',
scheduleExpression: typeof input.scheduleExpression === 'string' ? input.scheduleExpression : '',
functionName: c.req.param('id'),
})
return c.json(trigger, 201)
})
})
app.delete('/:cloud/services/serverless/resources/:id/eventbridge-triggers/:triggerId', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => {
await svc(c).deleteEventBridgeLambdaTrigger(cloud, c.req.param('id'), c.req.param('triggerId'))
return c.json({ok: true})
})
})
app.get('/:cloud/services/serverless/resources/:id/sqs-event-source-mappings', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => c.json(await svc(c).listSqsLambdaEventSourceMappings(cloud, c.req.param('id'))))
})
app.post('/:cloud/services/serverless/resources/:id/sqs-event-source-mappings', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => {
const input = await c.req.json<Record<string, unknown>>()
const trigger = await svc(c).createSqsLambdaEventSourceMapping(cloud, {
functionName: c.req.param('id'),
queueArn: typeof input.queueArn === 'string' ? input.queueArn : '',
batchSize: typeof input.batchSize === 'number' ? input.batchSize : undefined,
})
return c.json(trigger, 201)
})
})
app.delete('/:cloud/services/serverless/resources/:id/sqs-event-source-mappings/:mappingId', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => {
await svc(c).deleteSqsLambdaEventSourceMapping(cloud, c.req.param('id'), c.req.param('mappingId'))
return c.json({ok: true})
})
})
app.get('/:cloud/services/:service/resources/:id/collections', async (c) => {
const target = childTarget(c)
if (!target) return c.json({error: 'Unknown cloud or service'}, 404)
@@ -25,6 +25,13 @@ import type {
ApiGatewayTestRouteInput,
ApiGatewayTestRouteResult,
ApiGatewayRoutesResult,
ApiGatewayDeleteRouteInput,
S3LambdaTriggerInput,
S3LambdaTriggerOverview,
EventBridgeLambdaTriggerInput,
EventBridgeLambdaTriggerOverview,
SqsLambdaEventSourceMappingInput,
SqsLambdaEventSourceMappingOverview,
DatabaseSnapshot,
KubernetesFargateProfile,
KubernetesNodegroup,
@@ -261,6 +268,66 @@ export class CloudProxyService {
return adapter.listApiGatewayRoutes(apiId)
}
async deleteApiGatewayRoute(cloud: CloudProvider, apiId: string, input: ApiGatewayDeleteRouteInput): Promise<void> {
const adapter = this.requireAdapter(cloud, 'apigateway')
if (!adapter.deleteApiGatewayRoute) throw new NotSupportedError(`API Gateway route deletion is not supported for ${cloud}`)
await adapter.deleteApiGatewayRoute(apiId, input)
}
async createS3LambdaTrigger(cloud: CloudProvider, bucketName: string, input: S3LambdaTriggerInput): Promise<S3LambdaTriggerOverview> {
const adapter = this.requireAdapter(cloud, 'storage')
if (!adapter.createS3LambdaTrigger) throw new NotSupportedError(`S3 Lambda triggers are not supported for ${cloud}`)
return adapter.createS3LambdaTrigger(bucketName, input)
}
async listS3LambdaTriggers(cloud: CloudProvider, bucketName: string): Promise<S3LambdaTriggerOverview[]> {
const adapter = this.requireAdapter(cloud, 'storage')
if (!adapter.listS3LambdaTriggers) throw new NotSupportedError(`S3 Lambda triggers are not supported for ${cloud}`)
return adapter.listS3LambdaTriggers(bucketName)
}
async deleteS3LambdaTrigger(cloud: CloudProvider, bucketName: string, triggerId: string): Promise<void> {
const adapter = this.requireAdapter(cloud, 'storage')
if (!adapter.deleteS3LambdaTrigger) throw new NotSupportedError(`S3 Lambda triggers are not supported for ${cloud}`)
await adapter.deleteS3LambdaTrigger(bucketName, triggerId)
}
async createEventBridgeLambdaTrigger(cloud: CloudProvider, input: EventBridgeLambdaTriggerInput): Promise<EventBridgeLambdaTriggerOverview> {
const adapter = this.requireAdapter(cloud, 'serverless')
if (!adapter.createEventBridgeLambdaTrigger) throw new NotSupportedError(`EventBridge Lambda triggers are not supported for ${cloud}`)
return adapter.createEventBridgeLambdaTrigger(input)
}
async listEventBridgeLambdaTriggers(cloud: CloudProvider, functionName: string): Promise<EventBridgeLambdaTriggerOverview[]> {
const adapter = this.requireAdapter(cloud, 'serverless')
if (!adapter.listEventBridgeLambdaTriggers) throw new NotSupportedError(`EventBridge Lambda triggers are not supported for ${cloud}`)
return adapter.listEventBridgeLambdaTriggers(functionName)
}
async deleteEventBridgeLambdaTrigger(cloud: CloudProvider, functionName: string, triggerId: string): Promise<void> {
const adapter = this.requireAdapter(cloud, 'serverless')
if (!adapter.deleteEventBridgeLambdaTrigger) throw new NotSupportedError(`EventBridge Lambda triggers are not supported for ${cloud}`)
await adapter.deleteEventBridgeLambdaTrigger(functionName, triggerId)
}
async createSqsLambdaEventSourceMapping(cloud: CloudProvider, input: SqsLambdaEventSourceMappingInput): Promise<SqsLambdaEventSourceMappingOverview> {
const adapter = this.requireAdapter(cloud, 'serverless')
if (!adapter.createSqsLambdaEventSourceMapping) throw new NotSupportedError(`SQS Lambda event source mappings are not supported for ${cloud}`)
return adapter.createSqsLambdaEventSourceMapping(input)
}
async listSqsLambdaEventSourceMappings(cloud: CloudProvider, functionName: string): Promise<SqsLambdaEventSourceMappingOverview[]> {
const adapter = this.requireAdapter(cloud, 'serverless')
if (!adapter.listSqsLambdaEventSourceMappings) throw new NotSupportedError(`SQS Lambda event source mappings are not supported for ${cloud}`)
return adapter.listSqsLambdaEventSourceMappings(functionName)
}
async deleteSqsLambdaEventSourceMapping(cloud: CloudProvider, functionName: string, mappingId: string): Promise<void> {
const adapter = this.requireAdapter(cloud, 'serverless')
if (!adapter.deleteSqsLambdaEventSourceMapping) throw new NotSupportedError(`SQS Lambda event source mappings are not supported for ${cloud}`)
await adapter.deleteSqsLambdaEventSourceMapping(functionName, mappingId)
}
async updateResource(cloud: CloudProvider, service: CloudServiceType, id: string, input: UpdateResourceInput): Promise<CloudResource> {
const adapter = this.requireAdapter(cloud, service)
if (!adapter.update) throw new NotSupportedError(`Resource updates are not supported for ${cloud}/${service}`)
+91 -1
View File
@@ -26,7 +26,17 @@ export const apiEndpointKeys = {
decrypt: "clouds.services.resources.decrypt",
createApiGatewayRoute: "clouds.services.resources.apigateway.route.create",
listApiGatewayRoutes: "clouds.services.resources.apigateway.routes.list",
testApiGatewayRoute: "clouds.services.resources.apigateway.route.test",
deleteApiGatewayRoute: "clouds.services.resources.apigateway.route.delete",
testApiGatewayRoute: "clouds.services.resources.apigateway.route.test",
createS3LambdaTrigger: "clouds.services.resources.storage.lambda-trigger.create",
listS3LambdaTriggers: "clouds.services.resources.storage.lambda-triggers.list",
deleteS3LambdaTrigger: "clouds.services.resources.storage.lambda-trigger.delete",
createEventBridgeLambdaTrigger: "clouds.services.resources.serverless.eventbridge-trigger.create",
listEventBridgeLambdaTriggers: "clouds.services.resources.serverless.eventbridge-triggers.list",
deleteEventBridgeLambdaTrigger: "clouds.services.resources.serverless.eventbridge-trigger.delete",
createSqsLambdaEventSourceMapping: "clouds.services.resources.serverless.sqs-event-source-mapping.create",
listSqsLambdaEventSourceMappings: "clouds.services.resources.serverless.sqs-event-source-mappings.list",
deleteSqsLambdaEventSourceMapping: "clouds.services.resources.serverless.sqs-event-source-mapping.delete",
},
storage: {
objects: {
@@ -310,6 +320,14 @@ export const endpointRegistry: EndpointRegistry = new Map([
telemetry: { service: "apigateway" },
},
],
[
apiEndpointKeys.clouds.resources.deleteApiGatewayRoute,
{
path: "/clouds/:cloud/services/apigateway/resources/:id/routes/:resourceId/:method",
method: "DELETE",
telemetry: { service: "apigateway" },
},
],
[
apiEndpointKeys.clouds.resources.testApiGatewayRoute,
{
@@ -318,6 +336,78 @@ export const endpointRegistry: EndpointRegistry = new Map([
telemetry: { service: "apigateway" },
},
],
[
apiEndpointKeys.clouds.resources.createS3LambdaTrigger,
{
path: "/clouds/:cloud/services/storage/resources/:id/lambda-triggers",
method: "POST",
telemetry: { service: "storage" },
},
],
[
apiEndpointKeys.clouds.resources.listS3LambdaTriggers,
{
path: "/clouds/:cloud/services/storage/resources/:id/lambda-triggers",
method: "GET",
telemetry: { service: "storage" },
},
],
[
apiEndpointKeys.clouds.resources.deleteS3LambdaTrigger,
{
path: "/clouds/:cloud/services/storage/resources/:id/lambda-triggers/:triggerId",
method: "DELETE",
telemetry: { service: "storage" },
},
],
[
apiEndpointKeys.clouds.resources.createEventBridgeLambdaTrigger,
{
path: "/clouds/:cloud/services/serverless/resources/:id/eventbridge-triggers",
method: "POST",
telemetry: { service: "serverless" },
},
],
[
apiEndpointKeys.clouds.resources.listEventBridgeLambdaTriggers,
{
path: "/clouds/:cloud/services/serverless/resources/:id/eventbridge-triggers",
method: "GET",
telemetry: { service: "serverless" },
},
],
[
apiEndpointKeys.clouds.resources.deleteEventBridgeLambdaTrigger,
{
path: "/clouds/:cloud/services/serverless/resources/:id/eventbridge-triggers/:triggerId",
method: "DELETE",
telemetry: { service: "serverless" },
},
],
[
apiEndpointKeys.clouds.resources.createSqsLambdaEventSourceMapping,
{
path: "/clouds/:cloud/services/serverless/resources/:id/sqs-event-source-mappings",
method: "POST",
telemetry: { service: "serverless" },
},
],
[
apiEndpointKeys.clouds.resources.listSqsLambdaEventSourceMappings,
{
path: "/clouds/:cloud/services/serverless/resources/:id/sqs-event-source-mappings",
method: "GET",
telemetry: { service: "serverless" },
},
],
[
apiEndpointKeys.clouds.resources.deleteSqsLambdaEventSourceMapping,
{
path: "/clouds/:cloud/services/serverless/resources/:id/sqs-event-source-mappings/:mappingId",
method: "DELETE",
telemetry: { service: "serverless" },
},
],
[
apiEndpointKeys.clouds.resources.get,
{
@@ -245,6 +245,60 @@ export interface ApiGatewayRoutesResult {
stages: string[];
}
export interface DeleteApiGatewayRouteInput {
resourceId: string;
path: string;
method: string;
stage: string;
}
export interface CreateS3LambdaTriggerInput {
functionName: string;
events: string[];
prefix?: string;
suffix?: string;
}
export interface S3LambdaTriggerOverview {
id: string;
functionName: string;
functionArn: string;
events: string[];
prefix: string | null;
suffix: string | null;
}
export interface CreateEventBridgeLambdaTriggerInput {
ruleName: string;
scheduleExpression: string;
}
export interface EventBridgeLambdaTriggerOverview {
id: string;
ruleName: string;
ruleArn: string;
functionName: string;
functionArn: string;
scheduleExpression: string;
state: string | null;
}
export interface CreateSqsLambdaEventSourceMappingInput {
queueArn: string;
batchSize?: number;
}
export interface SqsLambdaEventSourceMappingOverview {
id: string;
uuid: string;
functionName: string;
functionArn: string | null;
queueArn: string;
batchSize: number | null;
state: string | null;
stateTransitionReason: string | null;
}
export async function createApiGatewayLambdaRoute(
cloud: CloudProvider,
apiId: string,
@@ -272,6 +326,19 @@ export async function listApiGatewayRoutes(
return res.data;
}
export async function deleteApiGatewayRoute(
cloud: CloudProvider,
apiId: string,
input: DeleteApiGatewayRouteInput,
signal?: AbortSignal,
): Promise<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(
cloud: CloudProvider,
apiId: string,
@@ -286,6 +353,126 @@ export async function testApiGatewayRoute(
return res.data;
}
export async function createS3LambdaTrigger(
cloud: CloudProvider,
bucketName: string,
input: CreateS3LambdaTriggerInput,
signal?: AbortSignal,
): Promise<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 =
| "SYMMETRIC_DEFAULT"
| "RSAES_OAEP_SHA_1"
@@ -7,8 +7,10 @@ import type {CloudResource} from '@/types/resource'
const apiMocks = vi.hoisted(() => ({
createApiGatewayLambdaRoute: vi.fn(),
deleteApiGatewayRoute: vi.fn(),
listApiGatewayRoutes: vi.fn(),
listCloudResources: vi.fn(),
queryLogs: vi.fn(),
testApiGatewayRoute: vi.fn(),
}))
@@ -124,3 +126,21 @@ test('selects a deployed route for testing', async () => {
expect(screen.getAllByRole('combobox')[0]).toHaveValue('POST')
expect(screen.getAllByRole('combobox')[1]).toHaveValue('orders-handler')
})
test('confirms before deleting a deployed route', async () => {
apiMocks.listCloudResources.mockResolvedValue([lambda])
apiMocks.listApiGatewayRoutes.mockResolvedValue({
routes: [{resourceId: 'resource-123', path: '/orders', method: 'POST', functionName: 'orders-handler'}], stages: ['dev'],
})
apiMocks.deleteApiGatewayRoute.mockResolvedValue(undefined)
const user = userEvent.setup()
const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}})
render(<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 {ArrowRight, Braces, Link2, ListTree, Loader2, Rocket, Send, Waypoints, X} from 'lucide-react'
import {ArrowRight, Braces, Copy, Link2, ListTree, Loader2, Rocket, Send, Trash2, Waypoints, X} from 'lucide-react'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {createApiGatewayLambdaRoute, listApiGatewayRoutes, listCloudResources, testApiGatewayRoute} from '@/api/cloudProxyClient'
import {createApiGatewayLambdaRoute, deleteApiGatewayRoute, listApiGatewayRoutes, listCloudResources, testApiGatewayRoute} from '@/api/cloudProxyClient'
import type {CloudProvider} from '@/types/cloud'
import type {CloudResource} from '@/types/resource'
import {LogsQueryPanel} from './LogsQueryPanel'
interface ApiGatewayRoutePanelProps {
cloud: CloudProvider
@@ -18,7 +19,11 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat
const [functionName, setFunctionName] = useState('')
const [testBody, setTestBody] = useState('{}')
const [testHeaders, setTestHeaders] = useState('{\n "content-type": "application/json"\n}')
const [testQuery, setTestQuery] = useState('')
const [curlCopied, setCurlCopied] = useState(false)
const [bodyFormatError, setBodyFormatError] = useState<string | null>(null)
const [logsRefreshKey, setLogsRefreshKey] = useState(0)
const [deleteConfirmation, setDeleteConfirmation] = useState<string | null>(null)
const queryClient = useQueryClient()
const functionsQuery = useQuery({
queryKey: ['cloud-resources', cloud, 'serverless'],
@@ -38,7 +43,11 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat
setFunctionName('')
setTestBody('{}')
setTestHeaders('{\n "content-type": "application/json"\n}')
setTestQuery('')
setCurlCopied(false)
setBodyFormatError(null)
setLogsRefreshKey(0)
setDeleteConfirmation(null)
}, [resource?.id])
const routeMutation = useMutation({
@@ -52,8 +61,17 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat
stage,
body: testBody,
headers: parseHeaders(testHeaders),
query: '',
query: testQuery,
}),
onSuccess: () => setLogsRefreshKey((value) => value + 1),
})
const deleteRouteMutation = useMutation({
mutationFn: ({resourceId, path, method}: {resourceId: string; path: string; method: string}) => deleteApiGatewayRoute(cloud, resource!.id, {resourceId, path, method, stage}),
onSuccess: () => {
setDeleteConfirmation(null)
testMutation.reset()
queryClient.invalidateQueries({queryKey: ['api-gateway-routes', cloud, resource!.id]})
},
})
if (!resource || resource.service !== 'apigateway') {
@@ -69,6 +87,8 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat
const functions = functionsQuery.data?.filter((item) => item.type === 'lambda') ?? []
const canCreate = cloud === 'aws' && runtimeReachable && Boolean(functionName) && !routeMutation.isPending
const existingPath = routesQuery.data?.routes.some((route) => route.path === path) ?? false
const curlCommand = buildCurlCommand(resource.id, stage, path, method, testHeaders, testQuery, testBody)
return (
<section className="table-panel">
@@ -78,6 +98,14 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat
<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>
</div>
{testMutation.data && functionName && (
<LogsQueryPanel
cloud={cloud}
logGroupName={`/aws/lambda/${functionName}`}
runtimeReachable={runtimeReachable}
autoRunKey={logsRefreshKey}
/>
)}
</div>
<div className="resource-create-inline">
<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">
<span>Route path</span>
<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 className="dynamic-field">
<span>Method</span>
@@ -137,7 +165,9 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat
<div className="apigateway-route-list">
{routesQuery.data.routes.map((route) => {
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)
setMethod(route.method)
setFunctionName(route.functionName ?? '')
@@ -147,7 +177,13 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat
<span className="apigateway-route-method">{route.method}</span>
<code>{route.path}</code>
<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>
)}
@@ -190,6 +226,11 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat
<span>Request headers (JSON)</span>
<textarea className="textarea code-textarea small" value={testHeaders} onChange={(event) => setTestHeaders(event.target.value)} spellCheck={false}/>
</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="apigateway-test-body-label">
<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 ? 'Sending' : 'Send test request'}
</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>}
</form>
<div className="apigateway-test-response">
@@ -271,3 +319,16 @@ function formatResponseBody(body: string): string {
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 {}')
})
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 {Plus} from 'lucide-react'
import {FormEvent, useRef, useState} from 'react'
import {Plus, Trash2} from 'lucide-react'
import type {FieldSchema, ServiceSchema} from '@/types/schema'
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 (
<input
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> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
@@ -187,13 +220,27 @@ function getInitialFormValues(
if (initialValues) {
for (const [key, val] of Object.entries(initialValues)) {
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
}
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> {
const values = Object.fromEntries(fields.map((field) => [field.name, field.defaultValue ?? '']))
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 {
createCloudResource,
createApiGatewayLambdaRoute,
createEventBridgeLambdaTrigger,
createS3LambdaTrigger,
createSqsLambdaEventSourceMapping,
clearEmailInbox,
deleteCloudResource,
getCloudResource,
getServiceSchema,
listCloudResources,
listStorageObjects,
updateCloudResource,
} from "@/api/cloudProxyClient";
import { DynamicFormRenderer } from "@/components/DynamicFormRenderer";
@@ -39,6 +45,12 @@ import { CreateRdsInstanceForm } from "@/components/CreateRdsInstanceForm";
import { AppConfigPanel } from "@/components/AppConfigPanel";
import { KmsCryptoPanel } from "@/components/KmsCryptoPanel";
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 {
cloud: CloudProvider;
@@ -73,6 +85,7 @@ export function DynamicResourceView({
const [selectedItems, setSelectedItems] = useState<CloudResource[]>([]);
const [deleteConfirm, setDeleteConfirm] = useState(false);
const [bulkDeleteError, setBulkDeleteError] = useState<string | null>(null);
const [deleteWarning, setDeleteWarning] = useState<string | null>(null);
const handleDatabaseTabChange = (tab: "instances" | "snapshots") => {
@@ -138,8 +151,94 @@ export function DynamicResourceView({
};
const createMut = useMutation({
mutationFn: (values: Record<string, unknown>) =>
createCloudResource(cloud, service, values),
mutationFn: async (values: Record<string, unknown>) => {
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) => {
setSelected(resource);
setCreateOpen(false);
@@ -150,15 +249,25 @@ export function DynamicResourceView({
});
const deleteMut = useMutation({
mutationFn: (resource: CloudResource) =>
deleteCloudResource(cloud, service, resource.id),
mutationFn: async (resource: CloudResource) => {
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) => {
setDeleteWarning(null);
if (selected?.id === resource.id) setSelected(undefined);
setSelectedItems((prev) => prev.filter((item) => item.id !== resource.id));
void qc.invalidateQueries({
queryKey: ["cloud-resources", cloud, service],
});
},
onError: (error) => {
setDeleteWarning(error instanceof Error ? error.message : "Unable to delete resource.");
},
});
const deleteMultipleMut = useMutation({
@@ -234,6 +343,13 @@ export function DynamicResourceView({
}
}, [successToast]);
useEffect(() => {
if (deleteWarning) {
const timer = setTimeout(() => setDeleteWarning(null), 5000);
return () => clearTimeout(timer);
}
}, [deleteWarning]);
const updateMut = useMutation({
mutationFn: ({ id, values }: { id: string; values: Record<string, unknown> }) =>
updateCloudResource(cloud, service, id, values),
@@ -280,8 +396,9 @@ export function DynamicResourceView({
if (!trimmed && !field.required) {
continue;
}
const initialStr =
initialVal !== undefined && initialVal !== null ? String(initialVal).trim() : "";
const initialStr = field.type === 'key-value' && initialVal && typeof initialVal === 'object'
? JSON.stringify(Object.entries(initialVal).map(([key, value]) => ({key, value: String(value)})))
: initialVal !== undefined && initialVal !== null ? String(initialVal).trim() : "";
if (trimmed !== initialStr) {
diff[field.name] = trimmed;
}
@@ -623,6 +740,14 @@ export function DynamicResourceView({
}}
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
key={`${cloud}:${service}:create`}
@@ -658,9 +783,14 @@ export function DynamicResourceView({
schema.updateFields &&
schema.updateFields.length > 0 &&
schema.actions.includes("update")
? (resource) => {
setEditingResource(resource);
setUpdateError(null);
? async (resource) => {
try {
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,
onDelete: (resource) => deleteMut.mutate(resource),
@@ -709,11 +839,31 @@ export function DynamicResourceView({
/>
)}
{service === "serverless" && (
<ServerlessInvokePanel
cloud={cloud}
resource={activeSelected}
runtimeReachable={canUseRuntime}
/>
<>
<ServerlessInvokePanel
cloud={cloud}
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" && (
<ApiGatewayRoutePanel
@@ -803,6 +953,35 @@ export function DynamicResourceView({
{successToast}
</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>
);
}
@@ -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
logGroupName?: string
runtimeReachable: boolean
autoRunKey?: number
}
/**
@@ -35,19 +36,24 @@ interface LogsQueryPanelProps {
* implement (stats/visualize, pattern analysis, live tail) — this repo's own
* 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 [rangeMinutes, setRangeMinutes] = useState(RANGE_OPTIONS[1].minutes)
const [expanded, setExpanded] = useState(false)
const queryMut = useMutation({
mutationFn: () => {
mutationFn: async () => {
const endTime = Math.floor(Date.now() / 1000)
return queryLogs(cloud, logGroupName ?? '', {
queryString: queryText,
startTime: endTime - rangeMinutes * 60,
endTime,
})
try {
return await queryLogs(cloud, logGroupName ?? '', {
queryString: queryText,
startTime: endTime - rangeMinutes * 60,
endTime,
})
} catch (error) {
if (isMissingLogGroup(error)) return {queryId: '', status: 'Complete', rows: []}
throw error
}
},
})
const {mutate: runQuery, reset: resetQuery} = queryMut
@@ -62,6 +68,10 @@ export function LogsQueryPanel({cloud, logGroupName, runtimeReachable}: LogsQuer
setExpanded(false)
}, [logGroupName, resetQuery])
useEffect(() => {
if (autoRunKey > 0 && logGroupName && runtimeReachable) runQuery()
}, [autoRunKey, logGroupName, runQuery, runtimeReachable])
if (!logGroupName) return null
function submit() {
@@ -150,6 +160,10 @@ function macShortcut(): string {
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'])
function LogsQueryResults({result, expanded}: {result?: LogsInsightsQueryResult; expanded: boolean}) {
@@ -120,7 +120,7 @@ export function ResourceTable({
)}
{canEdit && (
<button
className="icon-btn"
className={schema.cloud === 'aws' && schema.service === 'serverless' ? 'button compact lambda-edit-button' : 'icon-btn'}
type="button"
title={`Edit ${resource.name}`}
aria-label={`Edit ${resource.name}`}
@@ -130,6 +130,7 @@ export function ResourceTable({
}}
>
<Pencil size={13} />
{schema.cloud === 'aws' && schema.service === 'serverless' && 'Edit Lambda'}
</button>
)}
{canDelete && (
@@ -187,4 +188,3 @@ export function ResourceTable({
</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 {CloudProvider} from "@/types/cloud";
import type {CloudResource} from "@/types/resource";
import {LogsQueryPanel} from "./LogsQueryPanel";
interface ServerlessInvokePanelProps {
cloud: CloudProvider;
@@ -22,6 +23,7 @@ export function ServerlessInvokePanel({
const [showLog, setShowLog] = useState(false);
const [copied, setCopied] = useState(false);
const [validationError, setValidationError] = useState<string | null>(null);
const [logsRefreshKey, setLogsRefreshKey] = useState(0);
useEffect(() => {
setPayload("{\n \n}");
@@ -29,6 +31,7 @@ export function ServerlessInvokePanel({
setShowLog(false);
setCopied(false);
setValidationError(null);
setLogsRefreshKey(0);
}, [resource?.id]);
const isSupportedResource =
@@ -54,6 +57,7 @@ export function ServerlessInvokePanel({
setInvokeResult(result);
setShowLog(false);
setCopied(false);
setLogsRefreshKey((value) => value + 1);
},
});
@@ -221,6 +225,14 @@ const clearPayload = () => {
</div>
)}
</div>
{cloud === "aws" && invokeResult && (
<LogsQueryPanel
cloud={cloud}
logGroupName={`/aws/lambda/${resource.name}`}
runtimeReachable={runtimeReachable}
autoRunKey={logsRefreshKey}
/>
)}
</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 [createFolderOpen, setCreateFolderOpen] = useState(false)
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 [search, setSearch] = useState('')
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set())
const [bulkDeleting, setBulkDeleting] = useState(false)
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 uploadCapability = capabilityFor(resolvedCapabilities, 'upload')
@@ -69,10 +74,15 @@ export function StorageObjectBrowser({cloud, resource, capabilities = [], runtim
setFolderName('')
setCreateFolderOpen(false)
setDeleteConfirm(null)
setDeleteFolderConfirm(null)
setEmptyBucketConfirm(false)
setEmptyBucketText('')
setCopyObject(null)
setSearch('')
setSelectedKeys(new Set())
setPreviewObject(null)
setFolderUpload(null)
setFolderUploadNotice(null)
onSelectObject(undefined)
}, [resource?.id, onSelectObject])
@@ -91,6 +101,27 @@ export function StorageObjectBrowser({cloud, resource, capabilities = [], runtim
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({
mutationFn: async (name: string) => {
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({
mutationFn: async ({srcKey, destKey, destResourceId, mode}: {
srcKey: string; destKey: string; destResourceId?: string; mode: 'move' | 'copy'
@@ -137,6 +193,7 @@ export function StorageObjectBrowser({cloud, resource, capabilities = [], runtim
setSearch('')
setSelectedKeys(new Set())
setDeleteConfirm(null)
setDeleteFolderConfirm(null)
setPreviewObject(null)
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 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 (
<section className="object-browser">
@@ -224,6 +281,12 @@ export function StorageObjectBrowser({cloud, resource, capabilities = [], runtim
Back
</button>
)}
{cloud === 'aws' && (
<button className="button danger" type="button" disabled={emptyBucketMut.isPending} onClick={() => setEmptyBucketConfirm(true)}>
<Trash2 size={14}/>
Empty bucket
</button>
)}
{createFolderCapability && (
<button className="button" type="button" disabled={!canCreateFolder} title={createFolderCapability.reason} onClick={() => setCreateFolderOpen((open) => !open)}>
<Folder size={14}/>
@@ -267,6 +330,42 @@ export function StorageObjectBrowser({cloud, resource, capabilities = [], runtim
</button>
</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 && (
@@ -348,10 +447,25 @@ export function StorageObjectBrowser({cloud, resource, capabilities = [], runtim
<ChevronRight size={12}/>
</span>
</td>
<td>{object.type}</td>
<td>Folder</td>
<td>—</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>
))}
{filteredFiles.map((object) => {
@@ -389,7 +503,7 @@ export function StorageObjectBrowser({cloud, resource, capabilities = [], runtim
{object.name}
</span>
</td>
<td>{object.type}</td>
<td>{objectTypeLabel(object.name)}</td>
<td>{object.size === null ? '—' : formatBytes(object.size)}</td>
<td>{object.lastModified ?? '—'}</td>
<td className="table-actions">
@@ -639,3 +753,59 @@ function parentPrefix(prefix: string): string {
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);
}
.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 {
grid-column: 1 / -1;
}
@@ -2460,6 +2472,36 @@ pre,
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 {
display: inline-flex;
align-items: center;
@@ -2582,13 +2624,30 @@ pre,
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
cursor: pointer;
display: flex;
gap: 8px;
padding: 7px 9px;
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.selected {
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'
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'
// 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.