diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c147f7e9a..50df553e2e 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,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 diff --git a/temporal-sdk/build.gradle b/temporal-sdk/build.gradle index 594a7660e0..b74f330a68 100644 --- a/temporal-sdk/build.gradle +++ b/temporal-sdk/build.gradle @@ -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' 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..01212bd230 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/client/CloudTestNamespaceManager.java @@ -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 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 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 "); + } + } + + private void create(Map 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 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 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(); + } + } + } +} 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..1bbfd43597 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/client/CloudTestNamespaceManagerTest.java @@ -0,0 +1,291 @@ +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.Duration; +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.Namespace; +import io.temporal.api.cloud.operation.v1.AsyncOperation; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayDeque; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Queue; +import java.util.concurrent.atomic.AtomicLong; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +public class CloudTestNamespaceManagerTest { + @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void createNamespaceUsesIsolatedMtlsSpecAndWritesOutput() throws Exception { + byte[] clientCa = "test-client-ca".getBytes(StandardCharsets.UTF_8); + Map environment = environment(clientCa); + FakeCloudApi api = new FakeCloudApi(); + api.createNamespaceResponse = + CreateNamespaceResponse.newBuilder() + .setNamespace("sdk-java-ci-123-2.account") + .setAsyncOperation(operation("create-operation", AsyncOperation.State.STATE_PENDING)) + .build(); + api.operations.add(operation("create-operation", AsyncOperation.State.STATE_FULFILLED)); + + manager(api).run(new String[] {"create"}, environment); + + CreateNamespaceRequest request = api.createNamespaceRequest; + assertFalse(request.getAsyncOperationId().isEmpty()); + 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()); + assertEquals("create-operation", api.getAsyncOperationRequests.get(0).getAsyncOperationId()); + assertEquals( + "namespace=sdk-java-ci-123-2.account" + System.lineSeparator(), + new String( + Files.readAllBytes(new File(environment.get("GITHUB_OUTPUT")).toPath()), + StandardCharsets.UTF_8)); + } + + @Test + public void createNamespaceWritesOutputBeforePollingFailure() throws Exception { + Map environment = environment(new byte[] {1, 2, 3}); + FakeCloudApi api = new FakeCloudApi(); + api.createNamespaceResponse = + CreateNamespaceResponse.newBuilder() + .setNamespace("sdk-java-ci-123-2.account") + .setAsyncOperation(operation("create-operation", AsyncOperation.State.STATE_PENDING)) + .build(); + api.getOperationFailure = new IllegalStateException("poll failed"); + + IllegalStateException failure = + assertThrows( + IllegalStateException.class, + () -> manager(api).run(new String[] {"create"}, environment)); + + assertEquals("poll failed", failure.getMessage()); + assertTrue( + new String( + Files.readAllBytes(new File(environment.get("GITHUB_OUTPUT")).toPath()), + StandardCharsets.UTF_8) + .contains("namespace=sdk-java-ci-123-2.account")); + } + + @Test + public void deleteNamespaceUsesCurrentResourceVersion() throws Exception { + FakeCloudApi api = new FakeCloudApi(); + api.getNamespaceResponse = + GetNamespaceResponse.newBuilder() + .setNamespace( + Namespace.newBuilder() + .setNamespace("sdk-java-ci-123-2.account") + .setResourceVersion("resource-version")) + .build(); + api.deleteNamespaceResponse = + DeleteNamespaceResponse.newBuilder() + .setAsyncOperation(operation("delete-operation", AsyncOperation.State.STATE_PENDING)) + .build(); + api.operations.add(operation("delete-operation", AsyncOperation.State.STATE_FULFILLED)); + + manager(api) + .run(new String[] {"delete", "sdk-java-ci-123-2.account"}, new HashMap()); + + assertEquals("sdk-java-ci-123-2.account", api.getNamespaceRequest.getNamespace()); + assertEquals("sdk-java-ci-123-2.account", api.deleteNamespaceRequest.getNamespace()); + assertEquals("resource-version", api.deleteNamespaceRequest.getResourceVersion()); + assertFalse(api.deleteNamespaceRequest.getAsyncOperationId().isEmpty()); + } + + @Test + public void pollingHonorsServerDelayAndTerminalFailure() throws Exception { + FakeCloudApi api = new FakeCloudApi(); + api.operations.add( + operation("operation", AsyncOperation.State.STATE_REJECTED).toBuilder() + .setFailureReason("not allowed") + .build()); + AtomicLong nanoTime = new AtomicLong(); + Queue sleeps = new ArrayDeque<>(); + CloudTestNamespaceManager manager = + new CloudTestNamespaceManager( + api, + nanoTime::get, + milliseconds -> { + sleeps.add(milliseconds); + nanoTime.addAndGet(java.util.concurrent.TimeUnit.MILLISECONDS.toNanos(milliseconds)); + }); + + IllegalStateException failure = + assertThrows( + IllegalStateException.class, + () -> + manager.waitForOperation( + operation("operation", AsyncOperation.State.STATE_PENDING).toBuilder() + .setCheckDuration(Duration.newBuilder().setSeconds(2)) + .build())); + + assertEquals(Arrays.asList(2000L), Arrays.asList(sleeps.toArray(new Long[0]))); + assertEquals(1, api.getAsyncOperationRequests.size()); + assertEquals(CloudTestNamespaceManager.RPC_TIMEOUT, api.getAsyncOperationTimeouts.get(0)); + assertTrue(failure.getMessage().contains("STATE_REJECTED")); + assertTrue(failure.getMessage().contains("not allowed")); + } + + @Test + public void pollingUsesDefaultAndMinimumDelays() throws Exception { + assertEquals( + Arrays.asList(CloudTestNamespaceManager.DEFAULT_POLL_DELAY.toMillis()), + pollUntilFulfilled(operation("default-delay", AsyncOperation.State.STATE_PENDING))); + assertEquals( + Arrays.asList(CloudTestNamespaceManager.MIN_POLL_DELAY.toMillis()), + pollUntilFulfilled( + operation("minimum-delay", AsyncOperation.State.STATE_PENDING).toBuilder() + .setCheckDuration(Duration.newBuilder().setNanos(1)) + .build())); + } + + @Test + public void pollingStopsAtOverallTimeoutAndDoesNotRefetchFulfilledOperation() throws Exception { + FakeCloudApi api = new FakeCloudApi(); + AtomicLong nanoTime = new AtomicLong(); + Queue sleeps = new ArrayDeque<>(); + CloudTestNamespaceManager manager = + new CloudTestNamespaceManager( + api, + nanoTime::get, + milliseconds -> { + sleeps.add(milliseconds); + nanoTime.addAndGet(java.util.concurrent.TimeUnit.MILLISECONDS.toNanos(milliseconds)); + }); + + IllegalStateException timeout = + assertThrows( + IllegalStateException.class, + () -> + manager.waitForOperation( + operation("timeout", AsyncOperation.State.STATE_PENDING).toBuilder() + .setCheckDuration(Duration.newBuilder().setSeconds(700)) + .build())); + assertTrue(timeout.getMessage().contains("Timed out")); + assertEquals( + Arrays.asList(CloudTestNamespaceManager.OPERATION_TIMEOUT.toMillis()), + Arrays.asList(sleeps.toArray(new Long[0]))); + assertTrue(api.getAsyncOperationRequests.isEmpty()); + + manager.waitForOperation(operation("fulfilled", AsyncOperation.State.STATE_FULFILLED)); + assertTrue(api.getAsyncOperationRequests.isEmpty()); + } + + @Test + public void validatesArgumentsAndRequiredEnvironment() throws Exception { + FakeCloudApi api = new FakeCloudApi(); + CloudTestNamespaceManager manager = manager(api); + + assertThrows( + IllegalArgumentException.class, + () -> manager.run(new String[] {"unknown"}, new HashMap())); + + IllegalStateException missingEnvironment = + assertThrows( + IllegalStateException.class, + () -> manager.run(new String[] {"create"}, new HashMap())); + assertTrue(missingEnvironment.getMessage().contains("GITHUB_RUN_ID")); + } + + private Map environment(byte[] clientCa) throws Exception { + File caFile = temporaryFolder.newFile("ca.pem"); + Files.write(caFile.toPath(), clientCa); + File outputFile = temporaryFolder.newFile("github-output"); + Map environment = new HashMap<>(); + environment.put("GITHUB_RUN_ID", "123"); + environment.put("GITHUB_RUN_ATTEMPT", "2"); + environment.put("TEMPORAL_CLOUD_CLIENT_CA_PATH", caFile.getAbsolutePath()); + environment.put("GITHUB_OUTPUT", outputFile.getAbsolutePath()); + return environment; + } + + private static CloudTestNamespaceManager manager(FakeCloudApi api) { + return new CloudTestNamespaceManager(api, System::nanoTime, milliseconds -> {}); + } + + private static List pollUntilFulfilled(AsyncOperation initialOperation) throws Exception { + FakeCloudApi api = new FakeCloudApi(); + api.operations.add(operation(initialOperation.getId(), AsyncOperation.State.STATE_FULFILLED)); + AtomicLong nanoTime = new AtomicLong(); + Queue sleeps = new ArrayDeque<>(); + new CloudTestNamespaceManager( + api, + nanoTime::get, + milliseconds -> { + sleeps.add(milliseconds); + nanoTime.addAndGet(java.util.concurrent.TimeUnit.MILLISECONDS.toNanos(milliseconds)); + }) + .waitForOperation(initialOperation); + return Arrays.asList(sleeps.toArray(new Long[0])); + } + + private static AsyncOperation operation(String id, AsyncOperation.State state) { + return AsyncOperation.newBuilder().setId(id).setState(state).build(); + } + + private static final class FakeCloudApi implements CloudTestNamespaceManager.CloudApi { + private CreateNamespaceResponse createNamespaceResponse; + private DeleteNamespaceResponse deleteNamespaceResponse; + private GetNamespaceResponse getNamespaceResponse; + private CreateNamespaceRequest createNamespaceRequest; + private DeleteNamespaceRequest deleteNamespaceRequest; + private GetNamespaceRequest getNamespaceRequest; + private final List getAsyncOperationRequests = + new java.util.ArrayList<>(); + private final List getAsyncOperationTimeouts = new java.util.ArrayList<>(); + private final Queue operations = new ArrayDeque<>(); + private RuntimeException getOperationFailure; + + @Override + public CreateNamespaceResponse createNamespace(CreateNamespaceRequest request) { + createNamespaceRequest = request; + return createNamespaceResponse; + } + + @Override + public GetAsyncOperationResponse getAsyncOperation( + GetAsyncOperationRequest request, java.time.Duration rpcTimeout) { + getAsyncOperationRequests.add(request); + getAsyncOperationTimeouts.add(rpcTimeout); + if (getOperationFailure != null) { + throw getOperationFailure; + } + return GetAsyncOperationResponse.newBuilder().setAsyncOperation(operations.remove()).build(); + } + + @Override + public GetNamespaceResponse getNamespace(GetNamespaceRequest request) { + getNamespaceRequest = request; + return getNamespaceResponse; + } + + @Override + public DeleteNamespaceResponse deleteNamespace(DeleteNamespaceRequest request) { + deleteNamespaceRequest = request; + return deleteNamespaceResponse; + } + } +}