From d1466e4de6dab1fee7c3f176d8faa666f4272932 Mon Sep 17 00:00:00 2001 From: Francesco Giovannini Date: Thu, 2 Apr 2026 19:06:35 +0200 Subject: [PATCH 1/4] feat: add Lambda service plugin with full CRUD, invoke, versions, and aliases Implement a complete Lambda plugin following the established service pattern, covering both backend and frontend. Backend: - Add "lambda" to ALL_SERVICES and ENABLED_SERVICES in config.ts - Add LambdaClient to client-cache.ts and @aws-sdk/client-lambda dependency - Create plugins/lambda/ with schemas, service, routes, and index - LambdaService: listFunctions, getFunction, createFunction, updateCode, updateConfig, deleteFunction, invokeFunction, listVersions, listAliases - 9 Fastify routes (GET/POST/PUT/DELETE) with TypeBox validation - Register lambda in bundle.ts plugin map - Add lambda to docker-compose.yaml SERVICES Frontend: - Add React Query hooks for all Lambda operations (api/lambda.ts) - Add route pages: /lambda/ (list) and /lambda/$functionName (detail) - FunctionList: searchable table with create dialog and delete confirmation - FunctionDetail: 4-tab view (Configuration, Invoke, Versions, Aliases) - FunctionCreateDialog: form with runtime selector and zip upload - InvokeFunctionForm: payload editor, invocation type, result display with decoded logs - Add Lambda entry (Zap icon) to Sidebar and Dashboard Tests: - Unit tests for LambdaService (49 tests) and routes (24 tests) - Plugin index registration test - Integration test with full CRUD flow against LocalStack - Update config.test.ts and plugin-index.test.ts for lambda Documentation: - Create docs/lambda.md with API reference, examples, and UI guide - Update README.md: services table, ENABLED_SERVICES, project structure, Node.js 24 prerequisite, active service detection section --- README.md | 20 +- docker-compose.yaml | 4 +- docs/lambda.md | 187 ++++ packages/backend/package.json | 1 + packages/backend/src/aws/client-cache.ts | 3 + packages/backend/src/bundle.ts | 2 + packages/backend/src/config.ts | 3 +- packages/backend/src/plugins/lambda/index.ts | 6 + packages/backend/src/plugins/lambda/routes.ts | 241 +++++ .../backend/src/plugins/lambda/schemas.ts | 151 ++++ .../backend/src/plugins/lambda/service.ts | 295 +++++++ packages/backend/src/server.ts | 4 +- packages/backend/test/config.test.ts | 27 +- packages/backend/test/health.test.ts | 6 +- .../integration/lambda.integration.test.ts | 227 +++++ .../backend/test/plugins/lambda/index.test.ts | 57 ++ .../test/plugins/lambda/routes.test.ts | 498 +++++++++++ .../test/plugins/lambda/service.test.ts | 832 ++++++++++++++++++ .../backend/test/plugins/plugin-index.test.ts | 10 + packages/frontend/src/api/lambda.ts | 187 ++++ .../lambda/FunctionCreateDialog.tsx | 243 +++++ .../src/components/lambda/FunctionDetail.tsx | 414 +++++++++ .../src/components/lambda/FunctionList.tsx | 160 ++++ .../components/lambda/InvokeFunctionForm.tsx | 167 ++++ .../src/components/layout/Sidebar.tsx | 8 + packages/frontend/src/routeTree.gen.ts | 42 + packages/frontend/src/routes/index.tsx | 9 + .../src/routes/lambda/$functionName.tsx | 11 + packages/frontend/src/routes/lambda/index.tsx | 10 + pnpm-lock.yaml | 451 ++++++++++ 30 files changed, 4256 insertions(+), 20 deletions(-) create mode 100644 docs/lambda.md create mode 100644 packages/backend/src/plugins/lambda/index.ts create mode 100644 packages/backend/src/plugins/lambda/routes.ts create mode 100644 packages/backend/src/plugins/lambda/schemas.ts create mode 100644 packages/backend/src/plugins/lambda/service.ts create mode 100644 packages/backend/test/integration/lambda.integration.test.ts create mode 100644 packages/backend/test/plugins/lambda/index.test.ts create mode 100644 packages/backend/test/plugins/lambda/routes.test.ts create mode 100644 packages/backend/test/plugins/lambda/service.test.ts create mode 100644 packages/frontend/src/api/lambda.ts create mode 100644 packages/frontend/src/components/lambda/FunctionCreateDialog.tsx create mode 100644 packages/frontend/src/components/lambda/FunctionDetail.tsx create mode 100644 packages/frontend/src/components/lambda/FunctionList.tsx create mode 100644 packages/frontend/src/components/lambda/InvokeFunctionForm.tsx create mode 100644 packages/frontend/src/routes/lambda/$functionName.tsx create mode 100644 packages/frontend/src/routes/lambda/index.tsx diff --git a/README.md b/README.md index c8dfcd4..be620bb 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ LocalStack Explorer provides an AWS Console-like experience for your local devel | SQS | Fully implemented | Queue management, message operations, queue attributes, purge | | SNS | Fully implemented | Topics, subscriptions, publish, tags, filter policies | | IAM | Fully implemented | Users, groups, managed/inline policies, access keys, versioning | +| Lambda | Fully implemented | Functions CRUD, invoke, code/config update, versions, aliases | | CloudFormation | Fully implemented | Stack CRUD, update, template editor, events, cross-service links | | DynamoDB | Fully implemented | Table management, create, list, detail views | @@ -19,7 +20,7 @@ LocalStack Explorer provides an AWS Console-like experience for your local devel ### Prerequisites -- **Node.js** >= 20 +- **Node.js** >= 24 (see `.nvmrc`) - **pnpm** >= 9 - **Docker** (for LocalStack) @@ -37,7 +38,7 @@ pnpm install docker compose up -d ``` -This starts LocalStack with all required services (S3, SQS, SNS, IAM, CloudFormation, DynamoDB) on `http://localhost:4566`. +This starts LocalStack with all required services (S3, SQS, SNS, IAM, Lambda, CloudFormation, DynamoDB) on `http://localhost:4566`. ### Development @@ -118,7 +119,7 @@ The backend uses [env-schema](https://github.com/fastify/env-schema) for environ | `PORT` | `3001` | Backend server port | | `LOCALSTACK_ENDPOINT` | `http://localhost:4566` | Default LocalStack endpoint URL | | `LOCALSTACK_REGION` | `us-east-1` | Default AWS region for LocalStack clients | -| `ENABLED_SERVICES` | `s3,sqs,sns,iam,cloudformation,dynamodb` | Comma-separated list of enabled services | +| `ENABLED_SERVICES` | `s3,sqs,sns,iam,lambda,cloudformation,dynamodb` | Comma-separated list of enabled services | Create a `.env` file in `packages/backend/` to override defaults. @@ -142,10 +143,10 @@ By default, only a subset of services is enabled. You can control which services ENABLED_SERVICES=s3,sqs # Enable all available services -ENABLED_SERVICES=s3,sqs,sns,iam,cloudformation,dynamodb +ENABLED_SERVICES=s3,sqs,sns,iam,lambda,cloudformation,dynamodb ``` -Available service names: `s3`, `sqs`, `sns`, `iam`, `cloudformation`, `dynamodb`. +Available service names: `s3`, `sqs`, `sns`, `iam`, `lambda`, `cloudformation`, `dynamodb`. When a service is disabled: - Its backend API routes are **not registered** (requests return 404) @@ -154,6 +155,10 @@ When a service is disabled: The frontend fetches the list of enabled services from the `GET /api/services` endpoint at startup and filters the UI accordingly. +### Active Service Detection + +The health endpoint (`GET /api/health`) queries LocalStack's native `/_localstack/health` API and returns the list of services that are actually running. The frontend uses this data (refreshed every 30 seconds) to visually disable services that are configured but not currently active on the LocalStack instance — they appear greyed out and are not clickable. + ## Project Structure ``` @@ -162,7 +167,8 @@ localstack-explorer/ ├── packages/ │ ├── backend/ # Fastify API server │ │ └── src/ -│ │ ├── index.ts # Entry point (autoload plugins, serves frontend) +│ │ ├── index.ts # App factory (autoload plugins, serves frontend) +│ │ ├── server.ts # Server entry point (starts listening) │ │ ├── bundle.ts # Bundle entry point (explicit plugin imports) │ │ ├── config.ts # env-schema configuration │ │ ├── health.ts # LocalStack connectivity check @@ -172,6 +178,7 @@ localstack-explorer/ │ │ │ ├── sqs/ # Complete implementation │ │ │ ├── sns/ # Complete implementation │ │ │ ├── iam/ # Complete implementation +│ │ │ ├── lambda/ # Complete implementation │ │ │ ├── cloudformation/ # Complete implementation │ │ │ └── dynamodb/ # Complete implementation │ │ └── shared/ # Error handling, shared types @@ -219,6 +226,7 @@ localstack-explorer/ - **[SQS Service Guide](docs/sqs.md)** — Complete reference for SQS operations: queue management, message send/receive/delete, queue attributes, and purge. - **[SNS Service Guide](docs/sns.md)** — Complete reference for SNS operations: topics, subscriptions, publish (single/batch), filter policies, and tags. - **[IAM Service Guide](docs/iam.md)** — Complete reference for IAM operations: users, groups, managed/inline policies, access keys, policy versioning, and cascading deletes. +- **[Lambda Service Guide](docs/lambda.md)** — Complete reference for Lambda operations: functions CRUD, invoke with log output, code/config updates, versions, and aliases. - **[CloudFormation Service Guide](docs/cloudformation.md)** — Complete reference for CloudFormation operations: stack CRUD, update, template editor, events timeline, and cross-service resource navigation. - **[DynamoDB Service Guide](docs/dynamodb.md)** — Complete reference for DynamoDB operations: table management, creation, listing, and detail views. - **[Adding New Services](docs/adding-new-services.md)** — Step-by-step guide to implement a new AWS service following the established plugin pattern. diff --git a/docker-compose.yaml b/docker-compose.yaml index 39e9584..f05ab82 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -4,13 +4,13 @@ services: ports: - "4566:4566" environment: - - SERVICES=${ENABLED_SERVICES:-s3,sqs,sns,iam,cloudformation,dynamodb} + - SERVICES=${ENABLED_SERVICES:-s3,sqs,sns,iam,lambda,cloudformation,dynamodb} - DEBUG=0 - EAGER_SERVICE_LOADING=1 volumes: - localstack-data:/var/lib/localstack localstack-explorer: - image: fgiova/localstack-explorer:latest + image: fgiova/localstack-explorer:test ports: - "3001:3001" environment: diff --git a/docs/lambda.md b/docs/lambda.md new file mode 100644 index 0000000..cab70c6 --- /dev/null +++ b/docs/lambda.md @@ -0,0 +1,187 @@ +# Lambda Service Guide + +Lambda is a fully implemented service in LocalStack Explorer. It supports function management, code and configuration updates, synchronous invocation with log output, and browsing of versions and aliases. + +## Features + +- List, create, and delete Lambda functions +- View function details (configuration, environment variables, layers, architectures) +- Update function code (zip upload or S3 reference) +- Update function configuration (handler, runtime, memory, timeout, environment, role) +- Invoke functions synchronously with JSON payload +- View invocation results: status code, response payload, function errors, and decoded CloudWatch logs +- Browse function versions +- Browse function aliases +- Search/filter functions by name +- Active service detection: Lambda appears greyed out in the UI when not running on LocalStack + +## API Endpoints + +All endpoints are prefixed with `/api/lambda`. + +### Functions + +| Method | Path | Description | Request | Response | +|--------|-------------------------------|--------------------------|--------------------------------|--------------------------------| +| GET | `/` | List functions | `?marker=string` | `{ functions: [...] }` | +| POST | `/` | Create function | `CreateFunctionBody` | `{ message: string }` | +| GET | `/:functionName` | Get function detail | -- | `FunctionDetail` | +| PUT | `/:functionName/code` | Update function code | `{ zipFile?, s3Bucket?, s3Key? }` | `{ message: string }` | +| PUT | `/:functionName/config` | Update function config | `UpdateFunctionConfigBody` | `{ message: string }` | +| DELETE | `/:functionName` | Delete function | -- | `{ success: boolean }` | + +### Invocation + +| Method | Path | Description | Request | Response | +|--------|-------------------------------|--------------------------|--------------------------------|--------------------------------| +| POST | `/:functionName/invoke` | Invoke function | `{ payload?, invocationType? }` | `InvokeFunctionResponse` | + +### Versions & Aliases + +| Method | Path | Description | Request | Response | +|--------|-------------------------------|--------------------------|--------------------------------|--------------------------------| +| GET | `/:functionName/versions` | List versions | `?marker=string` | `{ versions: [...] }` | +| GET | `/:functionName/aliases` | List aliases | `?marker=string` | `{ aliases: [...] }` | + +### Request/Response Examples + +**List functions:** + +```bash +curl http://localhost:3001/api/lambda +``` + +```json +{ + "functions": [ + { + "functionName": "my-function", + "functionArn": "arn:aws:lambda:us-east-1:000000000000:function:my-function", + "runtime": "nodejs20.x", + "handler": "index.handler", + "codeSize": 284, + "lastModified": "2024-01-15T10:30:00.000+0000", + "memorySize": 128, + "timeout": 30 + } + ] +} +``` + +**Create function:** + +```bash +curl -X POST http://localhost:3001/api/lambda \ + -H "Content-Type: application/json" \ + -d '{ + "functionName": "my-function", + "runtime": "nodejs20.x", + "handler": "index.handler", + "role": "arn:aws:iam::000000000000:role/lambda-role", + "code": { "zipFile": "" }, + "memorySize": 128, + "timeout": 30 + }' +``` + +```json +{ "message": "Function 'my-function' created successfully" } +``` + +**Invoke function:** + +```bash +curl -X POST http://localhost:3001/api/lambda/my-function/invoke \ + -H "Content-Type: application/json" \ + -d '{ + "payload": "{\"key\": \"value\"}", + "invocationType": "RequestResponse" + }' +``` + +```json +{ + "statusCode": 200, + "payload": "{\"statusCode\":200,\"body\":\"hello\"}", + "logResult": "START RequestId: ...\nEND RequestId: ...\n" +} +``` + +**Get function detail:** + +```bash +curl http://localhost:3001/api/lambda/my-function +``` + +```json +{ + "functionName": "my-function", + "functionArn": "arn:aws:lambda:us-east-1:000000000000:function:my-function", + "runtime": "nodejs20.x", + "handler": "index.handler", + "role": "arn:aws:iam::000000000000:role/lambda-role", + "codeSize": 284, + "description": "My Lambda function", + "timeout": 30, + "memorySize": 128, + "lastModified": "2024-01-15T10:30:00.000+0000", + "codeSha256": "abc123...", + "version": "$LATEST", + "environment": { "NODE_ENV": "production" }, + "architectures": ["x86_64"], + "layers": [], + "packageType": "Zip" +} +``` + +**Delete function:** + +```bash +curl -X DELETE http://localhost:3001/api/lambda/my-function +``` + +```json +{ "success": true } +``` + +## Error Handling + +| Error | HTTP Status | Code | +|---------------------------------|-------------|----------------------| +| Function not found | 404 | `FUNCTION_NOT_FOUND` | +| Function already exists / in use | 409 | `FUNCTION_CONFLICT` | +| Invalid parameter value | 400 | `INVALID_PARAMETER` | +| Rate limit exceeded | 429 | `TOO_MANY_REQUESTS` | +| AWS service error | 502 | `SERVICE_ERROR` | + +## UI Components + +### Function List (`/lambda`) + +- Searchable table with columns: Name, Runtime, Memory, Timeout, Last Modified +- Function name links to detail view +- Create button opens a dialog with fields for name, runtime, handler, role, memory, timeout, and optional zip upload +- Per-row delete button with confirmation dialog + +### Function Detail (`/lambda/:functionName`) + +Four tabs: + +1. **Configuration** — attribute grid (runtime, handler, role, memory, timeout, code size, state, package type, architectures, SHA256) and environment variables table +2. **Invoke** — JSON payload textarea, invocation type selector (RequestResponse, Event, DryRun), result panel with status code, payload, error, and decoded log output +3. **Versions** — table of published versions with version number, ARN, runtime, and last modified date +4. **Aliases** — table of aliases with name, ARN, function version, and description + +## Backend Architecture + +The Lambda plugin follows the standard service plugin pattern: + +``` +packages/backend/src/plugins/lambda/ +├── index.ts # Plugin registration (5 lines) +├── routes.ts # 9 Fastify routes +├── service.ts # LambdaService class wrapping @aws-sdk/client-lambda +└── schemas.ts # TypeBox request/response schemas +``` + +The `LambdaService` class maps AWS SDK exceptions to `AppError` instances with appropriate HTTP status codes via a centralized `mapLambdaError` function. diff --git a/packages/backend/package.json b/packages/backend/package.json index 61a08ba..34abff0 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -15,6 +15,7 @@ "@aws-sdk/client-dynamodb": "^3.1018.0", "@aws-sdk/client-dynamodb-streams": "^3.1018.0", "@aws-sdk/client-iam": "^3.1018.0", + "@aws-sdk/client-lambda": "^3.1018.0", "@aws-sdk/client-s3": "^3.1018.0", "@aws-sdk/client-sns": "^3.1018.0", "@aws-sdk/client-sqs": "^3.1018.0", diff --git a/packages/backend/src/aws/client-cache.ts b/packages/backend/src/aws/client-cache.ts index 48bca9b..154a233 100644 --- a/packages/backend/src/aws/client-cache.ts +++ b/packages/backend/src/aws/client-cache.ts @@ -2,6 +2,7 @@ import { CloudFormationClient } from "@aws-sdk/client-cloudformation"; import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; import { DynamoDBStreamsClient } from "@aws-sdk/client-dynamodb-streams"; import { IAMClient } from "@aws-sdk/client-iam"; +import { LambdaClient } from "@aws-sdk/client-lambda"; import { S3Client } from "@aws-sdk/client-s3"; import { SNSClient } from "@aws-sdk/client-sns"; import { SQSClient } from "@aws-sdk/client-sqs"; @@ -14,6 +15,7 @@ export interface AwsClients { iam: IAMClient; cloudformation: CloudFormationClient; dynamodb: DynamoDBClient; + lambda: LambdaClient; dynamodbDocument: DynamoDBDocumentClient; dynamodbStreams: DynamoDBStreamsClient; } @@ -46,6 +48,7 @@ export class ClientCache { iam: new IAMClient(commonConfig), cloudformation: new CloudFormationClient(commonConfig), dynamodb, + lambda: new LambdaClient(commonConfig), dynamodbDocument: DynamoDBDocumentClient.from(dynamodb, { marshallOptions: { removeUndefinedValues: true }, }), diff --git a/packages/backend/src/bundle.ts b/packages/backend/src/bundle.ts index 449ff3e..80a2d1d 100644 --- a/packages/backend/src/bundle.ts +++ b/packages/backend/src/bundle.ts @@ -9,6 +9,7 @@ import clientCachePlugin from "./plugins/client-cache.js"; import cloudformationPlugin from "./plugins/cloudformation/index.js"; import dynamodbPlugin from "./plugins/dynamodb/index.js"; import iamPlugin from "./plugins/iam/index.js"; +import lambdaPlugin from "./plugins/lambda/index.js"; import localstackConfigPlugin from "./plugins/localstack-config.js"; // Explicit plugin imports (replaces autoload for bundled builds) import s3Plugin from "./plugins/s3/index.js"; @@ -29,6 +30,7 @@ const pluginMap: Record< sqs: sqsPlugin, sns: snsPlugin, iam: iamPlugin, + lambda: lambdaPlugin, cloudformation: cloudformationPlugin, dynamodb: dynamodbPlugin, }; diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index ad7baff..1b98cae 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -8,6 +8,7 @@ const ALL_SERVICES = [ "iam", "cloudformation", "dynamodb", + "lambda", ] as const; export type ServiceName = (typeof ALL_SERVICES)[number]; @@ -16,7 +17,7 @@ const configSchema = Type.Object({ LOCALSTACK_ENDPOINT: Type.String({ default: "http://localhost:4566" }), LOCALSTACK_REGION: Type.String({ default: "us-east-1" }), ENABLED_SERVICES: Type.String({ - default: "s3,sqs,sns,iam,cloudformation,dynamodb", + default: "s3,sqs,sns,iam,cloudformation,dynamodb,lambda", }), }); diff --git a/packages/backend/src/plugins/lambda/index.ts b/packages/backend/src/plugins/lambda/index.ts new file mode 100644 index 0000000..1ef7c83 --- /dev/null +++ b/packages/backend/src/plugins/lambda/index.ts @@ -0,0 +1,6 @@ +import type { FastifyInstance } from "fastify"; +import { lambdaRoutes } from "./routes.js"; + +export default async function lambdaPlugin(app: FastifyInstance) { + await app.register(lambdaRoutes); +} diff --git a/packages/backend/src/plugins/lambda/routes.ts b/packages/backend/src/plugins/lambda/routes.ts new file mode 100644 index 0000000..3b67d51 --- /dev/null +++ b/packages/backend/src/plugins/lambda/routes.ts @@ -0,0 +1,241 @@ +import type { FastifyInstance } from "fastify"; +import { ErrorResponseSchema } from "../../shared/types.js"; +import { + CreateFunctionBodySchema, + DeleteResponseSchema, + FunctionDetailSchema, + FunctionNameParamsSchema, + InvokeFunctionBodySchema, + InvokeFunctionResponseSchema, + ListAliasesResponseSchema, + ListFunctionsResponseSchema, + ListVersionsResponseSchema, + MessageResponseSchema, + UpdateFunctionCodeBodySchema, + UpdateFunctionConfigBodySchema, +} from "./schemas.js"; +import { LambdaService } from "./service.js"; + +export async function lambdaRoutes(app: FastifyInstance) { + // List functions + app.get("/", { + schema: { + response: { + 200: ListFunctionsResponseSchema, + 501: ErrorResponseSchema, + }, + }, + handler: async (request) => { + const clients = request.server.clientCache.getClients( + request.localstackConfig.endpoint, + request.localstackConfig.region, + ); + const service = new LambdaService(clients.lambda); + const { marker } = request.query as { marker?: string }; + return service.listFunctions(marker); + }, + }); + + // Create function + app.post("/", { + schema: { + body: CreateFunctionBodySchema, + response: { + 201: MessageResponseSchema, + 400: ErrorResponseSchema, + 409: ErrorResponseSchema, + }, + }, + handler: async (request, reply) => { + const clients = request.server.clientCache.getClients( + request.localstackConfig.endpoint, + request.localstackConfig.region, + ); + const service = new LambdaService(clients.lambda); + const result = await service.createFunction( + request.body as { + functionName: string; + runtime: string; + handler: string; + role: string; + code: { zipFile?: string; s3Bucket?: string; s3Key?: string }; + description?: string; + timeout?: number; + memorySize?: number; + environment?: Record; + architectures?: string[]; + }, + ); + return reply.status(201).send(result); + }, + }); + + // Get function detail + app.get("/:functionName", { + schema: { + params: FunctionNameParamsSchema, + response: { + 200: FunctionDetailSchema, + 404: ErrorResponseSchema, + }, + }, + handler: async (request) => { + const clients = request.server.clientCache.getClients( + request.localstackConfig.endpoint, + request.localstackConfig.region, + ); + const service = new LambdaService(clients.lambda); + const { functionName } = request.params as { functionName: string }; + return service.getFunction(functionName); + }, + }); + + // Update function code + app.put("/:functionName/code", { + schema: { + params: FunctionNameParamsSchema, + body: UpdateFunctionCodeBodySchema, + response: { + 200: MessageResponseSchema, + 404: ErrorResponseSchema, + 400: ErrorResponseSchema, + }, + }, + handler: async (request) => { + const clients = request.server.clientCache.getClients( + request.localstackConfig.endpoint, + request.localstackConfig.region, + ); + const service = new LambdaService(clients.lambda); + const { functionName } = request.params as { functionName: string }; + return service.updateFunctionCode( + functionName, + request.body as { + zipFile?: string; + s3Bucket?: string; + s3Key?: string; + }, + ); + }, + }); + + // Update function configuration + app.put("/:functionName/config", { + schema: { + params: FunctionNameParamsSchema, + body: UpdateFunctionConfigBodySchema, + response: { + 200: MessageResponseSchema, + 404: ErrorResponseSchema, + 400: ErrorResponseSchema, + }, + }, + handler: async (request) => { + const clients = request.server.clientCache.getClients( + request.localstackConfig.endpoint, + request.localstackConfig.region, + ); + const service = new LambdaService(clients.lambda); + const { functionName } = request.params as { functionName: string }; + return service.updateFunctionConfig( + functionName, + request.body as { + handler?: string; + runtime?: string; + description?: string; + timeout?: number; + memorySize?: number; + environment?: Record; + role?: string; + }, + ); + }, + }); + + // Delete function + app.delete("/:functionName", { + schema: { + params: FunctionNameParamsSchema, + response: { + 200: DeleteResponseSchema, + 404: ErrorResponseSchema, + }, + }, + handler: async (request) => { + const clients = request.server.clientCache.getClients( + request.localstackConfig.endpoint, + request.localstackConfig.region, + ); + const service = new LambdaService(clients.lambda); + const { functionName } = request.params as { functionName: string }; + return service.deleteFunction(functionName); + }, + }); + + // Invoke function + app.post("/:functionName/invoke", { + schema: { + params: FunctionNameParamsSchema, + body: InvokeFunctionBodySchema, + response: { + 200: InvokeFunctionResponseSchema, + 404: ErrorResponseSchema, + }, + }, + handler: async (request) => { + const clients = request.server.clientCache.getClients( + request.localstackConfig.endpoint, + request.localstackConfig.region, + ); + const service = new LambdaService(clients.lambda); + const { functionName } = request.params as { functionName: string }; + const { payload, invocationType } = request.body as { + payload?: string; + invocationType?: string; + }; + return service.invokeFunction(functionName, payload, invocationType); + }, + }); + + // List function versions + app.get("/:functionName/versions", { + schema: { + params: FunctionNameParamsSchema, + response: { + 200: ListVersionsResponseSchema, + 404: ErrorResponseSchema, + }, + }, + handler: async (request) => { + const clients = request.server.clientCache.getClients( + request.localstackConfig.endpoint, + request.localstackConfig.region, + ); + const service = new LambdaService(clients.lambda); + const { functionName } = request.params as { functionName: string }; + const { marker } = request.query as { marker?: string }; + return service.listVersions(functionName, marker); + }, + }); + + // List function aliases + app.get("/:functionName/aliases", { + schema: { + params: FunctionNameParamsSchema, + response: { + 200: ListAliasesResponseSchema, + 404: ErrorResponseSchema, + }, + }, + handler: async (request) => { + const clients = request.server.clientCache.getClients( + request.localstackConfig.endpoint, + request.localstackConfig.region, + ); + const service = new LambdaService(clients.lambda); + const { functionName } = request.params as { functionName: string }; + const { marker } = request.query as { marker?: string }; + return service.listAliases(functionName, marker); + }, + }); +} diff --git a/packages/backend/src/plugins/lambda/schemas.ts b/packages/backend/src/plugins/lambda/schemas.ts new file mode 100644 index 0000000..5255f43 --- /dev/null +++ b/packages/backend/src/plugins/lambda/schemas.ts @@ -0,0 +1,151 @@ +import Type, { type Static } from "typebox"; + +export const FunctionSummarySchema = Type.Object({ + functionName: Type.String(), + functionArn: Type.String(), + runtime: Type.Optional(Type.String()), + handler: Type.Optional(Type.String()), + codeSize: Type.Number(), + lastModified: Type.Optional(Type.String()), + memorySize: Type.Optional(Type.Number()), + timeout: Type.Optional(Type.Number()), + state: Type.Optional(Type.String()), +}); +export type FunctionSummary = Static; + +export const ListFunctionsResponseSchema = Type.Object({ + functions: Type.Array(FunctionSummarySchema), + nextMarker: Type.Optional(Type.String()), +}); + +export const EnvironmentVariableSchema = Type.Record( + Type.String(), + Type.String(), +); + +export const LayerSchema = Type.Object({ + arn: Type.String(), + codeSize: Type.Number(), +}); + +export const FunctionDetailSchema = Type.Object({ + functionName: Type.String(), + functionArn: Type.String(), + runtime: Type.Optional(Type.String()), + handler: Type.Optional(Type.String()), + role: Type.String(), + codeSize: Type.Number(), + description: Type.Optional(Type.String()), + timeout: Type.Optional(Type.Number()), + memorySize: Type.Optional(Type.Number()), + lastModified: Type.Optional(Type.String()), + codeSha256: Type.Optional(Type.String()), + version: Type.Optional(Type.String()), + state: Type.Optional(Type.String()), + stateReason: Type.Optional(Type.String()), + environment: Type.Optional(EnvironmentVariableSchema), + architectures: Type.Optional(Type.Array(Type.String())), + layers: Type.Optional(Type.Array(LayerSchema)), + packageType: Type.Optional(Type.String()), +}); +export type FunctionDetail = Static; + +export const FunctionNameParamsSchema = Type.Object({ + functionName: Type.String(), +}); + +export const CreateFunctionBodySchema = Type.Object({ + functionName: Type.String({ minLength: 1 }), + runtime: Type.String({ minLength: 1 }), + handler: Type.String({ minLength: 1 }), + role: Type.String({ minLength: 1 }), + code: Type.Object({ + zipFile: Type.Optional(Type.String({ description: "Base64-encoded zip" })), + s3Bucket: Type.Optional(Type.String()), + s3Key: Type.Optional(Type.String()), + }), + description: Type.Optional(Type.String()), + timeout: Type.Optional(Type.Integer({ minimum: 1, maximum: 900 })), + memorySize: Type.Optional( + Type.Integer({ minimum: 128, maximum: 10240 }), + ), + environment: Type.Optional(EnvironmentVariableSchema), + architectures: Type.Optional(Type.Array(Type.String())), +}); +export type CreateFunctionBody = Static; + +export const UpdateFunctionCodeBodySchema = Type.Object({ + zipFile: Type.Optional(Type.String({ description: "Base64-encoded zip" })), + s3Bucket: Type.Optional(Type.String()), + s3Key: Type.Optional(Type.String()), +}); +export type UpdateFunctionCodeBody = Static< + typeof UpdateFunctionCodeBodySchema +>; + +export const UpdateFunctionConfigBodySchema = Type.Object({ + handler: Type.Optional(Type.String()), + runtime: Type.Optional(Type.String()), + description: Type.Optional(Type.String()), + timeout: Type.Optional(Type.Integer({ minimum: 1, maximum: 900 })), + memorySize: Type.Optional( + Type.Integer({ minimum: 128, maximum: 10240 }), + ), + environment: Type.Optional(EnvironmentVariableSchema), + role: Type.Optional(Type.String()), +}); +export type UpdateFunctionConfigBody = Static< + typeof UpdateFunctionConfigBodySchema +>; + +export const InvokeFunctionBodySchema = Type.Object({ + payload: Type.Optional(Type.String()), + invocationType: Type.Optional( + Type.Union([ + Type.Literal("RequestResponse"), + Type.Literal("Event"), + Type.Literal("DryRun"), + ]), + ), +}); +export type InvokeFunctionBody = Static; + +export const InvokeFunctionResponseSchema = Type.Object({ + statusCode: Type.Number(), + payload: Type.Optional(Type.String()), + functionError: Type.Optional(Type.String()), + logResult: Type.Optional(Type.String()), +}); + +export const VersionSchema = Type.Object({ + version: Type.String(), + functionArn: Type.String(), + description: Type.Optional(Type.String()), + lastModified: Type.Optional(Type.String()), + runtime: Type.Optional(Type.String()), +}); + +export const ListVersionsResponseSchema = Type.Object({ + versions: Type.Array(VersionSchema), + nextMarker: Type.Optional(Type.String()), +}); + +export const AliasSchema = Type.Object({ + name: Type.String(), + aliasArn: Type.String(), + functionVersion: Type.String(), + description: Type.Optional(Type.String()), +}); + +export const ListAliasesResponseSchema = Type.Object({ + aliases: Type.Array(AliasSchema), + nextMarker: Type.Optional(Type.String()), +}); + +export const MessageResponseSchema = Type.Object({ + message: Type.String(), +}); + +export const DeleteResponseSchema = Type.Object({ + success: Type.Boolean(), +}); diff --git a/packages/backend/src/plugins/lambda/service.ts b/packages/backend/src/plugins/lambda/service.ts new file mode 100644 index 0000000..b7777c2 --- /dev/null +++ b/packages/backend/src/plugins/lambda/service.ts @@ -0,0 +1,295 @@ +import { + CreateFunctionCommand, + DeleteFunctionCommand, + GetFunctionCommand, + type Architecture, + type InvocationType, + InvokeCommand, + type LambdaClient, + ListAliasesCommand, + ListFunctionsCommand, + ListVersionsByFunctionCommand, + type Runtime, + UpdateFunctionCodeCommand, + UpdateFunctionConfigurationCommand, +} from "@aws-sdk/client-lambda"; +import { AppError } from "../../shared/errors.js"; + +function mapLambdaError(err: unknown, functionName: string): never { + const error = err as Error & { name: string }; + switch (error.name) { + case "ResourceNotFoundException": + throw new AppError( + `Function '${functionName}' not found`, + 404, + "FUNCTION_NOT_FOUND", + ); + case "ResourceConflictException": + throw new AppError( + `Function '${functionName}' already exists or is in use`, + 409, + "FUNCTION_CONFLICT", + ); + case "InvalidParameterValueException": + throw new AppError(error.message, 400, "INVALID_PARAMETER"); + case "ServiceException": + throw new AppError(error.message, 502, "SERVICE_ERROR"); + case "TooManyRequestsException": + throw new AppError(error.message, 429, "TOO_MANY_REQUESTS"); + default: + throw error; + } +} + +export class LambdaService { + constructor(private client: LambdaClient) {} + + async listFunctions(marker?: string) { + const response = await this.client.send( + new ListFunctionsCommand({ + ...(marker && { Marker: marker }), + }), + ); + const functions = (response.Functions ?? []).map((fn) => ({ + functionName: fn.FunctionName ?? "", + functionArn: fn.FunctionArn ?? "", + runtime: fn.Runtime, + handler: fn.Handler, + codeSize: fn.CodeSize ?? 0, + lastModified: fn.LastModified, + memorySize: fn.MemorySize, + timeout: fn.Timeout, + state: fn.State, + })); + return { functions, nextMarker: response.NextMarker }; + } + + async getFunction(functionName: string) { + try { + const response = await this.client.send( + new GetFunctionCommand({ FunctionName: functionName }), + ); + const config = response.Configuration; + if (!config) { + throw new AppError( + `Function '${functionName}' not found`, + 404, + "FUNCTION_NOT_FOUND", + ); + } + return { + functionName: config.FunctionName ?? "", + functionArn: config.FunctionArn ?? "", + runtime: config.Runtime, + handler: config.Handler, + role: config.Role ?? "", + codeSize: config.CodeSize ?? 0, + description: config.Description, + timeout: config.Timeout, + memorySize: config.MemorySize, + lastModified: config.LastModified, + codeSha256: config.CodeSha256, + version: config.Version, + state: config.State, + stateReason: config.StateReason, + environment: config.Environment?.Variables, + architectures: config.Architectures, + layers: (config.Layers ?? []).map((l) => ({ + arn: l.Arn ?? "", + codeSize: l.CodeSize ?? 0, + })), + packageType: config.PackageType, + }; + } catch (err) { + if (err instanceof AppError) throw err; + mapLambdaError(err, functionName); + } + } + + async createFunction(params: { + functionName: string; + runtime: string; + handler: string; + role: string; + code: { zipFile?: string; s3Bucket?: string; s3Key?: string }; + description?: string; + timeout?: number; + memorySize?: number; + environment?: Record; + architectures?: string[]; + }) { + try { + await this.client.send( + new CreateFunctionCommand({ + FunctionName: params.functionName, + Runtime: params.runtime as Runtime, + Handler: params.handler, + Role: params.role, + Code: { + ...(params.code.zipFile && { + ZipFile: Buffer.from(params.code.zipFile, "base64"), + }), + ...(params.code.s3Bucket && { S3Bucket: params.code.s3Bucket }), + ...(params.code.s3Key && { S3Key: params.code.s3Key }), + }, + Description: params.description, + Timeout: params.timeout, + MemorySize: params.memorySize, + ...(params.environment && { + Environment: { Variables: params.environment }, + }), + ...(params.architectures && { + Architectures: params.architectures as Architecture[], + }), + }), + ); + return { + message: `Function '${params.functionName}' created successfully`, + }; + } catch (err) { + mapLambdaError(err, params.functionName); + } + } + + async updateFunctionCode( + functionName: string, + params: { zipFile?: string; s3Bucket?: string; s3Key?: string }, + ) { + try { + await this.client.send( + new UpdateFunctionCodeCommand({ + FunctionName: functionName, + ...(params.zipFile && { + ZipFile: Buffer.from(params.zipFile, "base64"), + }), + ...(params.s3Bucket && { S3Bucket: params.s3Bucket }), + ...(params.s3Key && { S3Key: params.s3Key }), + }), + ); + return { + message: `Function '${functionName}' code updated successfully`, + }; + } catch (err) { + mapLambdaError(err, functionName); + } + } + + async updateFunctionConfig( + functionName: string, + params: { + handler?: string; + runtime?: string; + description?: string; + timeout?: number; + memorySize?: number; + environment?: Record; + role?: string; + }, + ) { + try { + await this.client.send( + new UpdateFunctionConfigurationCommand({ + FunctionName: functionName, + ...(params.handler && { Handler: params.handler }), + ...(params.runtime && { Runtime: params.runtime as Runtime }), + ...(params.description !== undefined && { + Description: params.description, + }), + ...(params.timeout && { Timeout: params.timeout }), + ...(params.memorySize && { MemorySize: params.memorySize }), + ...(params.environment && { + Environment: { Variables: params.environment }, + }), + ...(params.role && { Role: params.role }), + }), + ); + return { + message: `Function '${functionName}' configuration updated successfully`, + }; + } catch (err) { + mapLambdaError(err, functionName); + } + } + + async deleteFunction(functionName: string) { + try { + await this.client.send( + new DeleteFunctionCommand({ FunctionName: functionName }), + ); + return { success: true }; + } catch (err) { + mapLambdaError(err, functionName); + } + } + + async invokeFunction( + functionName: string, + payload?: string, + invocationType: string = "RequestResponse", + ) { + try { + const response = await this.client.send( + new InvokeCommand({ + FunctionName: functionName, + InvocationType: invocationType as InvocationType, + LogType: "Tail", + ...(payload && { Payload: new TextEncoder().encode(payload) }), + }), + ); + const responsePayload = response.Payload + ? new TextDecoder().decode(response.Payload) + : undefined; + return { + statusCode: response.StatusCode ?? 200, + payload: responsePayload, + functionError: response.FunctionError, + logResult: response.LogResult + ? Buffer.from(response.LogResult, "base64").toString("utf-8") + : undefined, + }; + } catch (err) { + mapLambdaError(err, functionName); + } + } + + async listVersions(functionName: string, marker?: string) { + try { + const response = await this.client.send( + new ListVersionsByFunctionCommand({ + FunctionName: functionName, + ...(marker && { Marker: marker }), + }), + ); + const versions = (response.Versions ?? []).map((v) => ({ + version: v.Version ?? "", + functionArn: v.FunctionArn ?? "", + description: v.Description, + lastModified: v.LastModified, + runtime: v.Runtime, + })); + return { versions, nextMarker: response.NextMarker }; + } catch (err) { + mapLambdaError(err, functionName); + } + } + + async listAliases(functionName: string, marker?: string) { + try { + const response = await this.client.send( + new ListAliasesCommand({ + FunctionName: functionName, + ...(marker && { Marker: marker }), + }), + ); + const aliases = (response.Aliases ?? []).map((a) => ({ + name: a.Name ?? "", + aliasArn: a.AliasArn ?? "", + functionVersion: a.FunctionVersion ?? "", + description: a.Description, + })); + return { aliases, nextMarker: response.NextMarker }; + } catch (err) { + mapLambdaError(err, functionName); + } + } +} diff --git a/packages/backend/src/server.ts b/packages/backend/src/server.ts index e9ed228..019f577 100644 --- a/packages/backend/src/server.ts +++ b/packages/backend/src/server.ts @@ -1,6 +1,6 @@ /* v8 ignore start */ -import { config } from "./config"; -import { buildApp } from "./index"; +import { config } from "./config.js"; +import { buildApp } from "./index.js"; async function main() { const app = await buildApp({ logger: true }); diff --git a/packages/backend/test/config.test.ts b/packages/backend/test/config.test.ts index 35cc36f..d53c127 100644 --- a/packages/backend/test/config.test.ts +++ b/packages/backend/test/config.test.ts @@ -1,8 +1,16 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -const ALL_SERVICES = ["s3", "sqs", "sns", "iam", "cloudformation", "dynamodb"]; - -let mockEnabledServices = "s3,sqs,sns,iam,cloudformation,dynamodb"; +const ALL_SERVICES = [ + "s3", + "sqs", + "sns", + "iam", + "cloudformation", + "dynamodb", + "lambda", +]; + +let mockEnabledServices = "s3,sqs,sns,iam,cloudformation,dynamodb,lambda"; vi.mock("env-schema", () => ({ envSchema: () => ({ @@ -19,7 +27,7 @@ beforeEach(() => { describe("config shape", () => { it("exposes port, endpoint, region with correct types", async () => { - mockEnabledServices = "s3,sqs,sns,iam,cloudformation,dynamodb"; + mockEnabledServices = "s3,sqs,sns,iam,cloudformation,dynamodb,lambda"; const { config } = await import("../src/config.js"); expect(config.port).toBe(3001); @@ -28,14 +36,21 @@ describe("config shape", () => { }); it("exposes all enabled services", async () => { - mockEnabledServices = "s3,sqs,sns,iam,cloudformation,dynamodb"; + mockEnabledServices = "s3,sqs,sns,iam,cloudformation,dynamodb,lambda"; const { config } = await import("../src/config.js"); - expect(config.enabledServices).toHaveLength(6); + expect(config.enabledServices).toHaveLength(7); for (const svc of config.enabledServices) { expect(ALL_SERVICES).toContain(svc); } }); + + it("includes lambda in enabled services", async () => { + mockEnabledServices = "s3,sqs,sns,iam,cloudformation,dynamodb,lambda"; + const { config } = await import("../src/config.js"); + + expect(config.enabledServices).toContain("lambda"); + }); }); describe("parseEnabledServices", () => { diff --git a/packages/backend/test/health.test.ts b/packages/backend/test/health.test.ts index c09a965..94e6d1c 100644 --- a/packages/backend/test/health.test.ts +++ b/packages/backend/test/health.test.ts @@ -23,7 +23,7 @@ describe("checkLocalstackHealth", () => { it("returns connected: true with active services when fetch resolves with an ok response", async () => { (fetch as ReturnType).mockResolvedValueOnce( - mockFetchOk({ s3: "running", sqs: "running", lambda: "running" }), + mockFetchOk({ s3: "running", sqs: "running", kinesis: "running" }), ); const result = await checkLocalstackHealth(ENDPOINT, REGION); @@ -34,8 +34,8 @@ describe("checkLocalstackHealth", () => { region: REGION, services: expect.arrayContaining(["s3", "sqs"]), }); - // lambda is not in enabled services, so it should be excluded - expect(result.services).not.toContain("lambda"); + // kinesis is not in enabled services, so it should be excluded + expect(result.services).not.toContain("kinesis"); expect(fetch).toHaveBeenCalledWith( `${ENDPOINT}/_localstack/health`, expect.objectContaining({ signal: expect.any(AbortSignal) }), diff --git a/packages/backend/test/integration/lambda.integration.test.ts b/packages/backend/test/integration/lambda.integration.test.ts new file mode 100644 index 0000000..7633b66 --- /dev/null +++ b/packages/backend/test/integration/lambda.integration.test.ts @@ -0,0 +1,227 @@ +import { deflateRawSync } from "node:zlib"; +import type { FastifyInstance } from "fastify"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { lambdaRoutes } from "../../src/plugins/lambda/routes.js"; +import { buildApp, getLocalstackHeaders } from "./app-helper.js"; + +// --------------------------------------------------------------------------- +// Minimal base64-encoded zip containing a valid Node.js Lambda handler. +// We build the zip programmatically using raw deflate so we have no external +// dependency. The zip contains a single file "index.js" with: +// exports.handler = async () => ({ statusCode: 200, body: "hello" }); +// --------------------------------------------------------------------------- +function buildMinimalZip(): string { + const fileName = "index.js"; + const fileContent = Buffer.from( + 'exports.handler = async () => ({ statusCode: 200, body: "hello" });\n', + ); + + // Compress with raw deflate (no zlib header/trailer) + const compressed = deflateRawSync(fileContent); + + // CRC-32 of the uncompressed content + function crc32(buf: Buffer): number { + const table = new Uint32Array(256); + for (let i = 0; i < 256; i++) { + let c = i; + for (let j = 0; j < 8; j++) { + c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + } + table[i] = c; + } + let crc = 0xffffffff; + for (const byte of buf) { + crc = table[(crc ^ byte) & 0xff] ^ (crc >>> 8); + } + return (crc ^ 0xffffffff) >>> 0; + } + + const nameBytes = Buffer.from(fileName, "utf8"); + const crc = crc32(fileContent); + const uncompressedSize = fileContent.length; + const compressedSize = compressed.length; + + // Local file header + const localHeader = Buffer.alloc(30 + nameBytes.length); + localHeader.writeUInt32LE(0x04034b50, 0); // signature + localHeader.writeUInt16LE(20, 4); // version needed + localHeader.writeUInt16LE(0, 6); // flags + localHeader.writeUInt16LE(8, 8); // compression: deflate + localHeader.writeUInt16LE(0, 10); // last mod time + localHeader.writeUInt16LE(0, 12); // last mod date + localHeader.writeUInt32LE(crc, 14); // crc-32 + localHeader.writeUInt32LE(compressedSize, 18); // compressed size + localHeader.writeUInt32LE(uncompressedSize, 22); // uncompressed size + localHeader.writeUInt16LE(nameBytes.length, 26); // file name length + localHeader.writeUInt16LE(0, 28); // extra field length + nameBytes.copy(localHeader, 30); + + const localOffset = 0; + const localEntry = Buffer.concat([localHeader, compressed]); + + // Central directory header + const centralHeader = Buffer.alloc(46 + nameBytes.length); + centralHeader.writeUInt32LE(0x02014b50, 0); // signature + centralHeader.writeUInt16LE(20, 4); // version made by + centralHeader.writeUInt16LE(20, 6); // version needed + centralHeader.writeUInt16LE(0, 8); // flags + centralHeader.writeUInt16LE(8, 10); // compression: deflate + centralHeader.writeUInt16LE(0, 12); // last mod time + centralHeader.writeUInt16LE(0, 14); // last mod date + centralHeader.writeUInt32LE(crc, 16); // crc-32 + centralHeader.writeUInt32LE(compressedSize, 20); // compressed size + centralHeader.writeUInt32LE(uncompressedSize, 24); // uncompressed size + centralHeader.writeUInt16LE(nameBytes.length, 28); // file name length + centralHeader.writeUInt16LE(0, 30); // extra field length + centralHeader.writeUInt16LE(0, 32); // file comment length + centralHeader.writeUInt16LE(0, 34); // disk number start + centralHeader.writeUInt16LE(0, 36); // internal attributes + centralHeader.writeUInt32LE(0, 38); // external attributes + centralHeader.writeUInt32LE(localOffset, 42); // relative offset of local header + nameBytes.copy(centralHeader, 46); + + const centralOffset = localEntry.length; + const centralSize = centralHeader.length; + + // End of central directory + const eocd = Buffer.alloc(22); + eocd.writeUInt32LE(0x06054b50, 0); // signature + eocd.writeUInt16LE(0, 4); // disk number + eocd.writeUInt16LE(0, 6); // disk with start of central directory + eocd.writeUInt16LE(1, 8); // number of entries on this disk + eocd.writeUInt16LE(1, 10); // total number of entries + eocd.writeUInt32LE(centralSize, 12); // size of central directory + eocd.writeUInt32LE(centralOffset, 16); // offset of central directory + eocd.writeUInt16LE(0, 20); // comment length + + return Buffer.concat([localEntry, centralHeader, eocd]).toString("base64"); +} + +describe("Lambda Integration", () => { + let app: FastifyInstance; + const headers = getLocalstackHeaders(); + const functionName = `test-fn-${Date.now()}`; + let zipBase64: string; + + beforeAll(async () => { + zipBase64 = buildMinimalZip(); + + app = await buildApp(async (a) => { + await a.register(lambdaRoutes); + }); + }); + + afterAll(async () => { + await app.close(); + }); + + it("should list functions (empty initially)", async () => { + const res = await app.inject({ method: "GET", url: "/", headers }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body).toHaveProperty("functions"); + expect(Array.isArray(body.functions)).toBe(true); + }); + + it("should create a function", async () => { + const res = await app.inject({ + method: "POST", + url: "/", + headers, + payload: { + functionName, + runtime: "nodejs20.x", + handler: "index.handler", + role: "arn:aws:iam::000000000000:role/lambda-role", + code: { zipFile: zipBase64 }, + description: "Integration test function", + timeout: 10, + memorySize: 128, + }, + }); + expect(res.statusCode).toBe(201); + expect(res.json().message).toContain("created"); + }); + + it("should get function detail", async () => { + const res = await app.inject({ + method: "GET", + url: `/${functionName}`, + headers, + }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.functionName).toBe(functionName); + expect(body.handler).toBe("index.handler"); + expect(body.runtime).toBe("nodejs20.x"); + expect(body).toHaveProperty("functionArn"); + }); + + it("should update function configuration (change description)", async () => { + const res = await app.inject({ + method: "PUT", + url: `/${functionName}/config`, + headers, + payload: { description: "Updated description" }, + }); + expect(res.statusCode).toBe(200); + expect(res.json().message).toContain("updated"); + }); + + it("should invoke function", async () => { + const res = await app.inject({ + method: "POST", + url: `/${functionName}/invoke`, + headers, + payload: { invocationType: "RequestResponse" }, + }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body).toHaveProperty("statusCode"); + expect(body.statusCode).toBe(200); + }); + + it("should list function versions", async () => { + const res = await app.inject({ + method: "GET", + url: `/${functionName}/versions`, + headers, + }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body).toHaveProperty("versions"); + expect(Array.isArray(body.versions)).toBe(true); + expect(body.versions.length).toBeGreaterThanOrEqual(1); + }); + + it("should list function aliases (empty)", async () => { + const res = await app.inject({ + method: "GET", + url: `/${functionName}/aliases`, + headers, + }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body).toHaveProperty("aliases"); + expect(Array.isArray(body.aliases)).toBe(true); + }); + + it("should delete the function", async () => { + const res = await app.inject({ + method: "DELETE", + url: `/${functionName}`, + headers, + }); + expect(res.statusCode).toBe(200); + expect(res.json()).toMatchObject({ success: true }); + }); + + it("should return 404 after deletion", async () => { + const res = await app.inject({ + method: "GET", + url: `/${functionName}`, + headers, + }); + expect(res.statusCode).toBe(404); + }); +}); diff --git a/packages/backend/test/plugins/lambda/index.test.ts b/packages/backend/test/plugins/lambda/index.test.ts new file mode 100644 index 0000000..6de063a --- /dev/null +++ b/packages/backend/test/plugins/lambda/index.test.ts @@ -0,0 +1,57 @@ +import Fastify from "fastify"; +import { describe, expect, it } from "vitest"; +import clientCachePlugin from "../../../src/plugins/client-cache.js"; +import lambdaPlugin from "../../../src/plugins/lambda/index.js"; +import localstackConfigPlugin from "../../../src/plugins/localstack-config.js"; + +describe("lambdaPlugin index", () => { + it("should register lambdaPlugin without error", async () => { + const app = Fastify(); + await app.register(localstackConfigPlugin); + await app.register(clientCachePlugin); + await app.register(lambdaPlugin, { prefix: "/api/lambda" }); + await expect(app.ready()).resolves.not.toThrow(); + await app.close(); + }); + + it("should expose expected routes under /api/lambda", async () => { + const app = Fastify(); + await app.register(localstackConfigPlugin); + await app.register(clientCachePlugin); + await app.register(lambdaPlugin, { prefix: "/api/lambda" }); + await app.ready(); + + const routes = app + .printRoutes({ includeHooks: false }) + .split("\n") + .filter(Boolean); + + // Routes string should contain the lambda prefix paths + const routeTree = app.printRoutes({ includeHooks: false }); + expect(routeTree).toContain("api/lambda"); + + await app.close(); + }); + + it("should have GET / route for listing functions", async () => { + const app = Fastify(); + await app.register(localstackConfigPlugin); + await app.register(clientCachePlugin); + await app.register(lambdaPlugin, { prefix: "/api/lambda" }); + await app.ready(); + + const res = await app.inject({ + method: "GET", + url: "/api/lambda/", + headers: { + "x-localstack-endpoint": "http://localhost:4566", + "x-localstack-region": "us-east-1", + }, + }); + + // The route exists (not 404); we may get a connection error but not "not found" + expect(res.statusCode).not.toBe(404); + + await app.close(); + }); +}); diff --git a/packages/backend/test/plugins/lambda/routes.test.ts b/packages/backend/test/plugins/lambda/routes.test.ts new file mode 100644 index 0000000..e811700 --- /dev/null +++ b/packages/backend/test/plugins/lambda/routes.test.ts @@ -0,0 +1,498 @@ +import Fastify, { type FastifyInstance } from "fastify"; +import { + afterAll, + beforeAll, + describe, + expect, + it, + type Mock, + vi, +} from "vitest"; +import type { ClientCache } from "../../../src/aws/client-cache.js"; +import { lambdaRoutes } from "../../../src/plugins/lambda/routes.js"; +import { registerErrorHandler } from "../../../src/shared/errors.js"; + +interface MockLambdaService { + listFunctions: Mock; + createFunction: Mock; + getFunction: Mock; + updateFunctionCode: Mock; + updateFunctionConfig: Mock; + deleteFunction: Mock; + invokeFunction: Mock; + listVersions: Mock; + listAliases: Mock; +} + +function createMockLambdaService(): MockLambdaService { + return { + listFunctions: vi.fn().mockResolvedValue({ functions: [], nextMarker: undefined }), + createFunction: vi.fn().mockResolvedValue({ + message: "Function 'my-function' created successfully", + }), + getFunction: vi.fn().mockResolvedValue({ + functionName: "my-function", + functionArn: "arn:aws:lambda:us-east-1:000000000000:function:my-function", + runtime: "nodejs18.x", + handler: "index.handler", + role: "arn:aws:iam::000000000000:role/lambda-role", + codeSize: 1024, + description: "A test function", + timeout: 30, + memorySize: 128, + lastModified: "2024-01-01T00:00:00.000Z", + codeSha256: "abc123", + version: "$LATEST", + state: "Active", + stateReason: undefined, + environment: { MY_VAR: "value" }, + architectures: ["x86_64"], + layers: [], + packageType: "Zip", + }), + updateFunctionCode: vi.fn().mockResolvedValue({ + message: "Function 'my-function' code updated successfully", + }), + updateFunctionConfig: vi.fn().mockResolvedValue({ + message: "Function 'my-function' configuration updated successfully", + }), + deleteFunction: vi.fn().mockResolvedValue({ success: true }), + invokeFunction: vi.fn().mockResolvedValue({ + statusCode: 200, + payload: '{"result":"ok"}', + functionError: undefined, + logResult: undefined, + }), + listVersions: vi.fn().mockResolvedValue({ versions: [], nextMarker: undefined }), + listAliases: vi.fn().mockResolvedValue({ aliases: [], nextMarker: undefined }), + }; +} + +vi.mock("../../../src/plugins/lambda/service.js", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("../../../src/plugins/lambda/service.js") + >(); + return { + ...actual, + LambdaService: vi.fn(), + }; +}); + +import { LambdaService as LambdaServiceClass } from "../../../src/plugins/lambda/service.js"; + +describe("Lambda Routes", () => { + let app: FastifyInstance; + let mockService: MockLambdaService; + + beforeAll(async () => { + app = Fastify(); + registerErrorHandler(app); + + mockService = createMockLambdaService(); + + (LambdaServiceClass as unknown as Mock).mockImplementation(() => mockService); + + const mockClientCache = { + getClients: vi.fn().mockReturnValue({ lambda: {} }), + }; + app.decorate("clientCache", mockClientCache as unknown as ClientCache); + + app.decorateRequest("localstackConfig", null); + app.addHook("onRequest", async (request) => { + request.localstackConfig = { + endpoint: "http://localhost:4566", + region: "us-east-1", + }; + }); + + await app.register(lambdaRoutes); + await app.ready(); + }); + + afterAll(async () => { + await app.close(); + }); + + // ── List Functions ─────────────────────────────────────────────────────── + + describe("GET /", () => { + it("should return list of functions", async () => { + const response = await app.inject({ method: "GET", url: "/" }); + expect(response.statusCode).toBe(200); + const body = response.json<{ functions: unknown[] }>(); + expect(body.functions).toEqual([]); + expect(mockService.listFunctions).toHaveBeenCalled(); + }); + + it("should pass marker query param when provided", async () => { + mockService.listFunctions.mockClear(); + const response = await app.inject({ + method: "GET", + url: "/?marker=next-page-token", + }); + expect(response.statusCode).toBe(200); + expect(mockService.listFunctions).toHaveBeenCalledWith("next-page-token"); + }); + + it("should call listFunctions without marker when not provided", async () => { + mockService.listFunctions.mockClear(); + await app.inject({ method: "GET", url: "/" }); + expect(mockService.listFunctions).toHaveBeenCalledWith(undefined); + }); + }); + + // ── Create Function ────────────────────────────────────────────────────── + + describe("POST /", () => { + it("should create a function and return 201", async () => { + const response = await app.inject({ + method: "POST", + url: "/", + payload: { + functionName: "my-function", + runtime: "nodejs18.x", + handler: "index.handler", + role: "arn:aws:iam::000000000000:role/lambda-role", + code: { zipFile: "UEsDBBQ=" }, + }, + }); + expect(response.statusCode).toBe(201); + const body = response.json<{ message: string }>(); + expect(body.message).toBe("Function 'my-function' created successfully"); + expect(mockService.createFunction).toHaveBeenCalledWith( + expect.objectContaining({ + functionName: "my-function", + runtime: "nodejs18.x", + handler: "index.handler", + role: "arn:aws:iam::000000000000:role/lambda-role", + code: { zipFile: "UEsDBBQ=" }, + }), + ); + }); + + it("should create a function with optional fields", async () => { + mockService.createFunction.mockClear(); + const response = await app.inject({ + method: "POST", + url: "/", + payload: { + functionName: "my-function", + runtime: "python3.11", + handler: "lambda_function.lambda_handler", + role: "arn:aws:iam::000000000000:role/lambda-role", + code: { s3Bucket: "my-bucket", s3Key: "my-function.zip" }, + description: "A test function", + timeout: 60, + memorySize: 256, + environment: { MY_VAR: "value" }, + architectures: ["arm64"], + }, + }); + expect(response.statusCode).toBe(201); + expect(mockService.createFunction).toHaveBeenCalledWith( + expect.objectContaining({ + description: "A test function", + timeout: 60, + memorySize: 256, + environment: { MY_VAR: "value" }, + architectures: ["arm64"], + }), + ); + }); + + it("should return 400 when required fields are missing", async () => { + const response = await app.inject({ + method: "POST", + url: "/", + payload: { functionName: "my-function" }, + }); + expect(response.statusCode).toBe(400); + }); + }); + + // ── Get Function ───────────────────────────────────────────────────────── + + describe("GET /:functionName", () => { + it("should return function detail", async () => { + const response = await app.inject({ + method: "GET", + url: "/my-function", + }); + expect(response.statusCode).toBe(200); + const body = response.json<{ functionName: string }>(); + expect(body.functionName).toBe("my-function"); + expect(mockService.getFunction).toHaveBeenCalledWith("my-function"); + }); + + it("should return 404 when function does not exist", async () => { + const { AppError } = await import("../../../src/shared/errors.js"); + mockService.getFunction.mockRejectedValueOnce( + new AppError("Function 'missing-fn' not found", 404, "FUNCTION_NOT_FOUND"), + ); + const response = await app.inject({ + method: "GET", + url: "/missing-fn", + }); + expect(response.statusCode).toBe(404); + }); + }); + + // ── Update Function Code ───────────────────────────────────────────────── + + describe("PUT /:functionName/code", () => { + it("should update function code with zipFile", async () => { + const response = await app.inject({ + method: "PUT", + url: "/my-function/code", + payload: { zipFile: "UEsDBBQ=" }, + }); + expect(response.statusCode).toBe(200); + const body = response.json<{ message: string }>(); + expect(body.message).toBe( + "Function 'my-function' code updated successfully", + ); + expect(mockService.updateFunctionCode).toHaveBeenCalledWith( + "my-function", + { zipFile: "UEsDBBQ=" }, + ); + }); + + it("should update function code with S3 reference", async () => { + mockService.updateFunctionCode.mockClear(); + const response = await app.inject({ + method: "PUT", + url: "/my-function/code", + payload: { s3Bucket: "my-bucket", s3Key: "my-function.zip" }, + }); + expect(response.statusCode).toBe(200); + expect(mockService.updateFunctionCode).toHaveBeenCalledWith( + "my-function", + { s3Bucket: "my-bucket", s3Key: "my-function.zip" }, + ); + }); + + it("should update function code with empty body (all fields optional)", async () => { + mockService.updateFunctionCode.mockClear(); + const response = await app.inject({ + method: "PUT", + url: "/my-function/code", + payload: {}, + }); + expect(response.statusCode).toBe(200); + }); + }); + + // ── Update Function Config ─────────────────────────────────────────────── + + describe("PUT /:functionName/config", () => { + it("should update function configuration", async () => { + const response = await app.inject({ + method: "PUT", + url: "/my-function/config", + payload: { + handler: "index.newHandler", + runtime: "nodejs20.x", + timeout: 60, + memorySize: 256, + environment: { NEW_VAR: "new-value" }, + role: "arn:aws:iam::000000000000:role/new-role", + }, + }); + expect(response.statusCode).toBe(200); + const body = response.json<{ message: string }>(); + expect(body.message).toBe( + "Function 'my-function' configuration updated successfully", + ); + expect(mockService.updateFunctionConfig).toHaveBeenCalledWith( + "my-function", + expect.objectContaining({ + handler: "index.newHandler", + runtime: "nodejs20.x", + timeout: 60, + memorySize: 256, + environment: { NEW_VAR: "new-value" }, + role: "arn:aws:iam::000000000000:role/new-role", + }), + ); + }); + + it("should update function configuration with partial fields", async () => { + mockService.updateFunctionConfig.mockClear(); + const response = await app.inject({ + method: "PUT", + url: "/my-function/config", + payload: { description: "Updated description" }, + }); + expect(response.statusCode).toBe(200); + expect(mockService.updateFunctionConfig).toHaveBeenCalledWith( + "my-function", + { description: "Updated description" }, + ); + }); + + it("should update function configuration with empty body (all fields optional)", async () => { + mockService.updateFunctionConfig.mockClear(); + const response = await app.inject({ + method: "PUT", + url: "/my-function/config", + payload: {}, + }); + expect(response.statusCode).toBe(200); + }); + }); + + // ── Delete Function ────────────────────────────────────────────────────── + + describe("DELETE /:functionName", () => { + it("should delete a function", async () => { + const response = await app.inject({ + method: "DELETE", + url: "/my-function", + }); + expect(response.statusCode).toBe(200); + const body = response.json<{ success: boolean }>(); + expect(body.success).toBe(true); + expect(mockService.deleteFunction).toHaveBeenCalledWith("my-function"); + }); + }); + + // ── Invoke Function ────────────────────────────────────────────────────── + + describe("POST /:functionName/invoke", () => { + it("should invoke a function with no payload", async () => { + const response = await app.inject({ + method: "POST", + url: "/my-function/invoke", + payload: {}, + }); + expect(response.statusCode).toBe(200); + const body = response.json<{ statusCode: number; payload?: string }>(); + expect(body.statusCode).toBe(200); + expect(mockService.invokeFunction).toHaveBeenCalledWith( + "my-function", + undefined, + undefined, + ); + }); + + it("should invoke a function with a payload", async () => { + mockService.invokeFunction.mockClear(); + const response = await app.inject({ + method: "POST", + url: "/my-function/invoke", + payload: { payload: '{"key":"value"}' }, + }); + expect(response.statusCode).toBe(200); + expect(mockService.invokeFunction).toHaveBeenCalledWith( + "my-function", + '{"key":"value"}', + undefined, + ); + }); + + it("should invoke a function with an invocationType", async () => { + mockService.invokeFunction.mockClear(); + const response = await app.inject({ + method: "POST", + url: "/my-function/invoke", + payload: { invocationType: "Event" }, + }); + expect(response.statusCode).toBe(200); + expect(mockService.invokeFunction).toHaveBeenCalledWith( + "my-function", + undefined, + "Event", + ); + }); + + it("should invoke a function with payload and invocationType", async () => { + mockService.invokeFunction.mockClear(); + const response = await app.inject({ + method: "POST", + url: "/my-function/invoke", + payload: { + payload: '{"key":"value"}', + invocationType: "RequestResponse", + }, + }); + expect(response.statusCode).toBe(200); + expect(mockService.invokeFunction).toHaveBeenCalledWith( + "my-function", + '{"key":"value"}', + "RequestResponse", + ); + }); + + it("should return 400 for an invalid invocationType", async () => { + const response = await app.inject({ + method: "POST", + url: "/my-function/invoke", + payload: { invocationType: "InvalidType" }, + }); + expect(response.statusCode).toBe(400); + }); + }); + + // ── List Versions ──────────────────────────────────────────────────────── + + describe("GET /:functionName/versions", () => { + it("should return list of function versions", async () => { + const response = await app.inject({ + method: "GET", + url: "/my-function/versions", + }); + expect(response.statusCode).toBe(200); + const body = response.json<{ versions: unknown[] }>(); + expect(body.versions).toEqual([]); + expect(mockService.listVersions).toHaveBeenCalledWith( + "my-function", + undefined, + ); + }); + + it("should pass marker query param when provided", async () => { + mockService.listVersions.mockClear(); + const response = await app.inject({ + method: "GET", + url: "/my-function/versions?marker=next-page-token", + }); + expect(response.statusCode).toBe(200); + expect(mockService.listVersions).toHaveBeenCalledWith( + "my-function", + "next-page-token", + ); + }); + }); + + // ── List Aliases ───────────────────────────────────────────────────────── + + describe("GET /:functionName/aliases", () => { + it("should return list of function aliases", async () => { + const response = await app.inject({ + method: "GET", + url: "/my-function/aliases", + }); + expect(response.statusCode).toBe(200); + const body = response.json<{ aliases: unknown[] }>(); + expect(body.aliases).toEqual([]); + expect(mockService.listAliases).toHaveBeenCalledWith( + "my-function", + undefined, + ); + }); + + it("should pass marker query param when provided", async () => { + mockService.listAliases.mockClear(); + const response = await app.inject({ + method: "GET", + url: "/my-function/aliases?marker=next-page-token", + }); + expect(response.statusCode).toBe(200); + expect(mockService.listAliases).toHaveBeenCalledWith( + "my-function", + "next-page-token", + ); + }); + }); +}); diff --git a/packages/backend/test/plugins/lambda/service.test.ts b/packages/backend/test/plugins/lambda/service.test.ts new file mode 100644 index 0000000..035cd0d --- /dev/null +++ b/packages/backend/test/plugins/lambda/service.test.ts @@ -0,0 +1,832 @@ +import type { LambdaClient } from "@aws-sdk/client-lambda"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { LambdaService } from "../../../src/plugins/lambda/service.js"; +import { AppError } from "../../../src/shared/errors.js"; + +function createMockLambdaClient() { + return { + send: vi.fn(), + } as unknown as LambdaClient; +} + +describe("LambdaService", () => { + let client: LambdaClient; + let service: LambdaService; + + beforeEach(() => { + client = createMockLambdaClient(); + service = new LambdaService(client); + }); + + describe("listFunctions", () => { + it("returns formatted function list", async () => { + (client.send as ReturnType).mockResolvedValueOnce({ + Functions: [ + { + FunctionName: "my-function", + FunctionArn: "arn:aws:lambda:us-east-1:000000000000:function:my-function", + Runtime: "nodejs20.x", + Handler: "index.handler", + CodeSize: 1024, + LastModified: "2024-01-01T00:00:00.000+0000", + MemorySize: 128, + Timeout: 30, + State: "Active", + }, + ], + NextMarker: undefined, + }); + + const result = await service.listFunctions(); + + expect(result).toEqual({ + functions: [ + { + functionName: "my-function", + functionArn: "arn:aws:lambda:us-east-1:000000000000:function:my-function", + runtime: "nodejs20.x", + handler: "index.handler", + codeSize: 1024, + lastModified: "2024-01-01T00:00:00.000+0000", + memorySize: 128, + timeout: 30, + state: "Active", + }, + ], + nextMarker: undefined, + }); + }); + + it("returns empty function list when no functions exist", async () => { + (client.send as ReturnType).mockResolvedValueOnce({ + Functions: [], + }); + + const result = await service.listFunctions(); + + expect(result).toEqual({ functions: [], nextMarker: undefined }); + }); + + it("returns empty function list when Functions is undefined", async () => { + (client.send as ReturnType).mockResolvedValueOnce({}); + + const result = await service.listFunctions(); + + expect(result).toEqual({ functions: [], nextMarker: undefined }); + }); + + it("passes marker when provided", async () => { + (client.send as ReturnType).mockResolvedValueOnce({ + Functions: [], + NextMarker: "next-page-token", + }); + + const result = await service.listFunctions("some-marker"); + + expect(result.nextMarker).toBe("next-page-token"); + const call = (client.send as ReturnType).mock.calls[0][0]; + expect(call.input).toMatchObject({ Marker: "some-marker" }); + }); + + it("uses empty string defaults for missing FunctionName and FunctionArn", async () => { + (client.send as ReturnType).mockResolvedValueOnce({ + Functions: [ + { + // FunctionName and FunctionArn intentionally absent + CodeSize: 0, + }, + ], + }); + + const result = await service.listFunctions(); + + expect(result.functions[0].functionName).toBe(""); + expect(result.functions[0].functionArn).toBe(""); + expect(result.functions[0].codeSize).toBe(0); + }); + }); + + describe("getFunction", () => { + it("returns full function detail from Configuration", async () => { + (client.send as ReturnType).mockResolvedValueOnce({ + Configuration: { + FunctionName: "my-function", + FunctionArn: "arn:aws:lambda:us-east-1:000000000000:function:my-function", + Runtime: "nodejs20.x", + Handler: "index.handler", + Role: "arn:aws:iam::000000000000:role/my-role", + CodeSize: 2048, + Description: "A test function", + Timeout: 60, + MemorySize: 256, + LastModified: "2024-01-01T00:00:00.000+0000", + CodeSha256: "abc123", + Version: "$LATEST", + State: "Active", + StateReason: undefined, + Environment: { Variables: { MY_VAR: "my-value" } }, + Architectures: ["x86_64"], + Layers: [ + { Arn: "arn:aws:lambda:us-east-1:000000000000:layer:my-layer:1", CodeSize: 512 }, + ], + PackageType: "Zip", + }, + }); + + const result = await service.getFunction("my-function"); + + expect(result).toEqual({ + functionName: "my-function", + functionArn: "arn:aws:lambda:us-east-1:000000000000:function:my-function", + runtime: "nodejs20.x", + handler: "index.handler", + role: "arn:aws:iam::000000000000:role/my-role", + codeSize: 2048, + description: "A test function", + timeout: 60, + memorySize: 256, + lastModified: "2024-01-01T00:00:00.000+0000", + codeSha256: "abc123", + version: "$LATEST", + state: "Active", + stateReason: undefined, + environment: { MY_VAR: "my-value" }, + architectures: ["x86_64"], + layers: [ + { arn: "arn:aws:lambda:us-east-1:000000000000:layer:my-layer:1", codeSize: 512 }, + ], + packageType: "Zip", + }); + }); + + it("throws AppError with 404 when Configuration is absent from response", async () => { + (client.send as ReturnType).mockResolvedValueOnce({ + // Configuration intentionally absent + }); + + await expect(service.getFunction("ghost-function")).rejects.toMatchObject({ + statusCode: 404, + code: "FUNCTION_NOT_FOUND", + }); + }); + + it("throws AppError with 404 on ResourceNotFoundException", async () => { + const error = Object.assign(new Error("Function not found"), { + name: "ResourceNotFoundException", + }); + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect(service.getFunction("missing-fn")).rejects.toMatchObject({ + statusCode: 404, + code: "FUNCTION_NOT_FOUND", + message: "Function 'missing-fn' not found", + }); + }); + + it("re-throws unknown errors from getFunction", async () => { + const error = new Error("Unexpected AWS error"); + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect(service.getFunction("my-function")).rejects.toThrow( + "Unexpected AWS error", + ); + }); + + it("maps empty arrays for Layers when absent", async () => { + (client.send as ReturnType).mockResolvedValueOnce({ + Configuration: { + FunctionName: "no-layers-fn", + FunctionArn: "arn:aws:lambda:us-east-1:000000000000:function:no-layers-fn", + Role: "arn:aws:iam::000000000000:role/my-role", + CodeSize: 0, + // Layers intentionally absent + }, + }); + + const result = await service.getFunction("no-layers-fn"); + + expect(result?.layers).toEqual([]); + }); + }); + + describe("createFunction", () => { + it("creates a function with zip code and returns success message", async () => { + (client.send as ReturnType).mockResolvedValueOnce({}); + + const result = await service.createFunction({ + functionName: "new-function", + runtime: "nodejs20.x", + handler: "index.handler", + role: "arn:aws:iam::000000000000:role/my-role", + code: { zipFile: Buffer.from("zip-content").toString("base64") }, + description: "A new function", + timeout: 30, + memorySize: 128, + environment: { ENV_KEY: "env-value" }, + architectures: ["x86_64"], + }); + + expect(result).toEqual({ + message: "Function 'new-function' created successfully", + }); + expect(client.send).toHaveBeenCalledOnce(); + }); + + it("creates a function with S3 code reference", async () => { + (client.send as ReturnType).mockResolvedValueOnce({}); + + const result = await service.createFunction({ + functionName: "s3-function", + runtime: "python3.12", + handler: "handler.main", + role: "arn:aws:iam::000000000000:role/my-role", + code: { s3Bucket: "my-bucket", s3Key: "functions/s3-function.zip" }, + }); + + expect(result).toEqual({ + message: "Function 's3-function' created successfully", + }); + + const call = (client.send as ReturnType).mock.calls[0][0]; + expect(call.input).toMatchObject({ + FunctionName: "s3-function", + Code: { S3Bucket: "my-bucket", S3Key: "functions/s3-function.zip" }, + }); + }); + + it("throws AppError with 409 on ResourceConflictException", async () => { + const error = Object.assign(new Error("Function already exists"), { + name: "ResourceConflictException", + }); + (client.send as ReturnType).mockRejectedValue(error); + + await expect( + service.createFunction({ + functionName: "existing-fn", + runtime: "nodejs20.x", + handler: "index.handler", + role: "arn:aws:iam::000000000000:role/my-role", + code: { zipFile: "abc" }, + }), + ).rejects.toMatchObject({ + statusCode: 409, + code: "FUNCTION_CONFLICT", + message: "Function 'existing-fn' already exists or is in use", + }); + }); + + it("throws AppError with 400 on InvalidParameterValueException", async () => { + const error = Object.assign(new Error("Invalid handler"), { + name: "InvalidParameterValueException", + }); + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect( + service.createFunction({ + functionName: "bad-fn", + runtime: "nodejs20.x", + handler: "bad handler", + role: "arn:aws:iam::000000000000:role/my-role", + code: {}, + }), + ).rejects.toMatchObject({ + statusCode: 400, + code: "INVALID_PARAMETER", + }); + }); + + it("re-throws unknown errors from createFunction", async () => { + const error = new Error("Unknown error"); + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect( + service.createFunction({ + functionName: "my-fn", + runtime: "nodejs20.x", + handler: "index.handler", + role: "arn:aws:iam::000000000000:role/my-role", + code: {}, + }), + ).rejects.toThrow("Unknown error"); + }); + }); + + describe("updateFunctionCode", () => { + it("updates function code with zip and returns success message", async () => { + (client.send as ReturnType).mockResolvedValueOnce({}); + + const result = await service.updateFunctionCode("my-function", { + zipFile: Buffer.from("new-code").toString("base64"), + }); + + expect(result).toEqual({ + message: "Function 'my-function' code updated successfully", + }); + expect(client.send).toHaveBeenCalledOnce(); + }); + + it("updates function code with S3 reference", async () => { + (client.send as ReturnType).mockResolvedValueOnce({}); + + const result = await service.updateFunctionCode("my-function", { + s3Bucket: "code-bucket", + s3Key: "functions/my-function-v2.zip", + }); + + expect(result).toEqual({ + message: "Function 'my-function' code updated successfully", + }); + + const call = (client.send as ReturnType).mock.calls[0][0]; + expect(call.input).toMatchObject({ + FunctionName: "my-function", + S3Bucket: "code-bucket", + S3Key: "functions/my-function-v2.zip", + }); + }); + + it("throws AppError with 404 on ResourceNotFoundException", async () => { + const error = Object.assign(new Error("Function not found"), { + name: "ResourceNotFoundException", + }); + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect( + service.updateFunctionCode("missing-fn", { zipFile: "abc" }), + ).rejects.toMatchObject({ + statusCode: 404, + code: "FUNCTION_NOT_FOUND", + }); + }); + + it("throws AppError with 409 on ResourceConflictException", async () => { + const error = Object.assign(new Error("Conflict"), { + name: "ResourceConflictException", + }); + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect( + service.updateFunctionCode("busy-fn", {}), + ).rejects.toMatchObject({ + statusCode: 409, + code: "FUNCTION_CONFLICT", + }); + }); + + it("re-throws unknown errors from updateFunctionCode", async () => { + const error = new Error("Unexpected error"); + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect( + service.updateFunctionCode("my-function", {}), + ).rejects.toThrow("Unexpected error"); + }); + }); + + describe("updateFunctionConfig", () => { + it("updates function configuration and returns success message", async () => { + (client.send as ReturnType).mockResolvedValueOnce({}); + + const result = await service.updateFunctionConfig("my-function", { + handler: "index.newHandler", + runtime: "nodejs20.x", + description: "Updated description", + timeout: 60, + memorySize: 256, + environment: { NEW_VAR: "new-value" }, + role: "arn:aws:iam::000000000000:role/new-role", + }); + + expect(result).toEqual({ + message: "Function 'my-function' configuration updated successfully", + }); + expect(client.send).toHaveBeenCalledOnce(); + }); + + it("sends only provided fields in the update command", async () => { + (client.send as ReturnType).mockResolvedValueOnce({}); + + await service.updateFunctionConfig("my-function", { + timeout: 45, + }); + + const call = (client.send as ReturnType).mock.calls[0][0]; + expect(call.input).toMatchObject({ + FunctionName: "my-function", + Timeout: 45, + }); + expect(call.input.Handler).toBeUndefined(); + expect(call.input.Runtime).toBeUndefined(); + }); + + it("includes Description even when set to empty string", async () => { + (client.send as ReturnType).mockResolvedValueOnce({}); + + await service.updateFunctionConfig("my-function", { + description: "", + }); + + const call = (client.send as ReturnType).mock.calls[0][0]; + expect(call.input).toMatchObject({ + FunctionName: "my-function", + Description: "", + }); + }); + + it("throws AppError with 404 on ResourceNotFoundException", async () => { + const error = Object.assign(new Error("Function not found"), { + name: "ResourceNotFoundException", + }); + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect( + service.updateFunctionConfig("missing-fn", { timeout: 30 }), + ).rejects.toMatchObject({ + statusCode: 404, + code: "FUNCTION_NOT_FOUND", + }); + }); + + it("re-throws unknown errors from updateFunctionConfig", async () => { + const error = new Error("Throttling"); + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect( + service.updateFunctionConfig("my-function", {}), + ).rejects.toThrow("Throttling"); + }); + }); + + describe("deleteFunction", () => { + it("deletes a function successfully", async () => { + (client.send as ReturnType).mockResolvedValueOnce({}); + + const result = await service.deleteFunction("my-function"); + + expect(result).toEqual({ success: true }); + expect(client.send).toHaveBeenCalledOnce(); + }); + + it("throws AppError with 404 on ResourceNotFoundException", async () => { + const error = Object.assign(new Error("Function not found"), { + name: "ResourceNotFoundException", + }); + (client.send as ReturnType).mockRejectedValue(error); + + await expect(service.deleteFunction("missing-fn")).rejects.toMatchObject({ + statusCode: 404, + code: "FUNCTION_NOT_FOUND", + message: "Function 'missing-fn' not found", + }); + }); + + it("throws AppError with 409 on ResourceConflictException", async () => { + const error = Object.assign(new Error("Conflict"), { + name: "ResourceConflictException", + }); + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect(service.deleteFunction("busy-fn")).rejects.toMatchObject({ + statusCode: 409, + code: "FUNCTION_CONFLICT", + }); + }); + + it("re-throws unknown errors from deleteFunction", async () => { + const error = new Error("Unexpected error"); + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect(service.deleteFunction("my-function")).rejects.toThrow( + "Unexpected error", + ); + }); + }); + + describe("invokeFunction", () => { + it("invokes a function and returns status, payload and decoded log", async () => { + const payloadBytes = new TextEncoder().encode('{"result":"ok"}'); + const logBase64 = Buffer.from("START RequestId: abc\nEND\n").toString("base64"); + + (client.send as ReturnType).mockResolvedValueOnce({ + StatusCode: 200, + Payload: payloadBytes, + LogResult: logBase64, + FunctionError: undefined, + }); + + const result = await service.invokeFunction( + "my-function", + '{"key":"value"}', + ); + + expect(result).toEqual({ + statusCode: 200, + payload: '{"result":"ok"}', + functionError: undefined, + logResult: "START RequestId: abc\nEND\n", + }); + }); + + it("invokes a function with no payload and no log result", async () => { + (client.send as ReturnType).mockResolvedValueOnce({ + StatusCode: 202, + // Payload and LogResult intentionally absent + }); + + const result = await service.invokeFunction("async-fn", undefined, "Event"); + + expect(result).toEqual({ + statusCode: 202, + payload: undefined, + functionError: undefined, + logResult: undefined, + }); + }); + + it("returns functionError when function execution fails", async () => { + const errorPayload = new TextEncoder().encode('{"errorMessage":"division by zero"}'); + + (client.send as ReturnType).mockResolvedValueOnce({ + StatusCode: 200, + Payload: errorPayload, + FunctionError: "Unhandled", + LogResult: undefined, + }); + + const result = await service.invokeFunction("failing-fn"); + + expect(result?.functionError).toBe("Unhandled"); + expect(result?.payload).toBe('{"errorMessage":"division by zero"}'); + }); + + it("uses RequestResponse as default invocation type", async () => { + (client.send as ReturnType).mockResolvedValueOnce({ + StatusCode: 200, + }); + + await service.invokeFunction("my-function"); + + const call = (client.send as ReturnType).mock.calls[0][0]; + expect(call.input).toMatchObject({ + FunctionName: "my-function", + InvocationType: "RequestResponse", + LogType: "Tail", + }); + }); + + it("throws AppError with 404 on ResourceNotFoundException", async () => { + const error = Object.assign(new Error("Function not found"), { + name: "ResourceNotFoundException", + }); + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect(service.invokeFunction("missing-fn")).rejects.toMatchObject({ + statusCode: 404, + code: "FUNCTION_NOT_FOUND", + }); + }); + + it("throws AppError with 429 on TooManyRequestsException", async () => { + const error = Object.assign(new Error("Rate exceeded"), { + name: "TooManyRequestsException", + }); + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect(service.invokeFunction("my-fn")).rejects.toMatchObject({ + statusCode: 429, + code: "TOO_MANY_REQUESTS", + }); + }); + + it("throws AppError with 502 on ServiceException", async () => { + const error = Object.assign(new Error("Service unavailable"), { + name: "ServiceException", + }); + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect(service.invokeFunction("my-fn")).rejects.toMatchObject({ + statusCode: 502, + code: "SERVICE_ERROR", + }); + }); + + it("re-throws unknown errors from invokeFunction", async () => { + const error = new Error("Network timeout"); + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect(service.invokeFunction("my-function")).rejects.toThrow( + "Network timeout", + ); + }); + }); + + describe("listVersions", () => { + it("returns formatted version list", async () => { + (client.send as ReturnType).mockResolvedValueOnce({ + Versions: [ + { + Version: "1", + FunctionArn: "arn:aws:lambda:us-east-1:000000000000:function:my-function:1", + Description: "Initial version", + LastModified: "2024-01-01T00:00:00.000+0000", + Runtime: "nodejs20.x", + }, + { + Version: "$LATEST", + FunctionArn: "arn:aws:lambda:us-east-1:000000000000:function:my-function:$LATEST", + Description: undefined, + LastModified: "2024-06-01T00:00:00.000+0000", + Runtime: "nodejs20.x", + }, + ], + NextMarker: undefined, + }); + + const result = await service.listVersions("my-function"); + + expect(result).toEqual({ + versions: [ + { + version: "1", + functionArn: "arn:aws:lambda:us-east-1:000000000000:function:my-function:1", + description: "Initial version", + lastModified: "2024-01-01T00:00:00.000+0000", + runtime: "nodejs20.x", + }, + { + version: "$LATEST", + functionArn: "arn:aws:lambda:us-east-1:000000000000:function:my-function:$LATEST", + description: undefined, + lastModified: "2024-06-01T00:00:00.000+0000", + runtime: "nodejs20.x", + }, + ], + nextMarker: undefined, + }); + }); + + it("returns empty versions list when Versions is undefined", async () => { + (client.send as ReturnType).mockResolvedValueOnce({}); + + const result = await service.listVersions("my-function"); + + expect(result).toEqual({ versions: [], nextMarker: undefined }); + }); + + it("passes marker and returns nextMarker when provided", async () => { + (client.send as ReturnType).mockResolvedValueOnce({ + Versions: [], + NextMarker: "next-version-token", + }); + + const result = await service.listVersions("my-function", "page-1-token"); + + expect(result?.nextMarker).toBe("next-version-token"); + const call = (client.send as ReturnType).mock.calls[0][0]; + expect(call.input).toMatchObject({ + FunctionName: "my-function", + Marker: "page-1-token", + }); + }); + + it("throws AppError with 404 on ResourceNotFoundException", async () => { + const error = Object.assign(new Error("Function not found"), { + name: "ResourceNotFoundException", + }); + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect(service.listVersions("missing-fn")).rejects.toMatchObject({ + statusCode: 404, + code: "FUNCTION_NOT_FOUND", + }); + }); + + it("re-throws unknown errors from listVersions", async () => { + const error = new Error("Unexpected error"); + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect(service.listVersions("my-function")).rejects.toThrow( + "Unexpected error", + ); + }); + }); + + describe("listAliases", () => { + it("returns formatted alias list", async () => { + (client.send as ReturnType).mockResolvedValueOnce({ + Aliases: [ + { + Name: "prod", + AliasArn: "arn:aws:lambda:us-east-1:000000000000:function:my-function:prod", + FunctionVersion: "5", + Description: "Production alias", + }, + { + Name: "staging", + AliasArn: "arn:aws:lambda:us-east-1:000000000000:function:my-function:staging", + FunctionVersion: "4", + Description: undefined, + }, + ], + NextMarker: undefined, + }); + + const result = await service.listAliases("my-function"); + + expect(result).toEqual({ + aliases: [ + { + name: "prod", + aliasArn: "arn:aws:lambda:us-east-1:000000000000:function:my-function:prod", + functionVersion: "5", + description: "Production alias", + }, + { + name: "staging", + aliasArn: "arn:aws:lambda:us-east-1:000000000000:function:my-function:staging", + functionVersion: "4", + description: undefined, + }, + ], + nextMarker: undefined, + }); + }); + + it("returns empty aliases list when Aliases is undefined", async () => { + (client.send as ReturnType).mockResolvedValueOnce({}); + + const result = await service.listAliases("my-function"); + + expect(result).toEqual({ aliases: [], nextMarker: undefined }); + }); + + it("passes marker and returns nextMarker when provided", async () => { + (client.send as ReturnType).mockResolvedValueOnce({ + Aliases: [], + NextMarker: "next-alias-token", + }); + + const result = await service.listAliases("my-function", "alias-page-1"); + + expect(result?.nextMarker).toBe("next-alias-token"); + const call = (client.send as ReturnType).mock.calls[0][0]; + expect(call.input).toMatchObject({ + FunctionName: "my-function", + Marker: "alias-page-1", + }); + }); + + it("uses empty string defaults for missing Name, AliasArn, FunctionVersion", async () => { + (client.send as ReturnType).mockResolvedValueOnce({ + Aliases: [ + { + // All string fields intentionally absent + }, + ], + }); + + const result = await service.listAliases("my-function"); + + expect(result?.aliases[0]).toEqual({ + name: "", + aliasArn: "", + functionVersion: "", + description: undefined, + }); + }); + + it("throws AppError with 404 on ResourceNotFoundException", async () => { + const error = Object.assign(new Error("Function not found"), { + name: "ResourceNotFoundException", + }); + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect(service.listAliases("missing-fn")).rejects.toMatchObject({ + statusCode: 404, + code: "FUNCTION_NOT_FOUND", + message: "Function 'missing-fn' not found", + }); + }); + + it("throws AppError with 409 on ResourceConflictException", async () => { + const error = Object.assign(new Error("Conflict"), { + name: "ResourceConflictException", + }); + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect(service.listAliases("my-function")).rejects.toMatchObject({ + statusCode: 409, + code: "FUNCTION_CONFLICT", + }); + }); + + it("re-throws unknown errors from listAliases", async () => { + const error = new Error("Network issue"); + (client.send as ReturnType).mockRejectedValueOnce(error); + + await expect(service.listAliases("my-function")).rejects.toThrow( + "Network issue", + ); + }); + }); +}); diff --git a/packages/backend/test/plugins/plugin-index.test.ts b/packages/backend/test/plugins/plugin-index.test.ts index a7012d6..4cf3fee 100644 --- a/packages/backend/test/plugins/plugin-index.test.ts +++ b/packages/backend/test/plugins/plugin-index.test.ts @@ -6,6 +6,7 @@ import clientCachePlugin from "../../src/plugins/client-cache.js"; import cloudformationPlugin from "../../src/plugins/cloudformation/index.js"; import dynamodbPlugin from "../../src/plugins/dynamodb/index.js"; import iamPlugin from "../../src/plugins/iam/index.js"; +import lambdaPlugin from "../../src/plugins/lambda/index.js"; import localstackConfigPlugin from "../../src/plugins/localstack-config.js"; import snsPlugin from "../../src/plugins/sns/index.js"; import sqsPlugin from "../../src/plugins/sqs/index.js"; @@ -55,4 +56,13 @@ describe("plugin index files", () => { await expect(app.ready()).resolves.not.toThrow(); await app.close(); }); + + it("should register lambdaPlugin without error", async () => { + const app = Fastify(); + await app.register(localstackConfigPlugin); + await app.register(clientCachePlugin); + await app.register(lambdaPlugin, { prefix: "/api/lambda" }); + await expect(app.ready()).resolves.not.toThrow(); + await app.close(); + }); }); diff --git a/packages/frontend/src/api/lambda.ts b/packages/frontend/src/api/lambda.ts new file mode 100644 index 0000000..3dfec42 --- /dev/null +++ b/packages/frontend/src/api/lambda.ts @@ -0,0 +1,187 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { apiClient } from "@/lib/api-client"; + +// --- Interfaces --- + +export interface LambdaFunction { + functionName: string; + functionArn: string; + runtime: string; + handler: string; + role: string; + description?: string; + timeout: number; + memorySize: number; + codeSize: number; + lastModified: string; + state?: string; + packageType?: string; + architectures?: string[]; + codeSha256?: string; + environment?: { + variables?: Record; + }; +} + +interface ListFunctionsResponse { + functions: LambdaFunction[]; +} + +interface FunctionVersion { + version: string; + functionArn: string; + runtime: string; + lastModified: string; + description?: string; +} + +interface ListVersionsResponse { + versions: FunctionVersion[]; +} + +interface FunctionAlias { + name: string; + aliasArn: string; + functionVersion: string; + description?: string; +} + +interface ListAliasesResponse { + aliases: FunctionAlias[]; +} + +interface CreateFunctionRequest { + functionName: string; + runtime: string; + handler: string; + role: string; + memorySize?: number; + timeout?: number; + zipFile: string; +} + +interface UpdateFunctionCodeRequest { + functionName: string; + zipFile: string; +} + +interface UpdateFunctionConfigRequest { + functionName: string; + runtime?: string; + handler?: string; + role?: string; + memorySize?: number; + timeout?: number; + description?: string; +} + +interface InvokeFunctionRequest { + functionName: string; + payload?: string; + invocationType?: "RequestResponse" | "Event" | "DryRun"; +} + +export interface InvokeFunctionResponse { + statusCode: number; + payload?: string; + functionError?: string; + logResult?: string; +} + +// --- Query hooks --- + +export function useListFunctions() { + return useQuery({ + queryKey: ["lambda", "functions"], + queryFn: () => apiClient.get("/lambda"), + }); +} + +export function useGetFunction(functionName: string) { + return useQuery({ + queryKey: ["lambda", "function", functionName], + queryFn: () => + apiClient.get(`/lambda/${functionName}`), + enabled: !!functionName, + }); +} + +export function useListVersions(functionName: string) { + return useQuery({ + queryKey: ["lambda", "versions", functionName], + queryFn: () => + apiClient.get(`/lambda/${functionName}/versions`), + enabled: !!functionName, + }); +} + +export function useListAliases(functionName: string) { + return useQuery({ + queryKey: ["lambda", "aliases", functionName], + queryFn: () => + apiClient.get(`/lambda/${functionName}/aliases`), + enabled: !!functionName, + }); +} + +// --- Mutation hooks --- + +export function useCreateFunction() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (request: CreateFunctionRequest) => + apiClient.post("/lambda", request), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["lambda", "functions"] }); + }, + }); +} + +export function useUpdateFunctionCode() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ functionName, ...body }: UpdateFunctionCodeRequest) => + apiClient.put(`/lambda/${functionName}/code`, body), + onSuccess: (_data, { functionName }) => { + queryClient.invalidateQueries({ + queryKey: ["lambda", "function", functionName], + }); + queryClient.invalidateQueries({ queryKey: ["lambda", "functions"] }); + }, + }); +} + +export function useUpdateFunctionConfig() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ functionName, ...body }: UpdateFunctionConfigRequest) => + apiClient.put(`/lambda/${functionName}/config`, body), + onSuccess: (_data, { functionName }) => { + queryClient.invalidateQueries({ + queryKey: ["lambda", "function", functionName], + }); + queryClient.invalidateQueries({ queryKey: ["lambda", "functions"] }); + }, + }); +} + +export function useDeleteFunction() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (functionName: string) => + apiClient.delete<{ success: boolean }>(`/lambda/${functionName}`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["lambda", "functions"] }); + }, + }); +} + +export function useInvokeFunction() { + return useMutation({ + mutationFn: ({ functionName, ...body }: InvokeFunctionRequest) => + apiClient.post( + `/lambda/${functionName}/invoke`, + body, + ), + }); +} diff --git a/packages/frontend/src/components/lambda/FunctionCreateDialog.tsx b/packages/frontend/src/components/lambda/FunctionCreateDialog.tsx new file mode 100644 index 0000000..a9f3a97 --- /dev/null +++ b/packages/frontend/src/components/lambda/FunctionCreateDialog.tsx @@ -0,0 +1,243 @@ +import { useRef, useState } from "react"; +import { useCreateFunction } from "@/api/lambda"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +const RUNTIMES = [ + "nodejs20.x", + "nodejs18.x", + "python3.12", + "python3.11", + "java21", + "java17", +] as const; + +interface FunctionCreateDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function FunctionCreateDialog({ + open, + onOpenChange, +}: FunctionCreateDialogProps) { + const [functionName, setFunctionName] = useState(""); + const [runtime, setRuntime] = useState("nodejs20.x"); + const [handler, setHandler] = useState(""); + const [role, setRole] = useState(""); + const [memorySize, setMemorySize] = useState(128); + const [timeout, setTimeout] = useState(30); + const [zipFile, setZipFile] = useState(null); + const [zipFileName, setZipFileName] = useState(""); + const fileInputRef = useRef(null); + + const createFunction = useCreateFunction(); + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + setZipFileName(file.name); + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result as string; + // Strip data URL prefix to get raw base64 + const base64 = result.split(",")[1]; + setZipFile(base64 ?? null); + }; + reader.readAsDataURL(file); + }; + + const resetForm = () => { + setFunctionName(""); + setRuntime("nodejs20.x"); + setHandler(""); + setRole(""); + setMemorySize(128); + setTimeout(30); + setZipFile(null); + setZipFileName(""); + if (fileInputRef.current) fileInputRef.current.value = ""; + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!functionName.trim() || !handler.trim() || !role.trim() || !zipFile) + return; + createFunction.mutate( + { + functionName: functionName.trim(), + runtime, + handler: handler.trim(), + role: role.trim(), + memorySize, + timeout, + zipFile, + }, + { + onSuccess: () => { + resetForm(); + onOpenChange(false); + }, + }, + ); + }; + + const isValid = + functionName.trim() !== "" && + handler.trim() !== "" && + role.trim() !== "" && + zipFile !== null; + + return ( + { + if (!next) resetForm(); + onOpenChange(next); + }} + > + + + Create Lambda Function + + Fill in the details to create a new Lambda function. + + +
+
+
+ + setFunctionName(e.target.value)} + autoFocus + /> +
+ +
+ + +
+ +
+ + setHandler(e.target.value)} + /> +
+ +
+ + setRole(e.target.value)} + /> +
+ +
+
+ + setMemorySize(Number(e.target.value))} + /> +
+
+ + setTimeout(Number(e.target.value))} + /> +
+
+ +
+ +
+ + + + {zipFileName || "No file selected"} + +
+
+ + {createFunction.isError && ( +

+ {createFunction.error.message} +

+ )} +
+ + + + +
+
+
+ ); +} diff --git a/packages/frontend/src/components/lambda/FunctionDetail.tsx b/packages/frontend/src/components/lambda/FunctionDetail.tsx new file mode 100644 index 0000000..56d72e9 --- /dev/null +++ b/packages/frontend/src/components/lambda/FunctionDetail.tsx @@ -0,0 +1,414 @@ +import { Link } from "@tanstack/react-router"; +import { ArrowLeft, Trash2 } from "lucide-react"; +import { useState } from "react"; +import { + useDeleteFunction, + useGetFunction, + useListAliases, + useListVersions, +} from "@/api/lambda"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { InvokeFunctionForm } from "./InvokeFunctionForm"; + +type TabId = "configuration" | "invoke" | "versions" | "aliases"; + +interface AttributeItemProps { + label: string; + value: string | number | undefined; +} + +function AttributeItem({ label, value }: AttributeItemProps) { + return ( +
+ {label} + + {value !== undefined && value !== "" ? String(value) : "—"} + +
+ ); +} + +interface FunctionDetailProps { + functionName: string; +} + +function VersionsTab({ functionName }: { functionName: string }) { + const { data, isLoading, error } = useListVersions(functionName); + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (error) { + return ( +
+ Error loading versions: {error.message} +
+ ); + } + + const versions = data?.versions ?? []; + + if (versions.length === 0) { + return ( +
+ No versions found. +
+ ); + } + + return ( + + + + Version + ARN + Runtime + Last Modified + + + + {versions.map((v) => ( + + {v.version} + + {v.functionArn} + + {v.runtime} + + {v.lastModified + ? new Date(v.lastModified).toLocaleString() + : "—"} + + + ))} + +
+ ); +} + +function AliasesTab({ functionName }: { functionName: string }) { + const { data, isLoading, error } = useListAliases(functionName); + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (error) { + return ( +
+ Error loading aliases: {error.message} +
+ ); + } + + const aliases = data?.aliases ?? []; + + if (aliases.length === 0) { + return ( +
+ No aliases found. +
+ ); + } + + return ( + + + + Name + ARN + Function Version + Description + + + + {aliases.map((a) => ( + + {a.name} + + {a.aliasArn} + + {a.functionVersion} + + {a.description || "—"} + + + ))} + +
+ ); +} + +export function FunctionDetail({ functionName }: FunctionDetailProps) { + const [activeTab, setActiveTab] = useState("configuration"); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + + const { data: fn, isLoading, error } = useGetFunction(functionName); + const deleteFunction = useDeleteFunction(); + + const handleDelete = () => { + deleteFunction.mutate(functionName, { + onSettled: () => setDeleteDialogOpen(false), + }); + }; + + const tabs: { id: TabId; label: string }[] = [ + { id: "configuration", label: "Configuration" }, + { id: "invoke", label: "Invoke" }, + { id: "versions", label: "Versions" }, + { id: "aliases", label: "Aliases" }, + ]; + + return ( +
+ {/* Header */} +
+
+ +
+

{functionName}

+ {fn?.functionArn && ( +

+ {fn.functionArn} +

+ )} +
+
+ +
+ + {/* Loading / Error states */} + {isLoading && ( +
+
+
+ )} + + {error && ( +
+ Error loading function: {error.message} +
+ )} + + {/* Tabs */} + {!isLoading && !error && ( + <> + {/* Tab bar */} +
+ +
+ + {/* Tab: Configuration */} + {activeTab === "configuration" && ( + + + Function Configuration + + +
+ + + + + + + + + + +
+ + {fn?.environment?.variables && + Object.keys(fn.environment.variables).length > 0 && ( +
+

+ Environment Variables +

+ + + + Key + Value + + + + {Object.entries(fn.environment.variables).map( + ([key, value]) => ( + + + {key} + + + {value} + + + ), + )} + +
+
+ )} +
+
+ )} + + {/* Tab: Invoke */} + {activeTab === "invoke" && ( + + + Invoke Function + + + + + + )} + + {/* Tab: Versions */} + {activeTab === "versions" && ( + + + Versions + + + + + + )} + + {/* Tab: Aliases */} + {activeTab === "aliases" && ( + + + Aliases + + + + + + )} + + )} + + {/* Delete confirmation dialog */} + + + + Delete Function + + Are you sure you want to delete function{" "} + {functionName}? This action cannot be undone. + + + + + + + + +
+ ); +} diff --git a/packages/frontend/src/components/lambda/FunctionList.tsx b/packages/frontend/src/components/lambda/FunctionList.tsx new file mode 100644 index 0000000..22ad710 --- /dev/null +++ b/packages/frontend/src/components/lambda/FunctionList.tsx @@ -0,0 +1,160 @@ +import { Plus, Search, Trash2 } from "lucide-react"; +import { useState } from "react"; +import { useDeleteFunction, useListFunctions } from "@/api/lambda"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { FunctionCreateDialog } from "./FunctionCreateDialog"; + +export function FunctionList() { + const { data, isLoading, error } = useListFunctions(); + const deleteFunction = useDeleteFunction(); + const [searchTerm, setSearchTerm] = useState(""); + const [createOpen, setCreateOpen] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); + + const filteredFunctions = + data?.functions.filter((fn) => + fn.functionName.toLowerCase().includes(searchTerm.toLowerCase()), + ) ?? []; + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (error) { + return ( +
+ Error loading functions: {error.message} +
+ ); + } + + return ( +
+
+

Lambda Functions

+ +
+ +
+ + setSearchTerm(e.target.value)} + className="pl-10" + /> +
+ + {filteredFunctions.length === 0 ? ( +
+ {data?.functions.length === 0 + ? "No functions found. Create one to get started." + : "No functions match your search."} +
+ ) : ( + + + + Name + Runtime + Memory + Timeout + Last Modified + Actions + + + + {filteredFunctions.map((fn) => ( + + + + {fn.functionName} + + + + {fn.runtime} + + {fn.memorySize} MB + {fn.timeout}s + + {fn.lastModified + ? new Date(fn.lastModified).toLocaleString() + : "—"} + + + + + + ))} + +
+ )} + + + + {/* Delete confirmation dialog */} + setDeleteTarget(null)}> + + + Delete Function + + Are you sure you want to delete function "{deleteTarget}"? + This action cannot be undone. + + + + + + + + +
+ ); +} diff --git a/packages/frontend/src/components/lambda/InvokeFunctionForm.tsx b/packages/frontend/src/components/lambda/InvokeFunctionForm.tsx new file mode 100644 index 0000000..dffe407 --- /dev/null +++ b/packages/frontend/src/components/lambda/InvokeFunctionForm.tsx @@ -0,0 +1,167 @@ +import { Play } from "lucide-react"; +import { useState } from "react"; +import { useInvokeFunction } from "@/api/lambda"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; + +type InvocationType = "RequestResponse" | "Event" | "DryRun"; + +interface InvokeFunctionFormProps { + functionName: string; +} + +export function InvokeFunctionForm({ functionName }: InvokeFunctionFormProps) { + const [payload, setPayload] = useState("{}"); + const [invocationType, setInvocationType] = + useState("RequestResponse"); + const [logExpanded, setLogExpanded] = useState(false); + + const invokeFunction = useInvokeFunction(); + + const handleInvoke = () => { + invokeFunction.mutate({ + functionName, + payload, + invocationType, + }); + }; + + const result = invokeFunction.data; + + let parsedPayload: string | undefined; + if (result?.payload) { + try { + parsedPayload = JSON.stringify( + JSON.parse(atob(result.payload)), + null, + 2, + ); + } catch { + try { + parsedPayload = atob(result.payload); + } catch { + parsedPayload = result.payload; + } + } + } + + let decodedLog: string | undefined; + if (result?.logResult) { + try { + decodedLog = atob(result.logResult); + } catch { + decodedLog = result.logResult; + } + } + + return ( +
+
+ + +
+ +
+ +