Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix issue #9410 #9419

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading