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..90d2f10 100644 --- a/TemporalioSamples.sln +++ b/TemporalioSamples.sln @@ -97,6 +97,10 @@ 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.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}" @@ -750,6 +754,30 @@ 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 + {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 @@ -798,6 +826,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..d72c5d6 --- /dev/null +++ b/src/LambdaWorker/Function.cs @@ -0,0 +1,25 @@ +// @@@SNIPSTART dotnet-lambda-worker +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), + config => + { + config.ApplyOpenTelemetryDefaults(); + LambdaWorkerSample.ConfigureWorkerOptions(config.WorkerOptions); + }); + + public Task HandlerAsync(Stream input, ILambdaContext context) => + WorkerHandler(input, context); +} +// @@@SNIPEND diff --git a/src/LambdaWorker/LambdaWorkerSample.cs b/src/LambdaWorker/LambdaWorkerSample.cs new file mode 100644 index 0000000..9603cde --- /dev/null +++ b/src/LambdaWorker/LambdaWorkerSample.cs @@ -0,0 +1,46 @@ +namespace TemporalioSamples.LambdaWorker; + +using Temporalio.Worker; + +public static class LambdaWorkerSample +{ + 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) + { + ArgumentNullException.ThrowIfNull(options); + + options.TaskQueue = TaskQueue; + return options. + 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/README.md b/src/LambdaWorker/README.md new file mode 100644 index 0000000..b7df7f6 --- /dev/null +++ b/src/LambdaWorker/README.md @@ -0,0 +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 exports +traces and metrics through AWS Distro for OpenTelemetry (ADOT). + +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 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 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: + +```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 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. + +## 2. Create the local configuration files + +The unsuffixed files are intentionally ignored because `temporal.toml` +contains namespace-specific configuration: + +```bash +cp temporal.toml.sample temporal.toml +cp otel-collector-config.yaml.sample otel-collector-config.yaml +``` + +Edit `temporal.toml` and replace the address and namespace placeholders: + +```toml +[profile.default] +address = "..tmprl.cloud:7233" +namespace = "." +``` + +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 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. + +## 3. Create the Lambda execution role and deploy + +Create the IAM role assumed by the Lambda service: + +```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 +./deploy-lambda.sh "$FUNCTION_NAME" "$EXECUTION_ROLE_ARN" +``` + +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. + +## 4. Configure Temporal and OpenTelemetry in Lambda + +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 +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" +``` + +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" +``` + +The collector sends traces to `awsxray` and metrics to `awsemf`. It has no +diagnostic or logs exporter. + +## 5. Create the role assumed by Temporal Cloud + +Get the Lambda ARN and create a CloudFormation stack containing the invocation +role: + +```bash +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" +``` + +The script waits for stack creation and prints the physical role name and ARN. +Capture the ARN for the Temporal deployment version: + +```bash +export INVOKE_ROLE_ARN="$(aws cloudformation describe-stacks \ + --stack-name "$STACK_NAME" \ + --query "Stacks[0].Outputs[?OutputKey=='RoleARN'].OutputValue | [0]" \ + --output text)" +``` + +## 6. Create and route the Temporal Worker Deployment Version + +The deployment name and build ID supplied to Temporal must match the Lambda +environment variables configured above: + +```bash +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 +``` + +`--allow-no-pollers` is expected because the Lambda worker has no +long-running poller before Temporal invokes the function. Verify the routing +state: + +```bash +temporal --config-file "$PWD/temporal.toml" --profile default \ + worker deployment describe \ + --name "$DEPLOYMENT_NAME" +``` + +You can also use Workers > Deployments > Actions > Validate Connection in the +Temporal Cloud UI. + +## 7. Start a Workflow + +Run the separate starter project: + +```bash +TEMPORAL_CONFIG_FILE="$PWD/temporal.toml" \ + mise exec dotnet@8 -- dotnet run \ + --project Starter/TemporalioSamples.LambdaWorker.Starter.csproj \ + -- workflow +``` + +A successful run prints the Workflow ID, Run ID, and: + +```text +Workflow result: Hello, Serverless Lambda Worker!! +``` + +## 8. Verify telemetry + +Check that the Lambda and collector initialized without configuration errors: + +```bash +aws logs tail "/aws/lambda/${FUNCTION_NAME}" --since 10m +``` + +The log should include the ADOT collector readiness message. The same invocation +should produce Temporal traces in X-Ray and `TemporalWorkerMetrics` metrics in +CloudWatch. + +## 9. Clean up a dry run + +First reset Temporal routing, then remove the Lambda-backed version and +deployment: + +```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 delete-function --function-name "$FUNCTION_NAME" + +temporal --config-file "$PWD/temporal.toml" --profile default \ + worker deployment delete-version \ + --deployment-name "$DEPLOYMENT_NAME" \ + --build-id "$BUILD_ID" \ + --skip-drainage + +temporal --config-file "$PWD/temporal.toml" --profile default \ + worker deployment delete \ + --name "$DEPLOYMENT_NAME" +``` + +Delete the AWS invocation stack, execution role, and retained log group: + +```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}" +``` + +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 "$PWD/temporal.toml" --profile default \ + workflow delete \ + --workflow-id "" \ + --run-id "" +``` diff --git a/src/LambdaWorker/SampleWorkflow.workflow.cs b/src/LambdaWorker/SampleWorkflow.workflow.cs new file mode 100644 index 0000000..43abd2e --- /dev/null +++ b/src/LambdaWorker/SampleWorkflow.workflow.cs @@ -0,0 +1,22 @@ +// @@@SNIPSTART dotnet-lambda-worker-workflow +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; + } +} +// @@@SNIPEND diff --git a/src/LambdaWorker/Starter/Program.cs b/src/LambdaWorker/Starter/Program.cs new file mode 100644 index 0000000..fccd123 --- /dev/null +++ b/src/LambdaWorker/Starter/Program.cs @@ -0,0 +1,31 @@ +using Microsoft.Extensions.Logging; +using Temporalio.Client; +using Temporalio.Common.EnvConfig; +using TemporalioSamples.LambdaWorker; + +if (args.Length > 0 && args[0] != "workflow") +{ + Console.WriteLine("Usage: dotnet run [workflow]"); + return; +} + +var connectOptions = ClientEnvConfig.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/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 new file mode 100644 index 0000000..1e668d1 --- /dev/null +++ b/src/LambdaWorker/TemporalioSamples.LambdaWorker.csproj @@ -0,0 +1,20 @@ + + + + true + + + + + + + + + + + + + + + + diff --git a/src/LambdaWorker/deploy-lambda.sh b/src/LambdaWorker/deploy-lambda.sh new file mode 100755 index 0000000..20c6d24 --- /dev/null +++ b/src/LambdaWorker/deploy-lambda.sh @@ -0,0 +1,88 @@ +#!/bin/bash +set -euo pipefail + +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) + ARCHITECTURE="x86_64" + ;; + linux-arm64) + ARCHITECTURE="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" + +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" \ + "$PUBLISH_DIR/" + +cd "$PUBLISH_DIR" +zip -r "$ZIP_FILE" . + +if aws lambda get-function \ + --function-name "$FUNCTION_NAME" \ + --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 new file mode 100755 index 0000000..71e1aea --- /dev/null +++ b/src/LambdaWorker/extra-setup-steps @@ -0,0 +1,52 @@ +#!/bin/bash +set -euo pipefail + +# Additional setup steps for OpenTelemetry support. +# 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 }" +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 \ + --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 new file mode 100644 index 0000000..629eea6 --- /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 also supplied when creating the Temporal Worker Deployment Version + 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 (also supplied to 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 TemporalCloudServerlessWorker + + 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..a7573d7 --- /dev/null +++ b/src/LambdaWorker/mk-iam-role.sh @@ -0,0 +1,26 @@ +#!/bin/bash +set -euo pipefail + +# Creates the IAM role that allows Temporal Cloud to invoke your Lambda function. +# 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://"$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 \ + --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 diff --git a/src/LambdaWorker/otel-collector-config.yaml.sample b/src/LambdaWorker/otel-collector-config.yaml.sample new file mode 100644 index 0000000..a04025e --- /dev/null +++ b/src/LambdaWorker/otel-collector-config.yaml.sample @@ -0,0 +1,34 @@ +# @@@SNIPSTART dotnet-lambda-worker-otel-collector-config +receivers: + otlp: + protocols: + grpc: + endpoint: "localhost:4317" + http: + endpoint: "localhost:4318" + +exporters: + 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] + metrics: + receivers: [otlp] + exporters: [awsemf] + telemetry: + logs: + level: info + metrics: + address: localhost:8888 +# @@@SNIPEND 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..a39e53d --- /dev/null +++ b/tests/LambdaWorker/LambdaWorkerTests.cs @@ -0,0 +1,85 @@ +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); + } + + [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); + } +} 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 @@ +