Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,16 @@ to docs, or any other relevant information.

## [Unreleased]

### Added

- Added the experimental `Temporalio.Extensions.Gcp.CloudRun` package for long-lived Temporal
workers on Google Cloud Run worker pools and services. A `GoogleCloudRunMetadata` helper reads
Cloud Run instance metadata (the `CLOUD_RUN_WORKER_POOL`/`CLOUD_RUN_REVISION` or
`K_SERVICE`/`K_REVISION` environment variables and the instance id from the metadata server) to
derive a worker identity and a `WorkerDeploymentVersion`, and the `ApplyGoogleCloudRunDefaultsAsync`
and `ApplyGoogleCloudRunDefaults` extension methods apply those to `TemporalClientConnectOptions`
and `TemporalWorkerOptions` respectively.

### Changed

- A non-retryable `ApplicationFailureException` with error type `PayloadValidationError` thrown by a
Expand Down
7 changes: 7 additions & 0 deletions Temporalio.sln
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Temporalio.Extensions.Aws.L
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Temporalio.Extensions.Gcp.CloudRun.OpenTelemetry", "src\Temporalio.Extensions.Gcp.CloudRun.OpenTelemetry\Temporalio.Extensions.Gcp.CloudRun.OpenTelemetry.csproj", "{1B74A58D-DB4A-49D1-8EF0-8AD91C2D66DA}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Temporalio.Extensions.Gcp.CloudRun", "src\Temporalio.Extensions.Gcp.CloudRun\Temporalio.Extensions.Gcp.CloudRun.csproj", "{74BFAEC2-FCBB-4A17-B684-072E1F982965}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -70,6 +72,10 @@ Global
{1B74A58D-DB4A-49D1-8EF0-8AD91C2D66DA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1B74A58D-DB4A-49D1-8EF0-8AD91C2D66DA}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1B74A58D-DB4A-49D1-8EF0-8AD91C2D66DA}.Release|Any CPU.Build.0 = Release|Any CPU
{74BFAEC2-FCBB-4A17-B684-072E1F982965}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{74BFAEC2-FCBB-4A17-B684-072E1F982965}.Debug|Any CPU.Build.0 = Debug|Any CPU
{74BFAEC2-FCBB-4A17-B684-072E1F982965}.Release|Any CPU.ActiveCfg = Release|Any CPU
{74BFAEC2-FCBB-4A17-B684-072E1F982965}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{7AE1422A-0937-40D7-9A62-431DD0E2F6D5} = {758B61E2-9AB6-46BF-B53C-16BD140BF56B}
Expand All @@ -81,5 +87,6 @@ Global
{2610AFAE-FD3A-4583-8CA5-4869E1347A3C} = {F2683DAA-F157-448E-96C8-DF7BB019886D}
{9A2C7274-7ED2-4C92-BE92-13887C3309B4} = {758B61E2-9AB6-46BF-B53C-16BD140BF56B}
{1B74A58D-DB4A-49D1-8EF0-8AD91C2D66DA} = {758B61E2-9AB6-46BF-B53C-16BD140BF56B}
{74BFAEC2-FCBB-4A17-B684-072E1F982965} = {758B61E2-9AB6-46BF-B53C-16BD140BF56B}
EndGlobalSection
EndGlobal
207 changes: 207 additions & 0 deletions src/Temporalio.Extensions.Gcp.CloudRun/GoogleCloudRunMetadata.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
using System;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Temporalio.Common;

