Skip to content

Commit eae2c11

Browse files
authored
Make TestContainers DevService self-provisioning (#239)
* Make TestContainers DevService self-provisioning * Make extension status stable * Create scope and collection in TestContainer * Map fixed ports if dynamic ports aren't used * Initialize JacksonHelper at run-time
1 parent dfa0ebb commit eae2c11

8 files changed

Lines changed: 99 additions & 43 deletions

File tree

deployment/src/main/java/com/couchbase/quarkus/extension/deployment/CouchbaseDevService.java

Lines changed: 58 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,13 @@
1616
package com.couchbase.quarkus.extension.deployment;
1717

1818
import java.util.Map;
19+
import java.util.Optional;
1920

2021
import jakarta.inject.Inject;
2122

2223
import org.eclipse.microprofile.config.ConfigProvider;
24+
import org.jboss.logging.Logger;
25+
import org.testcontainers.couchbase.BucketDefinition;
2326
import org.testcontainers.couchbase.CouchbaseContainer;
2427
import org.testcontainers.couchbase.CouchbaseService;
2528

@@ -39,6 +42,10 @@
3942
@BuildSteps(onlyIfNot = IsNormal.class, onlyIf = DevServicesConfig.Enabled.class)
4043
public class CouchbaseDevService {
4144

45+
private static final Logger log = Logger.getLogger(CouchbaseDevService.class);
46+
private static final String DEFAULT_USERNAME = "Administrator";
47+
private static final String DEFAULT_PASSWORD = "password";
48+
4249
static volatile RunningDevService devService;
4350

4451
@Inject
@@ -47,20 +54,36 @@ public class CouchbaseDevService {
4754
@BuildStep
4855
DevServicesResultBuildItem startCouchBase(
4956
CuratedApplicationShutdownBuildItem closeBuildItem) {
57+
58+
if (ConfigProvider.getConfig()
59+
.getOptionalValue("quarkus.couchbase.connection-string", String.class)
60+
.filter(connectionString -> !connectionString.isBlank())
61+
.isPresent()) {
62+
log.info("quarkus.couchbase.connection-string is set, skipping TestContainer deployment.");
63+
return null;
64+
}
65+
5066
if (devService != null) {
5167
return devService.toBuildItem();
5268
}
53-
QuarkusCouchbaseContainer couchbase = startContainer();
54-
55-
Map<String, String> dynamicConfig = Map.of();
56-
if (buildTimeConfig.useDynamicPorts()) {
57-
// Capture the dynamic connection string and UI port from the running container
58-
String connectionString = couchbase.getConnectionString();
59-
int uiPort = couchbase.getMappedPort(8091);
60-
dynamicConfig = Map.of(
61-
"quarkus.couchbase.connection-string", connectionString,
62-
"quarkus.couchbase.devservices.ui-port", String.valueOf(uiPort));
63-
}
69+
70+
// Credentials are required runtime config, but when DevServices provisions a container we
71+
// supply them ourselves (defaulting if unset) and inject the same values at runtime so the
72+
// app needs no credential configuration to connect to the container.
73+
var config = ConfigProvider.getConfig();
74+
String username = config.getOptionalValue("quarkus.couchbase.username", String.class).orElse(DEFAULT_USERNAME);
75+
String password = config.getOptionalValue("quarkus.couchbase.password", String.class).orElse(DEFAULT_PASSWORD);
76+
77+
QuarkusCouchbaseContainer couchbase = startContainer(username, password);
78+
79+
// The container always determines the connection string and UI port whether
80+
// dynamic or fixed, so we inject both at runtime. Since we only reach this point when no
81+
// connection string was set, we can safely set it.
82+
Map<String, String> dynamicConfig = Map.of(
83+
"quarkus.couchbase.connection-string", couchbase.getConnectionString(),
84+
"quarkus.couchbase.username", username,
85+
"quarkus.couchbase.password", password,
86+
"quarkus.couchbase.devservices.ui-port", String.valueOf(couchbase.getMappedPort(8091)));
6487

6588
// Pass the dynamic values via config overrides so they're available at runtime
6689
devService = new RunningDevService(CouchbaseQuarkusExtensionProcessor.FEATURE,
@@ -70,15 +93,14 @@ DevServicesResultBuildItem startCouchBase(
7093

7194
}
7295

73-
private QuarkusCouchbaseContainer startContainer() {
74-
// Credentials are runtime config (CouchbaseRuntimeConfig), but the dev-services container
75-
// needs to provision them at build time. Read directly from the config provider — these
76-
// are sourced from application.properties, so they're visible during the build phase.
77-
var config = ConfigProvider.getConfig();
78-
String username = config.getValue("quarkus.couchbase.username", String.class);
79-
String password = config.getValue("quarkus.couchbase.password", String.class);
96+
private QuarkusCouchbaseContainer startContainer(String username, String password) {
97+
// bucket-name is runtime config but sourced from application.properties, so it's visible
98+
// during the build phase. Provision that bucket so an injected Bucket bean is usable.
99+
Optional<String> bucketName = ConfigProvider.getConfig()
100+
.getOptionalValue("quarkus.couchbase.bucket-name", String.class)
101+
.filter(name -> !name.isBlank());
80102
QuarkusCouchbaseContainer couchbase = new QuarkusCouchbaseContainer(buildTimeConfig.version(), username, password,
81-
buildTimeConfig.useDynamicPorts());
103+
buildTimeConfig.useDynamicPorts(), bucketName);
82104
couchbase.start();
83105
return couchbase;
84106
}
@@ -105,18 +127,23 @@ private static class QuarkusCouchbaseContainer extends CouchbaseContainer {
105127
private static final int KV_PORT = 11210;
106128
private static final int KV_SSL_PORT = 11207;
107129

108-
public QuarkusCouchbaseContainer(String version, String userName, String password, boolean useDynamicPorts) {
130+
private final boolean useDynamicPorts;
131+
132+
public QuarkusCouchbaseContainer(String version, String userName, String password, boolean useDynamicPorts,
133+
Optional<String> bucketName) {
109134
super("couchbase/server:" + version);
135+
this.useDynamicPorts = useDynamicPorts;
110136
withCredentials(userName, password);
111137
// we enable all non-enterprise services because we don't know which ones are needed
112138
withEnabledServices(CouchbaseService.EVENTING, CouchbaseService.INDEX, CouchbaseService.KV,
113139
CouchbaseService.QUERY, CouchbaseService.SEARCH);
114140

141+
bucketName.ifPresent(name -> withBucket(new BucketDefinition(name)));
142+
115143
if (!useDynamicPorts) {
116-
// Fixed ports mode - map container ports to same host ports
144+
// Fixed ports mode, map container ports to the same host ports.
117145
addFixedExposedPort(MGMT_PORT, MGMT_PORT);
118146
addFixedExposedPort(MGMT_SSL_PORT, MGMT_SSL_PORT);
119-
addFixedExposedPort(ANALYTICS_PORT, ANALYTICS_PORT);
120147
addFixedExposedPort(VIEW_PORT, VIEW_PORT);
121148
addFixedExposedPort(VIEW_SSL_PORT, VIEW_SSL_PORT);
122149
addFixedExposedPort(ANALYTICS_PORT, ANALYTICS_PORT);
@@ -131,5 +158,14 @@ public QuarkusCouchbaseContainer(String version, String userName, String passwor
131158
addFixedExposedPort(KV_SSL_PORT, KV_SSL_PORT);
132159
}
133160
}
161+
162+
/**
163+
* In fixed-ports mode, return the original port instead of the parent's random mapping so the
164+
* container's advertised ports, bindings, and connection string all stay consistent.
165+
*/
166+
@Override
167+
public Integer getMappedPort(int originalPort) {
168+
return useDynamicPorts ? super.getMappedPort(originalPort) : originalPort;
169+
}
134170
}
135171
}

deployment/src/main/java/com/couchbase/quarkus/extension/deployment/CouchbaseProcessor.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,10 @@ void configureRuntimeInitializedClasses(BuildProducer<RuntimeInitializedClassBui
198198
"com.couchbase.client.core.io.netty.SslHandlerFactory$InitOnDemandHolder",
199199
"com.couchbase.client.core.cnc.apptelemetry.reporter.AppTelemetryReporterImpl", // has static Random instance
200200
"com.couchbase.client.core.deps.io.netty.handler.ssl.ReferenceCountedOpenSslEngine",
201-
"com.couchbase.client.core.endpoint.http.CoreHttpRequest$Builder");
201+
"com.couchbase.client.core.endpoint.http.CoreHttpRequest$Builder",
202+
// has a static JsonMapper (from the shaded Jackson) used to parse the cluster config.
203+
// At build time it captures a live ForkJoinWorkerThread into the heap, which breaks native compilation
204+
"com.couchbase.client.core.topology.JacksonHelper");
202205

203206
for (String className : classes) {
204207
runtimeInitialized.produce(new RuntimeInitializedClassBuildItem(className));

docs/modules/ROOT/pages/configuration.adoc

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ Full details can be found in the https://javadoc.io/doc/io.quarkiverse.couchbase
44

55
Currently, a minimal set of configuration options for the `Cluster` bean is provided.
66

7-
[IMPORTANT]
87
====
98
Configuration options fall into two categories that differ in *when* they take effect:
109
@@ -27,11 +26,13 @@ These config items do not default to placeholder values, and are required to be
2726

2827
* `quarkus.couchbase.connection-string`
2928
** *String*: Examples `localhost`, `couchbase://127.0.0.1`.
30-
** *Note*: Optional when DevServices are enabled with dynamic ports. DevServices will automatically set this value.
29+
** *Note*: This should be unset if using DevServices, else the TestContainer won't start.
3130
* `quarkus.couchbase.username`
3231
** *String*: The username to authenticate with.
32+
** *Note*: Not required when DevServices starts a container, injected to `Administrator`.
3333
* `quarkus.couchbase.password`
3434
** *String*: The password to authenticate with.
35+
** *Note*: Not required when DevServices starts a container injected to `password`.
3536

3637
== Optional
3738
These config items have default values, but can be overridden in `application.properties`.
@@ -49,7 +50,7 @@ These config items have default values, but can be overridden in `application.pr
4950
** *Seconds*: The interval at which metrics are emitted.
5051
** *Default*: `600` (10min)
5152
* `quarkus.couchbase.bucket-name`
52-
** *String*: A bucket exposed as an injectable `Bucket` bean. Only required if a `Bucket` is injected.
53+
** *String*: A bucket exposed as an injectable `Bucket` bean. Only required if a `Bucket` is injected. When DevServices starts a container, this bucket is also provisioned in it so the injected `Bucket` is immediately usable. If unset, DevServices creates no bucket.
5354
** *Default*: None
5455
* `quarkus.couchbase.preferredServerGroup`
5556
** *String*: The preferred server group for operations which support it.

docs/modules/ROOT/pages/index.adoc

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ include::./includes/attributes.adoc[]
1010

1111
Integrates Couchbase into Quarkus.
1212

13-
This extension is currently in beta status. It supports:
13+
This extension currently supports:
1414

1515
- Dependency injecting a Couchbase `Cluster`.
1616
- Configuring the Cluster through `application.properties`. Currently, a minimal set of configuration options is provided.
@@ -82,6 +82,12 @@ public class TestCouchbaseResource {
8282

8383
And test it at http://localhost:8080/couchbase/test.
8484

85+
== Dev Services
86+
87+
DevServices (Couchbase TestContainers) activate when `quarkus.devservices.enabled` is `true` and no `quarkus.couchbase.connection-string` is set.
88+
89+
Setting `quarkus.couchbase.connection-string` (for example to point at a real cluster or Capella) disables DevServices.
90+
8591
== Micrometer Metrics
8692

8793
You can enable Micrometer metrics by adding the following dependencies to your application:

integration-tests/pom.xml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
</dependency>
2121
<dependency>
2222
<groupId>io.quarkus</groupId>
23-
<artifactId>quarkus-junit5</artifactId>
23+
<artifactId>quarkus-junit</artifactId>
2424
<version>${quarkus.version}</version>
2525
<scope>test</scope>
2626
</dependency>
@@ -88,7 +88,6 @@
8888
<configuration>
8989
<systemPropertyVariables>
9090
<quarkus.couchbase.devservices.use-dynamic-ports>false</quarkus.couchbase.devservices.use-dynamic-ports>
91-
<quarkus.couchbase.connection-string>couchbase://localhost</quarkus.couchbase.connection-string>
9291
</systemPropertyVariables>
9392
<reportNameSuffix>fixed-ports</reportNameSuffix>
9493
</configuration>

integration-tests/src/test/java/com/couchbase/quarkus/extension/it/DevServiceTest.java

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -82,33 +82,42 @@ public DevServiceTest(Cluster cluster, Bucket injectedBucket) {
8282

8383
@BeforeAll
8484
void createAndGetKeyspace() {
85-
var isKeyspacePresent = false;
86-
87-
// Checks if the test bucket already exists, assuming the
88-
// scope and collection must exist with it. Relevant for continuous testing.
8985
try {
9086
cluster.buckets().createBucket(BucketSettings.create(BUCKET_NAME));
87+
ConsistencyUtil.waitUntilBucketPresent(cluster.core(), BUCKET_NAME);
9188
} catch (BucketExistsException exists) {
92-
isKeyspacePresent = true;
93-
logger.info("Bucket already exists, skipping keyspace creation.");
89+
logger.info("Bucket already exists, skipping bucket creation.");
9490
}
95-
// Create Bucket, Scope and Collection
96-
if (!isKeyspacePresent) {
97-
ConsistencyUtil.waitUntilBucketPresent(cluster.core(), BUCKET_NAME);
98-
bucket = cluster.bucket(BUCKET_NAME);
99-
bucket.waitUntilReady(Duration.ofSeconds(20));
91+
92+
bucket = cluster.bucket(BUCKET_NAME);
93+
bucket.waitUntilReady(Duration.ofSeconds(20));
94+
95+
if (!scopeExists(SCOPE_NAME)) {
10096
bucket.collections().createScope(SCOPE_NAME);
10197
ConsistencyUtil.waitUntilScopePresent(cluster.core(), BUCKET_NAME, SCOPE_NAME);
98+
}
99+
100+
if (!collectionExists(SCOPE_NAME, COLLECTION_NAME)) {
102101
bucket.collections().createCollection(SCOPE_NAME, COLLECTION_NAME);
103102
ConsistencyUtil.waitUntilCollectionPresent(cluster.core(), BUCKET_NAME, SCOPE_NAME, COLLECTION_NAME);
104-
} else {
105-
bucket = cluster.bucket(BUCKET_NAME);
106103
}
107104

108105
scope = bucket.scope(SCOPE_NAME);
109106
collection = scope.collection(COLLECTION_NAME);
110107
}
111108

109+
private boolean scopeExists(String scopeName) {
110+
return bucket.collections().getAllScopes().stream()
111+
.anyMatch(s -> s.name().equals(scopeName));
112+
}
113+
114+
private boolean collectionExists(String scopeName, String collectionName) {
115+
return bucket.collections().getAllScopes().stream()
116+
.filter(s -> s.name().equals(scopeName))
117+
.flatMap(s -> s.collections().stream())
118+
.anyMatch(c -> c.name().equals(collectionName));
119+
}
120+
112121
@Test
113122
void injectedBucketBeanIsUsable() {
114123
assertEquals(BUCKET_NAME, injectedBucket.name());

runtime/src/main/java/com/couchbase/quarkus/extension/runtime/CouchbaseRuntimeConfig.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,13 @@ public interface CouchbaseRuntimeConfig {
3535

3636
/**
3737
* The username to authenticate with.
38+
* Required, except when DevServices starts a container, in which case it is injected
3839
*/
3940
String username();
4041

4142
/**
4243
* The password to authenticate with.
44+
* Required, except when DevServices starts a container, in which case it is injected
4345
*/
4446
String password();
4547

runtime/src/main/resources/META-INF/quarkus-extension.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,4 @@ metadata:
1212
config:
1313
- "quarkus.couchbase."
1414
iconUrl: "https://raw.githubusercontent.com/quarkiverse/quarkus-couchbase/master/docs/modules/ROOT/assets/images/couchbase-filled.svg"
15-
status: "preview"
15+
status: "stable"

0 commit comments

Comments
 (0)