Files
floci-ui/packages/api/src/adapter-aws/AwsServerlessAdapter.ts
T
2026-09-24 22:34:04 -04:00

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;
}