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
69 changes: 61 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ jobs:
unit_test_cloud:
name: Unit test with cloud
runs-on: ubuntu-latest
timeout-minutes: 30
timeout-minutes: 60
steps:
- name: Checkout repo
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
Expand All @@ -132,17 +132,70 @@ jobs:
- name: Set up Gradle
uses: gradle/actions/setup-gradle@ac396bf1a80af16236baf54bd7330ae21dc6ece5 # v6

- name: Check Cloud test eligibility
id: cloud-test-eligibility
# Secrets are unavailable to Dependabot and pull requests from forks.
if: ${{ github.actor != 'dependabot[bot]' && (github.event.pull_request.head.repo.full_name == '' || github.event.pull_request.head.repo.full_name == 'temporalio/sdk-java') }}
env:
TEMPORAL_CLIENT_CLOUD_API_KEY: ${{ secrets.TEMPORAL_CLIENT_CLOUD_API_KEY }}
run: |
if [[ -n "$TEMPORAL_CLIENT_CLOUD_API_KEY" ]]; then
echo "enabled=true" >> "$GITHUB_OUTPUT"
else
echo "::notice title=Cloud tests skipped::TEMPORAL_CLIENT_CLOUD_API_KEY is unavailable"
fi

- name: Generate Cloud test certificates
if: ${{ steps.cloud-test-eligibility.outputs.enabled == 'true' }}
run: |
cert_dir="$RUNNER_TEMP/cloud-test-certs"
mkdir "$cert_dir"
openssl req -x509 -newkey rsa:2048 -nodes -days 1 \
-keyout "$cert_dir/ca.key" -out "$cert_dir/ca.pem" \
-subj '/CN=Temporal Java SDK Cloud CI CA'
openssl req -newkey rsa:2048 -nodes \
-keyout "$cert_dir/client.key" -out "$cert_dir/client.csr" \
-subj '/CN=Temporal Java SDK Cloud CI'
openssl x509 -req -days 1 -in "$cert_dir/client.csr" \
-CA "$cert_dir/ca.pem" -CAkey "$cert_dir/ca.key" -CAcreateserial \
-out "$cert_dir/client.pem" -extfile <(printf 'extendedKeyUsage=clientAuth')
{
echo "TEMPORAL_CLOUD_CLIENT_CA_PATH=$cert_dir/ca.pem"
echo "TEMPORAL_TLS_CLIENT_CERT_PATH=$cert_dir/client.pem"
echo "TEMPORAL_TLS_CLIENT_KEY_PATH=$cert_dir/client.key"
} >> "$GITHUB_ENV"

- name: Create Cloud namespace
id: create-cloud-namespace
if: ${{ steps.cloud-test-eligibility.outputs.enabled == 'true' }}
env:
TEMPORAL_CLIENT_CLOUD_API_KEY: ${{ secrets.TEMPORAL_CLIENT_CLOUD_API_KEY }}
TEMPORAL_CLIENT_CLOUD_API_VERSION: v0.19.1
run: ./gradlew --no-daemon :temporal-sdk:createCloudTestNamespace

- name: Run cloud test
# Only supported in non-fork runs, since secrets are not available in forks. We intentionally
# are only doing this check on the step instead of the job so we require job passing in CI
# even for those that can't run this step.
if: ${{ github.event.pull_request.head.repo.full_name == '' || github.event.pull_request.head.repo.full_name == 'temporalio/sdk-java' }}
if: ${{ steps.cloud-test-eligibility.outputs.enabled == 'true' }}
timeout-minutes: 15
env:
USER: unittest
TEMPORAL_CLIENT_CLOUD_NAMESPACE: sdk-ci.a2dd6
TEMPORAL_TEST_ENV_CONFIG_SERVER: "true"
TEMPORAL_ADDRESS: ${{ steps.create-cloud-namespace.outputs.namespace }}.tmprl.cloud:7233
TEMPORAL_NAMESPACE: ${{ steps.create-cloud-namespace.outputs.namespace }}
TEMPORAL_CLIENT_CLOUD_NAMESPACE: ${{ steps.create-cloud-namespace.outputs.namespace }}
TEMPORAL_CLIENT_CLOUD_API_KEY: ${{ secrets.TEMPORAL_CLIENT_CLOUD_API_KEY }}
TEMPORAL_CLIENT_CLOUD_API_VERSION: v0.19.1
run: |
./gradlew --no-daemon :temporal-sdk:test \
--tests '*CloudOperationsClientTest' \
--tests 'io.temporal.client.functional.SignalTest.signalCompletedWorkflow'

- name: Delete Cloud namespace
if: ${{ always() && steps.create-cloud-namespace.outputs.namespace != '' }}
env:
TEMPORAL_CLIENT_CLOUD_API_KEY: ${{ secrets.TEMPORAL_CLIENT_CLOUD_API_KEY }}
TEMPORAL_CLIENT_CLOUD_API_VERSION: 2024-05-13-00
run: ./gradlew --no-daemon :temporal-sdk:test --tests '*CloudOperationsClientTest'
TEMPORAL_CLIENT_CLOUD_API_VERSION: v0.19.1
TEMPORAL_CLOUD_TEST_NAMESPACE: ${{ steps.create-cloud-namespace.outputs.namespace }}
run: ./gradlew --no-daemon :temporal-sdk:deleteCloudTestNamespace

