diff --git a/.github/scripts/run-tests.sh b/.github/scripts/run-tests.sh index e04261bcd..fb78cebcc 100755 --- a/.github/scripts/run-tests.sh +++ b/.github/scripts/run-tests.sh @@ -217,8 +217,8 @@ case "${TEST_TYPE}" in sudo apt-get update sudo apt-get install jq -y mvn -B package -DskipTests - docker-compose -f ./src/packaging/docker-build/docker-compose.yml build - docker-compose -f ./src/packaging/docker-build/docker-compose.yml run build + docker compose -f ./src/packaging/docker-build/docker-compose.yml build + docker compose -f ./src/packaging/docker-build/docker-compose.yml run build VERSION=$(printf 'VER\t${project.version}' | mvn help:evaluate | grep '^VER' | cut -f2) docker build --build-arg SHADED_JAR=src/server/target/cassandra-reaper-${VERSION}.jar -f src/server/src/main/docker/Dockerfile -t cassandra-reaper:latest . docker images @@ -226,9 +226,9 @@ case "${TEST_TYPE}" in # Clear out Cassandra data before starting a new cluster sudo rm -vfr ./src/packaging/data/ - docker-compose -f ./src/packaging/docker-compose.yml up -d cassandra - sleep 30 && docker-compose -f ./src/packaging/docker-compose.yml run cqlsh-initialize-reaper_db - sleep 10 && docker-compose -f ./src/packaging/docker-compose.yml up -d reaper + docker compose -f ./src/packaging/docker-compose.yml up -d cassandra + sleep 30 && docker compose -f ./src/packaging/docker-compose.yml run cqlsh-initialize-reaper_db + sleep 10 && docker compose -f ./src/packaging/docker-compose.yml up -d reaper docker ps -a # requests python package is needed to use spreaper @@ -236,14 +236,14 @@ case "${TEST_TYPE}" in mkdir -p ~/.reaper echo "admin" > ~/.reaper/credentials sleep 30 && src/packaging/bin/spreaper login admin - src/packaging/bin/spreaper add-cluster $(docker-compose -f ./src/packaging/docker-compose.yml run nodetool status | grep UN | tr -s ' ' | cut -d' ' -f2) 7199 > cluster.json + src/packaging/bin/spreaper add-cluster $(docker compose -f ./src/packaging/docker-compose.yml run nodetool status | grep UN | tr -s ' ' | cut -d' ' -f2) 7199 > cluster.json cat cluster.json cluster_name=$(cat cluster.json|grep -v "#" | jq -r '.name') if [[ "$cluster_name" != "reaper-cluster" ]]; then echo "Failed registering cluster in Reaper running in Docker" exit 1 fi - sleep 5 && docker-compose -f ./src/packaging/docker-compose.yml down + sleep 5 && docker compose -f ./src/packaging/docker-compose.yml down ;; *) echo "Skipping, no actions for TEST_TYPE=${TEST_TYPE}." diff --git a/CHANGELOG.md b/CHANGELOG.md index 19b234da3..95f3e9401 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ ## Change Log +### 3.7.0 (2024/11/15 12:02 +00:00) +- [#1527](https://github.com/thelastpickle/cassandra-reaper/pull/1527) Upgrade the base docker image from Corretto 11.0.20 to 11.0.25 (#1527) (@adejanovski) +- [#1510](https://github.com/thelastpickle/cassandra-reaper/pull/1510) update doc for persistenceStoragePath (#1510) (@SarthakSahu) +- [#1457](https://github.com/thelastpickle/cassandra-reaper/pull/1457) Bump io.netty:netty-handler in /src/server (#1457) (@dependabot[bot]) +- [#1461](https://github.com/thelastpickle/cassandra-reaper/pull/1461) Bump org.apache.shiro:shiro-core from 1.12.0 to 1.13.0 in /src/server (#1461) (@dependabot[bot]) +- [#1524](https://github.com/thelastpickle/cassandra-reaper/pull/1524) Allow setting up per-cluster TLS connections (#1524) (@rzvoncek) +- [#1523](https://github.com/thelastpickle/cassandra-reaper/pull/1523) Update java driver 3.11.0 to 3.11.5 (#1523) (@bschoening) +- [#1516](https://github.com/thelastpickle/cassandra-reaper/pull/1516) Switch CI to docker-compose v2 (#1516) (@adejanovski) +- [#1509](https://github.com/thelastpickle/cassandra-reaper/pull/1509) Subrange incremental repair (#1509) (@adejanovski) + ### 3.6.1 (2024/06/11 07:14 +00:00) - [#1507](https://github.com/thelastpickle/cassandra-reaper/pull/1507) Allow mounting a volume to host the config and enable read only root FS (#1507) (@adejanovski) diff --git a/src/docs/content/docs/backends/memory.md b/src/docs/content/docs/backends/memory.md index 1a4ea1edb..38dd4bf24 100644 --- a/src/docs/content/docs/backends/memory.md +++ b/src/docs/content/docs/backends/memory.md @@ -11,6 +11,9 @@ To use in memory storage as the storage type for Reaper, the `storageType` setti ```yaml storageType: memory +persistenceStoragePath: /var/lib/cassandra-reaper/storage ``` In-memory storage is volatile and as such all registered cluster, column families and repair information will be lost upon service restart. This storage setting is intended for testing purposes only. + +Starting from 3.6.0, persistenceStoragePath is required for memory storage type. This enable lightweight deployments of Reaper, without requiring the use of a Cassandra database. It will store the data locally and reload them consistently upon startup. diff --git a/src/server/pom.xml b/src/server/pom.xml index 58aa96bca..83e92c928 100644 --- a/src/server/pom.xml +++ b/src/server/pom.xml @@ -33,7 +33,7 @@ 2.35 1.3.14 3.4.5 - 1.12.0 + 1.13.0 0.12.0 src/main/docker ${maven.build.timestamp} @@ -169,12 +169,12 @@ io.netty netty-handler - 4.1.70.Final + 4.1.94.Final com.datastax.cassandra cassandra-driver-core - 3.11.0 + 3.11.5 com.fasterxml.jackson.core diff --git a/src/server/src/main/docker/Dockerfile b/src/server/src/main/docker/Dockerfile index 11df35ef3..3bc4be82f 100644 --- a/src/server/src/main/docker/Dockerfile +++ b/src/server/src/main/docker/Dockerfile @@ -13,7 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -FROM amazoncorretto:11.0.22-alpine +FROM amazoncorretto:11.0.25-alpine ARG SHADED_JAR @@ -84,6 +84,7 @@ ENV REAPER_SEGMENT_COUNT_PER_NODE=64 \ REAPER_HTTP_MANAGEMENT_ENABLE="false" \ REAPER_HTTP_MANAGEMENT_KEYSTORE_PATH="" \ REAPER_HTTP_MANAGEMENT_TRUSTSTORE_PATH="" \ + REAPER_HTTP_MANAGEMENT_TRUSTSTORES_DIR="" \ REAPER_TMP_DIRECTORY="/var/tmp/cassandra-reaper" \ REAPER_MEMORY_STORAGE_DIRECTORY="/var/lib/cassandra-reaper/storage" diff --git a/src/server/src/main/docker/cassandra-reaper.yml b/src/server/src/main/docker/cassandra-reaper.yml index 5836e445c..1fde85785 100644 --- a/src/server/src/main/docker/cassandra-reaper.yml +++ b/src/server/src/main/docker/cassandra-reaper.yml @@ -86,4 +86,5 @@ httpManagement: mgmtApiMetricsPort: ${REAPER_MGMT_API_METRICS_PORT} keystore: ${REAPER_HTTP_MANAGEMENT_KEYSTORE_PATH} truststore: ${REAPER_HTTP_MANAGEMENT_TRUSTSTORE_PATH} + truststoresDir: ${REAPER_HTTP_MANAGEMENT_TRUSTSTORES_DIR} diff --git a/src/server/src/main/java/io/cassandrareaper/ReaperApplication.java b/src/server/src/main/java/io/cassandrareaper/ReaperApplication.java index 110bc844f..ed69e14e7 100644 --- a/src/server/src/main/java/io/cassandrareaper/ReaperApplication.java +++ b/src/server/src/main/java/io/cassandrareaper/ReaperApplication.java @@ -50,6 +50,8 @@ import io.cassandrareaper.storage.IDistributedStorage; import io.cassandrareaper.storage.InitializeStorage; +import java.nio.file.Files; +import java.nio.file.Paths; import java.util.EnumSet; import java.util.Optional; import java.util.concurrent.ScheduledExecutorService; @@ -341,8 +343,9 @@ private void maybeInitializeSidecarMode(ClusterResource addClusterResource) thro private boolean selfRegisterClusterForSidecar(ClusterResource addClusterResource, String seedHost) throws ReaperException { - final Optional cluster = addClusterResource.findClusterWithSeedHost(seedHost, Optional.empty(), - Optional.empty()); + final Optional cluster = addClusterResource.findClusterWithSeedHost( + seedHost, Optional.empty(),Optional.empty() + ); if (!cluster.isPresent()) { return false; } @@ -427,6 +430,19 @@ private void checkConfiguration(ReaperApplicationConfiguration config) { LOG.debug("repairParallelism: {}", config.getRepairParallelism()); LOG.debug("hangingRepairTimeoutMins: {}", config.getHangingRepairTimeoutMins()); LOG.debug("jmxPorts: {}", config.getJmxPorts()); + + if (config.getHttpManagement() != null) { + if (config.getHttpManagement().isEnabled()) { + if (config.getHttpManagement().getTruststoresDir() != null) { + if (!Files.exists(Paths.get(config.getHttpManagement().getTruststoresDir()))) { + throw new RuntimeException(String.format( + "HttpManagement truststores directory is configured as %s but it does not exist", + config.getHttpManagement().getTruststoresDir() + )); + } + } + } + } } private void tryInitializeStorage(ReaperApplicationConfiguration config, Environment environment) diff --git a/src/server/src/main/java/io/cassandrareaper/ReaperApplicationConfiguration.java b/src/server/src/main/java/io/cassandrareaper/ReaperApplicationConfiguration.java index 45a004906..705b78f9c 100644 --- a/src/server/src/main/java/io/cassandrareaper/ReaperApplicationConfiguration.java +++ b/src/server/src/main/java/io/cassandrareaper/ReaperApplicationConfiguration.java @@ -753,6 +753,9 @@ public static final class HttpManagement { @JsonProperty private String truststore; + @JsonProperty + private String truststoresDir; + @JsonProperty private Integer mgmtApiMetricsPort; @@ -772,6 +775,10 @@ public String getTruststore() { return truststore; } + public String getTruststoresDir() { + return truststoresDir; + } + @VisibleForTesting public void setEnabled(Boolean enabled) { this.enabled = enabled; @@ -787,6 +794,11 @@ public void setTruststore(String truststore) { this.truststore = truststore; } + @VisibleForTesting + public void setTruststoresDir(String truststoresDir) { + this.truststoresDir = truststoresDir; + } + public int getMgmtApiMetricsPort() { return mgmtApiMetricsPort == null ? DEFAULT_MGMT_API_METRICS_PORT : mgmtApiMetricsPort; } diff --git a/src/server/src/main/java/io/cassandrareaper/management/ClusterFacade.java b/src/server/src/main/java/io/cassandrareaper/management/ClusterFacade.java index 2745008f7..71aac259b 100644 --- a/src/server/src/main/java/io/cassandrareaper/management/ClusterFacade.java +++ b/src/server/src/main/java/io/cassandrareaper/management/ClusterFacade.java @@ -893,12 +893,10 @@ public ICassandraManagementProxy connect(Node node, Collection endpoints private ICassandraManagementProxy connectImpl(Cluster cluster, Collection endpoints) throws ReaperException { try { - ICassandraManagementProxy proxy = context.managementConnectionFactory - .connectAny( - endpoints - .stream() - .map(host -> Node.builder().withCluster(cluster).withHostname(host).build()) - .collect(Collectors.toList())); + ICassandraManagementProxy proxy = context.managementConnectionFactory.connectAny(endpoints.stream() + .map(host -> Node.builder().withCluster(cluster).withHostname(host).build()) + .collect(Collectors.toList()) + ); Async.markClusterActive(cluster, context); return proxy; diff --git a/src/server/src/main/java/io/cassandrareaper/management/http/HttpManagementConnectionFactory.java b/src/server/src/main/java/io/cassandrareaper/management/http/HttpManagementConnectionFactory.java index 0925a8788..47f76bbba 100644 --- a/src/server/src/main/java/io/cassandrareaper/management/http/HttpManagementConnectionFactory.java +++ b/src/server/src/main/java/io/cassandrareaper/management/http/HttpManagementConnectionFactory.java @@ -77,6 +77,11 @@ public class HttpManagementConnectionFactory implements IManagementConnectionFactory { private static final char[] KEYSTORE_PASSWORD = "changeit".toCharArray(); + + private static final String KEYSTORE_COMPONENT_NAME = "keystore.jks"; + + private static final String TRUSTSTORE_COMPONENT_NAME = "truststore.jks"; + private static final Logger LOG = LoggerFactory.getLogger(HttpManagementConnectionFactory.class); private static final ConcurrentMap HTTP_CONNECTIONS = Maps.newConcurrentMap(); private final MetricRegistry metricRegistry; @@ -95,13 +100,22 @@ public HttpManagementConnectionFactory(AppContext context, ScheduledExecutorServ this.config = context.config; registerConnectionsGauge(); this.jobStatusPollerExecutor = jobStatusPollerExecutor; - if (context.config.getHttpManagement().getKeystore() != null && !context.config.getHttpManagement().getKeystore() - .isEmpty()) { - try { - createSslWatcher(); - } catch (IOException e) { - throw new RuntimeException(e); + + String ts = context.config.getHttpManagement().getTruststore(); + boolean watchTruststore = ts != null && !ts.isEmpty(); + String ks = context.config.getHttpManagement().getKeystore(); + boolean watchKeystore = ks != null && !ks.isEmpty(); + String tsd = context.config.getHttpManagement().getTruststoresDir(); + boolean watchTruststoreDir = tsd != null && !tsd.isEmpty() && Files.isDirectory(Paths.get(tsd)); + + try { + if (watchKeystore || watchTruststore || watchTruststoreDir) { + createSslWatcher(watchTruststore, watchKeystore, watchTruststoreDir); + } else { + LOG.debug("Not setting up any SSL watchers"); } + } catch (IOException e) { + throw new RuntimeException(e); } } @@ -170,19 +184,26 @@ private HttpCassandraManagementProxy connectImpl(Node node) @Override public HttpCassandraManagementProxy apply(@Nullable String hostName) { ReaperApplicationConfiguration.HttpManagement httpConfig = config.getHttpManagement(); - boolean useMtls = httpConfig.getKeystore() != null && !httpConfig.getKeystore().isEmpty(); + + boolean useMtls = (httpConfig.getKeystore() != null && !httpConfig.getKeystore().isEmpty()) + || (httpConfig.getTruststoresDir() != null && !httpConfig.getTruststoresDir().isEmpty()); OkHttpClient.Builder clientBuilder = new OkHttpClient().newBuilder(); String protocol = "http"; + if (useMtls) { + + Path truststoreName = getTruststoreComponentPath(node, TRUSTSTORE_COMPONENT_NAME); + Path keystoreName = getTruststoreComponentPath(node, KEYSTORE_COMPONENT_NAME); + LOG.debug("Using TLS connection to " + node.getHostname()); // We have to split TrustManagers to its own function to please OkHttpClient TrustManager[] trustManagers; SSLContext sslContext; try { - trustManagers = getTrustManagers(); - sslContext = createSslContext(trustManagers); + trustManagers = getTrustManagers(truststoreName); + sslContext = createSslContext(trustManagers, keystoreName); } catch (ReaperException e) { LOG.error("Failed to create SSLContext: " + e.getLocalizedMessage(), e); throw new RuntimeException(e); @@ -218,8 +239,7 @@ public HttpCassandraManagementProxy apply(@Nullable String hostName) { } @VisibleForTesting - SSLContext createSslContext(TrustManager[] tms) throws ReaperException { - Path keyStorePath = Paths.get(config.getHttpManagement().getKeystore()); + SSLContext createSslContext(TrustManager[] tms, Path keyStorePath) throws ReaperException { try (InputStream ksIs = Files.newInputStream(keyStorePath, StandardOpenOption.READ)) { @@ -238,8 +258,11 @@ SSLContext createSslContext(TrustManager[] tms) throws ReaperException { } } - private TrustManager[] getTrustManagers() throws ReaperException { - Path trustStorePath = Paths.get(config.getHttpManagement().getTruststore()); + @VisibleForTesting + TrustManager[] getTrustManagers(Path trustStorePath) throws ReaperException { + + LOG.trace(String.format("Calling getSingleTrustManager with %s", trustStorePath)); + try (InputStream tsIs = Files.newInputStream(trustStorePath, StandardOpenOption.READ)) { KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType()); trustStore.load(tsIs, KEYSTORE_PASSWORD); @@ -249,30 +272,67 @@ private TrustManager[] getTrustManagers() throws ReaperException { return tmf.getTrustManagers(); } catch (IOException | NoSuchAlgorithmException | KeyStoreException | CertificateException e) { - throw new ReaperException(e); + throw new ReaperException("Error loading trust managers"); + } + } + + @VisibleForTesting + Path getTruststoreComponentPath(Node node, String truststoreComponentName) { + Path trustStorePath; + + String clusterName = node.getClusterName(); + // the cluster name is not available, or we don't have the per-cluster truststores + // we fall back to the global trust stores + if (clusterName.equals("") || config.getHttpManagement().getTruststoresDir() == null) { + + trustStorePath = truststoreComponentName.equals(TRUSTSTORE_COMPONENT_NAME) + ? Paths.get(config.getHttpManagement().getTruststore()).toAbsolutePath() + : Paths.get(config.getHttpManagement().getKeystore()).toAbsolutePath(); + } else { + // load a cluster-specific trust store otherwise + Path storesRootPath = Paths.get(config.getHttpManagement().getTruststoresDir()); + trustStorePath = storesRootPath + .resolve(String.format("%s-%s", clusterName, truststoreComponentName)) + .toAbsolutePath(); } + + return trustStorePath; } @VisibleForTesting - void createSslWatcher() throws IOException { + void createSslWatcher(boolean watchTruststore, boolean watchKeystore, boolean watchTruststoreDir) throws IOException { + WatchService watchService = FileSystems.getDefault().newWatchService(); - Path trustStorePath = Paths.get(config.getHttpManagement().getTruststore()); - Path keyStorePath = Paths.get(config.getHttpManagement().getKeystore()); - Path keystoreParent = trustStorePath.getParent(); - Path trustStoreParent = keyStorePath.getParent(); - - keystoreParent.register( - watchService, - StandardWatchEventKinds.ENTRY_CREATE, - StandardWatchEventKinds.ENTRY_DELETE, - StandardWatchEventKinds.ENTRY_MODIFY); - - if (!keystoreParent.equals(trustStoreParent)) { - trustStoreParent.register( + + Path trustStorePath = watchTruststore ? Paths.get(config.getHttpManagement().getTruststore()) : null; + Path keyStorePath = watchKeystore ? Paths.get(config.getHttpManagement().getKeystore()) : null ; + Path truststoreDirPath = watchTruststoreDir ? Paths.get(config.getHttpManagement().getTruststoresDir()) : null ; + + if (watchKeystore) { + keyStorePath.getParent().register( + watchService, + StandardWatchEventKinds.ENTRY_CREATE, + StandardWatchEventKinds.ENTRY_DELETE, + StandardWatchEventKinds.ENTRY_MODIFY + ); + } + if (watchTruststore && watchKeystore) { + if (!trustStorePath.getParent().equals(keyStorePath.getParent())) { + trustStorePath.getParent().register( + watchService, + StandardWatchEventKinds.ENTRY_CREATE, + StandardWatchEventKinds.ENTRY_DELETE, + StandardWatchEventKinds.ENTRY_MODIFY + ); + } + } + if (watchTruststoreDir) { + truststoreDirPath.register( watchService, StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_DELETE, - StandardWatchEventKinds.ENTRY_MODIFY); + StandardWatchEventKinds.ENTRY_MODIFY + ); } ExecutorService executorService = Executors.newSingleThreadExecutor(); @@ -290,11 +350,23 @@ void createSslWatcher() throws IOException { WatchEvent ev = (WatchEvent) event; Path eventFilename = ev.context(); - if (keystoreParent.resolve(eventFilename).equals(keyStorePath) - || trustStoreParent.resolve(eventFilename).equals(trustStorePath)) { - // Something in the TLS has been modified.. recreate HTTP connections - reloadNeeded = true; + if (watchKeystore) { + if (keyStorePath.getParent().resolve(eventFilename).equals(keyStorePath)) { + reloadNeeded = true; + } + } + if (watchTruststore) { + if (trustStorePath.getParent().resolve(eventFilename).equals(trustStorePath)) { + // Something in the TLS has been modified.. recreate HTTP connections + reloadNeeded = true; + } } + if (watchTruststoreDir) { + if (eventFilename.toString().endsWith(".jks")) { + reloadNeeded = true; + } + } + } if (!key.reset()) { // The watched directories have disappeared.. diff --git a/src/server/src/main/java/io/cassandrareaper/resources/ClusterResource.java b/src/server/src/main/java/io/cassandrareaper/resources/ClusterResource.java index 552649329..f769e66ea 100644 --- a/src/server/src/main/java/io/cassandrareaper/resources/ClusterResource.java +++ b/src/server/src/main/java/io/cassandrareaper/resources/ClusterResource.java @@ -220,7 +220,9 @@ public Response addOrUpdateCluster( @QueryParam("jmxPort") Optional jmxPort) { LOG.info("POST addOrUpdateCluster called with seedHost: {}", seedHost.orElse(null)); - return addOrUpdateCluster(uriInfo, Optional.empty(), seedHost, jmxPort, Optional.empty(), Optional.empty()); + return addOrUpdateCluster( + uriInfo, Optional.empty(), seedHost, jmxPort, Optional.empty(), Optional.empty() + ); } @POST @@ -248,7 +250,9 @@ public Response addOrUpdateCluster( "PUT addOrUpdateCluster called with: cluster_name = {}, seedHost = {}", clusterName, seedHost.orElse(null)); - return addOrUpdateCluster(uriInfo, Optional.of(clusterName), seedHost, jmxPort, Optional.empty(), Optional.empty()); + return addOrUpdateCluster( + uriInfo, Optional.of(clusterName), seedHost, jmxPort, Optional.empty(), Optional.empty() + ); } @PUT @@ -297,8 +301,10 @@ private Response addOrUpdateCluster( } } - final Optional cluster = findClusterWithSeedHost(seedHost.get(), jmxPort, - Optional.ofNullable(jmxCredentials)); + final Optional cluster = findClusterWithSeedHost( + seedHost.get(), jmxPort, Optional.ofNullable(jmxCredentials) + ); + if (!cluster.isPresent()) { return Response .status(Response.Status.BAD_REQUEST) @@ -361,9 +367,11 @@ private Response addOrUpdateCluster( return Response.created(location).build(); } - public Optional findClusterWithSeedHost(String seedHost, - Optional jmxPort, - Optional jmxCredentials) { + public Optional findClusterWithSeedHost( + String seedHost, + Optional jmxPort, + Optional jmxCredentials + ) { Set seedHosts = parseSeedHosts(seedHost); try { Cluster.Builder clusterBuilder = Cluster.builder() diff --git a/src/server/src/test/java/io/cassandrareaper/management/http/HttpCassandraManagementProxyTest.java b/src/server/src/test/java/io/cassandrareaper/management/http/HttpCassandraManagementProxyTest.java index 19f1ea7a7..2a9d555e3 100644 --- a/src/server/src/test/java/io/cassandrareaper/management/http/HttpCassandraManagementProxyTest.java +++ b/src/server/src/test/java/io/cassandrareaper/management/http/HttpCassandraManagementProxyTest.java @@ -20,6 +20,7 @@ import io.cassandrareaper.AppContext; import io.cassandrareaper.ReaperApplicationConfiguration; import io.cassandrareaper.ReaperException; +import io.cassandrareaper.core.Cluster; import io.cassandrareaper.core.GenericMetric; import io.cassandrareaper.core.Node; import io.cassandrareaper.core.RepairType; @@ -31,9 +32,9 @@ import java.math.BigInteger; import java.net.InetSocketAddress; -import java.nio.file.FileSystems; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.Paths; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -59,6 +60,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; import com.google.common.collect.Lists; import com.google.common.util.concurrent.MoreExecutors; import org.apache.cassandra.repair.RepairParallelism; @@ -817,11 +819,11 @@ public void testSSLHotReload() throws Exception { context.config = config; Path tempDirectory = Files.createTempDirectory("reload-test"); - // handle running tests locally vs. via GitHub actions - // Path ks = Paths.get("/home/runner/work/cassandra-reaper/cassandra-reaper/.github/files/keystore.jks"); - // Path ts = Paths.get("/home/runner/work/cassandra-reaper/cassandra-reaper/.github/files/truststore.jks"); - // use relative paths as we are likely running locally - Path projectRoot = FileSystems.getDefault().getPath("..", "..", ".."); + + Path projectRoot = Paths.get(".").toAbsolutePath(); + while (!projectRoot.endsWith("cassandra-reaper")) { + projectRoot = projectRoot.getParent(); + } Path ks = projectRoot.resolve(".github/files/keystore.jks"); Path ts = projectRoot.resolve(".github/files/truststore.jks"); @@ -833,17 +835,99 @@ public void testSSLHotReload() throws Exception { config.getHttpManagement().setEnabled(true); config.getHttpManagement().setKeystore(ksCopy.toAbsolutePath().toString()); config.getHttpManagement().setTruststore(tsCopy.toAbsolutePath().toString()); + + Path storesRoot = Files.createDirectory(tempDirectory.resolve("stores")); + Path clustersStore = Files.createDirectory(storesRoot.resolve("cluster1")); + Files.copy(ts, clustersStore.resolve(ts.getFileName())); + Files.copy(ks, clustersStore.resolve(ks.getFileName())); + config.getHttpManagement().setTruststoresDir(storesRoot.toAbsolutePath().toString()); + HttpManagementConnectionFactory connectionFactory = new HttpManagementConnectionFactory(context, null); HttpManagementConnectionFactory spy = spy(connectionFactory); - spy.createSslWatcher(); + spy.createSslWatcher(true, true, true); verify(spy, Mockito.timeout(1000)).clearHttpConnections(); // Modify filepaths Files.delete(ksCopy); + Files.delete(clustersStore.resolve(ts.getFileName())); - // We need 3 invocations, because we can't spy the original constructor call to the clearHttpConnections() and + // We need 4 invocations, because we can't spy the original constructor call to the clearHttpConnections() and // as such need to create more SslWatchers() for the same path - verify(spy, Mockito.timeout(30000).atLeast(2)).clearHttpConnections(); + verify(spy, Mockito.timeout(30000).atLeast(3)).clearHttpConnections(); + } + + @Test + public void testGetTruststore() throws Exception { + + Path projectRoot = Paths.get(".").toAbsolutePath(); + while (!projectRoot.endsWith("cassandra-reaper")) { + projectRoot = projectRoot.getParent(); + } + Path ks = projectRoot.resolve(".github/files/keystore.jks"); + Path ts = projectRoot.resolve(".github/files/truststore.jks"); + + Path tempDirectory = Files.createTempDirectory("get-truststore-test"); + Files.copy(ks, tempDirectory.resolve("keystore.jks")); + Files.copy(ts, tempDirectory.resolve("truststore.jks")); + + Path perClusterStores = tempDirectory.resolve(Paths.get("perClusterStores")); + Files.createDirectory(perClusterStores.toAbsolutePath()); + Files.copy(ks, perClusterStores.resolve("testCluster-keystore.jks")); + Files.copy(ts, perClusterStores .resolve("testCluster-truststore.jks")); + + ReaperApplicationConfiguration config = new ReaperApplicationConfiguration(); + config.getHttpManagement().setTruststoresDir(perClusterStores.toAbsolutePath().toString()); + + String tempTrustStore = tempDirectory.resolve("truststore.jks").toAbsolutePath().toString(); + String tempKeyStore = tempDirectory.resolve("keystore.jks").toAbsolutePath().toString(); + config.getHttpManagement().setTruststore(tempTrustStore); + config.getHttpManagement().setKeystore(tempKeyStore); + + AppContext context = mock(AppContext.class); + context.config = config; + HttpManagementConnectionFactory connectionFactory = new HttpManagementConnectionFactory(context, null); + + // if we specify a cluster name together with the seed host, Reaper will look for that cluster's trust stores + String seedHost = "testHost@testCluster"; + + Cluster clusterWithName = Cluster.builder() + .withName("testCluster") + .withSeedHosts(ImmutableSet.of(seedHost)) + .build(); + Node node = Node.builder() + .withHostname(seedHost) + .withCluster(clusterWithName) + .build(); + + Path expected = tempDirectory + .resolve("perClusterStores") + // something somewhere is doing a .lower() on the cluster name + .resolve("testcluster-truststore.jks"); + Path actual = connectionFactory.getTruststoreComponentPath(node, "truststore.jks"); + assertEquals(expected, actual); + + expected = tempDirectory.resolve("perClusterStores").resolve("testcluster-keystore.jks"); + actual = connectionFactory.getTruststoreComponentPath(node, "keystore.jks"); + assertEquals(expected, actual); + + // but if we don't provide the cluster name, reaper will we use the general one + Cluster clusterWithoutTruststore = Cluster.builder() + .withName("") + .withSeedHosts(ImmutableSet.of("testHost")) + .build(); + node = Node.builder() + .withHostname("testHost") + .withCluster(clusterWithoutTruststore) + .build(); + + expected = tempDirectory.resolve("truststore.jks"); + actual = connectionFactory.getTruststoreComponentPath(node, "truststore.jks"); + assertEquals(expected, actual); + + expected = tempDirectory.resolve("keystore.jks"); + actual = connectionFactory.getTruststoreComponentPath(node, "keystore.jks"); + assertEquals(expected, actual); } + } diff --git a/src/server/src/test/java/io/cassandrareaper/resources/ClusterResourceTest.java b/src/server/src/test/java/io/cassandrareaper/resources/ClusterResourceTest.java index df472e229..56e370c36 100644 --- a/src/server/src/test/java/io/cassandrareaper/resources/ClusterResourceTest.java +++ b/src/server/src/test/java/io/cassandrareaper/resources/ClusterResourceTest.java @@ -471,8 +471,15 @@ public void testModifyClusterSeeds() throws ReaperException { ClusterResource clusterResource = ClusterResource.create(mocks.context, mocks.cryptograph, mocks.context.storage.getEventsDao(), mocks.context.storage.getRepairRunDao()); - clusterResource.addOrUpdateCluster(mocks.uriInfo, Optional.of(SEED_HOST), Optional.of(Cluster.DEFAULT_JMX_PORT), - Optional.of(JMX_USERNAME), Optional.of(JMX_PASSWORD)); + + clusterResource.addOrUpdateCluster( + mocks.uriInfo, + Optional.of(SEED_HOST), + Optional.of(Cluster.DEFAULT_JMX_PORT), + Optional.of(JMX_USERNAME), + Optional.of(JMX_PASSWORD) + ); + doReturn(Arrays.asList(SEED_HOST + 1, SEED_HOST)).when(mocks.cassandraManagementProxy).getLiveNodes(); Response response = clusterResource @@ -495,7 +502,8 @@ public void testModifyClusterSeeds() throws ReaperException { Optional.of(SEED_HOST + 1), Optional.of(Cluster.DEFAULT_JMX_PORT), Optional.of(JMX_USERNAME), - Optional.of(JMX_PASSWORD)); + Optional.of(JMX_PASSWORD) + ); assertEquals(HttpStatus.NO_CONTENT_204, response.getStatus()); assertTrue(response.getLocation().toString().endsWith("/cluster/" + cluster.getName())); @@ -510,8 +518,14 @@ public void testModifyClusterSeedsWithClusterName() throws ReaperException { mocks.context.storage.getEventsDao(), mocks.context.storage.getRepairRunDao()); ; - clusterResource.addOrUpdateCluster(mocks.uriInfo, Optional.of(SEED_HOST), Optional.of(Cluster.DEFAULT_JMX_PORT), - Optional.of(JMX_USERNAME), Optional.of(JMX_PASSWORD)); + clusterResource.addOrUpdateCluster( + mocks.uriInfo, + Optional.of(SEED_HOST), + Optional.of(Cluster.DEFAULT_JMX_PORT), + Optional.of(JMX_USERNAME), + Optional.of(JMX_PASSWORD) + ); + doReturn(Arrays.asList(SEED_HOST + 1, SEED_HOST)).when(mocks.cassandraManagementProxy).getLiveNodes(); Response response = clusterResource.addOrUpdateCluster( @@ -565,9 +579,13 @@ public void addingAClusterAutomaticallySetupSchedulingRepairsWhenEnabled() throw mocks.context.storage.getRepairRunDao()); ; - Response response = clusterResource - .addOrUpdateCluster(mocks.uriInfo, Optional.of(SEED_HOST), Optional.of(Cluster.DEFAULT_JMX_PORT), - Optional.of(JMX_USERNAME), Optional.of(JMX_PASSWORD)); + Response response = clusterResource.addOrUpdateCluster( + mocks.uriInfo, + Optional.of(SEED_HOST), + Optional.of(Cluster.DEFAULT_JMX_PORT), + Optional.of(JMX_USERNAME), + Optional.of(JMX_PASSWORD) + ); assertEquals(HttpStatus.CREATED_201, response.getStatus()); assertEquals(1, mocks.context.storage.getRepairScheduleDao().getAllRepairSchedules().size()); @@ -599,9 +617,13 @@ public void testClusterDeleting() throws Exception { mocks.context.storage.getRepairRunDao()); ; - Response response = clusterResource - .addOrUpdateCluster(mocks.uriInfo, Optional.of(SEED_HOST), Optional.of(Cluster.DEFAULT_JMX_PORT), - Optional.of(JMX_USERNAME), Optional.of(JMX_PASSWORD)); + Response response = clusterResource.addOrUpdateCluster( + mocks.uriInfo, + Optional.of(SEED_HOST), + Optional.of(Cluster.DEFAULT_JMX_PORT), + Optional.of(JMX_USERNAME), + Optional.of(JMX_PASSWORD) + ); assertEquals(HttpStatus.CREATED_201, response.getStatus()); assertEquals(1, mocks.context.storage.getRepairScheduleDao().getAllRepairSchedules().size()); diff --git a/src/server/src/test/resources/cassandra-reaper.yaml b/src/server/src/test/resources/cassandra-reaper.yaml index 2fab62d98..59fe46015 100644 --- a/src/server/src/test/resources/cassandra-reaper.yaml +++ b/src/server/src/test/resources/cassandra-reaper.yaml @@ -114,4 +114,4 @@ cryptograph: type: symmetric systemPropertySecret: REAPER_ENCRYPTION_KEY -persistenceStoragePath: /tmp/reaper/storage/ \ No newline at end of file +persistenceStoragePath: /tmp/reaper/storage/