namespace Temporalio.Extensions.Gcp.CloudRun
{
/// <summary>
/// Reads Google Cloud Run instance metadata to derive a Temporal worker identity and a
/// <see cref="WorkerDeploymentVersion" /> for a long-lived worker, on both Cloud Run worker
/// pools and services.
/// </summary>
/// <remarks>
/// Cloud Run runs a long-lived container, so unlike the AWS Lambda extension this is a metadata
/// helper rather than a worker wrapper. Fetch the metadata once at startup and apply the results
/// to your normal client and worker options, for example via
/// <see cref="TemporalClientConnectOptionsExtensions.ApplyGoogleCloudRunDefaultsAsync" /> and
/// <see cref="TemporalWorkerOptionsExtensions.ApplyGoogleCloudRunDefaults" />.
/// WARNING: Google Cloud Run support is experimental.
/// </remarks>
public sealed class GoogleCloudRunMetadata
{
private const string WorkerPoolEnvironmentVariable = "CLOUD_RUN_WORKER_POOL";
private const string ServiceEnvironmentVariable = "K_SERVICE";
private const string WorkerPoolRevisionEnvironmentVariable = "CLOUD_RUN_REVISION";
private const string ServiceRevisionEnvironmentVariable = "K_REVISION";
private const string MetadataFlavorHeader = "Metadata-Flavor";
private const string MetadataFlavorValue = "Google";

private static readonly Uri DefaultMetadataUri =
new Uri("http://metadata.google.internal/computeMetadata/v1/instance/id");

private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(2);

/// <summary>
/// Initializes a new instance of the <see cref="GoogleCloudRunMetadata"/> class.
/// </summary>
/// <param name="instanceId">Cloud Run instance id from the metadata server.</param>
/// <param name="name">Cloud Run worker pool or service name.</param>
/// <param name="revision">Cloud Run revision name.</param>
internal GoogleCloudRunMetadata(string instanceId, string name, string revision)
{
InstanceId = instanceId;
Name = name;
Revision = revision;
}

/// <summary>
/// Gets the Cloud Run instance id read from the metadata server.
/// </summary>
public string InstanceId { get; }

/// <summary>
/// Gets the Cloud Run worker pool or service name, resolved from the
/// <c>CLOUD_RUN_WORKER_POOL</c> environment variable and then the <c>K_SERVICE</c>
/// environment variable, or an empty string if neither is set.
/// </summary>
public string Name { get; }

/// <summary>
/// Gets the Cloud Run revision name, resolved from the <c>CLOUD_RUN_REVISION</c> environment
/// variable and then the <c>K_REVISION</c> environment variable, or an empty string if
/// neither is set.
/// </summary>
public string Revision { get; }

/// <summary>
/// Gets the worker identity derived from the metadata. This is
/// <c>{InstanceId}@{Revision}</c>, falling back to <c>{InstanceId}@{Name}</c> when the
/// revision is empty, or just <c>{InstanceId}</c> when both are empty.
/// </summary>
public string WorkerIdentity
{
get
{
if (!string.IsNullOrEmpty(Revision))
{
return $"{InstanceId}@{Revision}";
}
if (!string.IsNullOrEmpty(Name))
{
return $"{InstanceId}@{Name}";
}
return InstanceId;
}
}

/// <summary>
/// Fetch Cloud Run metadata using the default metadata server URI and timeout.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The resolved Cloud Run metadata.</returns>
public static Task<GoogleCloudRunMetadata> FetchAsync(
CancellationToken cancellationToken = default) =>
FetchAsync(DefaultMetadataUri, DefaultTimeout, cancellationToken);

/// <summary>
/// Fetch Cloud Run metadata from the given metadata server URI.
/// </summary>
/// <param name="metadataUri">Metadata server URI for the instance id.</param>
/// <param name="timeout">Timeout for the metadata request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The resolved Cloud Run metadata.</returns>
/// <remarks>
/// The name and revision are read from environment variables that Cloud Run injects:
/// <c>CLOUD_RUN_WORKER_POOL</c> then <c>K_SERVICE</c> for the name, and
/// <c>CLOUD_RUN_REVISION</c> then <c>K_REVISION</c> for the revision. Worker pools receive
/// the <c>CLOUD_RUN_*</c> variables and services receive the <c>K_*</c> variables. The
/// instance id is read from the metadata server, which is available on both and requires the
/// <c>Metadata-Flavor: Google</c> request header.
/// </remarks>
/// <exception cref="InvalidOperationException">
/// Thrown when the instance id cannot be read from the metadata server, which usually means
/// the process is not running on a Google Cloud Run worker pool or service.
/// </exception>
public static async Task<GoogleCloudRunMetadata> FetchAsync(
Uri metadataUri,
TimeSpan timeout,
CancellationToken cancellationToken = default)
{
var name = FirstNonEmptyEnvironmentVariable(
WorkerPoolEnvironmentVariable,
ServiceEnvironmentVariable);
var revision = FirstNonEmptyEnvironmentVariable(
WorkerPoolRevisionEnvironmentVariable,
ServiceRevisionEnvironmentVariable);

using var httpClient = new HttpClient { Timeout = timeout };
using var request = new HttpRequestMessage(HttpMethod.Get, metadataUri);
request.Headers.Add(MetadataFlavorHeader, MetadataFlavorValue);

string instanceId;
try
{
using var response = await httpClient.SendAsync(request, cancellationToken).
ConfigureAwait(false);
response.EnsureSuccessStatusCode();
instanceId = (await response.Content.ReadAsStringAsync().ConfigureAwait(false)).
Trim();
}
catch (HttpRequestException e)
{
throw new InvalidOperationException(
"Failed to read the Google Cloud Run instance id from the metadata server at " +
$"{metadataUri}. This process may not be running on a Google Cloud Run worker " +
"pool or service.",
e);
}

return new GoogleCloudRunMetadata(instanceId, name, revision);
}

/// <summary>
/// Build a <see cref="WorkerDeploymentVersion" /> from the Cloud Run name and revision.
/// </summary>
/// <returns>
/// A version whose deployment name is the Cloud Run worker pool or service name and whose
/// build id is the Cloud Run revision.
/// </returns>
/// <exception cref="InvalidOperationException">
/// Thrown when the name or revision is empty, which usually means the process is not running
/// on a Google Cloud Run worker pool or service.
/// </exception>
public WorkerDeploymentVersion ToWorkerDeploymentVersion()
{
if (string.IsNullOrEmpty(Name) || string.IsNullOrEmpty(Revision))
{
throw new InvalidOperationException(
"Cannot build a WorkerDeploymentVersion without both a Cloud Run name and " +
"revision. This process may not be running on a Google Cloud Run worker pool " +
"or service.");
}
return new WorkerDeploymentVersion(Name, Revision);
}

/// <summary>
/// Fetch Cloud Run metadata, filling in the default metadata server URI and timeout for any
/// argument that is null.
/// </summary>
/// <param name="metadataUri">Metadata server URI, or null for the default.</param>
/// <param name="timeout">Metadata request timeout, or null for the default.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The resolved Cloud Run metadata.</returns>
internal static Task<GoogleCloudRunMetadata> FetchWithDefaultsAsync(
Uri? metadataUri,
TimeSpan? timeout,
CancellationToken cancellationToken) =>
FetchAsync(
metadataUri ?? DefaultMetadataUri,
timeout ?? DefaultTimeout,
cancellationToken);

private static string FirstNonEmptyEnvironmentVariable(params string[] names)
{
foreach (var name in names)
{
var value = Environment.GetEnvironmentVariable(name) ?? string.Empty;
if (value.Length > 0)
{
return value;
}
}
return string.Empty;
}
}
}
101 changes: 101 additions & 0 deletions src/Temporalio.Extensions.Gcp.CloudRun/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# Google Cloud Run Worker Support

