diff --git a/docs/modules/ROOT/pages/pubsub.adoc b/docs/modules/ROOT/pages/pubsub.adoc index 0ca093e0..2efa4eb5 100644 --- a/docs/modules/ROOT/pages/pubsub.adoc +++ b/docs/modules/ROOT/pages/pubsub.adoc @@ -184,3 +184,72 @@ public class PubSubResource { } } ---- + +== Dev Service + +=== Configuring the Dev Service + +The extension provides a Dev Service that can be used to run a local PubSub emulator. This is useful for testing purposes, so you don't have to rely on a real PubSub instance. By default, the Dev Service is disabled, but you can enable it by setting the + +* `quarkus.google.cloud.pubsub.devservice.enabled` property to `true` + +You can also set the + +* `quarkus.google.cloud.pubsub.devservice.port` property to change the port on which the emulator will be started (by default there is no port set, so the emulator will use a random port) + +=== Using the Dev Service + +If we want to connect to the Dev Service, we need to specify `TransportChannelProvider` when creating subscriptions and publishers. + +We can just reuse the code from the previous example and add the `TransportChannelProvider` to the `Subscriber` and `Publisher`. So what do we need to change? + +As a first thing, we should declare a variable which we can then reuse and also inject the `quarkus.google.cloud.pubsub.emulator-host` property: + +[source,java] +---- +@ConfigProperty(name = "quarkus.google.cloud.pubsub.emulator-host") +String emulatorHost; + +private TransportChannelProvider channelProvider; +---- + +Then, we can create a `TransportChannelProvider` that provides connection to devservice within the `init` method: + +[source,java] +---- +// Create a ChannelProvider that connects to the Dev Service +ManagedChannel channel = ManagedChannelBuilder.forTarget(emulatorHost).usePlaintext().build(); +channelProvider = FixedTransportChannelProvider.create(GrpcTransportChannel.create(channel)); +---- + +Also in the same method when creating the `Subscriber` we set the `TransportChannelProvider`: + +[source,java] +---- +// Create a subscriber and set the ChannelProvider +subscriber = Subscriber.newBuilder(subscriptionName, receiver).setChannelProvider(channelProvider).build(); +subscriber.startAsync().awaitRunning(); +---- + +The same is done when creating the `Publisher` in the `pubsub` method: + +[source,java] +---- +// Init a publisher to the topic +Publisher publisher = Publisher.newBuilder(topicName) +.setCredentialsProvider(credentialsProvider) +// Set the ChannelProvider +.setChannelProvider(channelProvider) +.build(); +---- + +And finally we also set the `TransportChannelProvider` when creating the `SubscriptionAdminClient` in the `initSubscription` method: + +[source,java] +---- +SubscriptionAdminSettings subscriptionAdminSettings = SubscriptionAdminSettings.newBuilder() +.setCredentialsProvider(credentialsProvider) +// Set the ChannelProvider +.setTransportChannelProvider(channelProvider) +.build(); +---- diff --git a/integration-tests/main/src/main/java/io/quarkiverse/googlecloudservices/it/pubsub/TopicManager.java b/integration-tests/main/src/main/java/io/quarkiverse/googlecloudservices/it/pubsub/TopicManager.java index 2ecc80bc..f6dab09f 100644 --- a/integration-tests/main/src/main/java/io/quarkiverse/googlecloudservices/it/pubsub/TopicManager.java +++ b/integration-tests/main/src/main/java/io/quarkiverse/googlecloudservices/it/pubsub/TopicManager.java @@ -31,6 +31,9 @@ public class TopicManager { @ConfigProperty(name = "pubsub.use-emulator", defaultValue = "false") boolean useEmulator; + @ConfigProperty(name = "quarkus.google.cloud.pubsub.emulator-host") + String emulatorHost; + private TopicName topicName; private Optional channelProvider; @@ -39,7 +42,7 @@ void init() { this.topicName = TopicName.of(projectId, "test-topic"); if (useEmulator) { - ManagedChannel channel = ManagedChannelBuilder.forTarget("localhost:8085").usePlaintext().build(); + ManagedChannel channel = ManagedChannelBuilder.forTarget(emulatorHost).usePlaintext().build(); channelProvider = Optional.of(FixedTransportChannelProvider.create(GrpcTransportChannel.create(channel))); } else { channelProvider = Optional.empty(); diff --git a/integration-tests/main/src/main/resources/application.properties b/integration-tests/main/src/main/resources/application.properties index 4b232196..5cd5e432 100644 --- a/integration-tests/main/src/main/resources/application.properties +++ b/integration-tests/main/src/main/resources/application.properties @@ -13,6 +13,7 @@ # Use pubsub emulator %test.pubsub.use-emulator=true +%test.quarkus.google.cloud.pubsub.devservice.enabled=true # Secret Manager Demo # You can load secrets from Google Cloud Secret Manager with the ${sm//} syntax. diff --git a/integration-tests/main/src/test/java/io/quarkiverse/googlecloudservices/it/PubSubResourceTest.java b/integration-tests/main/src/test/java/io/quarkiverse/googlecloudservices/it/PubSubResourceTest.java index dd99f3e3..6c2ef1d3 100644 --- a/integration-tests/main/src/test/java/io/quarkiverse/googlecloudservices/it/PubSubResourceTest.java +++ b/integration-tests/main/src/test/java/io/quarkiverse/googlecloudservices/it/PubSubResourceTest.java @@ -4,38 +4,17 @@ import static org.awaitility.Awaitility.await; import static org.hamcrest.core.IsEqual.equalTo; -import java.util.ArrayList; -import java.util.List; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; -import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; -import org.testcontainers.containers.PubSubEmulatorContainer; -import org.testcontainers.utility.DockerImageName; import io.quarkus.test.junit.QuarkusTest; import io.restassured.http.ContentType; @QuarkusTest public class PubSubResourceTest { - private static final PubSubEmulatorContainer EMULATOR = new PubSubEmulatorContainer( - DockerImageName.parse("gcr.io/google.com/cloudsdktool/cloud-sdk")); - - @BeforeAll - public static void startGcloudContainer() { - List portBindings = new ArrayList<>(); - portBindings.add("8085:8085"); - EMULATOR.setPortBindings(portBindings); - EMULATOR.start(); - } - - @AfterAll - public static void stopGcloudContainer() { - EMULATOR.stop(); - } @Test public void testPubSub() throws ExecutionException, InterruptedException, TimeoutException { diff --git a/pubsub/deployment/pom.xml b/pubsub/deployment/pom.xml index 7c33dfcc..3d50c8b2 100644 --- a/pubsub/deployment/pom.xml +++ b/pubsub/deployment/pom.xml @@ -28,6 +28,10 @@ io.quarkiverse.googlecloudservices quarkus-google-cloud-pubsub + + org.testcontainers + gcloud + @@ -48,4 +52,4 @@ - \ No newline at end of file + diff --git a/pubsub/deployment/src/main/java/io/quarkiverse/googlecloudservices/pubsub/deployement/PubSubBuildSteps.java b/pubsub/deployment/src/main/java/io/quarkiverse/googlecloudservices/pubsub/deployement/PubSubBuildSteps.java index bd547ea2..50cbc655 100644 --- a/pubsub/deployment/src/main/java/io/quarkiverse/googlecloudservices/pubsub/deployement/PubSubBuildSteps.java +++ b/pubsub/deployment/src/main/java/io/quarkiverse/googlecloudservices/pubsub/deployement/PubSubBuildSteps.java @@ -4,7 +4,7 @@ import io.quarkus.deployment.builditem.FeatureBuildItem; public class PubSubBuildSteps { - private static final String FEATURE = "google-cloud-pubsub"; + public static final String FEATURE = "google-cloud-pubsub"; @BuildStep public FeatureBuildItem feature() { diff --git a/pubsub/deployment/src/main/java/io/quarkiverse/googlecloudservices/pubsub/deployement/PubSubBuildTimeConfig.java b/pubsub/deployment/src/main/java/io/quarkiverse/googlecloudservices/pubsub/deployement/PubSubBuildTimeConfig.java new file mode 100644 index 00000000..075db05f --- /dev/null +++ b/pubsub/deployment/src/main/java/io/quarkiverse/googlecloudservices/pubsub/deployement/PubSubBuildTimeConfig.java @@ -0,0 +1,22 @@ +package io.quarkiverse.googlecloudservices.pubsub.deployement; + +import io.quarkus.runtime.annotations.ConfigItem; +import io.quarkus.runtime.annotations.ConfigPhase; +import io.quarkus.runtime.annotations.ConfigRoot; + +/** + * Root configuration class for Google Cloud Pub/Sub that operates at build time. + * This class provides a nested structure for configuration, including + * a separate group for the development service configuration. + */ +@ConfigRoot(name = "google.cloud.pubsub", phase = ConfigPhase.BUILD_TIME) +public class PubSubBuildTimeConfig { + + /** + * Configuration for the Pub/Sub development service. + * These settings will be used when Pub/Sub service is being configured + * for development purposes. + */ + @ConfigItem + public PubSubDevServiceConfig devservice; +} diff --git a/pubsub/deployment/src/main/java/io/quarkiverse/googlecloudservices/pubsub/deployement/PubSubDevServiceConfig.java b/pubsub/deployment/src/main/java/io/quarkiverse/googlecloudservices/pubsub/deployement/PubSubDevServiceConfig.java new file mode 100644 index 00000000..ac6db81a --- /dev/null +++ b/pubsub/deployment/src/main/java/io/quarkiverse/googlecloudservices/pubsub/deployement/PubSubDevServiceConfig.java @@ -0,0 +1,44 @@ +package io.quarkiverse.googlecloudservices.pubsub.deployement; + +import java.util.Optional; + +import io.quarkus.runtime.annotations.ConfigGroup; +import io.quarkus.runtime.annotations.ConfigItem; + +/** + * Configuration group for the PubSubDevService. This class holds all the configuration properties + * related to the Google Cloud Pub/Sub service for development environments. + *

