first commit
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
import {ValidationError} from '../cloud-spi/errors'
|
||||
import {
|
||||
CreateFunctionCommand,
|
||||
DeleteFunctionCommand,
|
||||
GetFunctionCommand,
|
||||
InvokeCommand,
|
||||
type InvokeCommandOutput,
|
||||
ListFunctionsCommand,
|
||||
type LambdaClient,
|
||||
} from "@aws-sdk/client-lambda";
|
||||
import { awsServerlessSchema } from "../cloud-spi/serverlessSchema";
|
||||
import type {
|
||||
CloudResource,
|
||||
CloudServiceAdapter,
|
||||
CreateResourceInput,
|
||||
ResourceQuery,
|
||||
ServerlessInvokeResult,
|
||||
ServiceSchema,
|
||||
} from "../cloud-spi/types";
|
||||
import { lambda as defaultLambda } from "../aws";
|
||||
import { createZipArchive, handlerFileName } 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) {}
|
||||
|
||||
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;
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
} 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 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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 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;
|
||||
}
|
||||
Reference in New Issue
Block a user