This extension derives a Temporal worker identity and a `WorkerDeploymentVersion` from Google Cloud
Run instance metadata, for use with a normal long-lived worker on Cloud Run worker pools and
services.

Add the `Temporalio.Extensions.Gcp.CloudRun` package from
[NuGet](https://www.nuget.org/packages/Temporalio.Extensions.Gcp.CloudRun). For example, using the
`dotnet` CLI:

dotnet add package Temporalio.Extensions.Gcp.CloudRun

## Quick Start

Apply the Cloud Run defaults to your client and worker options, then run a normal long-lived worker.
`ApplyGoogleCloudRunDefaultsAsync` fetches the metadata once and sets the client identity;
`ApplyGoogleCloudRunDefaults` forces the worker deployment version:

```csharp
using System;
using System.Threading;
using Temporalio.Client;
using Temporalio.Extensions.Gcp.CloudRun;
using Temporalio.Worker;

var connectOptions = new TemporalClientConnectOptions("my-namespace.a1b2c.tmprl.cloud:7233")
{
Namespace = "my-namespace",
// ... Temporal Cloud API key / mTLS credentials ...
};

// Fetch Cloud Run metadata once and set the client identity from it (only if not already set).
var metadata = await connectOptions.ApplyGoogleCloudRunDefaultsAsync();

var client = await TemporalClient.ConnectAsync(connectOptions);

using var worker = new TemporalWorker(
client,
new TemporalWorkerOptions("my-task-queue").
ApplyGoogleCloudRunDefaults(metadata).
AddWorkflow<MyWorkflow>().
AddActivity(MyActivities.DoThing));

// Cloud Run sends SIGTERM before stopping the instance; cancel the worker on it.
using var shutdown = new CancellationTokenSource();
Console.CancelKeyPress += (_, eventArgs) =>
{
eventArgs.Cancel = true;
shutdown.Cancel();
};

try
{
await worker.ExecuteAsync(shutdown.Token);
}
catch (OperationCanceledException)
{
// Expected shutdown path.
}
```

If you need the raw values, call `GoogleCloudRunMetadata.FetchAsync()` directly and read
`WorkerIdentity` / `ToWorkerDeploymentVersion()` yourself.

## How it works

Unlike AWS Lambda, Cloud Run runs a long-lived container with no per-invocation handler to wrap, so
this is a small metadata helper rather than a worker wrapper. You apply its results to the client
and worker options you already build.

`GoogleCloudRunMetadata.FetchAsync` gathers three values:

* `Name` (the Temporal deployment name) from the `CLOUD_RUN_WORKER_POOL` environment variable, then
the `K_SERVICE` environment variable.
* `Revision` from the `CLOUD_RUN_REVISION` environment variable, then the `K_REVISION` environment
variable.
* `InstanceId` from the Cloud Run metadata server
(`http://metadata.google.internal/computeMetadata/v1/instance/id`), read with the required
`Metadata-Flavor: Google` request header. This is the only value not available as an environment
variable, so the helper makes a single HTTP GET at startup.

Cloud Run worker pools receive the `CLOUD_RUN_*` variables (and no `K_*` variables), while Cloud Run
services receive the `K_*` variables. The metadata server is available on both, so resolving the
name and revision in that order covers both deployment types. Worker pools are the primary target.

From those values:

* `WorkerIdentity` is `{InstanceId}@{Revision}`, falling back to `{InstanceId}@{Name}` when the
revision is empty, or just `{InstanceId}` when both are empty.
`TemporalClientConnectOptions.ApplyGoogleCloudRunDefaultsAsync` assigns it to
`TemporalClientConnectOptions.Identity`, but only when the identity is not already set, so an
explicitly configured identity wins.
* `ToWorkerDeploymentVersion()` returns a `WorkerDeploymentVersion` whose deployment name is the
Cloud Run name and whose build id is the Cloud Run revision.
`TemporalWorkerOptions.ApplyGoogleCloudRunDefaults` sets it on
`TemporalWorkerOptions.DeploymentOptions` with `useWorkerVersioning: true`, so each Cloud Run
revision maps to a worker deployment version. It throws if the name or revision is empty, which
usually means the process is not running on Cloud Run.

`FetchAsync` also throws a clear `InvalidOperationException` if the metadata server cannot be
reached, which likewise usually means the process is not running on Cloud Run.
Loading
Loading