Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
and reduce error logging while trying to connect to Cassandra database at container startup
  • Loading branch information
maximevw committed Oct 19, 2024
1 parent cd29df9 commit 214c023
Show file tree
Hide file tree
Showing 12 changed files with 1,456 additions and 10 deletions.
13 changes: 13 additions & 0 deletions docs/modules/databases/cassandra.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,19 @@ This example connects to the Cassandra cluster:
[Running init script with required authentication](../../../modules/cassandra/src/test/java/org/testcontainers/cassandra/CassandraContainerTest.java) inside_block:init-with-auth
<!--/codeinclude-->

## Using secure connection (TLS)

If you override the default `cassandra.yaml` with a version setting the property `client_encryption_options.optional`
to `false`, you have to provide a valid client certificate and key (PEM format) when you initialize your container:

<!--codeinclude-->
[SSL setup](../../../modules/cassandra/src/test/java/org/testcontainers/cassandra/CassandraContainerTest.java) inside_block:with-ssl-config
<!--/codeinclude-->

!!! hint
To generate the client certificate and key, please refer to
[this documentation](https://docs.datastax.com/en/cassandra-oss/3.x/cassandra/configuration/secureSSLCertificates.html).

## Adding this module to your project dependencies

Add the following dependency to your `pom.xml`/`build.gradle` file:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package org.testcontainers.cassandra;

import com.github.dockerjava.api.command.InspectContainerResponse;
import org.apache.commons.lang3.StringUtils;
import org.testcontainers.cassandra.delegate.CassandraDatabaseDelegate;
import org.testcontainers.cassandra.wait.CassandraQueryWaitStrategy;
import org.testcontainers.containers.GenericContainer;
Expand Down Expand Up @@ -40,6 +41,10 @@ public class CassandraContainer extends GenericContainer<CassandraContainer> {

private String initScriptPath;

private String clientCertFile;

private String clientKeyFile;

public CassandraContainer(String dockerImageName) {
this(DockerImageName.parse(dockerImageName));
}
Expand Down Expand Up @@ -69,6 +74,15 @@ protected void configure() {
.ofNullable(configLocation)
.map(MountableFile::forClasspathResource)
.ifPresent(mountableFile -> withCopyFileToContainer(mountableFile, CONTAINER_CONFIG_LOCATION));

// If a secure connection is required by Cassandra configuration, copy the user certificate and key to a
// dedicated location and define a cqlshrc file with the appropriate SSL configuration.
// See: https://docs.datastax.com/en/cassandra-oss/3.x/cassandra/configuration/secureCqlshSSL.html
if (isSslRequired()) {
withCopyFileToContainer(MountableFile.forClasspathResource(clientCertFile), "ssl/user_cert.pem");
withCopyFileToContainer(MountableFile.forClasspathResource(clientKeyFile), "ssl/user_key.pem");
withCopyFileToContainer(MountableFile.forClasspathResource("cqlshrc"), "/root/.cassandra/cqlshrc");
}
}

@Override
Expand Down Expand Up @@ -110,9 +124,11 @@ private void runInitScriptIfRequired() {
* Initialize Cassandra with the custom overridden Cassandra configuration
* <p>
* Be aware, that Docker effectively replaces all /etc/cassandra content with the content of config location, so if
* Cassandra.yaml in configLocation is absent or corrupted, then Cassandra just won't launch
* Cassandra.yaml in configLocation is absent or corrupted, then Cassandra just won't launch.
*
* @param configLocation relative classpath with the directory that contains cassandra.yaml and other configuration files
* @param configLocation relative classpath with the directory that contains cassandra.yaml and other configuration
* files
* @return The updated {@link CassandraContainer}.
*/
public CassandraContainer withConfigurationOverride(String configLocation) {
this.configLocation = configLocation;
Expand All @@ -126,15 +142,38 @@ public CassandraContainer withConfigurationOverride(String configLocation) {
* </p>
*
* @param initScriptPath relative classpath resource
* @return The updated {@link CassandraContainer}.
*/
public CassandraContainer withInitScript(String initScriptPath) {
this.initScriptPath = initScriptPath;
return self();
}

/**
* Get username
* Configure secured connection (TLS) when required by the Cassandra configuration
* (i.e. cassandra.yaml file contains the property {@code client_encryption_options.optional} with value
* {@code false}).
*
* @param clientCertFile The client certificate required to execute CQL scripts.
* @param clientKeyFile The client key required to execute CQL scripts.
* @return The updated {@link CassandraContainer}.
*/
public CassandraContainer withSslClientConfig(String clientCertFile, String clientKeyFile) {
this.clientCertFile = clientCertFile;
this.clientKeyFile = clientKeyFile;
return self();
}

/**
* @return Whether a secure connection is required between the client and the Cassandra server.
*/
public boolean isSslRequired() {
return StringUtils.isNoneBlank(this.clientCertFile, this.clientKeyFile);
}

/**
* Get username
* <p>
* By default, Cassandra has authenticator: AllowAllAuthenticator in cassandra.yaml
* If username and password need to be used, then authenticator should be set as PasswordAuthenticator
* (through custom Cassandra configuration) and through CQL with default cassandra-cassandra credentials
Expand All @@ -146,7 +185,7 @@ public String getUsername() {

/**
* Get password
*
* <p>
* By default, Cassandra has authenticator: AllowAllAuthenticator in cassandra.yaml
* If username and password need to be used, then authenticator should be set as PasswordAuthenticator
* (through custom Cassandra configuration) and through CQL with default cassandra-cassandra credentials
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,13 @@ protected Void createNewConnection() {
return null;
}

@Override
public void execute(
String statement,
String scriptPath,
int lineNumber,
boolean continueOnError,
boolean ignoreFailedDrops
boolean ignoreFailedDrops,
boolean silentErrorLogs
) {
try {
// Use cqlsh command directly inside the container to execute statements
Expand All @@ -46,6 +46,9 @@ public void execute(
CassandraContainer cassandraContainer = (CassandraContainer) this.container;
String username = cassandraContainer.getUsername();
String password = cassandraContainer.getPassword();
if (cassandraContainer.isSslRequired()) {
cqlshCommand = ArrayUtils.add(cqlshCommand, "--ssl");
}
cqlshCommand = ArrayUtils.addAll(cqlshCommand, "-u", username, "-p", password);
}

Expand All @@ -68,14 +71,27 @@ public void execute(
log.info("CQL statement {} was applied", statement);
}
} else {
log.error("CQL script execution failed with error: \n{}", result.getStderr());
if (!silentErrorLogs) {
log.error("CQL script execution failed with error: \n{}", result.getStderr());
}
throw new ScriptStatementFailedException(statement, lineNumber, scriptPath);
}
} catch (IOException | InterruptedException e) {
throw new ScriptStatementFailedException(statement, lineNumber, scriptPath, e);
}
}

@Override
public void execute(
String statement,
String scriptPath,
int lineNumber,
boolean continueOnError,
boolean ignoreFailedDrops
) {
this.execute(statement, scriptPath, lineNumber, continueOnError, ignoreFailedDrops, false);
}

@Override
protected void closeConnectionQuietly(Void session) {
// Nothing to do here, because we run scripts using cqlsh command directly in the container.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package org.testcontainers.cassandra.wait;

import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.rnorth.ducttape.TimeoutException;
import org.testcontainers.cassandra.delegate.CassandraDatabaseDelegate;
import org.testcontainers.containers.ContainerLaunchException;
Expand All @@ -13,6 +15,7 @@
/**
* Waits until Cassandra returns its version
*/
@Slf4j
public class CassandraQueryWaitStrategy extends AbstractWaitStrategy {

private static final String SELECT_VERSION_QUERY = "SELECT release_version FROM system.local";
Expand All @@ -30,7 +33,9 @@ protected void waitUntilReady() {
getRateLimiter()
.doWhenReady(() -> {
try (DatabaseDelegate databaseDelegate = getDatabaseDelegate()) {
databaseDelegate.execute(SELECT_VERSION_QUERY, "", 1, false, false);
log.info("Checking connection is ready...");
((CassandraDatabaseDelegate) databaseDelegate)
.execute(SELECT_VERSION_QUERY, StringUtils.EMPTY, 1, false, false, true);
}
});
return true;
Expand Down
7 changes: 7 additions & 0 deletions modules/cassandra/src/main/resources/cqlshrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[ssl]
certfile = ssl/user_cert.pem
usercert = ssl/user_cert.pem
userkey = ssl/user_key.pem

[connection]
factory = cqlshlib.ssl.ssl_transport_factory
Original file line number Diff line number Diff line change
@@ -1,17 +1,28 @@
package org.testcontainers.cassandra;

import com.datastax.oss.driver.api.core.CqlSession;
import com.datastax.oss.driver.api.core.CqlSessionBuilder;
import com.datastax.oss.driver.api.core.config.DefaultDriverOption;
import com.datastax.oss.driver.api.core.config.DriverConfigLoader;
import com.datastax.oss.driver.api.core.config.ProgrammaticDriverConfigLoaderBuilder;
import com.datastax.oss.driver.api.core.context.DriverContext;
import com.datastax.oss.driver.api.core.cql.ResultSet;
import com.datastax.oss.driver.api.core.cql.Row;
import com.datastax.oss.driver.api.core.session.ProgrammaticArguments;
import com.datastax.oss.driver.internal.core.context.DefaultDriverContext;
import com.datastax.oss.driver.internal.core.ssl.DefaultSslEngineFactory;
import org.junit.Test;
import org.testcontainers.containers.ContainerLaunchException;
import org.testcontainers.utility.DockerImageName;

import java.net.URL;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.fail;

public class CassandraContainerTest {

private static final String CASSANDRA_IMAGE = "cassandra:3.11.2";
private static final String CASSANDRA_IMAGE = "cassandra:3.11.15";

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

Expand All @@ -20,7 +31,7 @@ public class CassandraContainerTest {
@Test
public void testSimple() {
try ( // container-definition {
CassandraContainer cassandraContainer = new CassandraContainer("cassandra:3.11.2")
CassandraContainer cassandraContainer = new CassandraContainer(CASSANDRA_IMAGE)
// }
) {
cassandraContainer.start();
Expand Down Expand Up @@ -60,6 +71,46 @@ public void testConfigurationOverride() {
}
}

@Test
public void testWithSslClientConfig() {
/*
Commands executed to generate certificates in 'cassandra-ssl-configuration' directory:
keytool -genkey -keyalg RSA -validity 36500 -alias localhost -keystore keystore.p12 -storepass cassandra \
-keypass cassandra -dname "CN=localhost, OU=Testcontainers, O=Testcontainers, L=None, C=None"
keytool -export -alias localhost -file cassandra.cer -keystore keystore.p12
keytool -import -v -trustcacerts -alias localhost -file cassandra.cer -keystore truststore.p12
Commands executed to generate the client certificate and key in 'client-ssl' directory:
keytool -importkeystore -srckeystore keystore.p12 -destkeystore test_node.p12 -deststoretype PKCS12 \
-srcstorepass cassandra -deststorepass cassandra
openssl pkcs12 -in test_node.p12 -nokeys -out cassandra.cer.pem -passin pass:cassandra
openssl pkcs12 -in test_node.p12 -nodes -nocerts -out cassandra.key.pem -passin pass:cassandra
Reference:
https://docs.datastax.com/en/cassandra-oss/3.x/cassandra/configuration/secureSSLCertificates.html
https://docs.datastax.com/en/cassandra-oss/3.x/cassandra/configuration/secureCqlshSSL.html
*/
try (
// with-ssl-config {
CassandraContainer cassandraContainer = new CassandraContainer(CASSANDRA_IMAGE)
.withConfigurationOverride("cassandra-ssl-configuration")
.withSslClientConfig("client-ssl/cassandra.cer.pem", "client-ssl/cassandra.key.pem")
// }
) {
cassandraContainer.start();
try {
ResultSet resultSet = performQueryWithSslClientConfig(cassandraContainer,
"SELECT cluster_name FROM system.local");
assertThat(resultSet.wasApplied()).as("Query was applied").isTrue();
assertThat(resultSet.one().getString(0))
.as("Cassandra configuration is configured with secured connection")
.isEqualTo(TEST_CLUSTER_NAME_IN_CONF);
} catch (Exception e) {
fail(e);
}
}
}

@Test(expected = ContainerLaunchException.class)
public void testEmptyConfigurationOverride() {
try (
Expand Down Expand Up @@ -153,6 +204,30 @@ private ResultSet performQueryWithAuth(CassandraContainer cassandraContainer, St
return performQuery(cqlSession, cql);
}

private ResultSet performQueryWithSslClientConfig(CassandraContainer cassandraContainer,
String cql) {
final ProgrammaticDriverConfigLoaderBuilder driverConfigLoaderBuilder =
DriverConfigLoader.programmaticBuilder();
driverConfigLoaderBuilder.withBoolean(DefaultDriverOption.SSL_HOSTNAME_VALIDATION, false);
final URL trustStoreUrl = this.getClass().getClassLoader()
.getResource("cassandra-ssl-configuration/truststore.p12");
driverConfigLoaderBuilder.withString(DefaultDriverOption.SSL_TRUSTSTORE_PATH, trustStoreUrl.getFile());
driverConfigLoaderBuilder.withString(DefaultDriverOption.SSL_TRUSTSTORE_PASSWORD, "cassandra");
final URL keyStoreUrl = this.getClass().getClassLoader()
.getResource("cassandra-ssl-configuration/keystore.p12");
driverConfigLoaderBuilder.withString(DefaultDriverOption.SSL_KEYSTORE_PATH, keyStoreUrl.getFile());
driverConfigLoaderBuilder.withString(DefaultDriverOption.SSL_KEYSTORE_PASSWORD, "cassandra");
final DriverContext driverContext = new DefaultDriverContext(driverConfigLoaderBuilder.build(),
ProgrammaticArguments.builder().build());

final CqlSessionBuilder sessionBuilder = CqlSession.builder();
final CqlSession cqlSession = sessionBuilder.addContactPoint(cassandraContainer.getContactPoint())
.withLocalDatacenter(cassandraContainer.getLocalDatacenter())
.withSslEngineFactory(new DefaultSslEngineFactory(driverContext))
.build();
return performQuery(cqlSession, cql);
}

private ResultSet performQuery(CqlSession session, String cql) {
final ResultSet rs = session.execute(cql);
session.close();
Expand Down
Binary file not shown.
Loading

0 comments on commit 214c023

Please sign in to comment.