- name: Publish Test Report
uses: mikepenz/action-junit-report@bccf2e31636835cf0874589931c4116687171386 # v6
Expand Down
24 changes: 24 additions & 0 deletions temporal-sdk/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,30 @@ task registerNamespace(type: JavaExec) {

test.dependsOn 'registerNamespace'

tasks.register('createCloudTestNamespace', JavaExec) {
group = 'verification'
description = 'Creates an isolated Temporal Cloud namespace for SDK tests.'
dependsOn testClasses
getMainClass().set('io.temporal.client.CloudTestNamespaceManager')
classpath = sourceSets.test.runtimeClasspath
args 'create'
}

tasks.register('deleteCloudTestNamespace', JavaExec) {
group = 'verification'
description = 'Deletes the isolated Temporal Cloud namespace used by SDK tests.'
dependsOn testClasses
getMainClass().set('io.temporal.client.CloudTestNamespaceManager')
classpath = sourceSets.test.runtimeClasspath
doFirst {
String namespace = System.getenv('TEMPORAL_CLOUD_TEST_NAMESPACE')
if (namespace == null || namespace.isEmpty()) {
throw new GradleException('TEMPORAL_CLOUD_TEST_NAMESPACE must be set.')
}
setArgs(['delete', namespace])
}
}

test {
useJUnit {
excludeCategories 'io.temporal.worker.IndependentResourceBasedTests'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
package io.temporal.client;

import com.google.protobuf.ByteString;
import com.google.protobuf.util.Durations;
import io.temporal.api.cloud.cloudservice.v1.CloudServiceGrpc;
import io.temporal.api.cloud.cloudservice.v1.CreateNamespaceRequest;
import io.temporal.api.cloud.cloudservice.v1.CreateNamespaceResponse;
import io.temporal.api.cloud.cloudservice.v1.DeleteNamespaceRequest;
import io.temporal.api.cloud.cloudservice.v1.DeleteNamespaceResponse;
import io.temporal.api.cloud.cloudservice.v1.GetAsyncOperationRequest;
import io.temporal.api.cloud.cloudservice.v1.GetAsyncOperationResponse;
import io.temporal.api.cloud.cloudservice.v1.GetNamespaceRequest;
import io.temporal.api.cloud.cloudservice.v1.GetNamespaceResponse;
import io.temporal.api.cloud.namespace.v1.MtlsAuthSpec;
import io.temporal.api.cloud.namespace.v1.NamespaceSpec;
import io.temporal.api.cloud.namespace.v1.ReplicaSpec;
import io.temporal.api.cloud.operation.v1.AsyncOperation;
import io.temporal.serviceclient.CloudServiceStubs;
import io.temporal.serviceclient.CloudServiceStubsOptions;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.time.Duration;
import java.util.concurrent.TimeUnit;

/** Creates and deletes an isolated Temporal Cloud namespace for SDK CI. */
public final class CloudTestNamespaceManager {
static final String CLOUD_REGION = "aws-ca-central-1";
static final Duration OPERATION_TIMEOUT = Duration.ofMinutes(10);
static final Duration DEFAULT_POLL_DELAY = Duration.ofSeconds(10);
static final Duration MIN_POLL_DELAY = Duration.ofSeconds(1);

private CloudTestNamespaceManager() {}

public static void main(String[] args) throws Exception {
boolean createRequested = args.length == 1 && "create".equals(args[0]);
boolean deleteRequested = args.length == 2 && "delete".equals(args[0]);
if (!createRequested && !deleteRequested) {
throw new IllegalArgumentException(
"Usage: CloudTestNamespaceManager create | delete <namespace>");
}

CloudServiceStubs serviceStubs = connect();
try {
CloudServiceGrpc.CloudServiceBlockingStub cloudService =
CloudOperationsClient.newInstance(serviceStubs).getCloudServiceStubs().blockingStub();
if (createRequested) {
create(cloudService);
} else {
delete(cloudService, args[1]);
}
} finally {
serviceStubs.shutdownNow();
}
}

private static void create(CloudServiceGrpc.CloudServiceBlockingStub cloudService)
throws Exception {
String namespaceName =
"sdk-java-ci-"
+ requiredEnvironmentVariable("GITHUB_RUN_ID")
+ "-"
+ requiredEnvironmentVariable("GITHUB_RUN_ATTEMPT");
byte[] clientCa =
Files.readAllBytes(Paths.get(requiredEnvironmentVariable("TEMPORAL_CLOUD_CLIENT_CA_PATH")));

CreateNamespaceResponse response =
cloudService.createNamespace(createNamespaceRequest(namespaceName, clientCa));
if (response.getNamespace().isEmpty()) {
throw new IllegalStateException("Create namespace response did not include a namespace.");
}

// Persist the namespace before polling so cleanup can run if provisioning later fails.
Files.write(
Paths.get(requiredEnvironmentVariable("GITHUB_OUTPUT")),
("namespace=" + response.getNamespace() + System.lineSeparator())
.getBytes(StandardCharsets.UTF_8),
StandardOpenOption.CREATE,
StandardOpenOption.APPEND);
waitForOperation(cloudService, response.getAsyncOperation());
}

private static void delete(
CloudServiceGrpc.CloudServiceBlockingStub cloudService, String namespace) throws Exception {
if (namespace == null || namespace.isEmpty()) {
throw new IllegalArgumentException("Namespace to delete must not be empty.");
}
GetNamespaceResponse existing =
cloudService.getNamespace(GetNamespaceRequest.newBuilder().setNamespace(namespace).build());
String resourceVersion = existing.getNamespace().getResourceVersion();
if (resourceVersion.isEmpty()) {
throw new IllegalStateException(
"Cloud namespace " + namespace + " did not include a resource version.");
}

DeleteNamespaceResponse response =
cloudService.deleteNamespace(deleteNamespaceRequest(namespace, resourceVersion));
waitForOperation(cloudService, response.getAsyncOperation());
}

static CreateNamespaceRequest createNamespaceRequest(String namespaceName, byte[] clientCa) {
return CreateNamespaceRequest.newBuilder()
.setSpec(
NamespaceSpec.newBuilder()
.setName(namespaceName)
.setRetentionDays(1)
.addReplicas(ReplicaSpec.newBuilder().setRegion(CLOUD_REGION))
.setMtlsAuth(
MtlsAuthSpec.newBuilder()
.setAcceptedClientCa(ByteString.copyFrom(clientCa))
.setEnabled(true)))
.build();
}

static DeleteNamespaceRequest deleteNamespaceRequest(String namespace, String resourceVersion) {
return DeleteNamespaceRequest.newBuilder()
.setNamespace(namespace)
.setResourceVersion(resourceVersion)
.build();
}

static void waitForOperation(
CloudServiceGrpc.CloudServiceBlockingStub cloudService, AsyncOperation initialOperation)
throws InterruptedException {
String operationId = initialOperation.getId();
if (operationId.isEmpty()) {
throw new IllegalStateException("Cloud operation response did not include an ID.");
}

long deadline = System.nanoTime() + OPERATION_TIMEOUT.toNanos();
while (true) {
if (System.nanoTime() >= deadline) {
throw new IllegalStateException(
"Timed out waiting for Cloud operation " + operationId + ".");
}

GetAsyncOperationResponse response =
cloudService.getAsyncOperation(
GetAsyncOperationRequest.newBuilder().setAsyncOperationId(operationId).build());
if (!response.hasAsyncOperation()) {
throw new IllegalStateException("Cloud operation " + operationId + " could not be read.");
}
AsyncOperation operation = response.getAsyncOperation();
if (operationComplete(operation)) {
return;
}

long remainingNanos = deadline - System.nanoTime();
if (remainingNanos <= 0) {
throw new IllegalStateException(
"Timed out waiting for Cloud operation " + operationId + ".");
}
long remainingMillis = Math.max(TimeUnit.NANOSECONDS.toMillis(remainingNanos), 1);
try {
Thread.sleep(Math.min(pollDelayMillis(operation), remainingMillis));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw e;
}
}
}

static boolean operationComplete(AsyncOperation operation) {
switch (operation.getState()) {
case STATE_FULFILLED:
return true;
case STATE_FAILED:
case STATE_CANCELLED:
case STATE_REJECTED:
throw new IllegalStateException(
"Cloud operation "
+ operation.getId()
+ " "
+ operation.getState()
+ ": "
+ operation.getFailureReason());
default:
return false;
}
}

static long pollDelayMillis(AsyncOperation operation) {
long delayMillis =
operation.hasCheckDuration()
? Durations.toMillis(operation.getCheckDuration())
: DEFAULT_POLL_DELAY.toMillis();
return Math.max(delayMillis, MIN_POLL_DELAY.toMillis());
}

private static CloudServiceStubs connect() {
String apiKey = requiredEnvironmentVariable("TEMPORAL_CLIENT_CLOUD_API_KEY");
String apiVersion = requiredEnvironmentVariable("TEMPORAL_CLIENT_CLOUD_API_VERSION");
return CloudServiceStubs.newServiceStubs(
CloudServiceStubsOptions.newBuilder()
.addApiKey(() -> apiKey)
.setVersion(apiVersion)
.setRpcTimeout(Duration.ofSeconds(30))
.build());
}

private static String requiredEnvironmentVariable(String name) {
String value = System.getenv(name);
if (value == null || value.isEmpty()) {
throw new IllegalStateException("Missing required environment variable " + name + ".");
}
return value;
}
}
Loading
Loading