diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c147f7e9a..173d54c93c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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 diff --git a/temporal-sdk/build.gradle b/temporal-sdk/build.gradle index 7fe5441252..d981a0c20a 100644 --- a/temporal-sdk/build.gradle +++ b/temporal-sdk/build.gradle @@ -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' diff --git a/temporal-sdk/src/test/java/io/temporal/client/CloudTestNamespaceManager.java b/temporal-sdk/src/test/java/io/temporal/client/CloudTestNamespaceManager.java new file mode 100644 index 0000000000..524cda36c7 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/client/CloudTestNamespaceManager.java @@ -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 "); + } + + 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; + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/client/CloudTestNamespaceManagerTest.java b/temporal-sdk/src/test/java/io/temporal/client/CloudTestNamespaceManagerTest.java new file mode 100644 index 0000000000..641b4da051 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/client/CloudTestNamespaceManagerTest.java @@ -0,0 +1,92 @@ +package io.temporal.client; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import com.google.protobuf.util.Durations; +import io.temporal.api.cloud.cloudservice.v1.CreateNamespaceRequest; +import io.temporal.api.cloud.cloudservice.v1.DeleteNamespaceRequest; +import io.temporal.api.cloud.operation.v1.AsyncOperation; +import org.junit.Test; + +public class CloudTestNamespaceManagerTest { + + @Test + public void createNamespaceRequestUsesIsolatedMtlsSpec() { + byte[] clientCa = new byte[] {1, 2, 3}; + + CreateNamespaceRequest request = + CloudTestNamespaceManager.createNamespaceRequest("sdk-java-ci-123-2", clientCa); + + assertEquals("sdk-java-ci-123-2", request.getSpec().getName()); + assertEquals(1, request.getSpec().getRetentionDays()); + assertEquals(1, request.getSpec().getReplicasCount()); + assertEquals( + CloudTestNamespaceManager.CLOUD_REGION, request.getSpec().getReplicas(0).getRegion()); + assertTrue(request.getSpec().getMtlsAuth().getEnabled()); + assertArrayEquals( + clientCa, request.getSpec().getMtlsAuth().getAcceptedClientCa().toByteArray()); + } + + @Test + public void deleteNamespaceRequestUsesResourceVersion() { + DeleteNamespaceRequest request = + CloudTestNamespaceManager.deleteNamespaceRequest( + "sdk-java-ci-123-2.account", "resource-version"); + + assertEquals("sdk-java-ci-123-2.account", request.getNamespace()); + assertEquals("resource-version", request.getResourceVersion()); + } + + @Test + public void operationStatesDistinguishPendingFulfilledAndRejected() { + assertFalse( + CloudTestNamespaceManager.operationComplete(operation(AsyncOperation.State.STATE_PENDING))); + assertTrue( + CloudTestNamespaceManager.operationComplete( + operation(AsyncOperation.State.STATE_FULFILLED))); + + IllegalStateException failure = + assertThrows( + IllegalStateException.class, + () -> + CloudTestNamespaceManager.operationComplete( + operation(AsyncOperation.State.STATE_REJECTED).toBuilder() + .setFailureReason("not allowed") + .build())); + assertTrue(failure.getMessage().contains("STATE_REJECTED")); + assertTrue(failure.getMessage().contains("not allowed")); + } + + @Test + public void pollingUsesDefaultAndMinimumDelays() { + assertEquals( + CloudTestNamespaceManager.DEFAULT_POLL_DELAY.toMillis(), + CloudTestNamespaceManager.pollDelayMillis(operation(AsyncOperation.State.STATE_PENDING))); + assertEquals( + CloudTestNamespaceManager.MIN_POLL_DELAY.toMillis(), + CloudTestNamespaceManager.pollDelayMillis( + operation(AsyncOperation.State.STATE_PENDING).toBuilder() + .setCheckDuration(Durations.fromMillis(100)) + .build())); + } + + @Test + public void pollingRequiresOperationId() { + IllegalStateException failure = + assertThrows( + IllegalStateException.class, + () -> + CloudTestNamespaceManager.waitForOperation( + null, AsyncOperation.getDefaultInstance())); + + assertEquals("Cloud operation response did not include an ID.", failure.getMessage()); + } + + private static AsyncOperation operation(AsyncOperation.State state) { + return AsyncOperation.newBuilder().setId("operation-id").setState(state).build(); + } +}