diff --git a/gcp/cloud-run/id/Dockerfile b/gcp/cloud-run/id/Dockerfile new file mode 100644 index 00000000..76bf90c3 --- /dev/null +++ b/gcp/cloud-run/id/Dockerfile @@ -0,0 +1,17 @@ +FROM eclipse-temurin:17-jdk-jammy AS build + +WORKDIR /workspace +COPY . . + +# temporal-gcp-cloud-run-id is unreleased; the composite build resolves it from a local SDK checkout (see README). +RUN ./gradlew --no-daemon :gcp:cloud-run:id:installDist + +FROM eclipse-temurin:17-jre-jammy + +RUN useradd --create-home --uid 10001 temporal +WORKDIR /app +COPY --from=build --chown=temporal:temporal \ + /workspace/gcp/cloud-run/id/build/install/cloud-run-id/ /app/ + +USER 10001 +ENTRYPOINT ["/app/bin/cloud-run-id"] diff --git a/gcp/cloud-run/id/README.md b/gcp/cloud-run/id/README.md new file mode 100644 index 00000000..111ed970 --- /dev/null +++ b/gcp/cloud-run/id/README.md @@ -0,0 +1,60 @@ +# Temporal Cloud Run Id sample + +A Temporal Java Worker for a Google Cloud Run **worker pool** that registers +`CloudRunIdPlugin` from `io.temporal:temporal-gcp-cloud-run-id` on the client, so the Temporal +client identity is set from Cloud Run instance metadata as `{instanceId}@{revision}`. The plugin +sets identity only. A small greeting workflow and activity run until Cloud Run stops the instance. + +> Google Cloud Run support is experimental and may change without notice. + +## Unreleased SDK dependency + +`temporal-gcp-cloud-run-id` is not yet released. `settings.gradle` resolves it (and the other +`io.temporal:*` modules) from a local SDK checkout via a Gradle composite build, defaulting to +`../sdk-java` and overridable with `-PtemporalSdkPath`. CI has no checkout, so its build stays red +until the module ships; then drop the composite block and bump `javaSDKVersion`. + +## How it works + +Cloud Run worker pools set `CLOUD_RUN_WORKER_POOL` and `CLOUD_RUN_REVISION` (services set `K_SERVICE` +and `K_REVISION`). `CloudRunIdPlugin` reads those plus the instance id from the Cloud Run metadata +server and sets the client identity to `{instanceId}@{revision}` unless one is already set; workers +created from the client inherit it. `GoogleCloudRunMetadata.fetch().identity()` exposes the same +value, which the worker logs at startup. + +The worker reads `TEMPORAL_ADDRESS` (default `127.0.0.1:7233`), `TEMPORAL_NAMESPACE` (default +`default`), and `TEMPORAL_TASK_QUEUE` (default `cloud-run-id`). A plaintext connection is used; +configure TLS or an API key in `CloudRunWorker.java` for a secured Service such as Temporal Cloud. + +## Build and test + +```bash +./gradlew :gcp:cloud-run:id:test +./gradlew -PtemporalSdkPath=/path/to/sdk-java :gcp:cloud-run:id:installDist +``` + +## Deploy + +Worker pools keep CPU allocated between requests. Until `temporal-gcp-cloud-run-id` is released a +remote `--source` build cannot resolve it, so build the image locally against your SDK checkout and +deploy it by tag: + +```bash +export REGION=us-central1 +gcloud run worker-pools deploy cloud-run-id \ + --image "$REGION-docker.pkg.dev/$PROJECT_ID//cloud-run-id:latest" \ + --region "$REGION" \ + --set-env-vars "TEMPORAL_ADDRESS=,TEMPORAL_NAMESPACE=,TEMPORAL_TASK_QUEUE=cloud-run-id" +``` + +Each revision starts a fresh instance whose worker reports a distinct identity. + +## Start a workflow + +```bash +temporal workflow start --type GreetingWorkflow --task-queue cloud-run-id \ + --workflow-id cloud-run-greeting --input '"Cloud Run"' +``` + +The identity appears on the task-queue pollers (`temporal task-queue describe`) and recorded events. +Delete the pool with `gcloud run worker-pools delete cloud-run-id --region "$REGION"`. diff --git a/gcp/cloud-run/id/build.gradle b/gcp/cloud-run/id/build.gradle new file mode 100644 index 00000000..a92468df --- /dev/null +++ b/gcp/cloud-run/id/build.gradle @@ -0,0 +1,23 @@ +apply plugin: 'application' + +dependencies { + implementation "io.temporal:temporal-sdk:$javaSDKVersion" + implementation "io.temporal:temporal-gcp-cloud-run-id:$javaSDKVersion" + runtimeOnly group: 'ch.qos.logback', name: 'logback-classic', version: '1.5.6' + + testImplementation "io.temporal:temporal-testing:$javaSDKVersion" + testImplementation "junit:junit:4.13.2" + testImplementation(platform("org.junit:junit-bom:5.10.3")) + testRuntimeOnly "org.junit.vintage:junit-vintage-engine" + + dependencies { + errorproneJavac('com.google.errorprone:javac:9+181-r4173-1') + errorprone('com.google.errorprone:error_prone_core:2.28.0') + } +} + +application { + mainClass = 'io.temporal.samples.gcp.cloudrun.id.CloudRunWorker' + // Stable launcher name the Dockerfile relies on, independent of the Gradle project path. + applicationName = 'cloud-run-id' +} diff --git a/gcp/cloud-run/id/src/main/java/io/temporal/samples/gcp/cloudrun/id/CloudRunWorker.java b/gcp/cloud-run/id/src/main/java/io/temporal/samples/gcp/cloudrun/id/CloudRunWorker.java new file mode 100644 index 00000000..a1e91468 --- /dev/null +++ b/gcp/cloud-run/id/src/main/java/io/temporal/samples/gcp/cloudrun/id/CloudRunWorker.java @@ -0,0 +1,82 @@ +package io.temporal.samples.gcp.cloudrun.id; + +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowClientOptions; +import io.temporal.gcp.cloudrun.id.CloudRunIdPlugin; +import io.temporal.gcp.cloudrun.id.GoogleCloudRunMetadata; +import io.temporal.serviceclient.WorkflowServiceStubs; +import io.temporal.serviceclient.WorkflowServiceStubsOptions; +import io.temporal.worker.Worker; +import io.temporal.worker.WorkerFactory; +import java.util.concurrent.TimeUnit; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** A Temporal Worker for a Google Cloud Run worker pool. */ +public final class CloudRunWorker { + private static final Logger logger = LoggerFactory.getLogger(CloudRunWorker.class); + + static final String ADDRESS_ENV = "TEMPORAL_ADDRESS"; + static final String NAMESPACE_ENV = "TEMPORAL_NAMESPACE"; + static final String TASK_QUEUE_ENV = "TEMPORAL_TASK_QUEUE"; + + static final String DEFAULT_ADDRESS = "127.0.0.1:7233"; + static final String DEFAULT_NAMESPACE = "default"; + static final String DEFAULT_TASK_QUEUE = "cloud-run-id"; + + private CloudRunWorker() {} + + public static void main(String[] args) { + String address = envOrDefault(ADDRESS_ENV, DEFAULT_ADDRESS); + String namespace = envOrDefault(NAMESPACE_ENV, DEFAULT_NAMESPACE); + String taskQueue = envOrDefault(TASK_QUEUE_ENV, DEFAULT_TASK_QUEUE); + + // Plaintext connection; add TLS or an API key here for Temporal Cloud. + WorkflowServiceStubs service = + WorkflowServiceStubs.newServiceStubs( + WorkflowServiceStubsOptions.newBuilder().setTarget(address).build()); + + // CloudRunIdPlugin sets the client identity to {instanceId}@{revision} from metadata. + WorkflowClient client = + WorkflowClient.newInstance( + service, + WorkflowClientOptions.newBuilder() + .setNamespace(namespace) + .setPlugins(new CloudRunIdPlugin()) + .build()); + + WorkerFactory factory = WorkerFactory.newInstance(client); + + Worker worker = factory.newWorker(taskQueue); + worker.registerWorkflowImplementationTypes(GreetingWorkflowImpl.class); + worker.registerActivitiesImplementations(new GreetingActivitiesImpl()); + + Runtime.getRuntime() + .addShutdownHook(new Thread(() -> shutdown(factory, service), "temporal-worker-shutdown")); + + factory.start(); + logger.info( + "Temporal worker started (identity={}, taskQueue={})", + GoogleCloudRunMetadata.fetch().identity(), + taskQueue); + + // Keep the process alive until Cloud Run sends SIGTERM. + factory.awaitTermination(Long.MAX_VALUE, TimeUnit.DAYS); + } + + private static void shutdown(WorkerFactory factory, WorkflowServiceStubs service) { + // Cloud Run sends SIGTERM before SIGKILL; stop polling, drain in-flight tasks, then close. + factory.shutdown(); + factory.awaitTermination(6, TimeUnit.SECONDS); + if (!factory.isTerminated()) { + factory.shutdownNow(); + factory.awaitTermination(1, TimeUnit.SECONDS); + } + service.shutdown(); + } + + private static String envOrDefault(String name, String defaultValue) { + String value = System.getenv(name); + return value == null || value.trim().isEmpty() ? defaultValue : value; + } +} diff --git a/gcp/cloud-run/id/src/main/java/io/temporal/samples/gcp/cloudrun/id/GreetingActivities.java b/gcp/cloud-run/id/src/main/java/io/temporal/samples/gcp/cloudrun/id/GreetingActivities.java new file mode 100644 index 00000000..836b24d6 --- /dev/null +++ b/gcp/cloud-run/id/src/main/java/io/temporal/samples/gcp/cloudrun/id/GreetingActivities.java @@ -0,0 +1,8 @@ +package io.temporal.samples.gcp.cloudrun.id; + +import io.temporal.activity.ActivityInterface; + +@ActivityInterface +public interface GreetingActivities { + String composeGreeting(String name); +} diff --git a/gcp/cloud-run/id/src/main/java/io/temporal/samples/gcp/cloudrun/id/GreetingActivitiesImpl.java b/gcp/cloud-run/id/src/main/java/io/temporal/samples/gcp/cloudrun/id/GreetingActivitiesImpl.java new file mode 100644 index 00000000..0b790d03 --- /dev/null +++ b/gcp/cloud-run/id/src/main/java/io/temporal/samples/gcp/cloudrun/id/GreetingActivitiesImpl.java @@ -0,0 +1,15 @@ +package io.temporal.samples.gcp.cloudrun.id; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public final class GreetingActivitiesImpl implements GreetingActivities { + + private static final Logger logger = LoggerFactory.getLogger(GreetingActivitiesImpl.class); + + @Override + public String composeGreeting(String name) { + logger.info("Composing greeting for {}", name); + return "Hello, " + name + "!"; + } +} diff --git a/gcp/cloud-run/id/src/main/java/io/temporal/samples/gcp/cloudrun/id/GreetingWorkflow.java b/gcp/cloud-run/id/src/main/java/io/temporal/samples/gcp/cloudrun/id/GreetingWorkflow.java new file mode 100644 index 00000000..3adbcc86 --- /dev/null +++ b/gcp/cloud-run/id/src/main/java/io/temporal/samples/gcp/cloudrun/id/GreetingWorkflow.java @@ -0,0 +1,10 @@ +package io.temporal.samples.gcp.cloudrun.id; + +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; + +@WorkflowInterface +public interface GreetingWorkflow { + @WorkflowMethod + String getGreeting(String name); +} diff --git a/gcp/cloud-run/id/src/main/java/io/temporal/samples/gcp/cloudrun/id/GreetingWorkflowImpl.java b/gcp/cloud-run/id/src/main/java/io/temporal/samples/gcp/cloudrun/id/GreetingWorkflowImpl.java new file mode 100644 index 00000000..f934fbdf --- /dev/null +++ b/gcp/cloud-run/id/src/main/java/io/temporal/samples/gcp/cloudrun/id/GreetingWorkflowImpl.java @@ -0,0 +1,17 @@ +package io.temporal.samples.gcp.cloudrun.id; + +import io.temporal.activity.ActivityOptions; +import io.temporal.workflow.Workflow; +import java.time.Duration; + +public final class GreetingWorkflowImpl implements GreetingWorkflow { + private final GreetingActivities activities = + Workflow.newActivityStub( + GreetingActivities.class, + ActivityOptions.newBuilder().setStartToCloseTimeout(Duration.ofSeconds(10)).build()); + + @Override + public String getGreeting(String name) { + return activities.composeGreeting(name); + } +} diff --git a/gcp/cloud-run/id/src/main/resources/logback.xml b/gcp/cloud-run/id/src/main/resources/logback.xml new file mode 100644 index 00000000..28eb2cba --- /dev/null +++ b/gcp/cloud-run/id/src/main/resources/logback.xml @@ -0,0 +1,14 @@ + + + + %d{HH:mm:ss.SSS} %-5level [%thread] %logger{36} - %msg%n + + + + + + + + + + diff --git a/gcp/cloud-run/id/src/test/java/io/temporal/samples/gcp/cloudrun/id/GreetingWorkflowTest.java b/gcp/cloud-run/id/src/test/java/io/temporal/samples/gcp/cloudrun/id/GreetingWorkflowTest.java new file mode 100644 index 00000000..f312acb4 --- /dev/null +++ b/gcp/cloud-run/id/src/test/java/io/temporal/samples/gcp/cloudrun/id/GreetingWorkflowTest.java @@ -0,0 +1,29 @@ +package io.temporal.samples.gcp.cloudrun.id; + +import static org.junit.Assert.assertEquals; + +import io.temporal.client.WorkflowOptions; +import io.temporal.testing.TestWorkflowRule; +import org.junit.Rule; +import org.junit.Test; + +public class GreetingWorkflowTest { + @Rule + public TestWorkflowRule testWorkflowRule = + TestWorkflowRule.newBuilder() + .setWorkflowTypes(GreetingWorkflowImpl.class) + .setActivityImplementations(new GreetingActivitiesImpl()) + .build(); + + @Test + public void returnsGreeting() { + GreetingWorkflow workflow = + testWorkflowRule + .getWorkflowClient() + .newWorkflowStub( + GreetingWorkflow.class, + WorkflowOptions.newBuilder().setTaskQueue(testWorkflowRule.getTaskQueue()).build()); + + assertEquals("Hello, Cloud Run!", workflow.getGreeting("Cloud Run")); + } +} diff --git a/settings.gradle b/settings.gradle index b19f2fd7..91299945 100644 --- a/settings.gradle +++ b/settings.gradle @@ -9,3 +9,10 @@ include 'springboot' include 'springboot-basic' include 'lambda-worker:starter' include 'lambda-worker:worker' +include 'gcp:cloud-run:id' + +// The gcp:cloud-run samples use unreleased temporal-gcp-cloud-run-* modules; resolve them from a local SDK checkout (default ../sdk-java, override with -PtemporalSdkPath) via composite build. +def temporalSdkPath = gradle.startParameter.projectProperties['temporalSdkPath'] ?: '../sdk-java' +if (file(temporalSdkPath).isDirectory()) { + includeBuild temporalSdkPath +}