Skip to content

Commit 16d93e3

Browse files
Fix CassandraContainer wait strategy when SSL is configured (#9419)
Fixes #9410 --------- Co-authored-by: Eddú Meléndez <[email protected]>
1 parent fb73ad1 commit 16d93e3

File tree

12 files changed

+1487
-10
lines changed

12 files changed

+1487
-10
lines changed

docs/modules/databases/cassandra.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,19 @@ This example connects to the Cassandra cluster:
2020
[Running init script with required authentication](../../../modules/cassandra/src/test/java/org/testcontainers/cassandra/CassandraContainerTest.java) inside_block:init-with-auth
2121
<!--/codeinclude-->
2222

23+
## Using secure connection (TLS)
24+
25+
If you override the default `cassandra.yaml` with a version setting the property `client_encryption_options.optional`
26+
to `false`, you have to provide a valid client certificate and key (PEM format) when you initialize your container:
27+
28+
<!--codeinclude-->
29+
[SSL setup](../../../modules/cassandra/src/test/java/org/testcontainers/cassandra/CassandraContainerTest.java) inside_block:with-ssl-config
30+
<!--/codeinclude-->
31+
32+
!!! hint
33+
To generate the client certificate and key, please refer to
34+
[this documentation](https://docs.datastax.com/en/cassandra-oss/3.x/cassandra/configuration/secureSSLCertificates.html).
35+
2336
## Adding this module to your project dependencies
2437

2538
Add the following dependency to your `pom.xml`/`build.gradle` file:

modules/cassandra/src/main/java/org/testcontainers/cassandra/CassandraContainer.java

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package org.testcontainers.cassandra;
22

33
import com.github.dockerjava.api.command.InspectContainerResponse;
4+
import org.apache.commons.lang3.StringUtils;
45
import org.testcontainers.containers.GenericContainer;
56
import org.testcontainers.ext.ScriptUtils;
67
import org.testcontainers.ext.ScriptUtils.ScriptLoadException;
@@ -37,6 +38,10 @@ public class CassandraContainer extends GenericContainer<CassandraContainer> {
3738

3839
private String initScriptPath;
3940

41+
private String clientCertFile;
42+
43+
private String clientKeyFile;
44+
4045
public CassandraContainer(String dockerImageName) {
4146
this(DockerImageName.parse(dockerImageName));
4247
}
@@ -66,6 +71,15 @@ protected void configure() {
6671
.ofNullable(configLocation)
6772
.map(MountableFile::forClasspathResource)
6873
.ifPresent(mountableFile -> withCopyFileToContainer(mountableFile, CONTAINER_CONFIG_LOCATION));
74+
75+
// If a secure connection is required by Cassandra configuration, copy the user certificate and key to a
76+
// dedicated location and define a cqlshrc file with the appropriate SSL configuration.
77+
// See: https://docs.datastax.com/en/cassandra-oss/3.x/cassandra/configuration/secureCqlshSSL.html
78+
if (isSslRequired()) {
79+
withCopyFileToContainer(MountableFile.forClasspathResource(clientCertFile), "ssl/user_cert.pem");
80+
withCopyFileToContainer(MountableFile.forClasspathResource(clientKeyFile), "ssl/user_key.pem");
81+
withCopyFileToContainer(MountableFile.forClasspathResource("cqlshrc"), "/root/.cassandra/cqlshrc");
82+
}
6983
}
7084

7185
@Override
@@ -106,9 +120,11 @@ private void runInitScriptIfRequired() {
106120
* Initialize Cassandra with the custom overridden Cassandra configuration
107121
* <p>
108122
* Be aware, that Docker effectively replaces all /etc/cassandra content with the content of config location, so if
109-
* Cassandra.yaml in configLocation is absent or corrupted, then Cassandra just won't launch
123+
* Cassandra.yaml in configLocation is absent or corrupted, then Cassandra just won't launch.
110124
*
111-
* @param configLocation relative classpath with the directory that contains cassandra.yaml and other configuration files
125+
* @param configLocation relative classpath with the directory that contains cassandra.yaml and other configuration
126+
* files
127+
* @return The updated {@link CassandraContainer}.
112128
*/
113129
public CassandraContainer withConfigurationOverride(String configLocation) {
114130
this.configLocation = configLocation;
@@ -122,15 +138,38 @@ public CassandraContainer withConfigurationOverride(String configLocation) {
122138
* </p>
123139
*
124140
* @param initScriptPath relative classpath resource
141+
* @return The updated {@link CassandraContainer}.
125142
*/
126143
public CassandraContainer withInitScript(String initScriptPath) {
127144
this.initScriptPath = initScriptPath;
128145
return self();
129146
}
130147

131148
/**
132-
* Get username
149+
* Configure secured connection (TLS) when required by the Cassandra configuration
150+
* (i.e. cassandra.yaml file contains the property {@code client_encryption_options.optional} with value
151+
* {@code false}).
133152
*
153+
* @param clientCertFile The client certificate required to execute CQL scripts.
154+
* @param clientKeyFile The client key required to execute CQL scripts.
155+
* @return The updated {@link CassandraContainer}.
156+
*/
157+
public CassandraContainer withSsl(String clientCertFile, String clientKeyFile) {
158+
this.clientCertFile = clientCertFile;
159+
this.clientKeyFile = clientKeyFile;
160+
return self();
161+
}
162+
163+
/**
164+
* @return Whether a secure connection is required between the client and the Cassandra server.
165+
*/
166+
boolean isSslRequired() {
167+
return StringUtils.isNoneBlank(this.clientCertFile, this.clientKeyFile);
168+
}
169+
170+
/**
171+
* Get username
172+
* <p>
134173
* By default, Cassandra has authenticator: AllowAllAuthenticator in cassandra.yaml
135174
* If username and password need to be used, then authenticator should be set as PasswordAuthenticator
136175
* (through custom Cassandra configuration) and through CQL with default cassandra-cassandra credentials
@@ -142,7 +181,7 @@ public String getUsername() {
142181

143182
/**
144183
* Get password
145-
*
184+
* <p>
146185
* By default, Cassandra has authenticator: AllowAllAuthenticator in cassandra.yaml
147186
* If username and password need to be used, then authenticator should be set as PasswordAuthenticator
148187
* (through custom Cassandra configuration) and through CQL with default cassandra-cassandra credentials

modules/cassandra/src/main/java/org/testcontainers/cassandra/CassandraDatabaseDelegate.java

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,13 @@ protected Void createNewConnection() {
2828
return null;
2929
}
3030

31-
@Override
3231
public void execute(
3332
String statement,
3433
String scriptPath,
3534
int lineNumber,
3635
boolean continueOnError,
37-
boolean ignoreFailedDrops
36+
boolean ignoreFailedDrops,
37+
boolean silentErrorLogs
3838
) {
3939
try {
4040
// Use cqlsh command directly inside the container to execute statements
@@ -45,6 +45,9 @@ public void execute(
4545
CassandraContainer cassandraContainer = (CassandraContainer) this.container;
4646
String username = cassandraContainer.getUsername();
4747
String password = cassandraContainer.getPassword();
48+
if (cassandraContainer.isSslRequired()) {
49+
cqlshCommand = ArrayUtils.add(cqlshCommand, "--ssl");
50+
}
4851
cqlshCommand = ArrayUtils.addAll(cqlshCommand, "-u", username, "-p", password);
4952
}
5053

@@ -67,14 +70,27 @@ public void execute(
6770
log.info("CQL statement {} was applied", statement);
6871
}
6972
} else {
70-
log.error("CQL script execution failed with error: \n{}", result.getStderr());
73+
if (!silentErrorLogs) {
74+
log.error("CQL script execution failed with error: \n{}", result.getStderr());
75+
}
7176
throw new ScriptStatementFailedException(statement, lineNumber, scriptPath);
7277
}
7378
} catch (IOException | InterruptedException e) {
7479
throw new ScriptStatementFailedException(statement, lineNumber, scriptPath, e);
7580
}
7681
}
7782

83+
@Override
84+
public void execute(
85+
String statement,
86+
String scriptPath,
87+
int lineNumber,
88+
boolean continueOnError,
89+
boolean ignoreFailedDrops
90+
) {
91+
this.execute(statement, scriptPath, lineNumber, continueOnError, ignoreFailedDrops, false);
92+
}
93+
7894
@Override
7995
protected void closeConnectionQuietly(Void session) {
8096
// Nothing to do here, because we run scripts using cqlsh command directly in the container.

modules/cassandra/src/main/java/org/testcontainers/cassandra/CassandraQueryWaitStrategy.java

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package org.testcontainers.cassandra;
22

3+
import lombok.extern.slf4j.Slf4j;
4+
import org.apache.commons.lang3.StringUtils;
35
import org.rnorth.ducttape.TimeoutException;
46
import org.testcontainers.containers.ContainerLaunchException;
57
import org.testcontainers.containers.wait.strategy.AbstractWaitStrategy;
@@ -12,6 +14,7 @@
1214
/**
1315
* Waits until Cassandra returns its version
1416
*/
17+
@Slf4j
1518
public class CassandraQueryWaitStrategy extends AbstractWaitStrategy {
1619

1720
private static final String SELECT_VERSION_QUERY = "SELECT release_version FROM system.local";
@@ -29,7 +32,15 @@ protected void waitUntilReady() {
2932
getRateLimiter()
3033
.doWhenReady(() -> {
3134
try (DatabaseDelegate databaseDelegate = getDatabaseDelegate()) {
32-
databaseDelegate.execute(SELECT_VERSION_QUERY, "", 1, false, false);
35+
log.info("Checking connection is ready...");
36+
((CassandraDatabaseDelegate) databaseDelegate).execute(
37+
SELECT_VERSION_QUERY,
38+
StringUtils.EMPTY,
39+
1,
40+
false,
41+
false,
42+
true
43+
);
3344
}
3445
});
3546
return true;
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
[ssl]
2+
certfile = ssl/user_cert.pem
3+
usercert = ssl/user_cert.pem
4+
userkey = ssl/user_key.pem
5+
6+
[connection]
7+
factory = cqlshlib.ssl.ssl_transport_factory

modules/cassandra/src/test/java/org/testcontainers/cassandra/CassandraContainerTest.java

Lines changed: 102 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,30 @@
11
package org.testcontainers.cassandra;
22

33
import com.datastax.oss.driver.api.core.CqlSession;
4+
import com.datastax.oss.driver.api.core.CqlSessionBuilder;
5+
import com.datastax.oss.driver.api.core.config.DefaultDriverOption;
6+
import com.datastax.oss.driver.api.core.config.DriverConfigLoader;
7+
import com.datastax.oss.driver.api.core.config.ProgrammaticDriverConfigLoaderBuilder;
8+
import com.datastax.oss.driver.api.core.context.DriverContext;
49
import com.datastax.oss.driver.api.core.cql.ResultSet;
510
import com.datastax.oss.driver.api.core.cql.Row;
11+
import com.datastax.oss.driver.api.core.session.ProgrammaticArguments;
12+
import com.datastax.oss.driver.internal.core.context.DefaultDriverContext;
13+
import com.datastax.oss.driver.internal.core.ssl.DefaultSslEngineFactory;
614
import org.junit.jupiter.api.Test;
15+
import org.testcontainers.containers.Container;
716
import org.testcontainers.containers.ContainerLaunchException;
817
import org.testcontainers.utility.DockerImageName;
918

19+
import java.net.URL;
20+
1021
import static org.assertj.core.api.Assertions.assertThat;
1122
import static org.assertj.core.api.Assertions.assertThatThrownBy;
23+
import static org.assertj.core.api.Assertions.fail;
1224

1325
class CassandraContainerTest {
1426

15-
private static final String CASSANDRA_IMAGE = "cassandra:3.11.2";
27+
private static final String CASSANDRA_IMAGE = "cassandra:3.11.15";
1628

1729
private static final String TEST_CLUSTER_NAME_IN_CONF = "Test Cluster Integration Test";
1830

@@ -21,7 +33,7 @@ class CassandraContainerTest {
2133
@Test
2234
void testSimple() {
2335
try ( // container-definition {
24-
CassandraContainer cassandraContainer = new CassandraContainer("cassandra:3.11.2")
36+
CassandraContainer cassandraContainer = new CassandraContainer(CASSANDRA_IMAGE)
2537
// }
2638
) {
2739
cassandraContainer.start();
@@ -61,6 +73,69 @@ void testConfigurationOverride() {
6173
}
6274
}
6375

76+
@Test
77+
public void testWithSslClientConfig() {
78+
/*
79+
Commands executed to generate certificates in 'cassandra-ssl-configuration' directory:
80+
keytool -genkey -keyalg RSA -validity 36500 -alias localhost -keystore keystore.p12 -storepass cassandra \
81+
-keypass cassandra -dname "CN=localhost, OU=Testcontainers, O=Testcontainers, L=None, C=None"
82+
keytool -export -alias localhost -file cassandra.cer -keystore keystore.p12
83+
keytool -import -v -trustcacerts -alias localhost -file cassandra.cer -keystore truststore.p12
84+
85+
Commands executed to generate the client certificate and key in 'client-ssl' directory:
86+
keytool -importkeystore -srckeystore keystore.p12 -destkeystore test_node.p12 -deststoretype PKCS12 \
87+
-srcstorepass cassandra -deststorepass cassandra
88+
openssl pkcs12 -in test_node.p12 -nokeys -out cassandra.cer.pem -passin pass:cassandra
89+
openssl pkcs12 -in test_node.p12 -nodes -nocerts -out cassandra.key.pem -passin pass:cassandra
90+
91+
Reference:
92+
https://docs.datastax.com/en/cassandra-oss/3.x/cassandra/configuration/secureSSLCertificates.html
93+
https://docs.datastax.com/en/cassandra-oss/3.x/cassandra/configuration/secureCqlshSSL.html
94+
*/
95+
try (
96+
// with-ssl-config {
97+
CassandraContainer cassandraContainer = new CassandraContainer(CASSANDRA_IMAGE)
98+
.withConfigurationOverride("cassandra-ssl-configuration")
99+
.withSsl("client-ssl/cassandra.cer.pem", "client-ssl/cassandra.key.pem")
100+
// }
101+
) {
102+
cassandraContainer.start();
103+
try {
104+
ResultSet resultSet = performQueryWithSslClientConfig(
105+
cassandraContainer,
106+
"SELECT cluster_name FROM system.local"
107+
);
108+
assertThat(resultSet.wasApplied()).as("Query was applied").isTrue();
109+
assertThat(resultSet.one().getString(0))
110+
.as("Cassandra configuration is configured with secured connection")
111+
.isEqualTo(TEST_CLUSTER_NAME_IN_CONF);
112+
} catch (Exception e) {
113+
fail(e);
114+
}
115+
}
116+
}
117+
118+
@Test
119+
public void testSimpleSslCqlsh() {
120+
try (
121+
CassandraContainer cassandraContainer = new CassandraContainer(CASSANDRA_IMAGE)
122+
.withConfigurationOverride("cassandra-ssl-configuration")
123+
.withSsl("client-ssl/cassandra.cer.pem", "client-ssl/cassandra.key.pem")
124+
) {
125+
cassandraContainer.start();
126+
127+
Container.ExecResult execResult = cassandraContainer.execInContainer(
128+
"cqlsh",
129+
"--ssl",
130+
"-e",
131+
"SELECT * FROM system_schema.keyspaces;"
132+
);
133+
assertThat(execResult.getStdout()).contains("keyspace_name");
134+
} catch (Exception e) {
135+
fail(e);
136+
}
137+
}
138+
64139
@Test
65140
void testEmptyConfigurationOverride() {
66141
try (
@@ -164,6 +239,31 @@ private ResultSet performQueryWithAuth(CassandraContainer cassandraContainer, St
164239
return performQuery(cqlSession, cql);
165240
}
166241

242+
private ResultSet performQueryWithSslClientConfig(CassandraContainer cassandraContainer, String cql) {
243+
final ProgrammaticDriverConfigLoaderBuilder driverConfigLoaderBuilder = DriverConfigLoader.programmaticBuilder();
244+
driverConfigLoaderBuilder.withBoolean(DefaultDriverOption.SSL_HOSTNAME_VALIDATION, false);
245+
final URL trustStoreUrl =
246+
this.getClass().getClassLoader().getResource("cassandra-ssl-configuration/truststore.p12");
247+
driverConfigLoaderBuilder.withString(DefaultDriverOption.SSL_TRUSTSTORE_PATH, trustStoreUrl.getFile());
248+
driverConfigLoaderBuilder.withString(DefaultDriverOption.SSL_TRUSTSTORE_PASSWORD, "cassandra");
249+
final URL keyStoreUrl =
250+
this.getClass().getClassLoader().getResource("cassandra-ssl-configuration/keystore.p12");
251+
driverConfigLoaderBuilder.withString(DefaultDriverOption.SSL_KEYSTORE_PATH, keyStoreUrl.getFile());
252+
driverConfigLoaderBuilder.withString(DefaultDriverOption.SSL_KEYSTORE_PASSWORD, "cassandra");
253+
final DriverContext driverContext = new DefaultDriverContext(
254+
driverConfigLoaderBuilder.build(),
255+
ProgrammaticArguments.builder().build()
256+
);
257+
258+
final CqlSessionBuilder sessionBuilder = CqlSession.builder();
259+
final CqlSession cqlSession = sessionBuilder
260+
.addContactPoint(cassandraContainer.getContactPoint())
261+
.withLocalDatacenter(cassandraContainer.getLocalDatacenter())
262+
.withSslEngineFactory(new DefaultSslEngineFactory(driverContext))
263+
.build();
264+
return performQuery(cqlSession, cql);
265+
}
266+
167267
private ResultSet performQuery(CqlSession session, String cql) {
168268
final ResultSet rs = session.execute(cql);
169269
session.close();
Binary file not shown.

0 commit comments

Comments
 (0)