+ * Here is an example of how to configure these properties: + *

+ * + *

+ * quarkus.pub-sub-dev-service.enabled = true
+ * quarkus.pub-sub-dev-service.image-name = gcr.io/google.com/cloudsdktool/google-cloud-cli # optional
+ * quarkus.pub-sub-dev-service.emulatorPort = 8085 # optional
+ * 
+ */ +@ConfigGroup +public class PubSubDevServiceConfig { + + /** + * Indicates whether the Pub/Sub service should be enabled or not. + * The default value is 'false'. + */ + @ConfigItem(defaultValue = "false") + public boolean enabled; + + /** + * Sets the Docker image name for the Google Cloud SDK. + * This image is used to emulate the Pub/Sub service in the development environment. + * The default value is 'gcr.io/google.com/cloudsdktool/google-cloud-cli'. + */ + @ConfigItem(name = "image-name", defaultValue = "gcr.io/google.com/cloudsdktool/google-cloud-cli") + public String imageName; + + /** + * Specifies the emulatorPort on which the Pub/Sub service should run in the development environment. + */ + @ConfigItem(name = "emulatorPort") + public Optional port = Optional.empty(); +} diff --git a/pubsub/deployment/src/main/java/io/quarkiverse/googlecloudservices/pubsub/deployement/PubSubDevServiceProcessor.java b/pubsub/deployment/src/main/java/io/quarkiverse/googlecloudservices/pubsub/deployement/PubSubDevServiceProcessor.java new file mode 100644 index 00000000..0ad5b278 --- /dev/null +++ b/pubsub/deployment/src/main/java/io/quarkiverse/googlecloudservices/pubsub/deployement/PubSubDevServiceProcessor.java @@ -0,0 +1,172 @@ +package io.quarkiverse.googlecloudservices.pubsub.deployement; + +import java.time.Duration; +import java.util.List; +import java.util.Optional; + +import org.jboss.logging.Logger; +import org.testcontainers.containers.PubSubEmulatorContainer; +import org.testcontainers.utility.DockerImageName; + +import io.quarkus.deployment.IsNormal; +import io.quarkus.deployment.annotations.BuildStep; +import io.quarkus.deployment.annotations.BuildSteps; +import io.quarkus.deployment.builditem.*; +import io.quarkus.deployment.console.ConsoleInstalledBuildItem; +import io.quarkus.deployment.console.StartupLogCompressor; +import io.quarkus.deployment.dev.devservices.GlobalDevServicesConfig; +import io.quarkus.deployment.logging.LoggingSetupBuildItem; + +/** + * Processor responsible for managing PubSub Dev Services. + *

+ * The processor starts the PubSub service in case it's not running. + */ +@BuildSteps(onlyIfNot = IsNormal.class, onlyIf = GlobalDevServicesConfig.Enabled.class) +public class PubSubDevServiceProcessor { + + private static final Logger LOGGER = Logger.getLogger(PubSubDevServiceProcessor.class.getName()); + + // Running dev service instance + private static volatile DevServicesResultBuildItem.RunningDevService devService; + // Configuration for the PubSub Dev service + private static volatile PubSubDevServiceConfig config; + + @BuildStep + public DevServicesResultBuildItem startPubSub(DockerStatusBuildItem dockerStatusBuildItem, + PubSubBuildTimeConfig pubSubBuildTimeConfig, + List devServicesSharedNetworkBuildItem, + Optional consoleInstalledBuildItem, + CuratedApplicationShutdownBuildItem closeBuildItem, + LaunchModeBuildItem launchMode, + LoggingSetupBuildItem loggingSetupBuildItem, + GlobalDevServicesConfig globalDevServicesConfig) { + // If dev service is running and config has changed, stop the service + if (devService != null && !pubSubBuildTimeConfig.devservice.equals(config)) { + stopContainer(); + } else if (devService != null) { + return devService.toBuildItem(); + } + + // Set up log compressor for startup logs + StartupLogCompressor compressor = new StartupLogCompressor( + (launchMode.isTest() ? "(test) " : "") + "Google Cloud PubSub Dev Services Starting:", + consoleInstalledBuildItem, + loggingSetupBuildItem); + + // Try starting the container if conditions are met + try { + devService = startContainerIfAvailable(dockerStatusBuildItem, pubSubBuildTimeConfig.devservice, + globalDevServicesConfig.timeout); + } catch (Throwable t) { + LOGGER.warn("Unable to start PubSub dev service", t); + // Dump captured logs in case of an error + compressor.closeAndDumpCaptured(); + return null; + } finally { + compressor.close(); + } + + return devService == null ? null : devService.toBuildItem(); + } + + /** + * Start the container if conditions are met. + * + * @param dockerStatusBuildItem, Docker status + * @param config, Configuration for the PubSub service + * @param timeout, Optional timeout for starting the service + * @return Running service item, or null if the service couldn't be started + */ + private DevServicesResultBuildItem.RunningDevService startContainerIfAvailable(DockerStatusBuildItem dockerStatusBuildItem, + PubSubDevServiceConfig config, + Optional timeout) { + if (!config.enabled) { + // PubSub service explicitly disabled + LOGGER.debug("Not starting Dev Services for PubSub as it has been disabled in the config"); + return null; + } + + if (!dockerStatusBuildItem.isDockerAvailable()) { + LOGGER.warn("Not starting devservice because docker is not available"); + return null; + } + + return startContainer(dockerStatusBuildItem, config, timeout); + } + + /** + * Starts the PubSub emulator container with provided configuration. + * + * @param dockerStatusBuildItem, Docker status + * @param config, Configuration for the PubSub service + * @param timeout, Optional timeout for starting the service + * @return Running service item, or null if the service couldn't be started + */ + private DevServicesResultBuildItem.RunningDevService startContainer(DockerStatusBuildItem dockerStatusBuildItem, + PubSubDevServiceConfig config, + Optional timeout) { + // Create and configure PubSub emulator container + PubSubEmulatorContainer pubSubEmulatorContainer = new QuarkusPubSubContainer( + DockerImageName.parse(config.imageName).asCompatibleSubstituteFor("gcr.io/google.com/cloudsdktool/cloud-sdk"), + config.port.orElse(null)); + + // Set container startup timeout if provided + timeout.ifPresent(pubSubEmulatorContainer::withStartupTimeout); + pubSubEmulatorContainer.start(); + + // Set the config for the started container + PubSubDevServiceProcessor.config = config; + + // Return running service item with container details + return new DevServicesResultBuildItem.RunningDevService(PubSubBuildSteps.FEATURE, + pubSubEmulatorContainer.getContainerId(), + pubSubEmulatorContainer::close, "quarkus.google.cloud.pubsub.emulator-host", + pubSubEmulatorContainer.getEmulatorEndpoint()); + } + + /** + * Stops the running PubSub emulator container. + */ + private void stopContainer() { + if (devService != null && devService.isOwner()) { + try { + // Try closing the running dev service + devService.close(); + } catch (Throwable e) { + LOGGER.error("Failed to stop pubsub container", e); + } finally { + devService = null; + } + } + } + + /** + * Class for creating and configuring a PubSub emulator container. + */ + private static class QuarkusPubSubContainer extends PubSubEmulatorContainer { + + private final Integer fixedExposedPort; + private static final int PUBSUB_INTERNAL_PORT = 8085; + + private QuarkusPubSubContainer(DockerImageName dockerImageName, Integer fixedExposedPort) { + super(dockerImageName); + this.fixedExposedPort = fixedExposedPort; + } + + /** + * Configures the PubSub emulator container. + */ + @Override + public void configure() { + super.configure(); + + // Expose PubSub emulatorPort + if (fixedExposedPort != null) { + addFixedExposedPort(fixedExposedPort, PUBSUB_INTERNAL_PORT); + } else { + addExposedPort(PUBSUB_INTERNAL_PORT); + } + } + } +} diff --git a/pubsub/runtime/src/main/resources/META-INF/quarkus-extension.yaml b/pubsub/runtime/src/main/resources/META-INF/quarkus-extension.yaml index 602e7673..06b714a8 100644 --- a/pubsub/runtime/src/main/resources/META-INF/quarkus-extension.yaml +++ b/pubsub/runtime/src/main/resources/META-INF/quarkus-extension.yaml @@ -1,5 +1,6 @@ --- name: "Google Cloud Pubsub" +artifact: ${project.groupId}:${project.artifactId}:${project.version} metadata: keywords: - "pubsub" @@ -7,6 +8,9 @@ metadata: - "gcloud" - "gcp" categories: - - "cloud" - - "messaging" + - "cloud" + - "messaging" + guide: "https://quarkiverse.github.io/quarkiverse-docs/quarkus-google-cloud-services/main/pubsub.html" status: "preview" + config: + - "quarkus.google.cloud.pubsub."