552 lines
20 KiB
TypeScript
552 lines
20 KiB
TypeScript
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 { 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,
|
|
private readonly eventBridge: EventBridgeClient = defaultEventBridge,
|
|
) {}
|
|
|
|
schema(): ServiceSchema {
|
|
return awsServerlessSchema();
|
|
}
|
|
|
|
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
|
|
const res = await this.lambda.send(new ListFunctionsCommand({}));
|
|
const resources: CloudResource[] = (res.Functions ?? []).map((fn) => ({
|
|
id: fn.FunctionName ?? fn.FunctionArn ?? "",
|
|
name: fn.FunctionName ?? "",
|
|
cloud: "aws" as const,
|
|
service: "serverless" as const,
|
|
type: "lambda",
|
|
region: null,
|
|
createdAt: null,
|
|
status: fn.State ?? null,
|
|
metadata: {
|
|
arn: fn.FunctionArn,
|
|
runtime: fn.Runtime,
|
|
handler: fn.Handler,
|
|
lastModified: fn.LastModified,
|
|
memorySize: fn.MemorySize,
|
|
timeout: fn.Timeout,
|
|
codeSize: fn.CodeSize,
|
|
packageType: fn.PackageType,
|
|
description: fn.Description,
|
|
},
|
|
}));
|
|
|
|
return filterBySearch(resources, query.search);
|
|
}
|
|
|
|
async get(id: string): Promise<CloudResource | null> {
|
|
try {
|
|
const res = await this.lambda.send(
|
|
new GetFunctionCommand({ FunctionName: id }),
|
|
);
|
|
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,
|
|
name: config.FunctionName ?? id,
|
|
cloud: "aws",
|
|
service: "serverless",
|
|
type: "lambda",
|
|
region: null,
|
|
createdAt: null,
|
|
status: config.State ?? null,
|
|
metadata: {
|
|
arn: config.FunctionArn,
|
|
runtime: config.Runtime,
|
|
handler: config.Handler,
|
|
lastModified: config.LastModified,
|
|
memorySize: config.MemorySize,
|
|
timeout: config.Timeout,
|
|
codeSize: config.CodeSize,
|
|
packageType: config.PackageType,
|
|
description: config.Description,
|
|
role: config.Role,
|
|
version: config.Version,
|
|
codeSha256: config.CodeSha256,
|
|
environment: config.Environment?.Variables,
|
|
...(source === null ? {} : {code: source}),
|
|
},
|
|
};
|
|
} catch (error) {
|
|
if (hasHttpStatus(error, 404)) return null;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async create(input: CreateResourceInput): Promise<CloudResource> {
|
|
const values = input.values;
|
|
|
|
const functionName = String(
|
|
values.functionName ?? values.name ?? "",
|
|
).trim();
|
|
const runtime = String(values.runtime ?? "").trim();
|
|
const architecture = String(values.architecture ?? 'x86_64').trim();
|
|
const handler = String(values.handler ?? "").trim();
|
|
const role = String(values.role ?? "").trim();
|
|
const description = String(values.description ?? "").trim();
|
|
const memorySize = positiveNumberOrDefault(values.memorySize, 128);
|
|
const timeout = positiveNumberOrDefault(values.timeout, 3);
|
|
const zipFile = String(values.zipFile ?? '').trim();
|
|
const code =
|
|
String(values.code ?? "").trim() ||
|
|
lambdaStarterCode(runtime)
|
|
|
|
if (!functionName) throw new ValidationError("functionName is required");
|
|
if (!runtime) throw new ValidationError("runtime is required");
|
|
if (architecture !== 'x86_64' && architecture !== 'arm64') {
|
|
throw new ValidationError('architecture must be x86_64 or arm64')
|
|
}
|
|
if (!handler) throw new ValidationError("handler is required");
|
|
if (!role) throw new ValidationError("role is required");
|
|
|
|
const res = await this.lambda.send(
|
|
new CreateFunctionCommand({
|
|
FunctionName: functionName,
|
|
Runtime: runtime as never,
|
|
Architectures: [architecture as never],
|
|
Handler: handler,
|
|
Role: role,
|
|
Description: description || undefined,
|
|
MemorySize: memorySize,
|
|
Timeout: timeout,
|
|
Code: {
|
|
// The browser submits uploaded files as base64. Otherwise package the
|
|
// editor source into the same AWS ZIP payload.
|
|
ZipFile: zipFile
|
|
? decodeBase64Zip(zipFile)
|
|
: createZipArchive([
|
|
{ name: handlerFileName(handler, runtime), content: code },
|
|
]),
|
|
},
|
|
}),
|
|
);
|
|
|
|
return {
|
|
id: res.FunctionName ?? res.FunctionArn ?? functionName,
|
|
name: res.FunctionName ?? functionName,
|
|
cloud: "aws",
|
|
service: "serverless",
|
|
type: "lambda",
|
|
region: null,
|
|
createdAt: null,
|
|
status: res.State ?? null,
|
|
metadata: {
|
|
arn: res.FunctionArn,
|
|
runtime: res.Runtime,
|
|
handler: res.Handler,
|
|
lastModified: res.LastModified,
|
|
memorySize: res.MemorySize,
|
|
timeout: res.Timeout,
|
|
codeSize: res.CodeSize,
|
|
packageType: res.PackageType,
|
|
description: res.Description,
|
|
role: res.Role,
|
|
version: res.Version,
|
|
codeSha256: res.CodeSha256,
|
|
},
|
|
};
|
|
}
|
|
|
|
async delete(id: string): Promise<void> {
|
|
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(
|
|
new InvokeCommand({
|
|
FunctionName: id,
|
|
Payload: new TextEncoder().encode(payload || "{}"),
|
|
// Tail returns the last 4 KB of the execution log, base64 encoded.
|
|
LogType: "Tail",
|
|
}),
|
|
);
|
|
const executionDuration = Math.round(performance.now() - startedAt);
|
|
|
|
return {
|
|
statusCode: res.StatusCode ?? 0,
|
|
payload: decodePayload(res.Payload),
|
|
...(res.FunctionError ? { functionError: res.FunctionError } : {}),
|
|
...(res.LogResult ? { logResult: decodeLogResult(res.LogResult) } : {}),
|
|
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 {
|
|
if (value.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(value)) {
|
|
throw new ValidationError('zipFile must be a base64-encoded ZIP archive')
|
|
}
|
|
return new Uint8Array(Buffer.from(value, 'base64'))
|
|
}
|
|
|
|
function positiveNumberOrDefault(value: unknown, defaultValue: number): number {
|
|
if (value === undefined || value === null || String(value).trim() === '') {
|
|
return defaultValue
|
|
}
|
|
const number = Number(value)
|
|
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);
|
|
}
|
|
|
|
/** Lambda returns the tailed log base64 encoded; surface it as plain text. */
|
|
function decodeLogResult(logResult: string): string {
|
|
try {
|
|
return Buffer.from(logResult, "base64").toString("utf8");
|
|
} catch {
|
|
return logResult;
|
|
}
|
|
}
|
|
|
|
function filterBySearch(
|
|
resources: CloudResource[],
|
|
search?: string,
|
|
): CloudResource[] {
|
|
const normalized = search?.trim().toLowerCase();
|
|
if (!normalized) return resources;
|
|
return resources.filter((r) => r.name.toLowerCase().includes(normalized));
|
|
}
|
|
|
|
function hasHttpStatus(error: unknown, status: number): boolean {
|
|
if (typeof error !== "object" || error === null) return false;
|
|
const metadata = (error as { $metadata?: { httpStatusCode?: number } })
|
|
.$metadata;
|
|
return metadata?.httpStatusCode === status;
|
|
}
|