diff --git a/MODULE.bazel b/MODULE.bazel index eb3a904438e..8fedfab8dab 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -37,7 +37,7 @@ bazel_dep( ) bazel_dep( name = "common-jvm", - version = "0.96.0", + version = "0.97.0", repo_name = "wfa_common_jvm", ) bazel_dep( diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 425cb76babc..fa19cc1f52a 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -244,8 +244,8 @@ "https://raw.githubusercontent.com/world-federation-of-advertisers/bazel-registry/main/modules/common-cpp/0.13.0/MODULE.bazel": "ad9f152e8f710943b2d9aa4ea19e06b47911abd8a5fb63d9e69e66636475794d", "https://raw.githubusercontent.com/world-federation-of-advertisers/bazel-registry/main/modules/common-cpp/0.13.0/source.json": "910ffe09948e90417f98e00fd88819dbaa54efda4175e05e528481acc81c5d71", "https://raw.githubusercontent.com/world-federation-of-advertisers/bazel-registry/main/modules/common-jvm/0.82.1/MODULE.bazel": "e5d9aee83831d55952c193f6f05b3952d3f3cd61d8d4e3bfedf3549e25edf308", - "https://raw.githubusercontent.com/world-federation-of-advertisers/bazel-registry/main/modules/common-jvm/0.96.0/MODULE.bazel": "1bca0537055c659b1797115b00362563811e5f28749ca8897b2327bf4274aa68", - "https://raw.githubusercontent.com/world-federation-of-advertisers/bazel-registry/main/modules/common-jvm/0.96.0/source.json": "09f07fe62c6b021f867fdb0b521550ce5ee2f2b75a0d070ccfa5a1673f652eb3", + "https://raw.githubusercontent.com/world-federation-of-advertisers/bazel-registry/main/modules/common-jvm/0.97.0/MODULE.bazel": "646c046456f7a2103f0276dc47ab62238f790520298731ab437e7b7e3364f269", + "https://raw.githubusercontent.com/world-federation-of-advertisers/bazel-registry/main/modules/common-jvm/0.97.0/source.json": "9a307b5e29b735a745567bfbd3bb88002298b7aa8ca7f1ef5bf45dd74768f2e7", "https://raw.githubusercontent.com/world-federation-of-advertisers/bazel-registry/main/modules/consent-signaling-client/0.22.0/MODULE.bazel": "6437a69ac68ec8e3589d595a5ed79d3f6404510124e455838ce9cf51b8b27d52", "https://raw.githubusercontent.com/world-federation-of-advertisers/bazel-registry/main/modules/consent-signaling-client/0.22.0/source.json": "844344f41ae9b780cfb0452438388465d3cf7c7b5accedb1fcc4900ace5111ca", "https://raw.githubusercontent.com/world-federation-of-advertisers/bazel-registry/main/modules/cross-media-measurement-api/0.62.0/MODULE.bazel": "ff213234cf605ff0cf7dc5d55b0e1d0aab7df065f32759e6e64d2d1315110b18", @@ -2938,7 +2938,7 @@ "@@rules_oci~//oci:extensions.bzl%oci": { "general": { "bzlTransitiveDigest": "J5PwFuMf25JLNypLwKeezBkpW3Cr1Ybov6Zv+f8BVCA=", - "usagesDigest": "vyaE5irFuZj14a1LJ1QU0mzRDCshjoQAUwZQYR4QDP0=", + "usagesDigest": "/zyc3Tb9kbc8uiPvIyCjwRmlukg1WJxQVRJxA8E/txw=", "recordedFileInputs": {}, "recordedDirentsInputs": {}, "envVariables": {}, diff --git a/maven_install.json b/maven_install.json index c31641e3de1..161cce90967 100755 --- a/maven_install.json +++ b/maven_install.json @@ -1,7 +1,7 @@ { "__AUTOGENERATED_FILE_DO_NOT_MODIFY_THIS_FILE_MANUALLY": "THERE_IS_NO_DATA_ONLY_ZUUL", - "__INPUT_ARTIFACTS_HASH": 937453670, - "__RESOLVED_ARTIFACTS_HASH": 916851004, + "__INPUT_ARTIFACTS_HASH": 681334911, + "__RESOLVED_ARTIFACTS_HASH": 799039339, "artifacts": { "args4j:args4j": { "shasums": { @@ -677,6 +677,13 @@ }, "version": "2.9.0" }, + "com.google.cloud.functions:functions-framework-api": { + "shasums": { + "jar": "f1e318303c6b7df3edce61c2d5b607ab5a34212cbda57aa44f502d36dbcb9c28", + "sources": "8eb9156b0ab40d32d4ce7711d88eae38b77194e49370d684d7138a7f97dd16c0" + }, + "version": "1.1.0" + }, "com.google.cloud.opentelemetry:detector-resources-support": { "shasums": { "jar": "6252d6a872563933a3f5b522d7e59bd31576ddfd6ed4d1d43d671408185c81ad", @@ -831,6 +838,13 @@ }, "version": "2.42.0" }, + "com.google.cloud:google-cloudevent-types": { + "shasums": { + "jar": "9750968913b4388f177283cdb36000b434a0db5c4efe2fd2028a527aa024d0da", + "sources": "c574c09603d97b05048bafe0a83a56579757d256e270979f3219d8289ec8f2be" + }, + "version": "0.16.0" + }, "com.google.cloud:grpc-gcp": { "shasums": { "jar": "5685df2913047269fd01c0fa469f66121406ccf65bfd1aea9efeb6da46a3575e", @@ -1187,6 +1201,20 @@ }, "version": "4.4.0" }, + "io.cloudevents:cloudevents-api": { + "shasums": { + "jar": "1eb08784349017f4d9bf7e31cd9107c7e1dd15138f60507168a3a68b0206cf5b", + "sources": "5a7aa69e3075a40249e3f8a3887c4c54d1c1471d0c0293e3718ff9ae42ac0d98" + }, + "version": "4.0.1" + }, + "io.cloudevents:cloudevents-core": { + "shasums": { + "jar": "f6226bac64b9fd84b15ec2111eb50b512febec55bbf40495cce841946b8dc120", + "sources": "6d05d3cb64fdf4fa482e8cacfe817c0b9de466cd40102f9add4b0b725b2278b4" + }, + "version": "4.0.1" + }, "io.dropwizard.metrics:metrics-core": { "shasums": { "jar": "79d903d4ae850c9dee8d3939e5bd8d4172a91fda40b31b7e40a5d8c3e1fe4534", @@ -2884,6 +2912,13 @@ }, "version": "1.19.3" }, + "org.testcontainers:gcloud": { + "shasums": { + "jar": "7cdf6654e01169a65fe7e0f6ef78d209fdfd9ab61924cde711648ad46b00d997", + "sources": "698a39cb7f9680db56a17475127ecf97d601338bbeb10b614d0d157412bae784" + }, + "version": "1.19.3" + }, "org.testcontainers:jdbc": { "shasums": { "jar": "5ed185d08a3a704b7265ebd2b5eafe3e9374c34194d982dc279a5ea62965edfe", @@ -3969,6 +4004,9 @@ "com.google.oauth-client:google-oauth-client", "com.google.protobuf:protobuf-java" ], + "com.google.cloud.functions:functions-framework-api": [ + "io.cloudevents:cloudevents-api" + ], "com.google.cloud.sql:cloud-sql-connector-r2dbc-core": [ "com.google.cloud.sql:jdbc-socket-factory-core", "io.netty:netty-handler", @@ -4695,6 +4733,12 @@ "org.slf4j:slf4j-api", "org.threeten:threetenbp" ], + "com.google.cloud:google-cloudevent-types": [ + "com.google.api-client:google-api-client", + "com.google.api.grpc:proto-google-common-protos", + "com.google.protobuf:protobuf-java", + "com.google.protobuf:protobuf-java-util" + ], "com.google.cloud:proto-google-cloud-firestore-bundle-v1": [ "com.google.api.grpc:proto-google-common-protos", "com.google.api:api-common", @@ -4859,6 +4903,9 @@ "commons-collections:commons-collections", "commons-logging:commons-logging" ], + "io.cloudevents:cloudevents-core": [ + "io.cloudevents:cloudevents-api" + ], "io.dropwizard.metrics:metrics-core": [ "org.slf4j:slf4j-api" ], @@ -5940,6 +5987,9 @@ "org.testcontainers:database-commons": [ "org.testcontainers:testcontainers" ], + "org.testcontainers:gcloud": [ + "org.testcontainers:testcontainers" + ], "org.testcontainers:jdbc": [ "org.testcontainers:database-commons" ], @@ -7025,6 +7075,9 @@ "com.google.datastore.v1.client", "com.google.datastore.v1.client.testing" ], + "com.google.cloud.functions:functions-framework-api": [ + "com.google.cloud.functions" + ], "com.google.cloud.opentelemetry:detector-resources-support": [ "com.google.cloud.opentelemetry.detection" ], @@ -7165,6 +7218,54 @@ "com.google.cloud.storage.testing", "com.google.cloud.storage.transfermanager" ], + "com.google.cloud:google-cloudevent-types": [ + "com.google.events.cloud.alloydb.v1", + "com.google.events.cloud.apigateway.v1", + "com.google.events.cloud.apigeeregistry.v1", + "com.google.events.cloud.audit.v1", + "com.google.events.cloud.batch.v1", + "com.google.events.cloud.beyondcorp.appconnections.v1", + "com.google.events.cloud.beyondcorp.appconnectors.v1", + "com.google.events.cloud.beyondcorp.appgateways.v1", + "com.google.events.cloud.beyondcorp.clientconnectorservices.v1", + "com.google.events.cloud.beyondcorp.clientgateways.v1", + "com.google.events.cloud.certificatemanager.v1", + "com.google.events.cloud.cloudbuild.v1", + "com.google.events.cloud.clouddms.v1", + "com.google.events.cloud.dataflow.v1beta3", + "com.google.events.cloud.datafusion.v1", + "com.google.events.cloud.dataplex.v1", + "com.google.events.cloud.datastore.v1", + "com.google.events.cloud.datastream.v1", + "com.google.events.cloud.deploy.v1", + "com.google.events.cloud.eventarc.v1", + "com.google.events.cloud.firestore.v1", + "com.google.events.cloud.functions.v2", + "com.google.events.cloud.gkebackup.v1", + "com.google.events.cloud.gkehub.v1", + "com.google.events.cloud.iot.v1", + "com.google.events.cloud.memcache.v1", + "com.google.events.cloud.metastore.v1", + "com.google.events.cloud.networkconnectivity.v1", + "com.google.events.cloud.networkmanagement.v1", + "com.google.events.cloud.networkservices.v1", + "com.google.events.cloud.notebooks.v1", + "com.google.events.cloud.pubsub.v1", + "com.google.events.cloud.redis.v1", + "com.google.events.cloud.scheduler.v1", + "com.google.events.cloud.speech.v1", + "com.google.events.cloud.storage.v1", + "com.google.events.cloud.video.transcoder.v1", + "com.google.events.cloud.visionai.v1", + "com.google.events.cloud.vmmigration.v1", + "com.google.events.cloud.workflows.v1", + "com.google.events.firebase.analytics.v1", + "com.google.events.firebase.auth.v1", + "com.google.events.firebase.database.v1", + "com.google.events.firebase.firebasealerts.v1", + "com.google.events.firebase.remoteconfig.v1", + "com.google.events.firebase.testlab.v1" + ], "com.google.cloud:grpc-gcp": [ "com.google.cloud.grpc", "com.google.cloud.grpc.multiendpoint", @@ -7484,6 +7585,27 @@ "info.picocli:picocli": [ "picocli" ], + "io.cloudevents:cloudevents-api": [ + "io.cloudevents", + "io.cloudevents.lang", + "io.cloudevents.rw", + "io.cloudevents.types" + ], + "io.cloudevents:cloudevents-core": [ + "io.cloudevents.core", + "io.cloudevents.core.builder", + "io.cloudevents.core.data", + "io.cloudevents.core.extensions", + "io.cloudevents.core.extensions.impl", + "io.cloudevents.core.format", + "io.cloudevents.core.impl", + "io.cloudevents.core.message", + "io.cloudevents.core.message.impl", + "io.cloudevents.core.provider", + "io.cloudevents.core.v03", + "io.cloudevents.core.v1", + "io.cloudevents.core.validator" + ], "io.dropwizard.metrics:metrics-core": [ "com.codahale.metrics" ], @@ -11209,6 +11331,9 @@ "org.testcontainers.exception", "org.testcontainers.ext" ], + "org.testcontainers:gcloud": [ + "org.testcontainers.containers" + ], "org.testcontainers:jdbc": [ "org.testcontainers.containers", "org.testcontainers.jdbc", @@ -12246,6 +12371,8 @@ "com.google.cloud.bigtable:bigtable-metrics-api:jar:sources", "com.google.cloud.datastore:datastore-v1-proto-client", "com.google.cloud.datastore:datastore-v1-proto-client:jar:sources", + "com.google.cloud.functions:functions-framework-api", + "com.google.cloud.functions:functions-framework-api:jar:sources", "com.google.cloud.opentelemetry:detector-resources-support", "com.google.cloud.opentelemetry:detector-resources-support:jar:sources", "com.google.cloud.opentelemetry:exporter-metrics", @@ -12290,6 +12417,8 @@ "com.google.cloud:google-cloud-spanner:jar:sources", "com.google.cloud:google-cloud-storage", "com.google.cloud:google-cloud-storage:jar:sources", + "com.google.cloud:google-cloudevent-types", + "com.google.cloud:google-cloudevent-types:jar:sources", "com.google.cloud:grpc-gcp", "com.google.cloud:grpc-gcp:jar:sources", "com.google.cloud:proto-google-cloud-firestore-bundle-v1", @@ -12391,6 +12520,10 @@ "dev.failsafe:failsafe:jar:sources", "info.picocli:picocli", "info.picocli:picocli:jar:sources", + "io.cloudevents:cloudevents-api", + "io.cloudevents:cloudevents-api:jar:sources", + "io.cloudevents:cloudevents-core", + "io.cloudevents:cloudevents-core:jar:sources", "io.dropwizard.metrics:metrics-core", "io.dropwizard.metrics:metrics-core:jar:sources", "io.github.classgraph:classgraph", @@ -12874,6 +13007,8 @@ "org.springframework:spring-webmvc:jar:sources", "org.testcontainers:database-commons", "org.testcontainers:database-commons:jar:sources", + "org.testcontainers:gcloud", + "org.testcontainers:gcloud:jar:sources", "org.testcontainers:jdbc", "org.testcontainers:jdbc:jar:sources", "org.testcontainers:postgresql", diff --git a/src/main/kotlin/org/wfanet/measurement/securecomputation/teesdk/BUILD.bazel b/src/main/kotlin/org/wfanet/measurement/securecomputation/teesdk/BUILD.bazel new file mode 100644 index 00000000000..79bd4ca9879 --- /dev/null +++ b/src/main/kotlin/org/wfanet/measurement/securecomputation/teesdk/BUILD.bazel @@ -0,0 +1,13 @@ +load("@wfa_rules_kotlin_jvm//kotlin:defs.bzl", "kt_jvm_library") + +package(default_visibility = ["//visibility:public"]) + +kt_jvm_library( + name = "base_tee_application", + srcs = ["BaseTeeApplication.kt"], + deps = [ + "@wfa_common_jvm//imports/java/com/google/protobuf", + "@wfa_common_jvm//imports/kotlin/kotlinx/coroutines:core", + "@wfa_common_jvm//src/main/kotlin/org/wfanet/measurement/queue:queue_subscriber", + ], +) diff --git a/src/main/kotlin/org/wfanet/measurement/securecomputation/teesdk/BaseTeeApplication.kt b/src/main/kotlin/org/wfanet/measurement/securecomputation/teesdk/BaseTeeApplication.kt new file mode 100644 index 00000000000..29cb52a4b38 --- /dev/null +++ b/src/main/kotlin/org/wfanet/measurement/securecomputation/teesdk/BaseTeeApplication.kt @@ -0,0 +1,88 @@ +/* + * Copyright 2024 The Cross-Media Measurement Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.wfanet.measurement.securecomputation.teesdk + +import com.google.protobuf.InvalidProtocolBufferException +import com.google.protobuf.Message +import com.google.protobuf.Parser +import java.util.logging.Level +import java.util.logging.Logger +import kotlinx.coroutines.channels.ReceiveChannel +import org.wfanet.measurement.queue.QueueSubscriber + +/** + * BaseTeeApplication is an abstract base class for TEE applications that automatically subscribes + * to a specified queue and processes messages as they arrive. + * + * @param T The type of message that this application will process. + * @param subscriptionId The name of the subscription to which this application subscribes. + * @param queueSubscriber A client that manages connections and interactions with the queue. + * @param parser [Parser] used to parse serialized queue messages into [T] instances. + */ +abstract class BaseTeeApplication( + private val subscriptionId: String, + private val queueSubscriber: QueueSubscriber, + private val parser: Parser, +) : AutoCloseable { + + /** Starts the TEE application by listening for messages on the specified queue. */ + suspend fun run() { + receiveAndProcessMessages() + } + + /** + * Begins listening for messages on the specified queue. Each message is processed as it arrives. + * If an error occurs during the message flow, it is logged and handling continues. + */ + private suspend fun receiveAndProcessMessages() { + val messageChannel: ReceiveChannel> = + queueSubscriber.subscribe(subscriptionId, parser) + for (message: QueueSubscriber.QueueMessage in messageChannel) { + processMessage(message) + } + } + + /** + * Processes each message received from the queue by attempting to parse and pass it to [runWork]. + * If parsing fails, the message is negatively acknowledged and discarded. If processing fails, + * the message is negatively acknowledged and optionally requeued. + * + * @param queueMessage The raw message received from the queue. + */ + private suspend fun processMessage(queueMessage: QueueSubscriber.QueueMessage) { + try { + runWork(queueMessage.body) + queueMessage.ack() + } catch (e: InvalidProtocolBufferException) { + logger.log(Level.SEVERE, e) { "Failed to parse protobuf message" } + queueMessage.nack() + } catch (e: Exception) { + logger.log(Level.SEVERE, e) { "Error processing message" } + queueMessage.nack() + } + } + + abstract suspend fun runWork(message: T) + + override fun close() { + queueSubscriber.close() + } + + companion object { + protected val logger = Logger.getLogger(this::class.java.name) + } +} diff --git a/src/test/kotlin/org/wfanet/measurement/securecomputation/teesdk/BUILD.bazel b/src/test/kotlin/org/wfanet/measurement/securecomputation/teesdk/BUILD.bazel new file mode 100644 index 00000000000..bf8f5b337f4 --- /dev/null +++ b/src/test/kotlin/org/wfanet/measurement/securecomputation/teesdk/BUILD.bazel @@ -0,0 +1,22 @@ +load("@wfa_rules_kotlin_jvm//kotlin:defs.bzl", "kt_jvm_test") + +kt_jvm_test( + name = "BaseTeeApplicationTest", + srcs = ["BaseTeeApplicationTest.kt"], + tags = [ + "no-remote-exec", + ], + test_class = "org.wfanet.measurement.securecomputation.teesdk.BaseTeeApplicationTest", + deps = [ + "//src/main/kotlin/org/wfanet/measurement/securecomputation/teesdk:base_tee_application", + "@wfa_common_jvm//imports/java/com/google/common/truth", + "@wfa_common_jvm//imports/kotlin/kotlin/test", + "@wfa_common_jvm//src/main/kotlin/org/wfanet/measurement/gcloud/pubsub:google_pub_sub_client", + "@wfa_common_jvm//src/main/kotlin/org/wfanet/measurement/gcloud/pubsub:publisher", + "@wfa_common_jvm//src/main/kotlin/org/wfanet/measurement/gcloud/pubsub:subscriber", + "@wfa_common_jvm//src/main/kotlin/org/wfanet/measurement/gcloud/pubsub/testing:google_pub_sub_emulator_client", + "@wfa_common_jvm//src/main/kotlin/org/wfanet/measurement/gcloud/pubsub/testing:google_pub_sub_emulator_provider", + "@wfa_common_jvm//src/main/kotlin/org/wfanet/measurement/queue:queue_subscriber", + "@wfa_common_jvm//src/main/proto/wfa/measurement/queue/testing:test_work_kt_jvm_proto", + ], +) diff --git a/src/test/kotlin/org/wfanet/measurement/securecomputation/teesdk/BaseTeeApplicationTest.kt b/src/test/kotlin/org/wfanet/measurement/securecomputation/teesdk/BaseTeeApplicationTest.kt new file mode 100644 index 00000000000..2e159b66c3e --- /dev/null +++ b/src/test/kotlin/org/wfanet/measurement/securecomputation/teesdk/BaseTeeApplicationTest.kt @@ -0,0 +1,111 @@ +// Copyright 2024 The Cross-Media Measurement Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package org.wfanet.measurement.securecomputation.teesdk + +import com.google.common.truth.Truth.assertThat +import com.google.protobuf.Parser +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Before +import org.junit.ClassRule +import org.junit.Test +import org.wfa.measurement.queue.testing.TestWork +import org.wfanet.measurement.gcloud.pubsub.Publisher +import org.wfanet.measurement.gcloud.pubsub.Subscriber +import org.wfanet.measurement.gcloud.pubsub.testing.GooglePubSubEmulatorClient +import org.wfanet.measurement.gcloud.pubsub.testing.GooglePubSubEmulatorProvider +import org.wfanet.measurement.queue.QueueSubscriber + +class BaseTeeApplicationImpl( + subscriptionId: String, + queueSubscriber: QueueSubscriber, + parser: Parser, +) : + BaseTeeApplication( + subscriptionId = subscriptionId, + queueSubscriber = queueSubscriber, + parser = parser, + ) { + val messageProcessed = CompletableDeferred() + + override suspend fun runWork(message: TestWork) { + messageProcessed.complete(message) + } +} + +class BaseTeeApplicationTest { + + private lateinit var emulatorClient: GooglePubSubEmulatorClient + + @Before + fun setupPubSubResources() { + runBlocking { + emulatorClient = + GooglePubSubEmulatorClient( + host = pubSubEmulatorProvider.host, + port = pubSubEmulatorProvider.port, + ) + emulatorClient.createTopic(PROJECT_ID, TOPIC_ID) + emulatorClient.createSubscription(PROJECT_ID, SUBSCRIPTION_ID, TOPIC_ID) + } + } + + @After + fun cleanPubSubResources() { + runBlocking { + emulatorClient.deleteTopic(PROJECT_ID, TOPIC_ID) + emulatorClient.deleteSubscription(PROJECT_ID, SUBSCRIPTION_ID) + } + } + + @Test + fun `test processing protobuf message`() = runBlocking { + val pubSubClient = Subscriber(projectId = PROJECT_ID, googlePubSubClient = emulatorClient) + val publisher = Publisher(PROJECT_ID, emulatorClient) + val app = + BaseTeeApplicationImpl( + subscriptionId = SUBSCRIPTION_ID, + queueSubscriber = pubSubClient, + parser = TestWork.parser(), + ) + val job = launch { app.run() } + + val message = "UserName1" + val testWork = createTestWork(message) + + publisher.publishMessage(TOPIC_ID, testWork) + + val processedMessage = app.messageProcessed.await() + assertThat(processedMessage).isEqualTo(testWork) + + job.cancelAndJoin() + } + + private fun createTestWork(message: String): TestWork { + return TestWork.newBuilder().setUserName(message).setUserAge("25").setUserCountry("US").build() + } + + companion object { + + private const val PROJECT_ID = "test-project" + private const val SUBSCRIPTION_ID = "test-subscription" + private const val TOPIC_ID = "test-topic" + + @get:ClassRule @JvmStatic val pubSubEmulatorProvider = GooglePubSubEmulatorProvider() + } +}