From 3d8cdd42e946bb7bded665a87ab99fcb5f3f8216 Mon Sep 17 00:00:00 2001 From: Edward Amsden Date: Fri, 29 May 2026 17:42:54 -0500 Subject: [PATCH] Draft: Lambda Worker Sample --- .gitignore | 3 + Directory.Build.props | 2 + Directory.Packages.props | 3 + README.md | 3 +- TemporalioSamples.sln | 15 + src/LambdaWorker/.gitignore | 2 + src/LambdaWorker/Activities.cs | 16 ++ src/LambdaWorker/Function.cs | 25 ++ src/LambdaWorker/LambdaWorkerSample.cs | 21 ++ src/LambdaWorker/Program.cs | 31 +++ src/LambdaWorker/README.md | 261 ++++++++++++++++++ src/LambdaWorker/SampleWorkflow.workflow.cs | 20 ++ .../TemporalioSamples.LambdaWorker.csproj | 16 ++ src/LambdaWorker/deploy-lambda.sh | 60 ++++ src/LambdaWorker/extra-setup-steps | 48 ++++ ...-role-for-temporal-lambda-invoke-test.yaml | 97 +++++++ src/LambdaWorker/mk-iam-role.sh | 17 ++ .../otel-collector-config.yaml.sample | 33 +++ src/LambdaWorker/temporal.toml.sample | 6 + .../NexusContextPropagationInterceptor.cs | 8 +- tests/LambdaWorker/LambdaWorkerTests.cs | 49 ++++ tests/TemporalioSamples.Tests.csproj | 1 + 22 files changed, 732 insertions(+), 5 deletions(-) create mode 100644 src/LambdaWorker/.gitignore create mode 100644 src/LambdaWorker/Activities.cs create mode 100644 src/LambdaWorker/Function.cs create mode 100644 src/LambdaWorker/LambdaWorkerSample.cs create mode 100644 src/LambdaWorker/Program.cs create mode 100644 src/LambdaWorker/README.md create mode 100644 src/LambdaWorker/SampleWorkflow.workflow.cs create mode 100644 src/LambdaWorker/TemporalioSamples.LambdaWorker.csproj create mode 100755 src/LambdaWorker/deploy-lambda.sh create mode 100755 src/LambdaWorker/extra-setup-steps create mode 100644 src/LambdaWorker/iam-role-for-temporal-lambda-invoke-test.yaml create mode 100755 src/LambdaWorker/mk-iam-role.sh create mode 100644 src/LambdaWorker/otel-collector-config.yaml.sample create mode 100644 src/LambdaWorker/temporal.toml.sample create mode 100644 tests/LambdaWorker/LambdaWorkerTests.cs diff --git a/.gitignore b/.gitignore index 97a330f..00e2bea 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,6 @@ obj/ /.vs /.vscode /.idea + +# Local developer overrides +Directory.Build.local.props diff --git a/Directory.Build.props b/Directory.Build.props index 3eb3e4c..ca7cae1 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -35,4 +35,6 @@ + + diff --git a/Directory.Packages.props b/Directory.Packages.props index 4fd371b..f3eb858 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,6 +4,7 @@ + @@ -18,6 +19,8 @@ + + diff --git a/README.md b/README.md index eb74da1..ff54bec 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ Prerequisites: * [EagerWorkflowStart](src/EagerWorkflowStart) - Demonstrates usage of Eager Workflow Start to reduce latency for workflows that start with a local activity. * [Encryption](src/Encryption) - End-to-end encryption with Temporal payload codecs. * [EnvConfig](src/EnvConfig) - Load client configuration from TOML files with programmatic overrides +* [LambdaWorker](src/LambdaWorker) - Run a Temporal Worker inside an AWS Lambda function. * [Mutex](src/Mutex) - How to implement a mutex as a workflow. Demonstrates how to avoid race conditions or parallel mutually exclusive operations on the same resource. * [NexusCancellation](src/NexusCancellation) - Demonstrates how to cancel a running Nexus operation from a caller workflow. * [NexusContextPropagation](src/NexusContextPropagation) - Context propagation through Nexus services. @@ -83,4 +84,4 @@ Can add options like: There is also a standalone project for running tests so output is more visible. To use it, run `dotnet run --project tests/TemporalioSamples.Tests.csproj` and can pass options after `--`, e.g. `-- -verbose` and/or -`-- -method "*.RunAsync_SimpleRun_SucceedsAfterRetry"`. \ No newline at end of file +`-- -method "*.RunAsync_SimpleRun_SucceedsAfterRetry"`. diff --git a/TemporalioSamples.sln b/TemporalioSamples.sln index 4f16013..e5dd716 100644 --- a/TemporalioSamples.sln +++ b/TemporalioSamples.sln @@ -97,6 +97,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TemporalioSamples.NexusMult EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TemporalioSamples.EnvConfig", "src\EnvConfig\TemporalioSamples.EnvConfig.csproj", "{52CE80AF-09C3-4209-8A21-6CFFAA3B2B01}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TemporalioSamples.LambdaWorker", "src\LambdaWorker\TemporalioSamples.LambdaWorker.csproj", "{D1D6B7AD-0E12-4D8C-83BE-9CE3EB916DD7}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TemporalioSamples.Timer", "src\Timer\TemporalioSamples.Timer.csproj", "{B37B3E98-4B04-48B8-9017-F0EDEDC7BD98}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TemporalioSamples.UpdatableTimer", "src\UpdatableTimer\TemporalioSamples.UpdatableTimer.csproj", "{5D02DFEA-DC08-4B7B-8E26-EDAC1942D347}" @@ -663,6 +665,18 @@ Global {5D493692-53AB-4FAA-BA4D-33B1E54E9A48}.Release|x64.Build.0 = Release|Any CPU {5D493692-53AB-4FAA-BA4D-33B1E54E9A48}.Release|x86.ActiveCfg = Release|Any CPU {5D493692-53AB-4FAA-BA4D-33B1E54E9A48}.Release|x86.Build.0 = Release|Any CPU + {D1D6B7AD-0E12-4D8C-83BE-9CE3EB916DD7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D1D6B7AD-0E12-4D8C-83BE-9CE3EB916DD7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D1D6B7AD-0E12-4D8C-83BE-9CE3EB916DD7}.Debug|x64.ActiveCfg = Debug|Any CPU + {D1D6B7AD-0E12-4D8C-83BE-9CE3EB916DD7}.Debug|x64.Build.0 = Debug|Any CPU + {D1D6B7AD-0E12-4D8C-83BE-9CE3EB916DD7}.Debug|x86.ActiveCfg = Debug|Any CPU + {D1D6B7AD-0E12-4D8C-83BE-9CE3EB916DD7}.Debug|x86.Build.0 = Debug|Any CPU + {D1D6B7AD-0E12-4D8C-83BE-9CE3EB916DD7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D1D6B7AD-0E12-4D8C-83BE-9CE3EB916DD7}.Release|Any CPU.Build.0 = Release|Any CPU + {D1D6B7AD-0E12-4D8C-83BE-9CE3EB916DD7}.Release|x64.ActiveCfg = Release|Any CPU + {D1D6B7AD-0E12-4D8C-83BE-9CE3EB916DD7}.Release|x64.Build.0 = Release|Any CPU + {D1D6B7AD-0E12-4D8C-83BE-9CE3EB916DD7}.Release|x86.ActiveCfg = Release|Any CPU + {D1D6B7AD-0E12-4D8C-83BE-9CE3EB916DD7}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -711,6 +725,7 @@ Global {18E26AEE-5DA3-7BF8-A1AD-13A28A6C7BA3} = {1A647B41-53D0-4638-AE5A-6630BAAE45FC} {8BE23F78-7178-4924-AB45-4AF74454CC97} = {18E26AEE-5DA3-7BF8-A1AD-13A28A6C7BA3} {52CE80AF-09C3-4209-8A21-6CFFAA3B2B01} = {1A647B41-53D0-4638-AE5A-6630BAAE45FC} + {D1D6B7AD-0E12-4D8C-83BE-9CE3EB916DD7} = {1A647B41-53D0-4638-AE5A-6630BAAE45FC} {B37B3E98-4B04-48B8-9017-F0EDEDC7BD98} = {1A647B41-53D0-4638-AE5A-6630BAAE45FC} {5D02DFEA-DC08-4B7B-8E26-EDAC1942D347} = {1A647B41-53D0-4638-AE5A-6630BAAE45FC} {AF077751-E4B9-4696-93CB-74653F0BB6C4} = {1A647B41-53D0-4638-AE5A-6630BAAE45FC} diff --git a/src/LambdaWorker/.gitignore b/src/LambdaWorker/.gitignore new file mode 100644 index 0000000..69b7c32 --- /dev/null +++ b/src/LambdaWorker/.gitignore @@ -0,0 +1,2 @@ +temporal.toml +otel-collector-config.yaml diff --git a/src/LambdaWorker/Activities.cs b/src/LambdaWorker/Activities.cs new file mode 100644 index 0000000..11b7e37 --- /dev/null +++ b/src/LambdaWorker/Activities.cs @@ -0,0 +1,16 @@ +namespace TemporalioSamples.LambdaWorker; + +using Microsoft.Extensions.Logging; +using Temporalio.Activities; + +public static class Activities +{ + [Activity] + public static string HelloActivity(string name) + { + ActivityExecutionContext.Current.Logger.LogInformation( + "HelloActivity started with name: {Name}", + name); + return $"Hello, {name}!"; + } +} diff --git a/src/LambdaWorker/Function.cs b/src/LambdaWorker/Function.cs new file mode 100644 index 0000000..e5f5f36 --- /dev/null +++ b/src/LambdaWorker/Function.cs @@ -0,0 +1,25 @@ +namespace TemporalioSamples.LambdaWorker; + +using Amazon.Lambda.Core; +using Temporalio.Common; +using Temporalio.Extensions.Aws.Lambda; +using Temporalio.Extensions.Aws.Lambda.OpenTelemetry; + +public class LambdaFunction +{ + private static readonly Func WorkerHandler = + TemporalLambdaWorker.CreateHandler( + new WorkerDeploymentVersion( + LambdaWorkerSample.DeploymentName, + LambdaWorkerSample.BuildId), + Configure); + + public Task HandlerAsync(Stream input, ILambdaContext context) => + WorkerHandler(input, context); + + private static void Configure(LambdaWorkerConfig config) + { + LambdaWorkerSample.ConfigureWorkerOptions(config.WorkerOptions); + LambdaWorkerOpenTelemetry.ApplyDefaults(config); + } +} diff --git a/src/LambdaWorker/LambdaWorkerSample.cs b/src/LambdaWorker/LambdaWorkerSample.cs new file mode 100644 index 0000000..c9af0fc --- /dev/null +++ b/src/LambdaWorker/LambdaWorkerSample.cs @@ -0,0 +1,21 @@ +namespace TemporalioSamples.LambdaWorker; + +using Temporalio.Worker; + +public static class LambdaWorkerSample +{ + public const string TaskQueue = "serverless-task-queue-dotnet"; + public const string WorkflowId = "serverless-workflow-id-1"; + public const string DeploymentName = "my-app"; + public const string BuildId = "build-1"; + + public static TemporalWorkerOptions ConfigureWorkerOptions(TemporalWorkerOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + options.TaskQueue = TaskQueue; + return options. + AddWorkflow(). + AddActivity(Activities.HelloActivity); + } +} diff --git a/src/LambdaWorker/Program.cs b/src/LambdaWorker/Program.cs new file mode 100644 index 0000000..343f2ab --- /dev/null +++ b/src/LambdaWorker/Program.cs @@ -0,0 +1,31 @@ +using Microsoft.Extensions.Logging; +using Temporalio.Client; +using Temporalio.Extensions.Aws.Lambda; +using TemporalioSamples.LambdaWorker; + +if (args.Length > 0 && args[0] != "workflow") +{ + Console.WriteLine("Usage: dotnet run [workflow]"); + return; +} + +var connectOptions = TemporalLambdaWorker.LoadClientConnectOptions(); +connectOptions.LoggerFactory = LoggerFactory.Create(builder => + builder. + AddSimpleConsole(options => options.TimestampFormat = "[HH:mm:ss] "). + SetMinimumLevel(LogLevel.Information)); +var client = await TemporalClient.ConnectAsync(connectOptions); +Console.WriteLine("Connected to Temporal Service"); + +var workflowId = $"{LambdaWorkerSample.WorkflowId}-{Guid.NewGuid()}"; +var handle = await client.StartWorkflowAsync( + (SampleWorkflow wf) => wf.RunAsync("Serverless Lambda Worker!"), + new( + id: workflowId, + taskQueue: LambdaWorkerSample.TaskQueue)); + +Console.WriteLine($"Started Workflow ID: {handle.Id}"); +Console.WriteLine($"Started Run ID: {handle.ResultRunId}"); + +var result = await handle.GetResultAsync(); +Console.WriteLine($"Workflow result: {result}"); diff --git a/src/LambdaWorker/README.md b/src/LambdaWorker/README.md new file mode 100644 index 0000000..e0883dd --- /dev/null +++ b/src/LambdaWorker/README.md @@ -0,0 +1,261 @@ +# Lambda Worker + +This sample demonstrates how to run a Temporal Worker inside an AWS Lambda +function using the `Temporalio.Extensions.Aws.Lambda` package. It includes +OpenTelemetry instrumentation that exports traces and metrics through AWS Distro +for OpenTelemetry (ADOT). + +The sample registers a simple greeting Workflow and Activity, but the pattern +applies to any Workflow/Activity definitions. + +## Prerequisites + +- A [Temporal Cloud](https://temporal.io/cloud) namespace (or a self-hosted + Temporal cluster accessible from your Lambda) +- AWS CLI configured with permissions to create Lambda functions, IAM roles, and + CloudFormation stacks +- A Temporal API key stored in the Lambda function's `TEMPORAL_API_KEY` + environment variable +- .NET 8 + +## Files + +| File | Description | +|------|-------------| +| `Function.cs` | Lambda entry point that configures the worker, registers Workflows/Activities, and exports the handler | +| `SampleWorkflow.workflow.cs` | Sample Workflow that executes a greeting Activity | +| `Activities.cs` | Sample Activity that returns a greeting string | +| `Program.cs` | Helper program to start a Workflow execution from a local machine | +| `temporal.toml` | Temporal client connection configuration (update with your namespace) | +| `otel-collector-config.yaml` | OpenTelemetry Collector sidecar configuration for ADOT | +| `deploy-lambda.sh` | Packages and deploys the Lambda function | +| `mk-iam-role.sh` | Creates the IAM role that allows Temporal Cloud to invoke the Lambda | +| `iam-role-for-temporal-lambda-invoke-test.yaml` | CloudFormation template for the IAM role | +| `extra-setup-steps` | Additional IAM and Lambda configuration for OpenTelemetry support | + +## Setup + +The instructions here are a slimmed down version of the more complete getting +started guide, which you can find +[here](https://docs.temporal.io/production-deployment/worker-deployments/serverless-workers/aws-lambda). + +### 1. Create a Lambda function for your .NET worker + +Use either the AWS web UI or CLI to create a .NET 8 runtime Lambda function. Ex: + +```bash +aws lambda create-function \ + --function-name my-temporal-worker \ + --runtime dotnet8 \ + --handler TemporalioSamples.LambdaWorker::TemporalioSamples.LambdaWorker.LambdaFunction::HandlerAsync \ + --role arn:aws:iam:::role/my-temporal-worker-execution \ + --timeout 600 \ + --memory-size 256 +``` + +The handler uses the .NET Lambda class/method handler convention. This differs +from the Python sample's module-level handler and the TypeScript sample's +bundled handler, but it exposes the same Temporal Lambda worker behavior. + +### 2. Configure Temporal connection + +Edit `temporal.toml` with your Temporal Cloud namespace address and namespace. +The sample reads the API key from the `TEMPORAL_API_KEY` environment variable +instead of bundling credentials with the Lambda code. When an API key is +present, the .NET SDK enables TLS automatically. + +The Lambda worker loads Temporal client configuration in this order: + +1. `TEMPORAL_CONFIG_FILE`, if set. +2. `temporal.toml` from the Lambda task root, when running in Lambda. +3. `temporal.toml` from the current working directory. + +The config loader applies environment variable overrides, including: + +- `TEMPORAL_ADDRESS` +- `TEMPORAL_NAMESPACE` +- `TEMPORAL_API_KEY` + +Set the API key on the Lambda function: + +```bash +aws lambda update-function-configuration \ + --function-name my-temporal-worker \ + --environment "Variables={TEMPORAL_API_KEY=,SSL_CERT_FILE=/etc/pki/tls/certs/ca-bundle.crt}" +``` + +If you also enable OpenTelemetry, include +`OPENTELEMETRY_COLLECTOR_CONFIG_URI=/var/task/otel-collector-config.yaml` in the +same Lambda environment configuration. `SSL_CERT_FILE` works around CA loading +behavior in the .NET Lambda runtime that can otherwise produce +`NativeCertsNotFound` when connecting to Temporal Cloud. + +### 3. Create the IAM role + +This creates the IAM role that Temporal Cloud assumes to invoke your Lambda +function: + +```bash +./mk-iam-role.sh +``` + +The External ID is provided by Temporal Cloud in your namespace's serverless +worker configuration. + +### 4. Enable OpenTelemetry + +The sample calls `LambdaWorkerOpenTelemetry.ApplyDefaults` in `Function.cs`. +If you want traces, metrics, and logs, attach the ADOT Collector layer to your +Lambda function. You will need to add the appropriate layer for your runtime and +region. See +[this page](https://aws-otel.github.io/docs/getting-started/lambda#getting-started-with-aws-lambda-layers) +for more info. + +Set this environment variable on the Lambda function so the ADOT Collector uses +the bundled config: + +```bash +OPENTELEMETRY_COLLECTOR_CONFIG_URI=/var/task/otel-collector-config.yaml +``` + +Then run the extra setup to grant the Lambda role the necessary permissions: + +```bash +./extra-setup-steps +``` + +The bundled `otel-collector-config.yaml` uses Lambda's `AWS_REGION` and +`AWS_LAMBDA_FUNCTION_NAME` environment variables, so it does not need edits for +the normal single-function test deployment. + +### 5. Deploy the Lambda function + +```bash +./deploy-lambda.sh +``` + +This runs `dotnet publish`, bundles the publish output with your code and +configuration files, and uploads to AWS Lambda. + +### 6. Configure Temporal to invoke your Lambda function + +Refer to the docs +[here](https://docs.temporal.io/production-deployment/worker-deployments/serverless-workers/aws-lambda#create-worker-deployment-version). + +The worker deployment version in this sample is: + +- Deployment name: `my-app` +- Build ID: `build-1` + +If you create the worker deployment version through the Temporal UI, it is set +as current automatically. If you create it through the Temporal CLI, set it as +current before starting a Workflow: + +```bash +temporal --config-file temporal.toml --profile default --api-key "$TEMPORAL_API_KEY" \ + worker deployment set-current-version \ + --deployment-name my-app \ + --build-id build-1 \ + --allow-no-pollers \ + --yes +``` + +`--allow-no-pollers` is expected for this sample because the Lambda worker has +no long-running pollers before Temporal invokes the function. + +You can verify the deployment routing state with: + +```bash +temporal --config-file temporal.toml --profile default --api-key "$TEMPORAL_API_KEY" \ + worker deployment describe \ + --name my-app +``` + +To verify that Temporal can assume the IAM role and invoke the Lambda function, +open Workers > Deployments, select the deployment, open the Actions menu on the +version, and click Validate Connection. + +For a direct Lambda smoke test, temporarily lower the function timeout first. +The worker normally runs until shortly before the Lambda deadline, so directly +invoking a 600-second function can exceed the AWS CLI read timeout even when the +worker is healthy: + +```bash +ORIGINAL_TIMEOUT=$(aws lambda get-function-configuration \ + --function-name my-temporal-worker \ + --query Timeout \ + --output text) + +aws lambda update-function-configuration \ + --function-name my-temporal-worker \ + --timeout 30 + +aws lambda wait function-updated \ + --function-name my-temporal-worker + +aws lambda invoke \ + --function-name my-temporal-worker \ + --cli-binary-format raw-in-base64-out \ + --cli-read-timeout 60 \ + --payload '{}' \ + /tmp/temporal-lambda-bootstrap.json + +aws lambda update-function-configuration \ + --function-name my-temporal-worker \ + --timeout "$ORIGINAL_TIMEOUT" +``` + +### 7. Start a Workflow + +Use the starter program to execute a Workflow on the Lambda worker, using the +same config file the Lambda uses for connecting to the server. + +From inside this directory: + +```bash +TEMPORAL_CONFIG_FILE=temporal.toml TEMPORAL_API_KEY= \ + mise exec dotnet@8 -- dotnet run --project TemporalioSamples.LambdaWorker.csproj -- workflow +``` + +## Local SDK Development + +The sample uses NuGet package references by default. To test against a local +checkout of `../sdk-dotnet` before packages are published, create an untracked +`Directory.Build.local.props` file at the samples repo root: + +```xml + + + true + + + + + + + + + + + + + + + + + + +``` + +`Directory.Build.local.props` is ignored by Git. + +When deploying this sample from local SDK project references on macOS, the +publish step does not build the Linux native Temporal bridge that Lambda needs. +`deploy-lambda.sh` copies `libtemporalio_sdk_core_c_bridge.so` from the local +NuGet cache when it is missing from the publish output. The script defaults to +`linux-x64`; set `TEMPORAL_DOTNET_LAMBDA_RUNTIME=linux-arm64` if your Lambda +function uses arm64. To use a specific locally built Linux bridge instead, set: + +```bash +TEMPORAL_DOTNET_NATIVE_BRIDGE=/path/to/libtemporalio_sdk_core_c_bridge.so +``` diff --git a/src/LambdaWorker/SampleWorkflow.workflow.cs b/src/LambdaWorker/SampleWorkflow.workflow.cs new file mode 100644 index 0000000..ca266a9 --- /dev/null +++ b/src/LambdaWorker/SampleWorkflow.workflow.cs @@ -0,0 +1,20 @@ +namespace TemporalioSamples.LambdaWorker; + +using Microsoft.Extensions.Logging; +using Temporalio.Common; +using Temporalio.Workflows; + +[Workflow(VersioningBehavior = VersioningBehavior.Pinned)] +public class SampleWorkflow +{ + [WorkflowRun] + public async Task RunAsync(string name) + { + Workflow.Logger.LogInformation("SampleWorkflow started with name: {Name}", name); + var result = await Workflow.ExecuteActivityAsync( + () => Activities.HelloActivity(name), + new() { StartToCloseTimeout = TimeSpan.FromSeconds(10) }); + Workflow.Logger.LogInformation("SampleWorkflow completed with result: {Result}", result); + return result; + } +} diff --git a/src/LambdaWorker/TemporalioSamples.LambdaWorker.csproj b/src/LambdaWorker/TemporalioSamples.LambdaWorker.csproj new file mode 100644 index 0000000..44537dd --- /dev/null +++ b/src/LambdaWorker/TemporalioSamples.LambdaWorker.csproj @@ -0,0 +1,16 @@ + + + + Exe + + + + + + + + + + + + diff --git a/src/LambdaWorker/deploy-lambda.sh b/src/LambdaWorker/deploy-lambda.sh new file mode 100755 index 0000000..9ea79e2 --- /dev/null +++ b/src/LambdaWorker/deploy-lambda.sh @@ -0,0 +1,60 @@ +#!/bin/bash +set -euo pipefail + +FUNCTION_NAME="${1:?Usage: deploy-lambda.sh }" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PUBLISH_DIR="$SCRIPT_DIR/bin/lambda-publish" +ZIP_FILE="$SCRIPT_DIR/function.zip" +TARGET_RUNTIME="${TEMPORAL_DOTNET_LAMBDA_RUNTIME:-linux-x64}" + +case "$TARGET_RUNTIME" in + linux-x64|linux-arm64) + ;; + *) + echo "Unsupported TEMPORAL_DOTNET_LAMBDA_RUNTIME: $TARGET_RUNTIME" >&2 + echo "Use linux-x64 or linux-arm64." >&2 + exit 1 + ;; +esac + +rm -rf "$PUBLISH_DIR" "$ZIP_FILE" +dotnet publish "$SCRIPT_DIR/TemporalioSamples.LambdaWorker.csproj" \ + --configuration Release \ + --runtime "$TARGET_RUNTIME" \ + --self-contained false \ + --output "$PUBLISH_DIR" + +BRIDGE_FILE="libtemporalio_sdk_core_c_bridge.so" +if [[ ! -f "$PUBLISH_DIR/$BRIDGE_FILE" ]]; then + if [[ -n "${TEMPORAL_DOTNET_NATIVE_BRIDGE:-}" ]]; then + cp "$TEMPORAL_DOTNET_NATIVE_BRIDGE" "$PUBLISH_DIR/$BRIDGE_FILE" + elif [[ "$TARGET_RUNTIME" == "linux-x64" && -n "${TEMPORAL_DOTNET_LINUX_X64_BRIDGE:-}" ]]; then + cp "$TEMPORAL_DOTNET_LINUX_X64_BRIDGE" "$PUBLISH_DIR/$BRIDGE_FILE" + else + NUGET_PACKAGES_ROOT="${NUGET_PACKAGES:-}" + if [[ -z "$NUGET_PACKAGES_ROOT" ]]; then + NUGET_PACKAGES_ROOT="$(dotnet nuget locals global-packages --list | sed 's/^global-packages: //')" + fi + BRIDGE_FROM_NUGET="$( + find "$NUGET_PACKAGES_ROOT/temporalio" \ + -path "*/runtimes/$TARGET_RUNTIME/native/$BRIDGE_FILE" \ + -print 2>/dev/null | sort -V | tail -1 + )" + if [[ -z "$BRIDGE_FROM_NUGET" ]]; then + echo "Missing $BRIDGE_FILE in publish output." >&2 + echo "Set TEMPORAL_DOTNET_NATIVE_BRIDGE to a $TARGET_RUNTIME Temporal bridge library path." >&2 + exit 1 + fi + cp "$BRIDGE_FROM_NUGET" "$PUBLISH_DIR/$BRIDGE_FILE" + fi +fi + +cp "$SCRIPT_DIR/temporal.toml" "$SCRIPT_DIR/otel-collector-config.yaml" \ + "$PUBLISH_DIR/" + +cd "$PUBLISH_DIR" +zip -r "$ZIP_FILE" . + +aws lambda update-function-code --function-name "$FUNCTION_NAME" --zip-file fileb://"$ZIP_FILE" + +rm -rf "$PUBLISH_DIR" "$ZIP_FILE" diff --git a/src/LambdaWorker/extra-setup-steps b/src/LambdaWorker/extra-setup-steps new file mode 100755 index 0000000..11493a0 --- /dev/null +++ b/src/LambdaWorker/extra-setup-steps @@ -0,0 +1,48 @@ +#!/bin/bash +set -euo pipefail + +# Additional setup steps for OpenTelemetry support. +# These are needed if you want metrics, logs, and traces from your Lambda worker. + +ROLE_NAME="${1:?Usage: extra-setup-steps }" +FUNCTION_NAME="${2:?Usage: extra-setup-steps }" +REGION="${3:?Usage: extra-setup-steps }" +ACCOUNT_ID="${4:?Usage: extra-setup-steps }" + +# Needed to allow metrics/logs/traces to be published +aws iam put-role-policy \ + --role-name "$ROLE_NAME" \ + --policy-name ADOT-Telemetry-Permissions \ + --policy-document "{ + \"Version\": \"2012-10-17\", + \"Statement\": [ + { + \"Effect\": \"Allow\", + \"Action\": [ + \"logs:CreateLogGroup\", + \"logs:CreateLogStream\", + \"logs:PutLogEvents\" + ], + \"Resource\": \"arn:aws:logs:${REGION}:${ACCOUNT_ID}:log-group:/aws/lambda/${FUNCTION_NAME}:*\" + }, + { + \"Effect\": \"Allow\", + \"Action\": [ + \"xray:PutTraceSegments\", + \"xray:PutTelemetryRecords\" + ], + \"Resource\": \"*\" + }, + { + \"Effect\": \"Allow\", + \"Action\": [ + \"cloudwatch:PutMetricData\" + ], + \"Resource\": \"*\" + } + ] + }" + +# Needed to make traces show up with type: `"AWS::Lambda::Function"` filter +aws lambda update-function-configuration \ + --function-name "$FUNCTION_NAME" --tracing-config Mode=Active diff --git a/src/LambdaWorker/iam-role-for-temporal-lambda-invoke-test.yaml b/src/LambdaWorker/iam-role-for-temporal-lambda-invoke-test.yaml new file mode 100644 index 0000000..82dd16a --- /dev/null +++ b/src/LambdaWorker/iam-role-for-temporal-lambda-invoke-test.yaml @@ -0,0 +1,97 @@ +# CloudFormation template for creating an IAM role that Temporal Cloud can assume to invoke Lambda functions. +AWSTemplateFormatVersion: "2010-09-09" +Description: Creates an IAM role that Temporal Cloud can assume to invoke multiple Lambda functions for Serverless Workers. + +Parameters: + AssumeRoleExternalId: + Type: String + Description: The External ID provided by Temporal Cloud + AllowedPattern: "[a-zA-Z0-9_+=,.@-]*" + MinLength: 5 + MaxLength: 45 + + LambdaFunctionARNs: + Type: CommaDelimitedList + Description: >- + Comma-separated list of Lambda function ARNs to invoke (e.g., + arn:aws:lambda:us-west-2:123456789012:function:worker-1,arn:aws:lambda:us-west-2:123456789012:function:worker-2) + + RoleName: + Type: String + Default: "Temporal-Cloud-Serverless-Worker" + +Metadata: + AWS::CloudFormation::Interface: + ParameterGroups: + - Label: + default: "Temporal Cloud Configuration" + Parameters: + - AssumeRoleExternalId + - Label: + default: "Lambda Configuration" + Parameters: + - LambdaFunctionARNs + - RoleName + ParameterLabels: + AssumeRoleExternalId: + default: "External ID (provided by Temporal Cloud)" + LambdaFunctionARNs: + default: "Lambda Function ARNs (comma-separated list)" + RoleName: + default: "IAM Role Name" + +Resources: + TemporalCloudServerlessWorker: + Type: AWS::IAM::Role + Properties: + RoleName: !Sub "${RoleName}-${AWS::StackName}" + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Principal: + AWS: + [ + arn:aws:iam::902542641901:role/wci-lambda-invoke, + arn:aws:iam::160190466495:role/wci-lambda-invoke, + arn:aws:iam::819232936619:role/wci-lambda-invoke, + arn:aws:iam::829909441867:role/wci-lambda-invoke, + arn:aws:iam::354116250941:role/wci-lambda-invoke, + ] + Action: sts:AssumeRole + Condition: + StringEquals: + "sts:ExternalId": [!Ref AssumeRoleExternalId] + Description: "The role Temporal Cloud uses to invoke Lambda functions for Serverless Workers" + MaxSessionDuration: 3600 # 1 hour + + TemporalCloudLambdaInvokePermissions: + Type: AWS::IAM::Policy + DependsOn: TemporalCloudServerlessWorker + Properties: + PolicyName: "Temporal-Cloud-Lambda-Invoke-Permissions" + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Action: + - lambda:InvokeFunction + - lambda:GetFunction + Resource: !Ref LambdaFunctionARNs + Roles: + - !Sub "${RoleName}-${AWS::StackName}" + +Outputs: + RoleARN: + Description: The ARN of the IAM role created for Temporal Cloud + Value: !GetAtt TemporalCloudServerlessWorker.Arn + Export: + Name: !Sub "${AWS::StackName}-RoleARN" + + RoleName: + Description: The name of the IAM role + Value: !Ref RoleName + + LambdaFunctionARNs: + Description: The Lambda function ARNs that can be invoked + Value: !Join [", ", !Ref LambdaFunctionARNs] diff --git a/src/LambdaWorker/mk-iam-role.sh b/src/LambdaWorker/mk-iam-role.sh new file mode 100755 index 0000000..506dd52 --- /dev/null +++ b/src/LambdaWorker/mk-iam-role.sh @@ -0,0 +1,17 @@ +#!/bin/bash +set -euo pipefail + +# Creates the IAM role that allows Temporal Cloud to invoke your Lambda function. +# You can find the External ID in your Temporal Cloud namespace settings. + +STACK_NAME="${1:?Usage: mk-iam-role.sh }" +EXTERNAL_ID="${2:?Usage: mk-iam-role.sh }" +LAMBDA_ARN="${3:?Usage: mk-iam-role.sh }" + +aws cloudformation create-stack \ + --stack-name "$STACK_NAME" \ + --template-body file://iam-role-for-temporal-lambda-invoke-test.yaml \ + --parameters \ + ParameterKey=AssumeRoleExternalId,ParameterValue="$EXTERNAL_ID" \ + ParameterKey=LambdaFunctionARNs,ParameterValue="$LAMBDA_ARN" \ + --capabilities CAPABILITY_NAMED_IAM diff --git a/src/LambdaWorker/otel-collector-config.yaml.sample b/src/LambdaWorker/otel-collector-config.yaml.sample new file mode 100644 index 0000000..9e3122c --- /dev/null +++ b/src/LambdaWorker/otel-collector-config.yaml.sample @@ -0,0 +1,33 @@ +receivers: + otlp: + protocols: + grpc: + endpoint: "localhost:4317" + http: + endpoint: "localhost:4318" + +exporters: + debug: + awsxray: + region: ${env:AWS_REGION} + awsemf: + namespace: TemporalWorkerMetrics + log_group_name: /aws/lambda/${env:AWS_LAMBDA_FUNCTION_NAME} + region: ${env:AWS_REGION} + dimension_rollup_option: NoDimensionRollup + resource_to_telemetry_conversion: + enabled: true + +service: + pipelines: + traces: + receivers: [otlp] + exporters: [awsxray, debug] + metrics: + receivers: [otlp] + exporters: [awsemf] + telemetry: + logs: + level: debug + metrics: + address: localhost:8888 diff --git a/src/LambdaWorker/temporal.toml.sample b/src/LambdaWorker/temporal.toml.sample new file mode 100644 index 0000000..a9bfe1e --- /dev/null +++ b/src/LambdaWorker/temporal.toml.sample @@ -0,0 +1,6 @@ +[profile.default] +address = "..tmprl.cloud:7233" +namespace = "." + +# Set TEMPORAL_API_KEY in the Lambda function environment. When an API key is +# present, the SDK enables TLS automatically. diff --git a/src/NexusContextPropagation/NexusContextPropagationInterceptor.cs b/src/NexusContextPropagation/NexusContextPropagationInterceptor.cs index 62d5425..31ec13d 100644 --- a/src/NexusContextPropagation/NexusContextPropagationInterceptor.cs +++ b/src/NexusContextPropagation/NexusContextPropagationInterceptor.cs @@ -48,8 +48,8 @@ private class WorkflowOutbound( string headerKey, WorkflowOutboundInterceptor next) : WorkflowOutboundInterceptor(next) { - public override Task> StartNexusOperationAsync( - StartNexusOperationInput input) + public override Task> ScheduleNexusOperationAsync( + ScheduleNexusOperationInput input) { if (context.Value is { } value) { @@ -58,7 +58,7 @@ public override Task> StartNexusOperationA headers.Add(headerKey, value); input = input with { Headers = headers }; } - return base.StartNexusOperationAsync(input); + return base.ScheduleNexusOperationAsync(input); } } -} \ No newline at end of file +} diff --git a/tests/LambdaWorker/LambdaWorkerTests.cs b/tests/LambdaWorker/LambdaWorkerTests.cs new file mode 100644 index 0000000..b5de5ec --- /dev/null +++ b/tests/LambdaWorker/LambdaWorkerTests.cs @@ -0,0 +1,49 @@ +namespace TemporalioSamples.Tests.LambdaWorker; + +using Temporalio.Client; +using Temporalio.Common; +using Temporalio.Testing; +using Temporalio.Worker; +using TemporalioSamples.LambdaWorker; +using Xunit; +using Xunit.Abstractions; + +public class LambdaWorkerTests : TestBase +{ + public LambdaWorkerTests(ITestOutputHelper output) + : base(output) + { + } + + [TimeSkippingServerFact] + public async Task RunAsync_SimpleRun_Succeeds() + { + await using var env = await WorkflowEnvironment.StartTimeSkippingAsync(); + using var worker = new TemporalWorker( + env.Client, + LambdaWorkerSample.ConfigureWorkerOptions( + new TemporalWorkerOptions("lambda-worker-test-task-queue"))); + await worker.ExecuteAsync(async () => + { + var result = await env.Client.ExecuteWorkflowAsync( + (SampleWorkflow wf) => wf.RunAsync("Serverless Lambda Worker!"), + new(id: $"workflow-{Guid.NewGuid()}", taskQueue: worker.Options.TaskQueue!)); + Assert.Equal("Hello, Serverless Lambda Worker!!", result); + }); + } + + [Fact] + public void ConfigureWorkerOptions_SetsExpectedWorkerRegistration() + { + var options = LambdaWorkerSample.ConfigureWorkerOptions(new TemporalWorkerOptions()); + + Assert.Equal(LambdaWorkerSample.TaskQueue, options.TaskQueue); + Assert.Contains(options.Workflows, workflow => workflow.Type == typeof(SampleWorkflow)); + Assert.Contains( + options.Activities, + activity => activity.MethodInfo?.DeclaringType == typeof(Activities)); + var workflow = options.Workflows.Single( + workflow => workflow.Type == typeof(SampleWorkflow)); + Assert.Equal(VersioningBehavior.Pinned, workflow.VersioningBehavior); + } +} diff --git a/tests/TemporalioSamples.Tests.csproj b/tests/TemporalioSamples.Tests.csproj index 5893aca..9a8751b 100644 --- a/tests/TemporalioSamples.Tests.csproj +++ b/tests/TemporalioSamples.Tests.csproj @@ -25,6 +25,7 @@ +