diff --git a/glide-samples/spring-framework-glide-example-cluster-mode-with-iam/README.md b/glide-samples/spring-framework-glide-example-cluster-mode-with-iam/README.md new file mode 100644 index 0000000..61524f3 --- /dev/null +++ b/glide-samples/spring-framework-glide-example-cluster-mode-with-iam/README.md @@ -0,0 +1,72 @@ +# Use Valkey GLIDE with Spring Boot - ElastiCache Cluster Mode Enabled and IAM Auth + +[Valkey Glide](https://github.com/valkey-io/valkey-glide) is the official open-source Valkey client library, proudly part of the Valkey organization. + +[Spring Boot](https://docs.spring.io/spring-boot/index.html) helps you to create stand-alone, production-grade Spring-based applications that you can run. Spring Boot takes an opinionated view of the Spring platform and third-party libraries, so that you can get started with minimum fuss. It contains a [caching](https://docs.spring.io/spring-boot/reference/io/caching.html) component, making implementation of caching in your application quick and simple. + +This demo will show you a simple way to implement the GLIDE client with Spring Boot caching. The target ElastiCache cluster will have cluster mode enabled and use IAM Authentication. + +## Prerequesites + +To build the application, you must have the following prerequisites. + +Some compute with the following installed: +- *Java 17* - To install the Java Development Kit (JDK) 17, run `sudo yum install -y java-17-amazon-corretto-devel` on your EC2 instance +- *Maven* - To install Apache Maven, run `sudo yum install -y maven` on your EC2 instance + +The compute must have access to Redis OSS or Valkey ElastiCache cluster with IAM Authentication configured. + +## Running + +To run the demo application, update `application.properties` to point to Redis or Valkey. Also update the `username`, `cachename` and `region` for your ElastiCache cluster. + +`spring.data.valkey.host=cache1-XXXXX.serverless.euw2.cache.amazonaws.com` + +Run `mvn spring-boot:run` in the root folder. + +See the results! + +## How it works + +To use Valkey Glide, we add 3 classes to the application: + +`SimpleValkeyGlideCache.java`. This class implements the Spring Framework Cache interface. It is simple in nature - all cache key objects must have the `toString()` method, and all cached objects must implement `java.lang.Serializable`. This class adapts Spring Framework to use the GLIDE client. + +`ValkeyGlideCacheManager.java`. This class implements Spring Framework `org.springframework.cache.CacheManager` interface and instantiates a Bean Component. On startup, Spring Framework looks for a Bean implementing this interface and uses it to create and manage access to caches at runtime. This class uses `SimpleValkeyGlideCache` for caches. + +`ElastiCachePasswordGenerator.java`. This class creates passwords for the ElastiCache cluster using IAM. + +The Maven POM file contains three dependencies: + +``` + + org.springframework.boot + spring-boot-starter-cache + + + io.valkey + valkey-glide + ${os.detected.classifier} + [1.0.0,2.0.0) + + + software.amazon.awssdk + auth + 2.32.2 + +``` + +`spring-boot-starter-cache` tells Spring Framework to implement caching. + +`valkey-glide` includes the Valkey GLIDE client in your application. See the GLIDE [client documentation](https://valkey.io/clients/) for more details. + +`software.amazon.awssdk.auth` provides utility classes for password creation using IAM. + +## Want to try this with your existing Spring Boot application? + +1. Remove any existing Caching providers from your dependencies (i.e. `spring-boot-starter-cache-redis`). +2. Add `SimpleValkeyGlideCache.java`, `ElastiCachePasswordGenerator.java` and `ValkeyGlideCacheManager.java` to your application. +3. Modify any entries in your `application.properties` that configure Redis, changing them to valkey (i.e. `spring.data.redis.host` becomes `spring.data.valkey.host`). +4. add `spring.data.valkey.username`, `spring.data.valkey.cacheName` and `spring.data.valkey.region` to `application.properties` with relevant values. + +Give your application a try! \ No newline at end of file diff --git a/glide-samples/spring-framework-glide-example-cluster-mode-with-iam/pom.xml b/glide-samples/spring-framework-glide-example-cluster-mode-with-iam/pom.xml new file mode 100644 index 0000000..3c89a1b --- /dev/null +++ b/glide-samples/spring-framework-glide-example-cluster-mode-with-iam/pom.xml @@ -0,0 +1,52 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.3.3 + + + com.example + elasticache-demo + 0.0.1-SNAPSHOT + elasticache-demo + Demo project for Spring Boot with Elasticache + + 17 + + + + org.springframework.boot + spring-boot-starter-cache + + + io.valkey + valkey-glide + ${os.detected.classifier} + [1.0.0,2.0.0) + + + software.amazon.awssdk + auth + 2.32.2 + + + + + + + kr.motd.maven + os-maven-plugin + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + diff --git a/glide-samples/spring-framework-glide-example-cluster-mode-with-iam/src/main/java/com/example/elasticache_demo/AppRunner.java b/glide-samples/spring-framework-glide-example-cluster-mode-with-iam/src/main/java/com/example/elasticache_demo/AppRunner.java new file mode 100644 index 0000000..b3787a1 --- /dev/null +++ b/glide-samples/spring-framework-glide-example-cluster-mode-with-iam/src/main/java/com/example/elasticache_demo/AppRunner.java @@ -0,0 +1,28 @@ +package com.example.elasticache_demo; + +import org.springframework.boot.CommandLineRunner; +import org.springframework.stereotype.Component; + +@Component +public class AppRunner implements CommandLineRunner { + + private static final long TEN_MINUTES = 60 * 10 * 1000; + private static final long ONE_HOUR_MILLIS = TEN_MINUTES * 6; + private static final long TWENTY_HOUR_MILLIS = ONE_HOUR_MILLIS * 20; + + private CacheableComponent cacheableComponent; + + public AppRunner(CacheableComponent cacheableComponent) { + this.cacheableComponent = cacheableComponent; + } + + @Override + public void run(String... args) throws Exception { + long started = System.currentTimeMillis(); + while ( System.currentTimeMillis() < started + TWENTY_HOUR_MILLIS ) { + String newValue = cacheableComponent.getCacheableValue("test-key" + System.currentTimeMillis()); + System.out.println("READ " + newValue); + Thread.sleep(TEN_MINUTES); + } + } +} \ No newline at end of file diff --git a/glide-samples/spring-framework-glide-example-cluster-mode-with-iam/src/main/java/com/example/elasticache_demo/CacheableComponent.java b/glide-samples/spring-framework-glide-example-cluster-mode-with-iam/src/main/java/com/example/elasticache_demo/CacheableComponent.java new file mode 100644 index 0000000..9811cf3 --- /dev/null +++ b/glide-samples/spring-framework-glide-example-cluster-mode-with-iam/src/main/java/com/example/elasticache_demo/CacheableComponent.java @@ -0,0 +1,13 @@ +package com.example.elasticache_demo; + +import org.springframework.stereotype.Component; +import org.springframework.cache.annotation.Cacheable; + +@Component +public class CacheableComponent { + + @Cacheable("test") + public String getCacheableValue(String key) throws Exception { + return key + System.currentTimeMillis(); + } +} diff --git a/glide-samples/spring-framework-glide-example-cluster-mode-with-iam/src/main/java/com/example/elasticache_demo/ElasticacheDemoApplication.java b/glide-samples/spring-framework-glide-example-cluster-mode-with-iam/src/main/java/com/example/elasticache_demo/ElasticacheDemoApplication.java new file mode 100644 index 0000000..9396011 --- /dev/null +++ b/glide-samples/spring-framework-glide-example-cluster-mode-with-iam/src/main/java/com/example/elasticache_demo/ElasticacheDemoApplication.java @@ -0,0 +1,16 @@ +package com.example.elasticache_demo; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.beans.factory.annotation.Autowired; + +@SpringBootApplication +@EnableCaching +public class ElasticacheDemoApplication { + + public static void main(String[] args) { + SpringApplication.run(ElasticacheDemoApplication.class, args); + } + +} diff --git a/glide-samples/spring-framework-glide-example-cluster-mode-with-iam/src/main/java/com/example/elasticache_demo/ElasticachePasswordGenerator.java b/glide-samples/spring-framework-glide-example-cluster-mode-with-iam/src/main/java/com/example/elasticache_demo/ElasticachePasswordGenerator.java new file mode 100644 index 0000000..c4da4b7 --- /dev/null +++ b/glide-samples/spring-framework-glide-example-cluster-mode-with-iam/src/main/java/com/example/elasticache_demo/ElasticachePasswordGenerator.java @@ -0,0 +1,83 @@ +package com.example.elasticache_demo; + +import java.net.URI; +import java.time.Duration; +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; +import software.amazon.awssdk.http.SdkHttpMethod; +import software.amazon.awssdk.http.SdkHttpRequest; +import software.amazon.awssdk.http.auth.aws.signer.AwsV4FamilyHttpSigner.AuthLocation; +import software.amazon.awssdk.http.auth.aws.signer.AwsV4HttpSigner; +import software.amazon.awssdk.utils.StringUtils; + +// example class from https://github.com/valkey-io/valkey-glide/wiki/Java-Wrapper#example---using-iam-authentication-with-glide-for-elasticache-and-memorydb +class ElasticachePasswordGenerator { + private final AwsV4HttpSigner awsV4HttpSigner; + private final AwsCredentialsProvider credentialsProvider; + + private final String cacheName; + private final String cacheRegion; + private final String userId; + private final boolean isServerless; + + private static final String FAKE_SCHEME = "https://"; + + ElasticachePasswordGenerator( + final String cacheName, + final String cacheRegion, + final String userId, + final AwsV4HttpSigner awsV4HttpSigner, + final AwsCredentialsProvider credentialsProvider, + final boolean isServerless) { + + this.cacheName = cacheName; + this.cacheRegion = cacheRegion; + this.userId = userId; + this.awsV4HttpSigner = awsV4HttpSigner; + this.credentialsProvider = credentialsProvider; + this.isServerless = isServerless; + } + + public static ElasticachePasswordGenerator create( + final String cacheName, final String cacheRegion, final String userId, final boolean isServerless) { + if (StringUtils.isEmpty(cacheName)) { + throw new IllegalArgumentException("cacheName must be provided"); + } + + if (StringUtils.isEmpty(cacheRegion)) { + throw new IllegalArgumentException("cacheRegion must be provided"); + } + + if (StringUtils.isEmpty(userId)) { + throw new IllegalArgumentException("userId must be provided"); + } + + return new ElasticachePasswordGenerator( + cacheName, cacheRegion, userId, AwsV4HttpSigner.create(), DefaultCredentialsProvider.create(), isServerless); + } + + public String generatePassword() { + final var requestUri = URI.create(String.format("%s%s/", FAKE_SCHEME, cacheName)); + final var requestBuilder = SdkHttpRequest.builder() + .method(SdkHttpMethod.GET) + .uri(requestUri) + .appendRawQueryParameter("Action", "connect") + .appendRawQueryParameter("User", userId); + if (this.isServerless) { + requestBuilder.appendRawQueryParameter("ResourceType", "ServerlessCache"); + } + + final var cacheRequest = requestBuilder.build(); + + final var signedRequest = awsV4HttpSigner.sign(signRequest -> signRequest + .request(cacheRequest) + .identity(credentialsProvider.resolveCredentials()) + .putProperty(AwsV4HttpSigner.EXPIRATION_DURATION, Duration.ofMinutes(15)) + .putProperty(AwsV4HttpSigner.SERVICE_SIGNING_NAME, "elasticache") + .putProperty(AwsV4HttpSigner.REGION_NAME, cacheRegion) + .putProperty(AwsV4HttpSigner.AUTH_LOCATION, AuthLocation.QUERY_STRING)); + + String password = signedRequest.request().getUri().toString().replace(FAKE_SCHEME, ""); + return password; + } +} \ No newline at end of file diff --git a/glide-samples/spring-framework-glide-example-cluster-mode-with-iam/src/main/java/com/example/elasticache_demo/SimpleValkeyGlideCache.java b/glide-samples/spring-framework-glide-example-cluster-mode-with-iam/src/main/java/com/example/elasticache_demo/SimpleValkeyGlideCache.java new file mode 100644 index 0000000..3fda995 --- /dev/null +++ b/glide-samples/spring-framework-glide-example-cluster-mode-with-iam/src/main/java/com/example/elasticache_demo/SimpleValkeyGlideCache.java @@ -0,0 +1,219 @@ +package com.example.elasticache_demo; + +import org.springframework.cache.Cache; +import org.springframework.cache.support.SimpleValueWrapper; +import glide.api.GlideClusterClient; +import glide.api.models.configuration.NodeAddress; +import glide.api.models.configuration.GlideClusterClientConfiguration; +import glide.api.models.commands.SetOptions; +import glide.api.models.commands.SetOptions.SetOptionsBuilder; +import glide.api.models.commands.scan.ScanOptions; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import glide.api.models.commands.scan.ClusterScanCursor; + +import java.io.*; + +import java.util.Base64; +import java.time.Duration; + +import glide.api.models.GlideString; +import static glide.api.models.GlideString.gs; + +/* + * A simple Valkey Glide implementation to be able to handle any cache entry, as long as the key has a + * toString() method, and the value implements java.io.Serializable + */ +public class SimpleValkeyGlideCache implements Cache { + + private String name; + private Duration ttl; + private GlideClusterClient client; + + public SimpleValkeyGlideCache(String name, GlideClusterClientConfiguration config, Duration ttl) { + try { + this.name = name; + this.ttl = ttl; + this.client = GlideClusterClient.createClient(config).get(); + } catch (Exception e) { + throw new RuntimeException("Failed to create cache " + name, e); + } + } + + public void updatePassword(String password, boolean immediate) { + this.client.updateConnectionPassword(password, immediate); + } + + private String buildKey(Object key) { + // prefix the cache name to the key in order to avoid clashes + return name + "::" + key.toString(); + } + + // this works for objects that implement java.io.Serializable + private String writeValue(Object value) throws java.io.IOException { + if (value == null) { + return ""; + } + + ByteArrayOutputStream serialObj = new ByteArrayOutputStream(); + ObjectOutputStream objStream = new ObjectOutputStream(serialObj); + objStream.writeObject(value); + objStream.close(); + + return Base64.getEncoder().encodeToString(serialObj.toByteArray()); + } + + // this works for objects that implement java.io.Serializable + private Object readValue(String value) throws java.io.IOException, java.lang.ClassNotFoundException { + if (value == null || value.length() == 0) return null; + + ByteArrayInputStream serialObj = new ByteArrayInputStream(Base64.getDecoder().decode((value))); + ObjectInputStream objStream = new ObjectInputStream(serialObj); + + return objStream.readObject(); + } + + @Override + public String getName() { + return this.name; + } + + @Override + public Object getNativeCache() { + return this.client; + } + + @Override + public ValueWrapper get(Object key) { + if (key == null) { + return null; + } + + try { + Object value = readValue(client.get(buildKey(key)).get()); + return (value != null ? new SimpleValueWrapper(value) : null); + } catch (Exception e) { + throw new RuntimeException("Failed to get value from cache", e); + } + } + + @Override + public T get(Object key, Class type) { + ValueWrapper wrapper = get(key); + if (wrapper == null) { + return null; + } + + Object value = wrapper.get(); + if (value != null && type != null && !type.isInstance(value)) { + throw new IllegalStateException("Cached value is not of required type [" + type.getName() + "]: " + value); + } + return (T) value; + } + + @Override + public T get(Object key, Callable valueLoader) { + ValueWrapper wrapper = get(key); + if (wrapper != null) { + return (T) wrapper.get(); + } + + T value; + try { + value = valueLoader.call(); + } catch (Exception ex) { + throw new ValueRetrievalException(key, valueLoader, ex); + } + + put(key, value); + return value; + } + + @Override + public void put(Object key, Object value) { + putWithOptions(key, value, SetOptions.builder()); + } + + @Override + public Cache.ValueWrapper putIfAbsent(Object key, Object value) { + ValueWrapper existingValue = get(key); + if (existingValue == null) { + putWithOptions(key, value, SetOptions.builder().conditionalSet(SetOptions.ConditionalSet.ONLY_IF_DOES_NOT_EXIST)); + } + return existingValue; + } + + private void putWithOptions(Object key, Object value, SetOptionsBuilder options) { + if ( key == null ) { + return; + } + + try { + if ( this.ttl != null ) { + options.expiry(SetOptions.Expiry.Milliseconds(System.currentTimeMillis() + ttl.toMillis())); + } + + client.set(buildKey(key), writeValue(value), options.build()).get(); + } catch (Exception e) { + throw new RuntimeException("Failed to put value in cache", e); + } + } + + @Override + public void evict(Object key) { + evictIfPresent(buildKey(key)); + } + + @Override + public boolean evictIfPresent(Object key) { + if ( key != null ) { + try { + return !client.getdel(buildKey(key)).get().equals(null); + } catch (Exception e) { + throw new RuntimeException("Failed to evict key from cache", e); + } + } + return false; + } + + @Override + public void clear() { + try { + // we could scan and unlink, but for simplicity just call invalidate + invalidate(); + } catch (Exception e) { + throw new RuntimeException("Failed to clear cache", e); + } + } + + @Override + public boolean invalidate() { + long count = 0; + + try { + ClusterScanCursor cursor = ClusterScanCursor.initalCursor(); + while (!cursor.isFinished()) { + final Object[] response = client.scan( + cursor, + ScanOptions.builder() + .matchPattern(name + "::*") + .type(ScanOptions.ObjectType.STRING) + .build() + ).get(); + cursor.releaseCursorHandle(); + + cursor = (ClusterScanCursor) response[0]; + final Object[] keys = (Object[]) response[1]; + String[] stringKeys = java.util.Arrays.stream(keys).map(obj -> obj.toString()).collect(java.util.stream.Collectors.joining(", ")).split(", "); + count += stringKeys.length; + + client.del(stringKeys).get(); + } + cursor.releaseCursorHandle(); + } catch (Exception e) { + throw new RuntimeException("Failed to invalidate cache", e); + } + + return false; + } +} \ No newline at end of file diff --git a/glide-samples/spring-framework-glide-example-cluster-mode-with-iam/src/main/java/com/example/elasticache_demo/ValkeyGlideCacheManager.java b/glide-samples/spring-framework-glide-example-cluster-mode-with-iam/src/main/java/com/example/elasticache_demo/ValkeyGlideCacheManager.java new file mode 100644 index 0000000..e45f25d --- /dev/null +++ b/glide-samples/spring-framework-glide-example-cluster-mode-with-iam/src/main/java/com/example/elasticache_demo/ValkeyGlideCacheManager.java @@ -0,0 +1,95 @@ +package com.example.elasticache_demo; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.cache.CacheManager; +import org.springframework.cache.Cache; + +import org.springframework.boot.convert.DurationStyle; +import org.springframework.util.StringUtils; + +import glide.api.models.configuration.ServerCredentials; +import glide.api.models.configuration.NodeAddress; +import glide.api.models.configuration.GlideClusterClientConfiguration; + +import java.util.Map; +import java.util.HashMap; +import java.util.Collection; +import java.time.Duration; + +@Component +public class ValkeyGlideCacheManager implements CacheManager { + + @Value( "${spring.cache.valkey.time-to-live:}" ) + private String timeToLive; + + @Value( "${spring.data.valkey.ssl.enabled:false}" ) + private Boolean useTLS; + + @Value( "${spring.data.valkey.host:127.0.0.1}" ) + private String host; + + @Value( "${spring.cache.valkey.port:6379}" ) + private Integer port; + + @Value("${spring.data.valkey.username}") + private String username; + + @Value("${spring.data.valkey.cacheName}") + private String cacheName; + + @Value("${spring.data.valkey.region}") + private String region; + + private Map caches = new HashMap(); + private Map cacheReauthenticated = new HashMap(); + + private GlideClusterClientConfiguration config; + private Duration ttlDuration; + private ElasticachePasswordGenerator generator; + + private static final long TEN_HOURS = 60 * 60 * 10 * 1000; + + public void setCacheConfiguration(GlideClusterClientConfiguration config) { + this.config = config; + } + + public Cache getCache(String name) { + if (!caches.containsKey(name)) { + if( generator == null ) { + generator = ElasticachePasswordGenerator.create(cacheName, region, username, false); + } + + if (config == null) { + ServerCredentials credentials = ServerCredentials.builder() + .username(username) + .password(generator.generatePassword()) + .build(); + + config = GlideClusterClientConfiguration.builder() + .address(NodeAddress.builder().host(host).port(port).build()) + .useTLS(useTLS) + .credentials(credentials) + .build(); + + if (ttlDuration == null && !StringUtils.isEmpty(timeToLive)) { + ttlDuration = DurationStyle.detectAndParse(timeToLive); + } + } + + caches.put(name, new SimpleValkeyGlideCache(name, config, ttlDuration)); + cacheReauthenticated.put(name, System.currentTimeMillis()); + } + + if(cacheReauthenticated.get(name) > System.currentTimeMillis() + TEN_HOURS) { + caches.get(name).updatePassword(generator.generatePassword(), true); + cacheReauthenticated.put(name, System.currentTimeMillis()); + } + + return caches.get(name); + } + + public Collection getCacheNames() { + return caches.keySet(); + } +} \ No newline at end of file diff --git a/glide-samples/spring-framework-glide-example-cluster-mode-with-iam/src/main/resources/application.properties b/glide-samples/spring-framework-glide-example-cluster-mode-with-iam/src/main/resources/application.properties new file mode 100644 index 0000000..8e3d424 --- /dev/null +++ b/glide-samples/spring-framework-glide-example-cluster-mode-with-iam/src/main/resources/application.properties @@ -0,0 +1,7 @@ +spring.data.valkey.ssl.enabled=false +spring.data.valkey.host= +spring.cache.valkey.time-to-live=10m + +spring.data.valkey.username= +spring.data.valkey.cacheName= +spring.data.valkey.region= \ No newline at end of file