From a46fc15f1bc00e2d042372a0a65f0ba6231b266c Mon Sep 17 00:00:00 2001 From: Edward Amsden Date: Fri, 29 May 2026 17:42:54 -0500 Subject: [PATCH 1/5] 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 | 2 +- tests/LambdaWorker/LambdaWorkerTests.cs | 49 ++++ tests/TemporalioSamples.Tests.csproj | 1 + 22 files changed, 729 insertions(+), 2 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 cff4a17..8b77a99 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 b569fe7..92d75f2 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. @@ -84,4 +85,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 4e560c4..f466756 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}" @@ -750,6 +752,18 @@ Global {6B6622AE-2970-4AAD-B2E4-A7EE1E2C40EA}.Release|x64.Build.0 = Release|Any CPU {6B6622AE-2970-4AAD-B2E4-A7EE1E2C40EA}.Release|x86.ActiveCfg = Release|Any CPU {6B6622AE-2970-4AAD-B2E4-A7EE1E2C40EA}.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 @@ -798,6 +812,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 dab70b9..31ec13d 100644 --- a/src/NexusContextPropagation/NexusContextPropagationInterceptor.cs +++ b/src/NexusContextPropagation/NexusContextPropagationInterceptor.cs @@ -61,4 +61,4 @@ public override Task> ScheduleNexusOperati 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 ef31659..d93cfd4 100644 --- a/tests/TemporalioSamples.Tests.csproj +++ b/tests/TemporalioSamples.Tests.csproj @@ -26,6 +26,7 @@ + From d5f59fe7289e8db9923f861c21a9618f20fa0769 Mon Sep 17 00:00:00 2001 From: Lenny Chen Date: Tue, 16 Jun 2026 15:32:48 -0700 Subject: [PATCH 2/5] Add snipsync markers for docs --- src/LambdaWorker/Function.cs | 2 ++ src/LambdaWorker/SampleWorkflow.workflow.cs | 2 ++ src/LambdaWorker/otel-collector-config.yaml.sample | 2 ++ 3 files changed, 6 insertions(+) diff --git a/src/LambdaWorker/Function.cs b/src/LambdaWorker/Function.cs index e5f5f36..54a40a6 100644 --- a/src/LambdaWorker/Function.cs +++ b/src/LambdaWorker/Function.cs @@ -1,3 +1,4 @@ +// @@@SNIPSTART dotnet-lambda-worker namespace TemporalioSamples.LambdaWorker; using Amazon.Lambda.Core; @@ -23,3 +24,4 @@ private static void Configure(LambdaWorkerConfig config) LambdaWorkerOpenTelemetry.ApplyDefaults(config); } } +// @@@SNIPEND diff --git a/src/LambdaWorker/SampleWorkflow.workflow.cs b/src/LambdaWorker/SampleWorkflow.workflow.cs index ca266a9..43abd2e 100644 --- a/src/LambdaWorker/SampleWorkflow.workflow.cs +++ b/src/LambdaWorker/SampleWorkflow.workflow.cs @@ -1,3 +1,4 @@ +// @@@SNIPSTART dotnet-lambda-worker-workflow namespace TemporalioSamples.LambdaWorker; using Microsoft.Extensions.Logging; @@ -18,3 +19,4 @@ public async Task RunAsync(string name) return result; } } +// @@@SNIPEND diff --git a/src/LambdaWorker/otel-collector-config.yaml.sample b/src/LambdaWorker/otel-collector-config.yaml.sample index 9e3122c..999ca7e 100644 --- a/src/LambdaWorker/otel-collector-config.yaml.sample +++ b/src/LambdaWorker/otel-collector-config.yaml.sample @@ -1,3 +1,4 @@ +# @@@SNIPSTART dotnet-lambda-worker-otel-collector-config receivers: otlp: protocols: @@ -31,3 +32,4 @@ service: level: debug metrics: address: localhost:8888 +# @@@SNIPEND From 8c80e29f36bb1cdd53f874b706a35d35f22217a7 Mon Sep 17 00:00:00 2001 From: Edward Amsden Date: Thu, 9 Jul 2026 12:05:19 -0500 Subject: [PATCH 3/5] Updates for Lambda worker sample to merged SDK lambda support --- src/LambdaWorker/Function.cs | 2 +- src/LambdaWorker/LambdaWorkerSample.cs | 33 ++++++++++++++++++++--- src/LambdaWorker/Program.cs | 4 +-- tests/LambdaWorker/LambdaWorkerTests.cs | 36 +++++++++++++++++++++++++ 4 files changed, 68 insertions(+), 7 deletions(-) diff --git a/src/LambdaWorker/Function.cs b/src/LambdaWorker/Function.cs index 54a40a6..e9e7c05 100644 --- a/src/LambdaWorker/Function.cs +++ b/src/LambdaWorker/Function.cs @@ -18,7 +18,7 @@ public class LambdaFunction public Task HandlerAsync(Stream input, ILambdaContext context) => WorkerHandler(input, context); - private static void Configure(LambdaWorkerConfig config) + private static void Configure(TemporalLambdaWorkerOptions config) { LambdaWorkerSample.ConfigureWorkerOptions(config.WorkerOptions); LambdaWorkerOpenTelemetry.ApplyDefaults(config); diff --git a/src/LambdaWorker/LambdaWorkerSample.cs b/src/LambdaWorker/LambdaWorkerSample.cs index c9af0fc..9603cde 100644 --- a/src/LambdaWorker/LambdaWorkerSample.cs +++ b/src/LambdaWorker/LambdaWorkerSample.cs @@ -4,10 +4,27 @@ namespace TemporalioSamples.LambdaWorker; 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 const string TaskQueueEnvironmentVariable = "TEMPORAL_TASK_QUEUE"; + public const string WorkflowIdEnvironmentVariable = "TEMPORAL_LAMBDA_WORKFLOW_ID_PREFIX"; + public const string DeploymentNameEnvironmentVariable = "TEMPORAL_LAMBDA_DEPLOYMENT_NAME"; + public const string BuildIdEnvironmentVariable = "TEMPORAL_LAMBDA_BUILD_ID"; + + public const string DefaultTaskQueue = "serverless-task-queue-dotnet"; + public const string DefaultWorkflowId = "serverless-workflow-id-1"; + public const string DefaultDeploymentName = "my-app"; + public const string DefaultBuildId = "build-1"; + + public static string TaskQueue => + GetEnvironmentVariableOrDefault(TaskQueueEnvironmentVariable, DefaultTaskQueue); + + public static string WorkflowId => + GetEnvironmentVariableOrDefault(WorkflowIdEnvironmentVariable, DefaultWorkflowId); + + public static string DeploymentName => + GetEnvironmentVariableOrDefault(DeploymentNameEnvironmentVariable, DefaultDeploymentName); + + public static string BuildId => + GetEnvironmentVariableOrDefault(BuildIdEnvironmentVariable, DefaultBuildId); public static TemporalWorkerOptions ConfigureWorkerOptions(TemporalWorkerOptions options) { @@ -18,4 +35,12 @@ public static TemporalWorkerOptions ConfigureWorkerOptions(TemporalWorkerOptions AddWorkflow(). AddActivity(Activities.HelloActivity); } + + private static string GetEnvironmentVariableOrDefault( + string name, + string defaultValue) => + Environment.GetEnvironmentVariable(name) is { } value && + !string.IsNullOrWhiteSpace(value) ? + value : + defaultValue; } diff --git a/src/LambdaWorker/Program.cs b/src/LambdaWorker/Program.cs index 343f2ab..fccd123 100644 --- a/src/LambdaWorker/Program.cs +++ b/src/LambdaWorker/Program.cs @@ -1,6 +1,6 @@ using Microsoft.Extensions.Logging; using Temporalio.Client; -using Temporalio.Extensions.Aws.Lambda; +using Temporalio.Common.EnvConfig; using TemporalioSamples.LambdaWorker; if (args.Length > 0 && args[0] != "workflow") @@ -9,7 +9,7 @@ return; } -var connectOptions = TemporalLambdaWorker.LoadClientConnectOptions(); +var connectOptions = ClientEnvConfig.LoadClientConnectOptions(); connectOptions.LoggerFactory = LoggerFactory.Create(builder => builder. AddSimpleConsole(options => options.TimestampFormat = "[HH:mm:ss] "). diff --git a/tests/LambdaWorker/LambdaWorkerTests.cs b/tests/LambdaWorker/LambdaWorkerTests.cs index b5de5ec..a39e53d 100644 --- a/tests/LambdaWorker/LambdaWorkerTests.cs +++ b/tests/LambdaWorker/LambdaWorkerTests.cs @@ -46,4 +46,40 @@ public void ConfigureWorkerOptions_SetsExpectedWorkerRegistration() workflow => workflow.Type == typeof(SampleWorkflow)); Assert.Equal(VersioningBehavior.Pinned, workflow.VersioningBehavior); } + + [Fact] + public void ConfigureWorkerOptions_UsesEnvironmentOverrides() + { + using var taskQueue = + new EnvironmentVariableScope(LambdaWorkerSample.TaskQueueEnvironmentVariable, "fresh-task-queue"); + using var workflowId = + new EnvironmentVariableScope(LambdaWorkerSample.WorkflowIdEnvironmentVariable, "fresh-workflow"); + using var deploymentName = + new EnvironmentVariableScope(LambdaWorkerSample.DeploymentNameEnvironmentVariable, "fresh-deployment"); + using var buildId = + new EnvironmentVariableScope(LambdaWorkerSample.BuildIdEnvironmentVariable, "fresh-build"); + + var options = LambdaWorkerSample.ConfigureWorkerOptions(new TemporalWorkerOptions()); + + Assert.Equal("fresh-task-queue", options.TaskQueue); + Assert.Equal("fresh-workflow", LambdaWorkerSample.WorkflowId); + Assert.Equal("fresh-deployment", LambdaWorkerSample.DeploymentName); + Assert.Equal("fresh-build", LambdaWorkerSample.BuildId); + } + + private sealed class EnvironmentVariableScope : IDisposable + { + private readonly string name; + private readonly string? originalValue; + + public EnvironmentVariableScope(string name, string value) + { + this.name = name; + originalValue = Environment.GetEnvironmentVariable(name); + Environment.SetEnvironmentVariable(name, value); + } + + public void Dispose() => + Environment.SetEnvironmentVariable(name, originalValue); + } } From 78c85582f46e0a1fa9cabb33407a4b6f4456cbec Mon Sep 17 00:00:00 2001 From: Edward Amsden Date: Mon, 13 Jul 2026 16:30:37 -0500 Subject: [PATCH 4/5] Fix AWS Lambda sample deployment workflow --- .gitignore | 3 - Directory.Build.props | 2 - src/LambdaWorker/Function.cs | 2 +- src/LambdaWorker/README.md | 63 +++++-------------- .../TemporalioSamples.LambdaWorker.csproj | 2 +- src/LambdaWorker/deploy-lambda.sh | 32 +++------- src/LambdaWorker/extra-setup-steps | 5 +- ...-role-for-temporal-lambda-invoke-test.yaml | 2 +- .../otel-collector-config.yaml.sample | 5 +- 9 files changed, 32 insertions(+), 84 deletions(-) diff --git a/.gitignore b/.gitignore index 00e2bea..97a330f 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,3 @@ obj/ /.vs /.vscode /.idea - -# Local developer overrides -Directory.Build.local.props diff --git a/Directory.Build.props b/Directory.Build.props index ca7cae1..3eb3e4c 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -35,6 +35,4 @@ - - diff --git a/src/LambdaWorker/Function.cs b/src/LambdaWorker/Function.cs index e9e7c05..63cc66f 100644 --- a/src/LambdaWorker/Function.cs +++ b/src/LambdaWorker/Function.cs @@ -21,7 +21,7 @@ public Task HandlerAsync(Stream input, ILambdaContext context) => private static void Configure(TemporalLambdaWorkerOptions config) { LambdaWorkerSample.ConfigureWorkerOptions(config.WorkerOptions); - LambdaWorkerOpenTelemetry.ApplyDefaults(config); + config.ApplyOpenTelemetryDefaults(); } } // @@@SNIPEND diff --git a/src/LambdaWorker/README.md b/src/LambdaWorker/README.md index e0883dd..f1510e3 100644 --- a/src/LambdaWorker/README.md +++ b/src/LambdaWorker/README.md @@ -81,7 +81,9 @@ 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}" + --environment "Variables={TEMPORAL_API_KEY=,SSL_CERT_FILE=/etc/pki/tls/certs/ca-bundle.crt}" \ + --query '{FunctionName:FunctionName,LastUpdateStatus:LastUpdateStatus,RevisionId:RevisionId}' \ + --output json ``` If you also enable OpenTelemetry, include @@ -104,8 +106,8 @@ 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 +The sample calls `ApplyOpenTelemetryDefaults` in `Function.cs`. +If you want traces and metrics, 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) @@ -137,6 +139,10 @@ the normal single-function test deployment. This runs `dotnet publish`, bundles the publish output with your code and configuration files, and uploads to AWS Lambda. +The script publishes for `linux-x64` by default. Set +`TEMPORAL_DOTNET_LAMBDA_RUNTIME=linux-arm64` when the Lambda function uses the +Arm64 architecture. + ### 6. Configure Temporal to invoke your Lambda function Refer to the docs @@ -188,7 +194,9 @@ ORIGINAL_TIMEOUT=$(aws lambda get-function-configuration \ aws lambda update-function-configuration \ --function-name my-temporal-worker \ - --timeout 30 + --timeout 30 \ + --query '{FunctionName:FunctionName,Timeout:Timeout,LastUpdateStatus:LastUpdateStatus}' \ + --output json aws lambda wait function-updated \ --function-name my-temporal-worker @@ -202,7 +210,9 @@ aws lambda invoke \ aws lambda update-function-configuration \ --function-name my-temporal-worker \ - --timeout "$ORIGINAL_TIMEOUT" + --timeout "$ORIGINAL_TIMEOUT" \ + --query '{FunctionName:FunctionName,Timeout:Timeout,LastUpdateStatus:LastUpdateStatus}' \ + --output json ``` ### 7. Start a Workflow @@ -216,46 +226,3 @@ From inside this directory: 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/TemporalioSamples.LambdaWorker.csproj b/src/LambdaWorker/TemporalioSamples.LambdaWorker.csproj index 44537dd..9e1e747 100644 --- a/src/LambdaWorker/TemporalioSamples.LambdaWorker.csproj +++ b/src/LambdaWorker/TemporalioSamples.LambdaWorker.csproj @@ -8,7 +8,7 @@ - + diff --git a/src/LambdaWorker/deploy-lambda.sh b/src/LambdaWorker/deploy-lambda.sh index 9ea79e2..7f5455f 100755 --- a/src/LambdaWorker/deploy-lambda.sh +++ b/src/LambdaWorker/deploy-lambda.sh @@ -24,29 +24,9 @@ dotnet publish "$SCRIPT_DIR/TemporalioSamples.LambdaWorker.csproj" \ --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 +if [[ ! -f "$PUBLISH_DIR/libtemporalio_sdk_core_c_bridge.so" ]]; then + echo "Publish output is missing the $TARGET_RUNTIME Temporal native bridge." >&2 + exit 1 fi cp "$SCRIPT_DIR/temporal.toml" "$SCRIPT_DIR/otel-collector-config.yaml" \ @@ -55,6 +35,10 @@ cp "$SCRIPT_DIR/temporal.toml" "$SCRIPT_DIR/otel-collector-config.yaml" \ cd "$PUBLISH_DIR" zip -r "$ZIP_FILE" . -aws lambda update-function-code --function-name "$FUNCTION_NAME" --zip-file fileb://"$ZIP_FILE" +aws lambda update-function-code \ + --function-name "$FUNCTION_NAME" \ + --zip-file fileb://"$ZIP_FILE" \ + --query '{FunctionName:FunctionName,CodeSha256:CodeSha256,LastModified:LastModified,RevisionId:RevisionId}' \ + --output json rm -rf "$PUBLISH_DIR" "$ZIP_FILE" diff --git a/src/LambdaWorker/extra-setup-steps b/src/LambdaWorker/extra-setup-steps index 11493a0..dd4d6e1 100755 --- a/src/LambdaWorker/extra-setup-steps +++ b/src/LambdaWorker/extra-setup-steps @@ -45,4 +45,7 @@ aws iam put-role-policy \ # 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 + --function-name "$FUNCTION_NAME" \ + --tracing-config Mode=Active \ + --query '{FunctionName:FunctionName,TracingConfig:TracingConfig,LastUpdateStatus:LastUpdateStatus,RevisionId:RevisionId}' \ + --output json diff --git a/src/LambdaWorker/iam-role-for-temporal-lambda-invoke-test.yaml b/src/LambdaWorker/iam-role-for-temporal-lambda-invoke-test.yaml index 82dd16a..e4499b4 100644 --- a/src/LambdaWorker/iam-role-for-temporal-lambda-invoke-test.yaml +++ b/src/LambdaWorker/iam-role-for-temporal-lambda-invoke-test.yaml @@ -90,7 +90,7 @@ Outputs: RoleName: Description: The name of the IAM role - Value: !Ref RoleName + Value: !Ref TemporalCloudServerlessWorker LambdaFunctionARNs: Description: The Lambda function ARNs that can be invoked diff --git a/src/LambdaWorker/otel-collector-config.yaml.sample b/src/LambdaWorker/otel-collector-config.yaml.sample index 999ca7e..a04025e 100644 --- a/src/LambdaWorker/otel-collector-config.yaml.sample +++ b/src/LambdaWorker/otel-collector-config.yaml.sample @@ -8,7 +8,6 @@ receivers: endpoint: "localhost:4318" exporters: - debug: awsxray: region: ${env:AWS_REGION} awsemf: @@ -23,13 +22,13 @@ service: pipelines: traces: receivers: [otlp] - exporters: [awsxray, debug] + exporters: [awsxray] metrics: receivers: [otlp] exporters: [awsemf] telemetry: logs: - level: debug + level: info metrics: address: localhost:8888 # @@@SNIPEND From c148a23b424b4db4d3c68aad190d3c4286cc72a1 Mon Sep 17 00:00:00 2001 From: Edward Amsden Date: Mon, 13 Jul 2026 17:27:14 -0500 Subject: [PATCH 5/5] Align Lambda sample with tested deployment flow --- TemporalioSamples.sln | 14 + src/LambdaWorker/Function.cs | 12 +- src/LambdaWorker/README.md | 363 +++++++++++------- src/LambdaWorker/{ => Starter}/Program.cs | 0 ...poralioSamples.LambdaWorker.Starter.csproj | 11 + .../TemporalioSamples.LambdaWorker.csproj | 6 +- src/LambdaWorker/deploy-lambda.sh | 60 ++- src/LambdaWorker/extra-setup-steps | 3 +- ...-role-for-temporal-lambda-invoke-test.yaml | 4 +- src/LambdaWorker/mk-iam-role.sh | 15 +- src/LambdaWorker/mk-lambda-execution-role.sh | 35 ++ 11 files changed, 355 insertions(+), 168 deletions(-) rename src/LambdaWorker/{ => Starter}/Program.cs (100%) create mode 100644 src/LambdaWorker/Starter/TemporalioSamples.LambdaWorker.Starter.csproj create mode 100755 src/LambdaWorker/mk-lambda-execution-role.sh diff --git a/TemporalioSamples.sln b/TemporalioSamples.sln index f466756..90d2f10 100644 --- a/TemporalioSamples.sln +++ b/TemporalioSamples.sln @@ -99,6 +99,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TemporalioSamples.EnvConfig 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.LambdaWorker.Starter", "src\LambdaWorker\Starter\TemporalioSamples.LambdaWorker.Starter.csproj", "{8A7E9E3C-3F5A-4C0B-9F4D-6C4C673EB25D}" +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}" @@ -764,6 +766,18 @@ Global {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 + {8A7E9E3C-3F5A-4C0B-9F4D-6C4C673EB25D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8A7E9E3C-3F5A-4C0B-9F4D-6C4C673EB25D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8A7E9E3C-3F5A-4C0B-9F4D-6C4C673EB25D}.Debug|x64.ActiveCfg = Debug|Any CPU + {8A7E9E3C-3F5A-4C0B-9F4D-6C4C673EB25D}.Debug|x64.Build.0 = Debug|Any CPU + {8A7E9E3C-3F5A-4C0B-9F4D-6C4C673EB25D}.Debug|x86.ActiveCfg = Debug|Any CPU + {8A7E9E3C-3F5A-4C0B-9F4D-6C4C673EB25D}.Debug|x86.Build.0 = Debug|Any CPU + {8A7E9E3C-3F5A-4C0B-9F4D-6C4C673EB25D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8A7E9E3C-3F5A-4C0B-9F4D-6C4C673EB25D}.Release|Any CPU.Build.0 = Release|Any CPU + {8A7E9E3C-3F5A-4C0B-9F4D-6C4C673EB25D}.Release|x64.ActiveCfg = Release|Any CPU + {8A7E9E3C-3F5A-4C0B-9F4D-6C4C673EB25D}.Release|x64.Build.0 = Release|Any CPU + {8A7E9E3C-3F5A-4C0B-9F4D-6C4C673EB25D}.Release|x86.ActiveCfg = Release|Any CPU + {8A7E9E3C-3F5A-4C0B-9F4D-6C4C673EB25D}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/LambdaWorker/Function.cs b/src/LambdaWorker/Function.cs index 63cc66f..d72c5d6 100644 --- a/src/LambdaWorker/Function.cs +++ b/src/LambdaWorker/Function.cs @@ -13,15 +13,13 @@ public class LambdaFunction new WorkerDeploymentVersion( LambdaWorkerSample.DeploymentName, LambdaWorkerSample.BuildId), - Configure); + config => + { + config.ApplyOpenTelemetryDefaults(); + LambdaWorkerSample.ConfigureWorkerOptions(config.WorkerOptions); + }); public Task HandlerAsync(Stream input, ILambdaContext context) => WorkerHandler(input, context); - - private static void Configure(TemporalLambdaWorkerOptions config) - { - LambdaWorkerSample.ConfigureWorkerOptions(config.WorkerOptions); - config.ApplyOpenTelemetryDefaults(); - } } // @@@SNIPEND diff --git a/src/LambdaWorker/README.md b/src/LambdaWorker/README.md index f1510e3..b7df7f6 100644 --- a/src/LambdaWorker/README.md +++ b/src/LambdaWorker/README.md @@ -1,228 +1,299 @@ # 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). +function using the `Temporalio.Extensions.Aws.Lambda` package. It 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. +The sample registers a greeting Workflow and Activity. A separate starter +project starts the Workflow from a local machine; starter-only code is not +included in the Lambda deployment package. ## 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 +- A [Temporal Cloud](https://temporal.io/cloud) namespace, or a self-hosted + Temporal cluster that the Lambda function can reach +- A Temporal API key for that namespace, stored in a local file outside this + repository +- The Temporal CLI +- AWS CLI credentials with permission to manage Lambda, IAM, CloudFormation, + CloudWatch Logs, CloudWatch metrics, and X-Ray in the target account +- .NET 8, `zip`, and `openssl` +- An ADOT Collector Lambda layer ARN for the target region and architecture; + see the + [ADOT Lambda documentation](https://aws-otel.github.io/docs/getting-started/lambda#getting-started-with-aws-lambda-layers) + +Run the commands below from `src/LambdaWorker`. ## 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 | +| `Function.cs` | Lambda entry point that configures and runs the worker | +| `LambdaWorkerSample.cs` | Shared task queue, deployment, and worker registration | +| `SampleWorkflow.workflow.cs` | Greeting Workflow | +| `Activities.cs` | Greeting Activity | +| `Starter/Program.cs` | Local Workflow starter | +| `temporal.toml.sample` | Temporal client configuration template | +| `otel-collector-config.yaml.sample` | ADOT Collector configuration template | +| `mk-lambda-execution-role.sh` | Creates the role assumed by the Lambda service | +| `deploy-lambda.sh` | Publishes the worker and creates or updates the Lambda function | +| `mk-iam-role.sh` | Creates the role that Temporal Cloud assumes to invoke Lambda | +| `extra-setup-steps` | Grants telemetry permissions and enables active X-Ray tracing | + +## 1. Choose fresh identifiers and AWS context + +Use unique names for every dry run so existing deployments or concurrent tests +cannot affect the result: -## Setup +```bash +export AWS_PROFILE="" +export AWS_REGION="us-west-2" +export AWS_ACCOUNT_ID="$(aws sts get-caller-identity --query Account --output text)" + +export SUFFIX="$(date -u +%Y%m%d%H%M%S)-$(openssl rand -hex 3)" +export FUNCTION_NAME="temporal-dotnet-lambda-${SUFFIX}" +export EXECUTION_ROLE_NAME="temporal-dotnet-exec-${SUFFIX}" +export STACK_NAME="tdnl-${SUFFIX}" +export DEPLOYMENT_NAME="dotnet-lambda-${SUFFIX}" +export BUILD_ID="build-${SUFFIX}" +export TASK_QUEUE="dotnet-lambda-tq-${SUFFIX}" +export WORKFLOW_ID_PREFIX="dotnet-lambda-wf-${SUFFIX}" +export EXTERNAL_ID="$(openssl rand -hex 16)" + +export ADOT_LAYER_ARN="" +export TEMPORAL_API_KEY_FILE="" +export TEMPORAL_API_KEY="$(tr -d '\r\n' < "$TEMPORAL_API_KEY_FILE")" +``` -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). +The AWS CLI commands and scripts inherit `AWS_PROFILE` and `AWS_REGION`. +The same `EXTERNAL_ID` must be supplied to both AWS IAM and Temporal Cloud. -### 1. Create a Lambda function for your .NET worker +## 2. Create the local configuration files -Use either the AWS web UI or CLI to create a .NET 8 runtime Lambda function. Ex: +The unsuffixed files are intentionally ignored because `temporal.toml` +contains namespace-specific configuration: ```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 +cp temporal.toml.sample temporal.toml +cp otel-collector-config.yaml.sample otel-collector-config.yaml ``` -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. +Edit `temporal.toml` and replace the address and namespace placeholders: -### 2. Configure Temporal connection +```toml +[profile.default] +address = "..tmprl.cloud:7233" +namespace = "." +``` -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 API key remains in `TEMPORAL_API_KEY`; do not add it to either file. +When an API key is present, the .NET SDK and Temporal CLI enable TLS +automatically. -The Lambda worker loads Temporal client configuration in this order: +The Lambda worker loads Temporal configuration in this order: 1. `TEMPORAL_CONFIG_FILE`, if set. -2. `temporal.toml` from the Lambda task root, when running in Lambda. +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: +## 3. Create the Lambda execution role and deploy -- `TEMPORAL_ADDRESS` -- `TEMPORAL_NAMESPACE` -- `TEMPORAL_API_KEY` +Create the IAM role assumed by the Lambda service: -Set the API key on the Lambda function: +```bash +./mk-lambda-execution-role.sh "$EXECUTION_ROLE_NAME" + +export EXECUTION_ROLE_ARN="$(aws iam get-role \ + --role-name "$EXECUTION_ROLE_NAME" \ + --query Role.Arn \ + --output text)" +``` + +Publish the worker for Linux and create the 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}" \ - --query '{FunctionName:FunctionName,LastUpdateStatus:LastUpdateStatus,RevisionId:RevisionId}' \ - --output json +./deploy-lambda.sh "$FUNCTION_NAME" "$EXECUTION_ROLE_ARN" ``` -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. +The script defaults to `linux-x64` and creates an `x86_64` function. For +Arm64, set `TEMPORAL_DOTNET_LAMBDA_RUNTIME=linux-arm64`; the script then +creates an `arm64` function. On later runs, the same command updates the +existing function code and waits until the update is complete. -### 3. Create the IAM role +## 4. Configure Temporal and OpenTelemetry in Lambda -This creates the IAM role that Temporal Cloud assumes to invoke your Lambda -function: +This command supplies all environment variables together because the Lambda +`--environment` option replaces the complete variable map. Its output is +restricted so the API key is never echoed: ```bash -./mk-iam-role.sh +aws lambda update-function-configuration \ + --function-name "$FUNCTION_NAME" \ + --timeout 60 \ + --layers "$ADOT_LAYER_ARN" \ + --environment "Variables={TEMPORAL_API_KEY=${TEMPORAL_API_KEY},SSL_CERT_FILE=/etc/pki/tls/certs/ca-bundle.crt,OPENTELEMETRY_COLLECTOR_CONFIG_URI=/var/task/otel-collector-config.yaml,TEMPORAL_TASK_QUEUE=${TASK_QUEUE},TEMPORAL_LAMBDA_WORKFLOW_ID_PREFIX=${WORKFLOW_ID_PREFIX},TEMPORAL_LAMBDA_DEPLOYMENT_NAME=${DEPLOYMENT_NAME},TEMPORAL_LAMBDA_BUILD_ID=${BUILD_ID}}" \ + --query '{FunctionName:FunctionName,Timeout:Timeout,Layers:Layers[*].Arn,LastUpdateStatus:LastUpdateStatus,RevisionId:RevisionId}' \ + --output json + +aws lambda wait function-updated --function-name "$FUNCTION_NAME" ``` -The External ID is provided by Temporal Cloud in your namespace's serverless -worker configuration. +A 60-second timeout keeps a dry run short. For a production worker, choose a +timeout appropriate for the workload; the worker continues polling until +shortly before the Lambda deadline. + +Grant the execution role permission to publish X-Ray traces and CloudWatch EMF +metrics, then enable active X-Ray tracing: + +```bash +./extra-setup-steps \ + "$EXECUTION_ROLE_NAME" \ + "$FUNCTION_NAME" \ + "$AWS_REGION" \ + "$AWS_ACCOUNT_ID" +``` -### 4. Enable OpenTelemetry +The collector sends traces to `awsxray` and metrics to `awsemf`. It has no +diagnostic or logs exporter. -The sample calls `ApplyOpenTelemetryDefaults` in `Function.cs`. -If you want traces and metrics, 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. +## 5. Create the role assumed by Temporal Cloud -Set this environment variable on the Lambda function so the ADOT Collector uses -the bundled config: +Get the Lambda ARN and create a CloudFormation stack containing the invocation +role: ```bash -OPENTELEMETRY_COLLECTOR_CONFIG_URI=/var/task/otel-collector-config.yaml +export LAMBDA_ARN="$(aws lambda get-function-configuration \ + --function-name "$FUNCTION_NAME" \ + --query FunctionArn \ + --output text)" + +./mk-iam-role.sh "$STACK_NAME" "$EXTERNAL_ID" "$LAMBDA_ARN" ``` -Then run the extra setup to grant the Lambda role the necessary permissions: +The script waits for stack creation and prints the physical role name and ARN. +Capture the ARN for the Temporal deployment version: ```bash -./extra-setup-steps +export INVOKE_ROLE_ARN="$(aws cloudformation describe-stacks \ + --stack-name "$STACK_NAME" \ + --query "Stacks[0].Outputs[?OutputKey=='RoleARN'].OutputValue | [0]" \ + --output text)" ``` -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. +## 6. Create and route the Temporal Worker Deployment Version -### 5. Deploy the Lambda function +The deployment name and build ID supplied to Temporal must match the Lambda +environment variables configured above: ```bash -./deploy-lambda.sh +temporal --config-file "$PWD/temporal.toml" --profile default \ + worker deployment create-version \ + --deployment-name "$DEPLOYMENT_NAME" \ + --build-id "$BUILD_ID" \ + --aws-lambda-function-arn "$LAMBDA_ARN" \ + --aws-lambda-assume-role-arn "$INVOKE_ROLE_ARN" \ + --aws-lambda-assume-role-external-id "$EXTERNAL_ID" + +temporal --config-file "$PWD/temporal.toml" --profile default \ + worker deployment set-current-version \ + --deployment-name "$DEPLOYMENT_NAME" \ + --build-id "$BUILD_ID" \ + --allow-no-pollers \ + --yes ``` -This runs `dotnet publish`, bundles the publish output with your code and -configuration files, and uploads to AWS Lambda. +`--allow-no-pollers` is expected because the Lambda worker has no +long-running poller before Temporal invokes the function. Verify the routing +state: -The script publishes for `linux-x64` by default. Set -`TEMPORAL_DOTNET_LAMBDA_RUNTIME=linux-arm64` when the Lambda function uses the -Arm64 architecture. +```bash +temporal --config-file "$PWD/temporal.toml" --profile default \ + worker deployment describe \ + --name "$DEPLOYMENT_NAME" +``` -### 6. Configure Temporal to invoke your Lambda function +You can also use Workers > Deployments > Actions > Validate Connection in the +Temporal Cloud UI. -Refer to the docs -[here](https://docs.temporal.io/production-deployment/worker-deployments/serverless-workers/aws-lambda#create-worker-deployment-version). +## 7. Start a Workflow -The worker deployment version in this sample is: +Run the separate starter project: -- Deployment name: `my-app` -- Build ID: `build-1` +```bash +TEMPORAL_CONFIG_FILE="$PWD/temporal.toml" \ + mise exec dotnet@8 -- dotnet run \ + --project Starter/TemporalioSamples.LambdaWorker.Starter.csproj \ + -- workflow +``` -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: +A successful run prints the Workflow ID, Run ID, and: -```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 +```text +Workflow result: Hello, Serverless Lambda Worker!! ``` -`--allow-no-pollers` is expected for this sample because the Lambda worker has -no long-running pollers before Temporal invokes the function. +## 8. Verify telemetry -You can verify the deployment routing state with: +Check that the Lambda and collector initialized without configuration errors: ```bash -temporal --config-file temporal.toml --profile default --api-key "$TEMPORAL_API_KEY" \ - worker deployment describe \ - --name my-app +aws logs tail "/aws/lambda/${FUNCTION_NAME}" --since 10m ``` -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. +The log should include the ADOT collector readiness message. The same invocation +should produce Temporal traces in X-Ray and `TemporalWorkerMetrics` metrics in +CloudWatch. -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: +## 9. Clean up a dry run -```bash -ORIGINAL_TIMEOUT=$(aws lambda get-function-configuration \ - --function-name my-temporal-worker \ - --query Timeout \ - --output text) +First reset Temporal routing, then remove the Lambda-backed version and +deployment: -aws lambda update-function-configuration \ - --function-name my-temporal-worker \ - --timeout 30 \ - --query '{FunctionName:FunctionName,Timeout:Timeout,LastUpdateStatus:LastUpdateStatus}' \ - --output json +```bash +temporal --config-file "$PWD/temporal.toml" --profile default \ + worker deployment set-current-version \ + --deployment-name "$DEPLOYMENT_NAME" \ + --unversioned \ + --allow-no-pollers \ + --yes -aws lambda wait function-updated \ - --function-name my-temporal-worker +aws lambda delete-function --function-name "$FUNCTION_NAME" -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 +temporal --config-file "$PWD/temporal.toml" --profile default \ + worker deployment delete-version \ + --deployment-name "$DEPLOYMENT_NAME" \ + --build-id "$BUILD_ID" \ + --skip-drainage -aws lambda update-function-configuration \ - --function-name my-temporal-worker \ - --timeout "$ORIGINAL_TIMEOUT" \ - --query '{FunctionName:FunctionName,Timeout:Timeout,LastUpdateStatus:LastUpdateStatus}' \ - --output json +temporal --config-file "$PWD/temporal.toml" --profile default \ + worker deployment delete \ + --name "$DEPLOYMENT_NAME" ``` -### 7. Start a Workflow +Delete the AWS invocation stack, execution role, and retained log group: -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. +```bash +aws cloudformation delete-stack --stack-name "$STACK_NAME" +aws cloudformation wait stack-delete-complete --stack-name "$STACK_NAME" + +aws iam delete-role-policy \ + --role-name "$EXECUTION_ROLE_NAME" \ + --policy-name ADOT-Telemetry-Permissions +aws iam detach-role-policy \ + --role-name "$EXECUTION_ROLE_NAME" \ + --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole +aws iam delete-role --role-name "$EXECUTION_ROLE_NAME" + +aws logs delete-log-group --log-group-name "/aws/lambda/${FUNCTION_NAME}" +``` -From inside this directory: +Completed Workflow histories remain in Temporal Cloud unless explicitly +deleted. Use the Workflow and Run IDs printed by the starter if the dry-run +history must also be removed: ```bash -TEMPORAL_CONFIG_FILE=temporal.toml TEMPORAL_API_KEY= \ - mise exec dotnet@8 -- dotnet run --project TemporalioSamples.LambdaWorker.csproj -- workflow +temporal --config-file "$PWD/temporal.toml" --profile default \ + workflow delete \ + --workflow-id "" \ + --run-id "" ``` diff --git a/src/LambdaWorker/Program.cs b/src/LambdaWorker/Starter/Program.cs similarity index 100% rename from src/LambdaWorker/Program.cs rename to src/LambdaWorker/Starter/Program.cs diff --git a/src/LambdaWorker/Starter/TemporalioSamples.LambdaWorker.Starter.csproj b/src/LambdaWorker/Starter/TemporalioSamples.LambdaWorker.Starter.csproj new file mode 100644 index 0000000..deb61eb --- /dev/null +++ b/src/LambdaWorker/Starter/TemporalioSamples.LambdaWorker.Starter.csproj @@ -0,0 +1,11 @@ + + + + Exe + + + + + + + diff --git a/src/LambdaWorker/TemporalioSamples.LambdaWorker.csproj b/src/LambdaWorker/TemporalioSamples.LambdaWorker.csproj index 9e1e747..1e668d1 100644 --- a/src/LambdaWorker/TemporalioSamples.LambdaWorker.csproj +++ b/src/LambdaWorker/TemporalioSamples.LambdaWorker.csproj @@ -1,7 +1,7 @@ - Exe + true @@ -13,4 +13,8 @@ + + + + diff --git a/src/LambdaWorker/deploy-lambda.sh b/src/LambdaWorker/deploy-lambda.sh index 7f5455f..20c6d24 100755 --- a/src/LambdaWorker/deploy-lambda.sh +++ b/src/LambdaWorker/deploy-lambda.sh @@ -1,14 +1,24 @@ #!/bin/bash set -euo pipefail -FUNCTION_NAME="${1:?Usage: deploy-lambda.sh }" +FUNCTION_NAME="${1:?Usage: deploy-lambda.sh [execution-role-arn]}" +EXECUTION_ROLE_ARN="${2:-${LAMBDA_EXECUTION_ROLE_ARN:-}}" 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}" +cleanup() { + rm -rf "$PUBLISH_DIR" "$ZIP_FILE" +} +trap cleanup EXIT + case "$TARGET_RUNTIME" in - linux-x64|linux-arm64) + linux-x64) + ARCHITECTURE="x86_64" + ;; + linux-arm64) + ARCHITECTURE="arm64" ;; *) echo "Unsupported TEMPORAL_DOTNET_LAMBDA_RUNTIME: $TARGET_RUNTIME" >&2 @@ -35,10 +45,44 @@ cp "$SCRIPT_DIR/temporal.toml" "$SCRIPT_DIR/otel-collector-config.yaml" \ cd "$PUBLISH_DIR" zip -r "$ZIP_FILE" . -aws lambda update-function-code \ +if aws lambda get-function \ --function-name "$FUNCTION_NAME" \ - --zip-file fileb://"$ZIP_FILE" \ - --query '{FunctionName:FunctionName,CodeSha256:CodeSha256,LastModified:LastModified,RevisionId:RevisionId}' \ - --output json - -rm -rf "$PUBLISH_DIR" "$ZIP_FILE" + --query 'Configuration.FunctionName' \ + --output text >/dev/null 2>&1; then + aws lambda update-function-code \ + --function-name "$FUNCTION_NAME" \ + --zip-file fileb://"$ZIP_FILE" \ + --query '{FunctionName:FunctionName,CodeSha256:CodeSha256,LastModified:LastModified,RevisionId:RevisionId}' \ + --output json + aws lambda wait function-updated --function-name "$FUNCTION_NAME" +else + if [[ -z "$EXECUTION_ROLE_ARN" ]]; then + echo "Lambda function $FUNCTION_NAME does not exist." >&2 + echo "Pass its execution-role ARN as the second argument to create it." >&2 + exit 1 + fi + # A newly created IAM role can take a few seconds to become assumable by Lambda. + for attempt in {1..12}; do + if CREATE_OUTPUT="$(aws lambda create-function \ + --function-name "$FUNCTION_NAME" \ + --runtime dotnet8 \ + --handler TemporalioSamples.LambdaWorker::TemporalioSamples.LambdaWorker.LambdaFunction::HandlerAsync \ + --role "$EXECUTION_ROLE_ARN" \ + --architectures "$ARCHITECTURE" \ + --timeout 600 \ + --memory-size 256 \ + --zip-file fileb://"$ZIP_FILE" \ + --query '{FunctionName:FunctionName,FunctionArn:FunctionArn,Runtime:Runtime,Architectures:Architectures,State:State,RevisionId:RevisionId}' \ + --output json 2>&1)"; then + printf '%s\n' "$CREATE_OUTPUT" + break + fi + if [[ "$CREATE_OUTPUT" != *"cannot be assumed by Lambda"* || "$attempt" -eq 12 ]]; then + printf '%s\n' "$CREATE_OUTPUT" >&2 + exit 1 + fi + echo "Waiting for the execution role to propagate to Lambda..." >&2 + sleep 5 + done + aws lambda wait function-active --function-name "$FUNCTION_NAME" +fi diff --git a/src/LambdaWorker/extra-setup-steps b/src/LambdaWorker/extra-setup-steps index dd4d6e1..71e1aea 100755 --- a/src/LambdaWorker/extra-setup-steps +++ b/src/LambdaWorker/extra-setup-steps @@ -2,7 +2,7 @@ set -euo pipefail # Additional setup steps for OpenTelemetry support. -# These are needed if you want metrics, logs, and traces from your Lambda worker. +# These are needed to export metrics to CloudWatch EMF and traces to X-Ray. ROLE_NAME="${1:?Usage: extra-setup-steps }" FUNCTION_NAME="${2:?Usage: extra-setup-steps }" @@ -49,3 +49,4 @@ aws lambda update-function-configuration \ --tracing-config Mode=Active \ --query '{FunctionName:FunctionName,TracingConfig:TracingConfig,LastUpdateStatus:LastUpdateStatus,RevisionId:RevisionId}' \ --output json +aws lambda wait function-updated --function-name "$FUNCTION_NAME" diff --git a/src/LambdaWorker/iam-role-for-temporal-lambda-invoke-test.yaml b/src/LambdaWorker/iam-role-for-temporal-lambda-invoke-test.yaml index e4499b4..629eea6 100644 --- a/src/LambdaWorker/iam-role-for-temporal-lambda-invoke-test.yaml +++ b/src/LambdaWorker/iam-role-for-temporal-lambda-invoke-test.yaml @@ -5,7 +5,7 @@ Description: Creates an IAM role that Temporal Cloud can assume to invoke multip Parameters: AssumeRoleExternalId: Type: String - Description: The External ID provided by Temporal Cloud + Description: The External ID also supplied when creating the Temporal Worker Deployment Version AllowedPattern: "[a-zA-Z0-9_+=,.@-]*" MinLength: 5 MaxLength: 45 @@ -34,7 +34,7 @@ Metadata: - RoleName ParameterLabels: AssumeRoleExternalId: - default: "External ID (provided by Temporal Cloud)" + default: "External ID (also supplied to Temporal Cloud)" LambdaFunctionARNs: default: "Lambda Function ARNs (comma-separated list)" RoleName: diff --git a/src/LambdaWorker/mk-iam-role.sh b/src/LambdaWorker/mk-iam-role.sh index 506dd52..a7573d7 100755 --- a/src/LambdaWorker/mk-iam-role.sh +++ b/src/LambdaWorker/mk-iam-role.sh @@ -2,16 +2,25 @@ 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. +# Use the same External ID when creating the Temporal Worker Deployment Version. STACK_NAME="${1:?Usage: mk-iam-role.sh }" EXTERNAL_ID="${2:?Usage: mk-iam-role.sh }" LAMBDA_ARN="${3:?Usage: mk-iam-role.sh }" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" aws cloudformation create-stack \ --stack-name "$STACK_NAME" \ - --template-body file://iam-role-for-temporal-lambda-invoke-test.yaml \ + --template-body file://"$SCRIPT_DIR/iam-role-for-temporal-lambda-invoke-test.yaml" \ --parameters \ ParameterKey=AssumeRoleExternalId,ParameterValue="$EXTERNAL_ID" \ ParameterKey=LambdaFunctionARNs,ParameterValue="$LAMBDA_ARN" \ - --capabilities CAPABILITY_NAMED_IAM + --capabilities CAPABILITY_NAMED_IAM \ + --query StackId \ + --output text + +aws cloudformation wait stack-create-complete --stack-name "$STACK_NAME" +aws cloudformation describe-stacks \ + --stack-name "$STACK_NAME" \ + --query "Stacks[0].Outputs[?OutputKey=='RoleARN' || OutputKey=='RoleName'].{Key:OutputKey,Value:OutputValue}" \ + --output table diff --git a/src/LambdaWorker/mk-lambda-execution-role.sh b/src/LambdaWorker/mk-lambda-execution-role.sh new file mode 100755 index 0000000..099bb9f --- /dev/null +++ b/src/LambdaWorker/mk-lambda-execution-role.sh @@ -0,0 +1,35 @@ +#!/bin/bash +set -euo pipefail + +# Creates the IAM execution role used by the Lambda function itself. + +ROLE_NAME="${1:?Usage: mk-lambda-execution-role.sh }" +TRUST_POLICY="$(mktemp -t temporal-lambda-trust.XXXXXX)" + +cleanup() { + rm -f "$TRUST_POLICY" +} +trap cleanup EXIT + +cat > "$TRUST_POLICY" <<'JSON' +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { "Service": "lambda.amazonaws.com" }, + "Action": "sts:AssumeRole" + } + ] +} +JSON + +aws iam create-role \ + --role-name "$ROLE_NAME" \ + --assume-role-policy-document file://"$TRUST_POLICY" \ + --query 'Role.{RoleName:RoleName,Arn:Arn}' \ + --output json +aws iam wait role-exists --role-name "$ROLE_NAME" +aws iam attach-role-policy \ + --role-name "$ROLE_NAME" \ + --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole