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
75 changes: 67 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,76 @@ 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
id: delete-cloud-namespace
if: ${{ always() && steps.create-cloud-namespace.outputs.namespace != '' }}
continue-on-error: true
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: Report Cloud namespace cleanup failure
if: ${{ always() && steps.delete-cloud-namespace.outcome == 'failure' }}
run: echo "::warning title=Cloud namespace cleanup failed::Failed to delete Cloud namespace ${{ steps.create-cloud-namespace.outputs.namespace }}"

- 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 @@ -149,6 +149,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,272 @@
package io.temporal.client;

import com.google.protobuf.ByteString;
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.Map;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.function.LongSupplier;

/** 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 RPC_TIMEOUT = Duration.ofSeconds(30);
static final Duration DEFAULT_POLL_DELAY = Duration.ofSeconds(10);
static final Duration MIN_POLL_DELAY = Duration.ofSeconds(1);

private final CloudApi api;
private final LongSupplier nanoTime;
private final Sleeper sleeper;

CloudTestNamespaceManager(CloudApi api, LongSupplier nanoTime, Sleeper sleeper) {
this.api = api;
this.nanoTime = nanoTime;
this.sleeper = sleeper;
}

public static void main(String[] args) throws Exception {
Map<String, String> environment = System.getenv();
GrpcCloudApi api = GrpcCloudApi.connect(environment);
try {
new CloudTestNamespaceManager(api, System::nanoTime, Thread::sleep).run(args, environment);
} finally {
api.close();
}
}

void run(String[] args, Map<String, String> environment) throws Exception {
if (args.length == 1 && "create".equals(args[0])) {
create(environment);
} else if (args.length == 2 && "delete".equals(args[0])) {
delete(args[1]);
} else {
throw new IllegalArgumentException(
"Usage: CloudTestNamespaceManager create | delete <namespace>");
}
}

private void create(Map<String, String> environment) throws Exception {
String namespaceName =
"sdk-java-ci-"
+ requiredEnvironmentVariable(environment, "GITHUB_RUN_ID")
+ "-"
+ requiredEnvironmentVariable(environment, "GITHUB_RUN_ATTEMPT");
byte[] clientCa =
Files.readAllBytes(
Paths.get(requiredEnvironmentVariable(environment, "TEMPORAL_CLOUD_CLIENT_CA_PATH")));

CreateNamespaceResponse response =
api.createNamespace(
CreateNamespaceRequest.newBuilder()
.setAsyncOperationId(UUID.randomUUID().toString())
.setSpec(
NamespaceSpec.newBuilder()
.setName(namespaceName)
.setRetentionDays(1)
.addReplicas(ReplicaSpec.newBuilder().setRegion(CLOUD_REGION))
.setMtlsAuth(
MtlsAuthSpec.newBuilder()
.setAcceptedClientCa(ByteString.copyFrom(clientCa))
.setEnabled(true)))
.build());
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(environment, "GITHUB_OUTPUT")),
("namespace=" + response.getNamespace() + System.lineSeparator())
.getBytes(StandardCharsets.UTF_8),
StandardOpenOption.CREATE,
StandardOpenOption.APPEND);
waitForOperation(response.getAsyncOperation());
}

private void delete(String namespace) throws Exception {
if (namespace == null || namespace.isEmpty()) {
throw new IllegalArgumentException("Namespace to delete must not be empty.");
}
GetNamespaceResponse existing =
api.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 =
api.deleteNamespace(
DeleteNamespaceRequest.newBuilder()
.setNamespace(namespace)
.setResourceVersion(resourceVersion)
.setAsyncOperationId(UUID.randomUUID().toString())
.build());
waitForOperation(response.getAsyncOperation());
}

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

long deadline = nanoTime.getAsLong() + OPERATION_TIMEOUT.toNanos();
AsyncOperation operation = initialOperation;
while (true) {
switch (operation.getState()) {
case STATE_FULFILLED:
return;
case STATE_FAILED:
case STATE_CANCELLED:
case STATE_REJECTED:
throw new IllegalStateException(
"Cloud operation "
+ operationId
+ " "
+ operation.getState()
+ ": "
+ operation.getFailureReason());
default:
break;
}

long remainingNanos = deadline - nanoTime.getAsLong();
if (remainingNanos <= 0) {
throw new IllegalStateException(
"Timed out waiting for Cloud operation " + operationId + ".");
}

Duration delay =
operation.hasCheckDuration()
? Duration.ofSeconds(
operation.getCheckDuration().getSeconds(),
operation.getCheckDuration().getNanos())
: DEFAULT_POLL_DELAY;
if (delay.compareTo(MIN_POLL_DELAY) < 0) {
delay = MIN_POLL_DELAY;
}
long delayNanos = Math.min(delay.toNanos(), remainingNanos);
try {
sleeper.sleep(Math.max(TimeUnit.NANOSECONDS.toMillis(delayNanos), 1));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException(
"Interrupted while waiting for Cloud operation " + operationId + ".", e);
}

remainingNanos = deadline - nanoTime.getAsLong();
if (remainingNanos <= 0) {
throw new IllegalStateException(
"Timed out waiting for Cloud operation " + operationId + ".");
}
Duration rpcTimeout = Duration.ofNanos(Math.min(RPC_TIMEOUT.toNanos(), remainingNanos));
GetAsyncOperationResponse response =
api.getAsyncOperation(
GetAsyncOperationRequest.newBuilder().setAsyncOperationId(operationId).build(),
rpcTimeout);
if (!response.hasAsyncOperation()) {
throw new IllegalStateException("Cloud operation " + operationId + " could not be read.");
}
operation = response.getAsyncOperation();
}
}

private static String requiredEnvironmentVariable(Map<String, String> environment, String name) {
String value = environment.get(name);
if (value == null || value.isEmpty()) {
throw new IllegalStateException("Missing required environment variable " + name + ".");
}
return value;
}

interface Sleeper {
void sleep(long milliseconds) throws InterruptedException;
}

interface CloudApi {
CreateNamespaceResponse createNamespace(CreateNamespaceRequest request);

GetAsyncOperationResponse getAsyncOperation(
GetAsyncOperationRequest request, Duration rpcTimeout);

GetNamespaceResponse getNamespace(GetNamespaceRequest request);

DeleteNamespaceResponse deleteNamespace(DeleteNamespaceRequest request);
}

private static final class GrpcCloudApi implements CloudApi {
private final CloudServiceStubs serviceStubs;
private final CloudServiceGrpc.CloudServiceBlockingStub blockingStub;

private GrpcCloudApi(CloudServiceStubs serviceStubs) {
this.serviceStubs = serviceStubs;
this.blockingStub =
CloudOperationsClient.newInstance(serviceStubs).getCloudServiceStubs().blockingStub();
}

static GrpcCloudApi connect(Map<String, String> environment) {
String apiKey = requiredEnvironmentVariable(environment, "TEMPORAL_CLIENT_CLOUD_API_KEY");
String apiVersion =
requiredEnvironmentVariable(environment, "TEMPORAL_CLIENT_CLOUD_API_VERSION");
CloudServiceStubs serviceStubs =
CloudServiceStubs.newServiceStubs(
CloudServiceStubsOptions.newBuilder()
.addApiKey(() -> apiKey)
.setVersion(apiVersion)
.setRpcTimeout(Duration.ofSeconds(30))
.build());
return new GrpcCloudApi(serviceStubs);
}

@Override
public CreateNamespaceResponse createNamespace(CreateNamespaceRequest request) {
return blockingStub.createNamespace(request);
}

@Override
public GetAsyncOperationResponse getAsyncOperation(
GetAsyncOperationRequest request, Duration rpcTimeout) {
return blockingStub
.withDeadlineAfter(rpcTimeout.toNanos(), TimeUnit.NANOSECONDS)
.getAsyncOperation(request);
}

@Override
public GetNamespaceResponse getNamespace(GetNamespaceRequest request) {
return blockingStub.getNamespace(request);
}

@Override
public DeleteNamespaceResponse deleteNamespace(DeleteNamespaceRequest request) {
return blockingStub.deleteNamespace(request);
}

void close() {
serviceStubs.shutdown();
if (!serviceStubs.awaitTermination(5, TimeUnit.SECONDS)) {
serviceStubs.shutdownNow();
}
}
}
}
Loading
Loading