From f4a3b5196a255a9c6fdbefa82d5acae3ee165e50 Mon Sep 17 00:00:00 2001 From: Jelle Aret Date: Tue, 16 Sep 2025 14:19:29 +0200 Subject: [PATCH] Add support for Redis Sentinel HA Setup (#32). As Tanzu for Valkey on Cloud Foundry supports high-available marketplace offerings, out-of-the box Spring Boot property binding was missing. This change try to implement just that, supporting Sentinel setup either with or without TLS. --- .../spring/boot/RedisCfEnvProcessor.java | 66 +++++++-- .../spring/boot/RedisCfEnvProcessorTests.java | 131 ++++++++++++++++++ .../boot/test-redis-info-sentinel-tls.json | 24 ++++ .../spring/boot/test-redis-info-sentinel.json | 22 +++ 4 files changed, 235 insertions(+), 8 deletions(-) create mode 100644 java-cfenv-boot/src/test/resources/io/pivotal/cfenv/spring/boot/test-redis-info-sentinel-tls.json create mode 100644 java-cfenv-boot/src/test/resources/io/pivotal/cfenv/spring/boot/test-redis-info-sentinel.json diff --git a/java-cfenv-boot/src/main/java/io/pivotal/cfenv/spring/boot/RedisCfEnvProcessor.java b/java-cfenv-boot/src/main/java/io/pivotal/cfenv/spring/boot/RedisCfEnvProcessor.java index 22a0835..20aaf6e 100644 --- a/java-cfenv-boot/src/main/java/io/pivotal/cfenv/spring/boot/RedisCfEnvProcessor.java +++ b/java-cfenv-boot/src/main/java/io/pivotal/cfenv/spring/boot/RedisCfEnvProcessor.java @@ -15,6 +15,8 @@ */ package io.pivotal.cfenv.spring.boot; +import java.util.ArrayList; +import java.util.List; import java.util.Map; import java.util.Optional; @@ -52,15 +54,27 @@ public void process(CfCredentials cfCredentials, Map properties) String uri = cfCredentials.getUri(redisSchemes); if (uri == null) { - properties.put(PREFIX + ".host", cfCredentials.getHost()); - properties.put(PREFIX + ".password", cfCredentials.getPassword()); + if (configureSentinelSetup(cfCredentials.getMap())) { + properties.put(PREFIX + ".password", cfCredentials.getPassword()); - Optional tlsPort = Optional.ofNullable(cfCredentials.getString("tls_port")); - if (tlsPort.isPresent()) { - properties.put(PREFIX + ".port", tlsPort.get()); - properties.put(PREFIX + ".ssl.enabled", Boolean.TRUE); - } else { - properties.put(PREFIX + ".port", cfCredentials.getPort()); + // Sentinel configuration + properties.put(PREFIX + ".sentinel.master", cfCredentials.getString("master_name")); + properties.put(PREFIX + ".sentinel.nodes", extractSentinelNodes(cfCredentials.getMap())); + properties.put(PREFIX + ".sentinel.username", ""); + properties.put(PREFIX + ".sentinel.password", cfCredentials.getString("sentinel_password")); + properties.put(PREFIX + ".ssl.enabled", useSentinelTls(cfCredentials.getMap())); + } + else { + properties.put(PREFIX + ".host", cfCredentials.getHost()); + properties.put(PREFIX + ".password", cfCredentials.getPassword()); + + Optional tlsPort = Optional.ofNullable(cfCredentials.getString("tls_port")); + if (tlsPort.isPresent()) { + properties.put(PREFIX + ".port", tlsPort.get()); + properties.put(PREFIX + ".ssl.enabled", Boolean.TRUE); + } else { + properties.put(PREFIX + ".port", cfCredentials.getPort()); + } } } else { UriInfo uriInfo = new UriInfo(uri); @@ -81,4 +95,40 @@ public CfEnvProcessorProperties getProperties() { .build(); } + boolean configureSentinelSetup(Map credentialValues) { + return credentialValues.containsKey("sentinels"); + } + + boolean useSentinelTls(Map credentialValues) { + if (credentialValues.get("sentinels") instanceof List sentinels) { + for (Object o : sentinels) { + Map sentinel = (Map) o; + if (sentinel.containsKey("tls_port")) { + return true; + } + } + } + return false; + } + + private String extractSentinelNodes(Map credentialValues) { + List nodes = new ArrayList<>(); + if (credentialValues.containsKey("sentinels")) { + if (credentialValues.get("sentinels") instanceof List sentinels) { + for (Object o : sentinels) { + Map sentinel = (Map) o; + String port; + if (sentinel.containsKey("tls_port")) { + port = sentinel.get("tls_port").toString(); + } + else { + port = sentinel.get("port").toString(); + } + + nodes.add(sentinel.get("host") + ":" + port); + } + } + } + return String.join(",", nodes); + } } diff --git a/java-cfenv-boot/src/test/java/io/pivotal/cfenv/spring/boot/RedisCfEnvProcessorTests.java b/java-cfenv-boot/src/test/java/io/pivotal/cfenv/spring/boot/RedisCfEnvProcessorTests.java index 3ea3edc..02f14b8 100644 --- a/java-cfenv-boot/src/test/java/io/pivotal/cfenv/spring/boot/RedisCfEnvProcessorTests.java +++ b/java-cfenv-boot/src/test/java/io/pivotal/cfenv/spring/boot/RedisCfEnvProcessorTests.java @@ -15,6 +15,9 @@ */ package io.pivotal.cfenv.spring.boot; +import java.util.ArrayList; +import java.util.List; + import org.junit.Test; import org.springframework.core.env.Environment; @@ -83,6 +86,56 @@ public void testNoCredentials() { assertThat(environment.getProperty(SPRING_DATA_REDIS + ".ssl")).isNull(); } + @Test + public void testRedisBootPropertiesSentinelSetup() { + String payload = new RedisFileSentinelPayloadBuilder("test-redis-info-sentinel.json") + .withServiceName("redis-ha-1") + .withName("redis-db") + .withPassword("password") + .withRedisPort(port) + .withSentinelMaster("my-master") + .withSentinelPassword("sentinel-password") + .withSentinel("sent1", 26379) + .withSentinel("sent2", 26380) + .payload(); + + mockVcapServices(getServicesPayload(payload)); + + Environment environment = getEnvironment(); + + assertThat(environment.getProperty(SPRING_DATA_REDIS + ".password")).isEqualTo("password"); + assertThat(environment.getProperty(SPRING_DATA_REDIS + ".sentinel.master")).isEqualTo("my-master"); + assertThat(environment.getProperty(SPRING_DATA_REDIS + ".sentinel.nodes")).isEqualTo("sent1:26379,sent2:26380"); + assertThat(environment.getProperty(SPRING_DATA_REDIS + ".sentinel.username")).isEqualTo(""); + assertThat(environment.getProperty(SPRING_DATA_REDIS + ".sentinel.password")).isEqualTo("sentinel-password"); + assertThat(environment.getProperty(SPRING_DATA_REDIS + ".ssl.enabled")).isEqualTo("false"); + } + + @Test + public void testRedisBootPropertiesSentinelTlsSetup() { + String payload = new RedisFileSentinelPayloadBuilder("test-redis-info-sentinel-tls.json") + .withServiceName("redis-ha-1") + .withName("redis-db") + .withPassword("password") + .withRedisPort(port) + .withSentinelMaster("my-master") + .withSentinelPassword("sentinel-password") + .withSentinelTls("sent1", 26379, 26380) + .withSentinelTls("sent2", 26380, 26381) + .payload(); + + mockVcapServices(getServicesPayload(payload)); + + Environment environment = getEnvironment(); + + assertThat(environment.getProperty(SPRING_DATA_REDIS + ".password")).isEqualTo("password"); + assertThat(environment.getProperty(SPRING_DATA_REDIS + ".sentinel.master")).isEqualTo("my-master"); + assertThat(environment.getProperty(SPRING_DATA_REDIS + ".sentinel.nodes")).isEqualTo("sent1:26380,sent2:26381"); + assertThat(environment.getProperty(SPRING_DATA_REDIS + ".sentinel.username")).isEqualTo(""); + assertThat(environment.getProperty(SPRING_DATA_REDIS + ".sentinel.password")).isEqualTo("sentinel-password"); + assertThat(environment.getProperty(SPRING_DATA_REDIS + ".ssl.enabled")).isEqualTo("true"); + } + @Test public void testGetProperties() { assertThat(new RedisCfEnvProcessor().getProperties().getPropertyPrefixes()).isEqualTo(SPRING_DATA_REDIS); @@ -149,6 +202,84 @@ String payload() { .replace("$tls_port", String.valueOf(tlsPort)) .replace("$name", name); } + } + + private class RedisFileSentinelPayloadBuilder { + + private String payload; + private String serviceName; + private String name; + private Integer redisPort; + private String password; + private String sentinelPassword; + private String sentinelMaster; + private List sentinels; + + RedisFileSentinelPayloadBuilder(String filename) { + this.payload = readTestDataFile(filename); + this.sentinels = new ArrayList<>(); + } + + RedisFileSentinelPayloadBuilder withServiceName(String serviceName) { + this.serviceName = serviceName; + return this; + } + + RedisFileSentinelPayloadBuilder withName(String name) { + this.name = name; + return this; + } + + RedisFileSentinelPayloadBuilder withRedisPort(int redisPort) { + this.redisPort = redisPort; + return this; + } + + RedisFileSentinelPayloadBuilder withPassword(String password) { + this.password = password; + return this; + } + RedisFileSentinelPayloadBuilder withSentinelPassword(String sentinelPassword) { + this.sentinelPassword = sentinelPassword; + return this; + } + + RedisFileSentinelPayloadBuilder withSentinel(String host, int port) { + return withSentinelTls(host, port, -1); + } + + RedisFileSentinelPayloadBuilder withSentinelTls(String host, int port, int tlsPort) { + this.sentinels.add(new SentinelDefinition(host, port, tlsPort)); + return this; + } + + RedisFileSentinelPayloadBuilder withSentinelMaster(String sentinelMaster) { + this.sentinelMaster = sentinelMaster; + return this; + } + + String payload() { + var result = payload.replace("$serviceName", serviceName) + .replace("$redisPort", String.valueOf(redisPort)) + .replace("$redisPassword", password) + .replace("$sentinelPassword", sentinelPassword) + .replace("$sentinelMaster", sentinelMaster) + .replace("$name", name); + for (int i = 0; i < this.sentinels.size(); i++) { + var sentinelDefinition = this.sentinels.get(i); + + var hostKey = "$sentinel" + (i+1) + "-hostname"; + var portKey = "$sentinel" + (i+1) + "-port"; + var tlsPortKey = "$sentinel" + (i+1) + "-tlsPort"; + result = result.replace(hostKey, sentinelDefinition.host); + result = result.replace(portKey, Integer.toString(sentinelDefinition.port)); + result = result.replace(tlsPortKey, Integer.toString(sentinelDefinition.tlsPort)); + } + + return result; + } } + + record SentinelDefinition(String host, int port, int tlsPort) { } } diff --git a/java-cfenv-boot/src/test/resources/io/pivotal/cfenv/spring/boot/test-redis-info-sentinel-tls.json b/java-cfenv-boot/src/test/resources/io/pivotal/cfenv/spring/boot/test-redis-info-sentinel-tls.json new file mode 100644 index 0000000..c4e4670 --- /dev/null +++ b/java-cfenv-boot/src/test/resources/io/pivotal/cfenv/spring/boot/test-redis-info-sentinel-tls.json @@ -0,0 +1,24 @@ +{ + "name": "$serviceName", + "label": "rediscloud", + "plan": "ha", + "tags": [ "redis", "key-value" ], + "credentials": { + "master_name": "$sentinelMaster", + "password": "$redisPassword", + "port": "$redisPort", + "sentinel_password": "$sentinelPassword", + "sentinels": [ + { + "host": "$sentinel1-hostname", + "port": "$sentinel1-port", + "tls_port": "$sentinel1-tlsPort" + }, + { + "host": "$sentinel2-hostname", + "port": "$sentinel2-port", + "tls_port": "$sentinel2-tlsPort" + } + ] + } +} \ No newline at end of file diff --git a/java-cfenv-boot/src/test/resources/io/pivotal/cfenv/spring/boot/test-redis-info-sentinel.json b/java-cfenv-boot/src/test/resources/io/pivotal/cfenv/spring/boot/test-redis-info-sentinel.json new file mode 100644 index 0000000..1498cb4 --- /dev/null +++ b/java-cfenv-boot/src/test/resources/io/pivotal/cfenv/spring/boot/test-redis-info-sentinel.json @@ -0,0 +1,22 @@ +{ + "name": "$serviceName", + "label": "rediscloud", + "plan": "ha", + "tags": [ "redis", "key-value" ], + "credentials": { + "master_name": "$sentinelMaster", + "password": "$redisPassword", + "port": "$redisPort", + "sentinel_password": "$sentinelPassword", + "sentinels": [ + { + "host": "$sentinel1-hostname", + "port": "$sentinel1-port" + }, + { + "host": "$sentinel2-hostname", + "port": "$sentinel2-port" + } + ] + } +} \ No newline at end of file