diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 6245e4af..6c3dbde8 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -39,7 +39,7 @@ jobs: # We pass the list of examples here, but we can't pass an array as argument # Instead, we pass a String with a valid JSON array. # The workaround is mentioned here https://github.com/orgs/community/discussions/11692 - examples: "[ 'APIGatewayV1', 'APIGatewayV2', 'APIGatewayV2+LambdaAuthorizer', 'BackgroundTasks', 'HelloJSON', 'HelloWorld', 'HelloWorldNoTraits', 'HummingbirdLambda', 'MultiSourceAPI', 'ResourcesPackaging', 'S3EventNotifier', 'S3_AWSSDK', 'S3_Soto', 'Streaming', 'Streaming+Codable', 'ServiceLifecycle+Postgres', 'Testing', 'Tutorial' ]" + examples: "[ 'APIGatewayV1', 'APIGatewayV2', 'APIGatewayV2+LambdaAuthorizer', 'BackgroundTasks', 'HelloJSON', 'HelloWorld', 'HelloWorldNoTraits', 'HummingbirdLambda', 'MultiSourceAPI', 'MultiTenant', 'ResourcesPackaging', 'S3EventNotifier', 'S3_AWSSDK', 'S3_Soto', 'Streaming', 'Streaming+Codable', 'ServiceLifecycle+Postgres', 'Testing', 'Tutorial' ]" archive_plugin_examples: "[ 'HelloWorld', 'ResourcesPackaging' ]" archive_plugin_enabled: true diff --git a/Examples/HelloWorldNoTraits/.gitignore b/Examples/HelloWorldNoTraits/.gitignore index e41d0be5..610d9dad 100644 --- a/Examples/HelloWorldNoTraits/.gitignore +++ b/Examples/HelloWorldNoTraits/.gitignore @@ -2,3 +2,4 @@ response.json samconfig.toml template.yaml Makefile +Dockerfile diff --git a/Examples/MultiTenant/.gitignore b/Examples/MultiTenant/.gitignore new file mode 100644 index 00000000..a03a102d --- /dev/null +++ b/Examples/MultiTenant/.gitignore @@ -0,0 +1,3 @@ +response.json +samconfig.toml +Makefile diff --git a/Examples/MultiTenant/Package.swift b/Examples/MultiTenant/Package.swift new file mode 100644 index 00000000..c22dd399 --- /dev/null +++ b/Examples/MultiTenant/Package.swift @@ -0,0 +1,55 @@ +// swift-tools-version:6.2 + +import PackageDescription + +// needed for CI to test the local version of the library +import struct Foundation.URL + +let package = Package( + name: "swift-aws-lambda-runtime-example", + platforms: [.macOS(.v15)], + products: [ + .executable(name: "MultiTenant", targets: ["MultiTenant"]) + ], + dependencies: [ + // during CI, the dependency on local version of swift-aws-lambda-runtime is added dynamically below + .package(url: "https://github.com/awslabs/swift-aws-lambda-runtime.git", from: "2.0.0"), + .package(url: "https://github.com/awslabs/swift-aws-lambda-events.git", from: "1.0.0"), + ], + targets: [ + .executableTarget( + name: "MultiTenant", + dependencies: [ + .product(name: "AWSLambdaRuntime", package: "swift-aws-lambda-runtime"), + .product(name: "AWSLambdaEvents", package: "swift-aws-lambda-events"), + ] + ) + ] +) + +if let localDepsPath = Context.environment["LAMBDA_USE_LOCAL_DEPS"], + localDepsPath != "", + let v = try? URL(fileURLWithPath: localDepsPath).resourceValues(forKeys: [.isDirectoryKey]), + v.isDirectory == true +{ + // when we use the local runtime as deps, let's remove the dependency added above + let indexToRemove = package.dependencies.firstIndex { dependency in + if case .sourceControl( + name: _, + location: "https://github.com/awslabs/swift-aws-lambda-runtime.git", + requirement: _ + ) = dependency.kind { + return true + } + return false + } + if let indexToRemove { + package.dependencies.remove(at: indexToRemove) + } + + // then we add the dependency on LAMBDA_USE_LOCAL_DEPS' path (typically ../..) + print("[INFO] Compiling against swift-aws-lambda-runtime located at \(localDepsPath)") + package.dependencies += [ + .package(name: "swift-aws-lambda-runtime", path: localDepsPath) + ] +} diff --git a/Examples/MultiTenant/README.md b/Examples/MultiTenant/README.md new file mode 100644 index 00000000..0e8bba07 --- /dev/null +++ b/Examples/MultiTenant/README.md @@ -0,0 +1,316 @@ +# Multi-Tenant Lambda Function Example + +This example demonstrates how to build a multi-tenant Lambda function using Swift and AWS Lambda's tenant isolation mode. Tenant isolation ensures that execution environments are dedicated to specific tenants, providing strict isolation for processing tenant-specific code or data. + +## Overview + +This example implements a request tracking system that maintains separate counters and request histories for each tenant. The Lambda function: + +- Accepts requests from multiple tenants via API Gateway +- Maintains isolated execution environments per tenant +- Tracks request counts and timestamps for each tenant +- Returns tenant-specific data in JSON format + +## What is Tenant Isolation Mode? + +AWS Lambda's tenant isolation mode routes requests to execution environments based on a customer-specified tenant identifier. This ensures that: + +- **Execution environments are never reused across different tenants** - Each tenant gets dedicated execution environments +- **Data isolation** - Tenant-specific data remains isolated from other tenants +- **Firecracker virtualization** - Provides workload isolation at the infrastructure level + +### When to Use Tenant Isolation + +Use tenant isolation mode when building multi-tenant applications that: + +- **Execute end-user supplied code** - Limits the impact of potentially incorrect or malicious user code +- **Process tenant-specific data** - Prevents exposure of sensitive data to other tenants +- **Require strict isolation guarantees** - Such as SaaS platforms for workflow automation or code execution + +## Architecture + +The example consists of: + +1. **TenantData** - Immutable struct tracking tenant information: + - `tenantID`: Unique identifier for the tenant + - `requestCount`: Total number of requests from this tenant + - `firstRequest`: Unix timestamp (seconds since epoch) of the first request + - `requests`: Array of individual request records + +2. **TenantDataStore** - Actor-based storage providing thread-safe access to tenant data across invocations + +3. **Lambda Handler** - Processes API Gateway requests and manages tenant data + +## Code Structure + +```swift +// Immutable tenant data structure +struct TenantData: Codable { + let tenantID: String + let requestCount: Int + let firstRequest: String + let requests: [TenantRequest] + + func addingRequest() -> TenantData { + // Returns new instance with incremented count + } +} + +// Thread-safe tenant storage using Swift actors +actor TenantDataStore { + private var tenants: [String: TenantData] = [:] + + subscript(id: String) -> TenantData? { + tenants[id] + } + + func update(id: String, data: TenantData) { + tenants[id] = data + } +} + +// Lambda handler extracts tenant ID from context +let runtime = LambdaRuntime { + (event: APIGatewayRequest, context: LambdaContext) -> APIGatewayResponse in + + guard let tenantID = context.tenantID else { + return APIGatewayResponse(statusCode: .badRequest, body: "No Tenant ID provided") + } + + // Process request for this tenant + let currentData = await tenants[tenantID] ?? TenantData(tenantID: tenantID) + let updatedData = currentData.addingRequest() + await tenants.update(id: tenantID, data: updatedData) + + return try APIGatewayResponse(statusCode: .ok, encodableBody: updatedData) +} +``` + +## Configuration + +### SAM Template (template.yaml) + +The function is configured with tenant isolation mode and API Gateway parameter mapping in the SAM template: + +```yaml +# API Gateway REST API with parameter mapping +MultiTenantApi: + Type: AWS::Serverless::Api + Properties: + StageName: Prod + DefinitionBody: + openapi: 3.0.1 + paths: + /: + get: + parameters: + - name: tenant-id + in: query + required: true + x-amazon-apigateway-integration: + type: aws_proxy + httpMethod: POST + uri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${MultiTenantLambda.Arn}/invocations + # Map query parameter to Lambda tenant header + requestParameters: + integration.request.header.X-Amz-Tenant-Id: method.request.querystring.tenant-id + +# Lambda function with tenant isolation +MultiTenantLambda: + Type: AWS::Serverless::Function + Properties: + Runtime: provided.al2023 + Architectures: + - arm64 + # Enable tenant isolation mode + TenancyConfig: + TenantIsolationMode: PER_TENANT +``` + +### Key Configuration Points + +- **TenancyConfig.TenantIsolationMode**: Set to `PER_TENANT` to enable tenant isolation +- **Parameter Mapping**: API Gateway maps the `tenant-id` query parameter to the `X-Amz-Tenant-Id` header required by Lambda +- **REST API**: Uses REST API (not HTTP API) to support request parameter mapping +- **OpenAPI Definition**: Defines the integration using OpenAPI 3.0 specification for fine-grained control +- **Immutable property**: Tenant isolation can only be enabled when creating a new function +- **Required tenant-id**: All invocations must include a tenant identifier + +### Why Parameter Mapping is Required + +Lambda's tenant isolation feature requires the tenant ID to be passed via the `X-Amz-Tenant-Id` header. When using API Gateway: + +1. **Client sends request** with `tenant-id` as a query parameter +2. **API Gateway transforms** the query parameter into the `X-Amz-Tenant-Id` header +3. **Lambda receives** the header and routes to the appropriate tenant-isolated environment + +This mapping is configured in the `x-amazon-apigateway-integration` section using: +```yaml +requestParameters: + integration.request.header.X-Amz-Tenant-Id: method.request.querystring.tenant-id +``` + +## Deployment + +### Prerequisites + +- Swift (>=6.2) +- Docker (for cross-compilation to Amazon Linux) +- AWS SAM CLI (>=1.147.1) +- AWS CLI configured with appropriate credentials + +### Build and Deploy + +1. **Build the Lambda function**: + ```bash + swift package archive --allow-network-connections docker + ``` + +2. **Deploy using SAM**: + ```bash + sam deploy --guided + ``` + +3. **Note the API Gateway endpoint** from the CloudFormation outputs + +## Testing + +### Using API Gateway + +The tenant ID is passed as a query parameter. API Gateway automatically maps it to the `X-Amz-Tenant-Id` header: + +```bash +# Request from tenant "alice" +curl "https://your-api-id.execute-api.us-east-1.amazonaws.com/Prod?tenant-id=alice" + +# Request from tenant "bob" +curl "https://your-api-id.execute-api.us-east-1.amazonaws.com/Prod?tenant-id=bob" + +# Multiple requests from the same tenant will reuse the execution environment +for i in {1..5}; do + curl "https://your-api-id.execute-api.us-east-1.amazonaws.com/Prod?tenant-id=alice" +done +``` + +### Using AWS CLI (Direct Lambda Invocation) + +For direct Lambda invocation without API Gateway: + +```bash +# Synchronous invocation +aws lambda invoke \ + --function-name MultiTenantLambda \ + --tenant-id alice \ + response.json + +# View the response +cat response.json +``` + +### Expected Response + +```json +{ + "tenantID": "alice", + "requestCount": 3, + "firstRequest": "1705320000.123456", + "requests": [ + { + "requestNumber": 1, + "timestamp": "1705320000.123456" + }, + { + "requestNumber": 2, + "timestamp": "1705320075.789012" + }, + { + "requestNumber": 3, + "timestamp": "1705320150.345678" + } + ] +} +``` + +**Note**: Timestamps are Unix epoch times (seconds since January 1, 1970) for cross-platform compatibility. + +## How Tenant Isolation Works + +1. **Request arrives** with a tenant identifier (via query parameter, header, or direct invocation) +2. **Lambda routes the request** to an execution environment dedicated to that tenant +3. **Environment reuse** - Subsequent requests from the same tenant reuse the same environment (warm start) +4. **Isolation guarantee** - Execution environments are never shared between different tenants +5. **Data persistence** - Tenant data persists in memory across invocations within the same execution environment + +## Important Considerations + +### Concurrency and Scaling + +- Lambda imposes a limit of **2,500 tenant-isolated execution environments** (active or idle) for every 1,000 concurrent executions +- Each tenant can scale independently based on their request volume +- Cold starts occur more frequently due to tenant-specific environments + +### Pricing + +- Standard Lambda pricing applies (compute time and requests) +- **Additional charge** when Lambda creates a new tenant-isolated execution environment +- Price depends on allocated memory and CPU architecture +- See [AWS Lambda Pricing](https://aws.amazon.com/lambda/pricing) for details + +### Limitations + +Tenant isolation mode is **not supported** with: +- Function URLs +- Provisioned concurrency +- SnapStart + +### Supported Invocation Methods + +- ✅ Synchronous invocations +- ✅ Asynchronous invocations +- ✅ API Gateway event triggers +- ✅ AWS SDK invocations + +## Security Best Practices + +1. **Execution role applies to all tenants** - Use IAM policies to restrict access to tenant-specific resources +2. **Validate tenant identifiers** - Ensure tenant IDs are properly authenticated and authorized +3. **Implement tenant-aware logging** - Include tenant ID in CloudWatch logs for audit trails +4. **Set appropriate timeouts** - Configure function timeout based on expected workload +5. **Monitor per-tenant metrics** - Use CloudWatch to track invocations, errors, and duration per tenant + +## Monitoring + +### CloudWatch Metrics + +Lambda automatically publishes metrics with tenant dimensions: + +- `Invocations` - Number of invocations per tenant +- `Duration` - Execution time per tenant +- `Errors` - Error count per tenant +- `Throttles` - Throttled requests per tenant + +### Accessing Metrics + +```bash +# Get invocation count for a specific tenant +aws cloudwatch get-metric-statistics \ + --namespace AWS/Lambda \ + --metric-name Invocations \ + --dimensions Name=FunctionName,Value=MultiTenant Name=TenantId,Value=alice \ + --start-time 2024-01-15T00:00:00Z \ + --end-time 2024-01-15T23:59:59Z \ + --period 3600 \ + --statistics Sum +``` + +## Learn More + +- [AWS Lambda Tenant Isolation Documentation](https://docs.aws.amazon.com/lambda/latest/dg/tenant-isolation.html) +- [Configuring Tenant Isolation](https://docs.aws.amazon.com/lambda/latest/dg/tenant-isolation-configure.html) +- [Invoking Tenant-Isolated Functions](https://docs.aws.amazon.com/lambda/latest/dg/tenant-isolation-invoke.html) +- [AWS Blog: Streamlined Multi-Tenant Application Development](https://aws.amazon.com/blogs/aws/streamlined-multi-tenant-application-development-with-tenant-isolation-mode-in-aws-lambda/) +- [Swift AWS Lambda Runtime](https://github.com/swift-server/swift-aws-lambda-runtime) + +## License + +This example is part of the Swift AWS Lambda Runtime project and is licensed under Apache License 2.0. diff --git a/Examples/MultiTenant/Sources/main.swift b/Examples/MultiTenant/Sources/main.swift new file mode 100644 index 00000000..8229c908 --- /dev/null +++ b/Examples/MultiTenant/Sources/main.swift @@ -0,0 +1,107 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftAWSLambdaRuntime open source project +// +// Copyright SwiftAWSLambdaRuntime project authors +// Copyright (c) Amazon.com, Inc. or its affiliates. +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAWSLambdaRuntime project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import AWSLambdaEvents +import AWSLambdaRuntime + +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif + +let tenants = TenantDataStore() + +let runtime = LambdaRuntime { + (event: APIGatewayRequest, context: LambdaContext) -> APIGatewayResponse in + + // Extract tenant ID from context + guard let tenantID = context.tenantID else { + return APIGatewayResponse(statusCode: .badRequest, body: "No Tenant ID provided") + } + + // Get or create tenant data + let currentData = await tenants[tenantID] ?? TenantData(tenantID: tenantID) + + // Add new request + let updatedData = currentData.addingRequest() + + // Store updated data + await tenants.update(id: tenantID, data: updatedData) + + return try APIGatewayResponse(statusCode: .ok, encodableBody: updatedData) +} + +try await runtime.run() + +actor TenantDataStore { + private var tenants: [String: TenantData] = [:] + + subscript(id: String) -> TenantData? { + tenants[id] + } + + // subscript setters can't be called from outside of the actor + func update(id: String, data: TenantData) { + tenants[id] = data + } +} + +struct TenantData: Codable { + struct TenantRequest: Codable { + let requestNumber: Int + let timestamp: String + } + + let tenantID: String + let requestCount: Int + let firstRequest: String + let requests: [TenantRequest] + + init(tenantID: String) { + self.init( + tenantID: tenantID, + requestCount: 0, + firstRequest: "\(Date().timeIntervalSince1970)", + requests: [] + ) + } + + func addingRequest() -> TenantData { + let newCount = requestCount + 1 + let newRequest = TenantRequest( + requestNumber: newCount, + timestamp: "\(Date().timeIntervalSince1970)" + ) + return TenantData( + tenantID: tenantID, + requestCount: newCount, + firstRequest: firstRequest, + requests: requests + [newRequest] + ) + } + + private init( + tenantID: String, + requestCount: Int, + firstRequest: String, + requests: [TenantRequest] + ) { + self.tenantID = tenantID + self.requestCount = requestCount + self.firstRequest = firstRequest + self.requests = requests + } +} diff --git a/Examples/MultiTenant/template.yaml b/Examples/MultiTenant/template.yaml new file mode 100644 index 00000000..78ab635c --- /dev/null +++ b/Examples/MultiTenant/template.yaml @@ -0,0 +1,121 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: SAM Template for Multi Tenant Lambda Example + +# This is an example SAM template for the purpose of this project. +# When deploying such infrastructure in production environment, +# we strongly encourage you to follow these best practices for improved security and resiliency +# - Enable access logging on API Gateway +# See: https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-logging.html) +# - Ensure that AWS Lambda function is configured for function-level concurrent execution limit +# See: https://docs.aws.amazon.com/lambda/latest/dg/lambda-concurrency.html +# https://docs.aws.amazon.com/lambda/latest/dg/configuration-concurrency.html +# - Check encryption settings for Lambda environment variable +# See: https://docs.aws.amazon.com/lambda/latest/dg/configuration-envvars-encryption.html +# - Ensure that AWS Lambda function is configured for a Dead Letter Queue(DLQ) +# See: https://docs.aws.amazon.com/lambda/latest/dg/invocation-async-retain-records.html#invocation-dlq +# - Ensure that AWS Lambda function is configured inside a VPC when it needs to access private resources +# See: https://docs.aws.amazon.com/lambda/latest/dg/configuration-vpc.html +# Code Example: https://github.com/awslabs/swift-aws-lambda-runtime/tree/main/Examples/ServiceLifecycle%2BPostgres + +Resources: + # API Gateway REST API + MultiTenantApi: + Type: AWS::Serverless::Api + Properties: + StageName: Prod + DefinitionBody: + openapi: 3.0.1 + info: + title: MultiTenant API + version: 1.0.0 + paths: + /{proxy+}: + x-amazon-apigateway-any-method: + parameters: + - name: tenant-id + in: query + required: true + schema: + type: string + - name: proxy + in: path + required: true + schema: + type: string + x-amazon-apigateway-request-validator: params-only + x-amazon-apigateway-integration: + type: aws_proxy + httpMethod: POST + uri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${MultiTenantLambda.Arn}/invocations + requestParameters: + integration.request.header.X-Amz-Tenant-Id: method.request.querystring.tenant-id + /: + x-amazon-apigateway-any-method: + parameters: + - name: tenant-id + in: query + required: true + schema: + type: string + x-amazon-apigateway-request-validator: params-only + x-amazon-apigateway-integration: + type: aws_proxy + httpMethod: POST + uri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${MultiTenantLambda.Arn}/invocations + requestParameters: + integration.request.header.X-Amz-Tenant-Id: method.request.querystring.tenant-id + x-amazon-apigateway-request-validators: + params-only: + validateRequestParameters: true + validateRequestBody: false + + # Lambda function + MultiTenantLambda: + Type: AWS::Serverless::Function + Properties: + CodeUri: .build/plugins/AWSLambdaPackager/outputs/AWSLambdaPackager/MultiTenant/MultiTenant.zip + Timeout: 60 + Handler: swift.bootstrap # ignored by the Swift runtime + Runtime: provided.al2023 + MemorySize: 128 + Architectures: + - arm64 + # https://docs.aws.amazon.com/lambda/latest/dg/tenant-isolation-configure.html#tenant-isolation-cfn + TenancyConfig: + TenantIsolationMode: PER_TENANT + Environment: + Variables: + # by default, AWS Lambda runtime produces no log + # use `LOG_LEVEL: debug` for lifecycle and event handling information + # use `LOG_LEVEL: trace` for detailed input event information + LOG_LEVEL: trace + Events: + RootPath: + Type: Api + Properties: + RestApiId: !Ref MultiTenantApi + Path: / + Method: ANY + ProxyPath: + Type: Api + Properties: + RestApiId: !Ref MultiTenantApi + Path: /{proxy+} + Method: ANY + + # Permission for API Gateway to invoke Lambda + MultiTenantLambdaPermission: + Type: AWS::Lambda::Permission + Properties: + FunctionName: !Ref MultiTenantLambda + Action: lambda:InvokeFunction + Principal: apigateway.amazonaws.com + SourceArn: !Sub arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${MultiTenantApi}/*/* + +Outputs: + # print API Gateway endpoint + APIGatewayEndpoint: + Description: API Gateway endpoint URL + # https://docs.aws.amazon.com/lambda/latest/dg/tenant-isolation-invoke.html#tenant-isolation-invoke-apigateway + Value: !Sub "https://${MultiTenantApi}.execute-api.${AWS::Region}.amazonaws.com/Prod?tenant-id=seb" diff --git a/Sources/AWSLambdaRuntime/ControlPlaneRequest.swift b/Sources/AWSLambdaRuntime/ControlPlaneRequest.swift index 4d82d76b..677a134e 100644 --- a/Sources/AWSLambdaRuntime/ControlPlaneRequest.swift +++ b/Sources/AWSLambdaRuntime/ControlPlaneRequest.swift @@ -46,6 +46,8 @@ package struct InvocationMetadata: Hashable, Sendable { package let clientContext: String? @usableFromInline package let cognitoIdentity: String? + @usableFromInline + package let tenantID: String? package init(headers: HTTPHeaders) throws(LambdaRuntimeError) { guard let requestID = headers.first(name: AmazonHeaders.requestID), !requestID.isEmpty else { @@ -69,6 +71,7 @@ package struct InvocationMetadata: Hashable, Sendable { headers.first(name: AmazonHeaders.traceID) ?? "Root=\(AmazonHeaders.generateXRayTraceID());Sampled=0" self.clientContext = headers["Lambda-Runtime-Client-Context"].first self.cognitoIdentity = headers["Lambda-Runtime-Cognito-Identity"].first + self.tenantID = headers[AmazonHeaders.tenantID].first } } diff --git a/Sources/AWSLambdaRuntime/Lambda.swift b/Sources/AWSLambdaRuntime/Lambda.swift index 7f748457..d559bda2 100644 --- a/Sources/AWSLambdaRuntime/Lambda.swift +++ b/Sources/AWSLambdaRuntime/Lambda.swift @@ -73,6 +73,7 @@ public enum Lambda { context: LambdaContext( requestID: invocation.metadata.requestID, traceID: invocation.metadata.traceID, + tenantID: invocation.metadata.tenantID, invokedFunctionARN: invocation.metadata.invokedFunctionARN, deadline: LambdaClock.Instant( millisecondsSinceEpoch: invocation.metadata.deadlineInMillisSinceEpoch diff --git a/Sources/AWSLambdaRuntime/LambdaContext.swift b/Sources/AWSLambdaRuntime/LambdaContext.swift index 111c97ec..d14e16c6 100644 --- a/Sources/AWSLambdaRuntime/LambdaContext.swift +++ b/Sources/AWSLambdaRuntime/LambdaContext.swift @@ -86,9 +86,14 @@ public struct ClientContext: Codable, Sendable { /// The Lambda runtime generates and passes the `LambdaContext` to the Lambda handler as an argument. @available(LambdaSwift 2.0, *) public struct LambdaContext: CustomDebugStringConvertible, Sendable { + + // use a final class as storage to have value type semantic with + // low overhead of class for copy on write operations + // https://www.youtube.com/watch?v=iLDldae64xE final class _Storage: Sendable { let requestID: String let traceID: String + let tenantID: String? let invokedFunctionARN: String let deadline: LambdaClock.Instant let cognitoIdentity: String? @@ -98,6 +103,7 @@ public struct LambdaContext: CustomDebugStringConvertible, Sendable { init( requestID: String, traceID: String, + tenantID: String?, invokedFunctionARN: String, deadline: LambdaClock.Instant, cognitoIdentity: String?, @@ -106,6 +112,7 @@ public struct LambdaContext: CustomDebugStringConvertible, Sendable { ) { self.requestID = requestID self.traceID = traceID + self.tenantID = tenantID self.invokedFunctionARN = invokedFunctionARN self.deadline = deadline self.cognitoIdentity = cognitoIdentity @@ -126,6 +133,11 @@ public struct LambdaContext: CustomDebugStringConvertible, Sendable { self.storage.traceID } + /// The Tenant ID. + public var tenantID: String? { + self.storage.tenantID + } + /// The ARN of the Lambda function, version, or alias that's specified in the invocation. public var invokedFunctionARN: String { self.storage.invokedFunctionARN @@ -153,9 +165,36 @@ public struct LambdaContext: CustomDebugStringConvertible, Sendable { self.storage.logger } + @available( + *, + deprecated, + message: + "This method will be removed in a future major version update. Use init(requestID:traceID:tenantID:invokedFunctionARN:deadline:cognitoIdentity:clientContext:logger) instead." + ) + public init( + requestID: String, + traceID: String, + invokedFunctionARN: String, + deadline: LambdaClock.Instant, + cognitoIdentity: String? = nil, + clientContext: ClientContext? = nil, + logger: Logger + ) { + self.init( + requestID: requestID, + traceID: traceID, + tenantID: nil, + invokedFunctionARN: invokedFunctionARN, + deadline: deadline, + cognitoIdentity: cognitoIdentity, + clientContext: clientContext, + logger: logger + ) + } public init( requestID: String, traceID: String, + tenantID: String?, invokedFunctionARN: String, deadline: LambdaClock.Instant, cognitoIdentity: String? = nil, @@ -165,6 +204,7 @@ public struct LambdaContext: CustomDebugStringConvertible, Sendable { self.storage = _Storage( requestID: requestID, traceID: traceID, + tenantID: tenantID, invokedFunctionARN: invokedFunctionARN, deadline: deadline, cognitoIdentity: cognitoIdentity, @@ -187,6 +227,7 @@ public struct LambdaContext: CustomDebugStringConvertible, Sendable { package static func __forTestsOnly( requestID: String, traceID: String, + tenantID: String?, invokedFunctionARN: String, timeout: Duration, logger: Logger @@ -194,6 +235,7 @@ public struct LambdaContext: CustomDebugStringConvertible, Sendable { LambdaContext( requestID: requestID, traceID: traceID, + tenantID: tenantID, invokedFunctionARN: invokedFunctionARN, deadline: LambdaClock().now.advanced(by: timeout), logger: logger diff --git a/Sources/AWSLambdaRuntime/LambdaRuntimeClient+ChannelHandler.swift b/Sources/AWSLambdaRuntime/LambdaRuntimeClient+ChannelHandler.swift index 6238fac4..e4fa6e16 100644 --- a/Sources/AWSLambdaRuntime/LambdaRuntimeClient+ChannelHandler.swift +++ b/Sources/AWSLambdaRuntime/LambdaRuntimeClient+ChannelHandler.swift @@ -392,6 +392,7 @@ extension LambdaChannelHandler: ChannelInboundHandler { switch self.state { case .connected(let context, .waitingForNextInvocation(let continuation)): do { + self.logger.trace("Lambda Invocation Headers", metadata: ["headers": "\(response.head.headers)"]) let metadata = try InvocationMetadata(headers: response.head.headers) self.state = .connected(context, .waitingForResponse) continuation.resume(returning: Invocation(metadata: metadata, event: response.body ?? ByteBuffer())) diff --git a/Sources/AWSLambdaRuntime/Utils.swift b/Sources/AWSLambdaRuntime/Utils.swift index 6a80e7f6..8a8d0442 100644 --- a/Sources/AWSLambdaRuntime/Utils.swift +++ b/Sources/AWSLambdaRuntime/Utils.swift @@ -28,7 +28,13 @@ enum Consts { static let initializationError = "InitializationError" } -/// AWS Lambda HTTP Headers, used to populate the `LambdaContext` object. +/// AWS Lambda HTTP Headers, used to populate the `LambdaContext` object. E.g. +/// Content-Type: application/json; +/// Lambda-Runtime-Aws-Request-Id: bfcc9017-7f34-4154-9699-ff0229e9ad2b; +/// Lambda-Runtime-Aws-Tenant-Id: seb; +/// Lambda-Runtime-Deadline-Ms: 1763672952393; +/// Lambda-Runtime-Invoked-Function-Arn: arn:aws:lambda:us-west-2:486652066693:function:MultiTenant-MultiTenantLambda-1E9mgLUtIQ9N; +/// Lambda-Runtime-Trace-Id: Root=1-691f833c-79cf6a2b23942f8925881714;Parent=76ab2f41125eef94;Sampled=0;Lineage=1:9581a8d4:0; Date: Thu, 20 Nov 2025 21:08:12 GMT; Transfer-Encoding: chunked enum AmazonHeaders { static let requestID = "Lambda-Runtime-Aws-Request-Id" static let traceID = "Lambda-Runtime-Trace-Id" @@ -36,6 +42,7 @@ enum AmazonHeaders { static let cognitoIdentity = "X-Amz-Cognito-Identity" static let deadline = "Lambda-Runtime-Deadline-Ms" static let invokedFunctionARN = "Lambda-Runtime-Invoked-Function-Arn" + static let tenantID = "Lambda-Runtime-Aws-Tenant-Id" } extension String { diff --git a/Tests/AWSLambdaRuntimeTests/InvocationTests.swift b/Tests/AWSLambdaRuntimeTests/InvocationTests.swift index 13e5956e..e30cbda1 100644 --- a/Tests/AWSLambdaRuntimeTests/InvocationTests.swift +++ b/Tests/AWSLambdaRuntimeTests/InvocationTests.swift @@ -41,4 +41,23 @@ struct InvocationTest { let invocation = try #require(maybeInvocation) #expect(!invocation.traceID.isEmpty) } + + @Test + @available(LambdaSwift 2.0, *) + func testInvocationTenantID() throws { + let tenantID = "123" + let headers = HTTPHeaders([ + (AmazonHeaders.requestID, "test"), + (AmazonHeaders.deadline, String(Date(timeIntervalSinceNow: 60).millisSinceEpoch)), + (AmazonHeaders.invokedFunctionARN, "arn:aws:lambda:us-east-1:123456789012:function:custom-runtime"), + (AmazonHeaders.tenantID, tenantID), + ]) + + var maybeInvocation: InvocationMetadata? + + #expect(throws: Never.self) { maybeInvocation = try InvocationMetadata(headers: headers) } + let invocation = try #require(maybeInvocation) + #expect(invocation.tenantID == tenantID) + } + } diff --git a/Tests/AWSLambdaRuntimeTests/Lambda+CodableTests.swift b/Tests/AWSLambdaRuntimeTests/Lambda+CodableTests.swift index 4e7e0219..5d24cb63 100644 --- a/Tests/AWSLambdaRuntimeTests/Lambda+CodableTests.swift +++ b/Tests/AWSLambdaRuntimeTests/Lambda+CodableTests.swift @@ -67,6 +67,7 @@ struct JSONTests { let context = LambdaContext.__forTestsOnly( requestID: UUID().uuidString, traceID: UUID().uuidString, + tenantID: nil, invokedFunctionARN: "arn:", timeout: .milliseconds(6000), logger: self.logger diff --git a/Tests/AWSLambdaRuntimeTests/LambdaContextTests.swift b/Tests/AWSLambdaRuntimeTests/LambdaContextTests.swift index e0f6a7b4..bac85e5e 100644 --- a/Tests/AWSLambdaRuntimeTests/LambdaContextTests.swift +++ b/Tests/AWSLambdaRuntimeTests/LambdaContextTests.swift @@ -122,6 +122,7 @@ struct LambdaContextTests { let context = LambdaContext.__forTestsOnly( requestID: "test-request", traceID: "test-trace", + tenantID: nil, invokedFunctionARN: "test-arn", timeout: .seconds(30), logger: Logger(label: "test")