Skip to content

Commit 214c023

Browse files
committed
Fix issue #9410
and reduce error logging while trying to connect to Cassandra database at container startup
1 parent cd29df9 commit 214c023

File tree

12 files changed

+1456
-10
lines changed

12 files changed

+1456
-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.cassandra.delegate.CassandraDatabaseDelegate;
56
import org.testcontainers.cassandra.wait.CassandraQueryWaitStrategy;
67
import org.testcontainers.containers.GenericContainer;
@@ -40,6 +41,10 @@ public class CassandraContainer extends GenericContainer<CassandraContainer> {
4041

4142
private String initScriptPath;
4243

44+
private String clientCertFile;
45+
46+
private String clientKeyFile;
47+
4348
public CassandraContainer(String dockerImageName) {
4449
this(DockerImageName.parse(dockerImageName));
4550
}
@@ -69,6 +74,15 @@ protected void configure() {
6974
.ofNullable(configLocation)
7075
.map(MountableFile::forClasspathResource)
7176
.ifPresent(mountableFile -> withCopyFileToContainer(mountableFile, CONTAINER_CONFIG_LOCATION));
77+
78+
// If a secure connection is required by Cassandra configuration, copy the user certificate and key to a
79+
// dedicated location and define a cqlshrc file with the appropriate SSL configuration.
80+
// See: https://docs.datastax.com/en/cassandra-oss/3.x/cassandra/configuration/secureCqlshSSL.html
81+
if (isSslRequired()) {
82+
withCopyFileToContainer(MountableFile.forClasspathResource(clientCertFile), "ssl/user_cert.pem");
83+
withCopyFileToContainer(MountableFile.forClasspathResource(clientKeyFile), "ssl/user_key.pem");
84+
withCopyFileToContainer(MountableFile.forClasspathResource("cqlshrc"), "/root/.cassandra/cqlshrc");
85+
}
7286
}
7387

7488
@Override
@@ -110,9 +124,11 @@ private void runInitScriptIfRequired() {
110124
* Initialize Cassandra with the custom overridden Cassandra configuration
111125
* <p>
112126
* Be aware, that Docker effectively replaces all /etc/cassandra content with the content of config location, so if
113-
* Cassandra.yaml in configLocation is absent or corrupted, then Cassandra just won't launch
127+
* Cassandra.yaml in configLocation is absent or corrupted, then Cassandra just won't launch.
114128
*
115-
* @param configLocation relative classpath with the directory that contains cassandra.yaml and other configuration files
129+
* @param configLocation relative classpath with the directory that contains cassandra.yaml and other configuration
130+
* files
131+
* @return The updated {@link CassandraContainer}.
116132
*/
117133
public CassandraContainer withConfigurationOverride(String configLocation) {
118134
this.configLocation = configLocation;
@@ -126,15 +142,38 @@ public CassandraContainer withConfigurationOverride(String configLocation) {
126142
* </p>
127143
*
128144
* @param initScriptPath relative classpath resource
145+
* @return The updated {@link CassandraContainer}.
129146
*/
130147
public CassandraContainer withInitScript(String initScriptPath) {
131148
this.initScriptPath = initScriptPath;
132149
return self();
133150
}
134151

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

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

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

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

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

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

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

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

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

3+
import lombok.extern.slf4j.Slf4j;
4+
import org.apache.commons.lang3.StringUtils;
35
import org.rnorth.ducttape.TimeoutException;
46
import org.testcontainers.cassandra.delegate.CassandraDatabaseDelegate;
57
import org.testcontainers.containers.ContainerLaunchException;
@@ -13,6 +15,7 @@
1315
/**
1416
* Waits until Cassandra returns its version
1517
*/
18+
@Slf4j
1619
public class CassandraQueryWaitStrategy extends AbstractWaitStrategy {
1720

1821
private static final String SELECT_VERSION_QUERY = "SELECT release_version FROM system.local";
@@ -30,7 +33,9 @@ protected void waitUntilReady() {
3033
getRateLimiter()
3134
.doWhenReady(() -> {
3235
try (DatabaseDelegate databaseDelegate = getDatabaseDelegate()) {
33-
databaseDelegate.execute(SELECT_VERSION_QUERY, "", 1, false, false);
36+
log.info("Checking connection is ready...");
37+
((CassandraDatabaseDelegate) databaseDelegate)
38+
.execute(SELECT_VERSION_QUERY, StringUtils.EMPTY, 1, false, false, true);
3439
}
3540
});
3641
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: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,28 @@
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.Test;
715
import org.testcontainers.containers.ContainerLaunchException;
816
import org.testcontainers.utility.DockerImageName;
917

18+
import java.net.URL;
19+
1020
import static org.assertj.core.api.Assertions.assertThat;
21+
import static org.assertj.core.api.Assertions.fail;
1122

1223
public class CassandraContainerTest {
1324

14-
private static final String CASSANDRA_IMAGE = "cassandra:3.11.2";
25+
private static final String CASSANDRA_IMAGE = "cassandra:3.11.15";
1526

1627
private static final String TEST_CLUSTER_NAME_IN_CONF = "Test Cluster Integration Test";
1728

@@ -20,7 +31,7 @@ public class CassandraContainerTest {
2031
@Test
2132
public void testSimple() {
2233
try ( // container-definition {
23-
CassandraContainer cassandraContainer = new CassandraContainer("cassandra:3.11.2")
34+
CassandraContainer cassandraContainer = new CassandraContainer(CASSANDRA_IMAGE)
2435
// }
2536
) {
2637
cassandraContainer.start();
@@ -60,6 +71,46 @@ public void testConfigurationOverride() {
6071
}
6172
}
6273

74+
@Test
75+
public void testWithSslClientConfig() {
76+
/*
77+
Commands executed to generate certificates in 'cassandra-ssl-configuration' directory:
78+
keytool -genkey -keyalg RSA -validity 36500 -alias localhost -keystore keystore.p12 -storepass cassandra \
79+
-keypass cassandra -dname "CN=localhost, OU=Testcontainers, O=Testcontainers, L=None, C=None"
80+
keytool -export -alias localhost -file cassandra.cer -keystore keystore.p12
81+
keytool -import -v -trustcacerts -alias localhost -file cassandra.cer -keystore truststore.p12
82+
83+
Commands executed to generate the client certificate and key in 'client-ssl' directory:
84+
keytool -importkeystore -srckeystore keystore.p12 -destkeystore test_node.p12 -deststoretype PKCS12 \
85+
-srcstorepass cassandra -deststorepass cassandra
86+
openssl pkcs12 -in test_node.p12 -nokeys -out cassandra.cer.pem -passin pass:cassandra
87+
openssl pkcs12 -in test_node.p12 -nodes -nocerts -out cassandra.key.pem -passin pass:cassandra
88+
89+
Reference:
90+
https://docs.datastax.com/en/cassandra-oss/3.x/cassandra/configuration/secureSSLCertificates.html
91+
https://docs.datastax.com/en/cassandra-oss/3.x/cassandra/configuration/secureCqlshSSL.html
92+
*/
93+
try (
94+
// with-ssl-config {
95+
CassandraContainer cassandraContainer = new CassandraContainer(CASSANDRA_IMAGE)
96+
.withConfigurationOverride("cassandra-ssl-configuration")
97+
.withSslClientConfig("client-ssl/cassandra.cer.pem", "client-ssl/cassandra.key.pem")
98+
// }
99+
) {
100+
cassandraContainer.start();
101+
try {
102+
ResultSet resultSet = performQueryWithSslClientConfig(cassandraContainer,
103+
"SELECT cluster_name FROM system.local");
104+
assertThat(resultSet.wasApplied()).as("Query was applied").isTrue();
105+
assertThat(resultSet.one().getString(0))
106+
.as("Cassandra configuration is configured with secured connection")
107+
.isEqualTo(TEST_CLUSTER_NAME_IN_CONF);
108+
} catch (Exception e) {
109+
fail(e);
110+
}
111+
}
112+
}
113+
63114
@Test(expected = ContainerLaunchException.class)
64115
public void testEmptyConfigurationOverride() {
65116
try (
@@ -153,6 +204,30 @@ private ResultSet performQueryWithAuth(CassandraContainer cassandraContainer, St
153204
return performQuery(cqlSession, cql);
154205
}
155206

207+
private ResultSet performQueryWithSslClientConfig(CassandraContainer cassandraContainer,
208+
String cql) {
209+
final ProgrammaticDriverConfigLoaderBuilder driverConfigLoaderBuilder =
210+
DriverConfigLoader.programmaticBuilder();
211+
driverConfigLoaderBuilder.withBoolean(DefaultDriverOption.SSL_HOSTNAME_VALIDATION, false);
212+
final URL trustStoreUrl = this.getClass().getClassLoader()
213+
.getResource("cassandra-ssl-configuration/truststore.p12");
214+
driverConfigLoaderBuilder.withString(DefaultDriverOption.SSL_TRUSTSTORE_PATH, trustStoreUrl.getFile());
215+
driverConfigLoaderBuilder.withString(DefaultDriverOption.SSL_TRUSTSTORE_PASSWORD, "cassandra");
216+
final URL keyStoreUrl = this.getClass().getClassLoader()
217+
.getResource("cassandra-ssl-configuration/keystore.p12");
218+
driverConfigLoaderBuilder.withString(DefaultDriverOption.SSL_KEYSTORE_PATH, keyStoreUrl.getFile());
219+
driverConfigLoaderBuilder.withString(DefaultDriverOption.SSL_KEYSTORE_PASSWORD, "cassandra");
220+
final DriverContext driverContext = new DefaultDriverContext(driverConfigLoaderBuilder.build(),
221+
ProgrammaticArguments.builder().build());
222+
223+
final CqlSessionBuilder sessionBuilder = CqlSession.builder();
224+
final CqlSession cqlSession = sessionBuilder.addContactPoint(cassandraContainer.getContactPoint())
225+
.withLocalDatacenter(cassandraContainer.getLocalDatacenter())
226+
.withSslEngineFactory(new DefaultSslEngineFactory(driverContext))
227+
.build();
228+
return performQuery(cqlSession, cql);
229+
}
230+
156231
private ResultSet performQuery(CqlSession session, String cql) {
157232
final ResultSet rs = session.execute(cql);
158233
session.close();
Binary file not shown.

0 commit comments

Comments
 (0)