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 { 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 { 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 { 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 { await this.lambda.send(new DeleteFunctionCommand({ FunctionName: id })); } async update(id: string, input: UpdateResourceInput): Promise { const values = input.values; const source = stringValue(values.code); const zipFile = stringValue(values.zipFile); if (source || zipFile) { const current = await this.lambda.send(new GetFunctionCommand({FunctionName: id})); const handler = stringValue(values.handler) || current.Configuration?.Handler; const runtime = stringValue(values.runtime) || current.Configuration?.Runtime; if (!handler || !runtime) throw new ValidationError('Lambda handler and runtime are required to package source code'); await this.lambda.send(new UpdateFunctionCodeCommand({ FunctionName: id, ZipFile: zipFile ? decodeBase64Zip(zipFile) : createZipArchive([{name: handlerFileName(handler, runtime), content: source}]), })); } const configuration = { FunctionName: id, ...(values.runtime === undefined ? {} : {Runtime: stringValue(values.runtime) as never}), ...(values.handler === undefined ? {} : {Handler: stringValue(values.handler)}), ...(values.description === undefined ? {} : {Description: stringValue(values.description)}), ...(values.environment === undefined ? {} : {Environment: {Variables: environmentVariables(values.environment)}}), ...(values.memorySize === undefined ? {} : {MemorySize: positiveNumber(values.memorySize, 'memorySize')}), ...(values.timeout === undefined ? {} : {Timeout: positiveNumber(values.timeout, 'timeout')}), }; if (Object.keys(configuration).length > 1) { await this.lambda.send(new UpdateFunctionConfigurationCommand(configuration)); } const updated = await this.get(id); if (!updated) throw new ValidationError(`Lambda function ${id} was not found after update`); return updated; } async invoke(id: string, payload: string): Promise { const startedAt = performance.now(); const res = await this.lambda.send( 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 { const ruleName = stringValue(input.ruleName) const scheduleExpression = stringValue(input.scheduleExpression) const functionName = stringValue(input.functionName) if (!ruleName) throw new ValidationError('ruleName is required') if (!scheduleExpression) throw new ValidationError('scheduleExpression is required') if (!functionName) throw new ValidationError('functionName is required') const functionResult = await this.lambda.send(new GetFunctionCommand({FunctionName: functionName})) const functionArn = functionResult.Configuration?.FunctionArn if (!functionArn) throw new ValidationError(`Lambda function ${functionName} did not return an ARN`) const rule = await this.eventBridge.send(new PutRuleCommand({ Name: ruleName, ScheduleExpression: scheduleExpression, State: 'ENABLED', })) if (!rule.RuleArn) throw new ValidationError(`EventBridge rule ${ruleName} did not return an ARN`) const targetId = eventBridgeTargetId(functionName) await this.eventBridge.send(new PutTargetsCommand({ Rule: ruleName, Targets: [{Id: targetId, Arn: functionArn}], })) await this.ensureEventBridgePermission(functionName, ruleName, rule.RuleArn) return { id: ruleName, ruleName, ruleArn: rule.RuleArn, functionName, functionArn, scheduleExpression, state: 'ENABLED', } } async listEventBridgeLambdaTriggers(functionName: string): Promise { const triggers: EventBridgeLambdaTriggerOverview[] = [] let nextToken: string | undefined do { const rules = await this.eventBridge.send(new ListRulesCommand({NextToken: nextToken})) for (const rule of rules.Rules ?? []) { if (!rule.Name || !rule.Arn || !rule.ScheduleExpression) continue const targets = await this.listEventBridgeTargets(rule.Name) for (const target of targets) { if (!target.Arn || lambdaFunctionName(target.Arn) !== functionName) continue triggers.push({ id: rule.Name, ruleName: rule.Name, ruleArn: rule.Arn, functionName, functionArn: target.Arn, scheduleExpression: rule.ScheduleExpression, state: rule.State ?? null, }) } } nextToken = rules.NextToken } while (nextToken) return triggers.sort((left, right) => left.ruleName.localeCompare(right.ruleName)) } async deleteEventBridgeLambdaTrigger(functionName: string, triggerId: string): Promise { const ruleName = stringValue(triggerId) if (!ruleName) throw new ValidationError('triggerId is required') const targets = await this.listEventBridgeTargets(ruleName) const matchingTargets = targets.filter((target) => target.Arn && lambdaFunctionName(target.Arn) === functionName) const targetIds = matchingTargets.map((target) => target.Id).filter((id): id is string => Boolean(id)) if (targetIds.length > 0) { await this.eventBridge.send(new RemoveTargetsCommand({Rule: ruleName, Ids: targetIds})) } try { await this.lambda.send(new RemovePermissionCommand({ FunctionName: functionName, StatementId: eventBridgePermissionStatementId(ruleName), })) } catch (error) { if (!hasHttpStatus(error, 404)) throw error } if (targets.length === matchingTargets.length) { await this.eventBridge.send(new DeleteRuleCommand({Name: ruleName})) } } async createSqsLambdaEventSourceMapping(input: SqsLambdaEventSourceMappingInput): Promise { const functionName = stringValue(input.functionName) const queueArn = stringValue(input.queueArn) if (!functionName) throw new ValidationError('functionName is required') if (!queueArn) throw new ValidationError('queueArn is required') const batchSize = input.batchSize === undefined ? undefined : sqsBatchSize(input.batchSize) const mapping = await this.lambda.send(new CreateEventSourceMappingCommand({ FunctionName: functionName, EventSourceArn: queueArn, ...(batchSize === undefined ? {} : {BatchSize: batchSize}), })) return sqsEventSourceMappingOverview(mapping, functionName) } async listSqsLambdaEventSourceMappings(functionName: string): Promise { const normalizedFunctionName = stringValue(functionName) if (!normalizedFunctionName) throw new ValidationError('functionName is required') const mappings: SqsLambdaEventSourceMappingOverview[] = [] let marker: string | undefined do { const result = await this.lambda.send(new ListEventSourceMappingsCommand({ FunctionName: normalizedFunctionName, Marker: marker, })) for (const mapping of result.EventSourceMappings ?? []) { if (mapping.EventSourceArn?.includes(':sqs:')) { mappings.push(sqsEventSourceMappingOverview(mapping, normalizedFunctionName)) } } marker = result.NextMarker } while (marker) return mappings.sort((left, right) => left.uuid.localeCompare(right.uuid)) } async deleteSqsLambdaEventSourceMapping(functionName: string, mappingId: string): Promise { if (!stringValue(functionName)) throw new ValidationError('functionName is required') const uuid = stringValue(mappingId) if (!uuid) throw new ValidationError('mappingId is required') await this.lambda.send(new DeleteEventSourceMappingCommand({UUID: uuid})) } private async listEventBridgeTargets(ruleName: string) { const targets = [] let nextToken: string | undefined do { const result = await this.eventBridge.send(new ListTargetsByRuleCommand({Rule: ruleName, NextToken: nextToken})) targets.push(...(result.Targets ?? [])) nextToken = result.NextToken } while (nextToken) return targets } private async ensureEventBridgePermission(functionName: string, ruleName: string, ruleArn: string): Promise { const permission = { FunctionName: functionName, StatementId: eventBridgePermissionStatementId(ruleName), Action: 'lambda:InvokeFunction', Principal: 'events.amazonaws.com', SourceArn: ruleArn, } try { await this.lambda.send(new AddPermissionCommand(permission)) } catch (error) { if (!hasHttpStatus(error, 409)) throw error } } } function decodeBase64Zip(value: string): Uint8Array { 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 { if (!location || !handler || !runtime) return null try { const response = await globalThis.fetch(location) if (!response.ok) return null return readStoredZipEntry(new Uint8Array(await response.arrayBuffer()), handlerFileName(handler, runtime)) } catch { return null } } function environmentVariables(value: unknown): Record { let variables: unknown = value if (typeof value === 'string') { try { variables = JSON.parse(value) } catch { throw new ValidationError('environment must be a JSON object of string values') } } if (!variables || typeof variables !== 'object' || Array.isArray(variables)) { if (!Array.isArray(variables)) throw new ValidationError('environment must be a JSON object of string values') const entries = variables.map((entry) => { if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { throw new ValidationError('environment rows must have non-empty keys and string values') } const row = entry as {key?: unknown; value?: unknown} if (typeof row.key !== 'string' || !row.key || typeof row.value !== 'string') { throw new ValidationError('environment rows must have non-empty keys and string values') } return [row.key, row.value] as const }) if (new Set(entries.map(([key]) => key)).size !== entries.length) { throw new ValidationError('environment variable names must be unique') } return Object.fromEntries(entries) } const entries = Object.entries(variables) if (entries.some(([key, variable]) => !key || typeof variable !== 'string')) { throw new ValidationError('environment must be a JSON object of non-empty keys and string values') } return Object.fromEntries(entries) as Record } function decodePayload(payload: InvokeCommandOutput["Payload"]): string { if (!payload) return ""; return new TextDecoder().decode(payload); } /** 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; }