diff --git a/src/Aspire.Hosting.Redis/AssemblyInfo.cs b/src/Aspire.Hosting.Redis/AssemblyInfo.cs new file mode 100644 index 00000000000..ec4567dd0d3 --- /dev/null +++ b/src/Aspire.Hosting.Redis/AssemblyInfo.cs @@ -0,0 +1,6 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Aspire.Hosting.Redis.Tests, PublicKey=00240000048000009400000006020000002400005253413100040000010001004b86c4cb78549b34bab61a3b1800e23bfeb5b3ec390074041536a7e3cbd97f5f04cf0f857155a8928eaa29ebfd11cfbbad3ba70efea7bda3226c6a8d370a4cd303f714486b6ebc225985a638471e6ef571cc92a4613c00b8fa65d61ccee0cbe5f36330c9a01f4183559f1bef24cc2917c6d913e3a541333a1d05d9bed22b38cb")] diff --git a/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs b/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs index 8d8c47dc0e8..3f215cbbded 100644 --- a/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs +++ b/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs @@ -128,6 +128,15 @@ public static IResourceBuilder AddRedis( additionalArgs.Add(persistenceAnnotation.KeysChangedThreshold.ToString(CultureInfo.InvariantCulture)); } + if (redis.TryGetAnnotationsOfType(out var moduleAnnotations)) + { + foreach (var moduleAnnotation in moduleAnnotations) + { + additionalArgs.Add("--loadmodule"); + additionalArgs.Add(moduleAnnotation.Path); + } + } + // This is a temporary workaround to allow the args list to be expanded dynamically at run time with additional server certificate arguments. if (context.ExecutionContext.IsRunMode) { @@ -520,6 +529,78 @@ private sealed class PersistenceAnnotation(TimeSpan? interval, long keysChangedT public long KeysChangedThreshold => keysChangedThreshold; } + /// + /// Configures the Redis resource to use the specified native Redis module. + /// + /// The resource builder. + /// The well-known, pre-installed Redis module to load, or the path to the Redis module .so file on the container. + /// The . + /// The resource builder. + [AspireExport] + internal static IResourceBuilder WithModule(this IResourceBuilder builder, [AspireUnion(typeof(RedisNativeModule), typeof(string))] object module) + { + ArgumentNullException.ThrowIfNull(builder); + + return module switch + { + RedisNativeModule nativeModule => builder.WithModule(nativeModule), + string path => builder.WithModule(path), + null => throw new ArgumentNullException(nameof(module)), + _ => throw new ArgumentException($"Unsupported Redis module type '{module.GetType()}'.", nameof(module)), + }; + } + + /// + /// Configures the Redis resource to use the specified native Redis module. + /// + /// The resource builder. + /// The well-known, pre-installed Redis module to load. + /// The . + [AspireExportIgnore(Reason = "Polyglot app hosts use the canonical withModule export with a RedisNativeModule|string union.")] + public static IResourceBuilder WithModule(this IResourceBuilder builder, RedisNativeModule nativeModule) + { + ArgumentNullException.ThrowIfNull(builder); + + return builder.WithAnnotation(new RedisModuleAnnotation(GetModulePath(nativeModule))); + + static string GetModulePath(RedisNativeModule nativeModule) => nativeModule switch + { + RedisNativeModule.Search => "/usr/local/lib/redis/modules/redisearch.so", + RedisNativeModule.Json => "/usr/local/lib/redis/modules/rejson.so", + RedisNativeModule.BloomFilter => "/usr/local/lib/redis/modules/redisbloom.so", + RedisNativeModule.TimeSeries => "/usr/local/lib/redis/modules/redistimeseries.so", + _ => throw new NotSupportedException($"The Redis native module '{nativeModule}' is not supported."), + }; + } + + /// + /// Configures the Redis resource to use the specified Redis module by providing the path to the module's .so file on the container. + /// + /// The resource builder. + /// The path to the Redis module .so file on the container. This should be an absolute path. + /// The . + [AspireExportIgnore(Reason = "Polyglot app hosts use the canonical withModule export with a RedisNativeModule|string union.")] + public static IResourceBuilder WithModule(this IResourceBuilder builder, string path) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(path); + if (path[0] is not '/') + { + throw new ArgumentException("The Redis module path must be an absolute container path.", nameof(path)); + } + + if (!path.EndsWith(".so", StringComparison.Ordinal)) + { + throw new ArgumentException("The Redis module path must point to a .so file.", nameof(path)); + } + + return builder.WithAnnotation(new RedisModuleAnnotation(path)); + } + + private record RedisModuleAnnotation( + string Path + ) : IResourceAnnotation; + /// /// Adds a named volume for the data folder to a Redis Insight container resource. /// diff --git a/src/Aspire.Hosting.Redis/RedisNativeModule.cs b/src/Aspire.Hosting.Redis/RedisNativeModule.cs new file mode 100644 index 00000000000..c12eeb62276 --- /dev/null +++ b/src/Aspire.Hosting.Redis/RedisNativeModule.cs @@ -0,0 +1,33 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting; + +/// +/// Well-known Redis modules that are included in the Redis container image from version 8 onward. +/// +/// +/// See https://redis.io/blog/redis-8-ga/. +/// +public enum RedisNativeModule +{ + /// + /// Redis JSON module for storing, updating, and querying JSON documents in Redis. + /// + Json, + + /// + /// Redis Search module for secondary indexing and querying of data stored in Redis. + /// + Search, + + /// + /// Redis Bloom Filter module for probabilistic data structures including Bloom filters, Cuckoo filters, Count-Min Sketches, and Top-K filters. + /// + BloomFilter, + + /// + /// Redis TimeSeries module for efficient storage and querying of time series data in Redis. + /// + TimeSeries, +} diff --git a/src/Aspire.Hosting.RemoteHost/AtsCapabilityScanner.cs b/src/Aspire.Hosting.RemoteHost/AtsCapabilityScanner.cs index 55fc81446c9..bc0cf018c82 100644 --- a/src/Aspire.Hosting.RemoteHost/AtsCapabilityScanner.cs +++ b/src/Aspire.Hosting.RemoteHost/AtsCapabilityScanner.cs @@ -616,6 +616,14 @@ private static void CollectEnumClrTypes(AtsTypeRef? typeRef, Dictionary diff --git a/tests/Aspire.Hosting.Redis.Tests/AddRedisTests.cs b/tests/Aspire.Hosting.Redis.Tests/AddRedisTests.cs index c057d678283..e9693a748cb 100644 --- a/tests/Aspire.Hosting.Redis.Tests/AddRedisTests.cs +++ b/tests/Aspire.Hosting.Redis.Tests/AddRedisTests.cs @@ -656,6 +656,43 @@ public async Task WithPersistenceReplacesPreviousAnnotationInstances() Assert.DoesNotContain("--save", args.Substring(saveIndex + 1)); } + [Fact] + public async Task WithModuleAddsCommandLineArgsForNativeModule() + { + using var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(testOutputHelper); + var redis = builder.AddRedis("myRedis") + .WithModule(RedisNativeModule.Json); + + var args = await GetCommandLineArgs(redis); + + Assert.Contains("--loadmodule /usr/local/lib/redis/modules/rejson.so", args); + } + + [Fact] + public async Task WithModuleAddsCommandLineArgsForModulePath() + { + using var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(testOutputHelper); + var redis = builder.AddRedis("myRedis") + .WithModule("/opt/redis/custom-module.so"); + + var args = await GetCommandLineArgs(redis); + + Assert.Contains("--loadmodule /opt/redis/custom-module.so", args); + } + + [Fact] + public async Task WithModuleAddsCommandLineArgsForMultipleModules() + { + using var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(testOutputHelper); + var redis = builder.AddRedis("myRedis") + .WithModule(RedisNativeModule.Search) + .WithModule("/opt/redis/custom-module.so"); + + var args = await GetCommandLineArgs(redis); + + Assert.Contains("--loadmodule /usr/local/lib/redis/modules/redisearch.so --loadmodule /opt/redis/custom-module.so", args); + } + private static async Task GetCommandLineArgs(IResourceBuilder builder) { var args = await ArgumentEvaluator.GetArgumentListAsync(builder.Resource); diff --git a/tests/Aspire.Hosting.Redis.Tests/RedisPublicApiTests.cs b/tests/Aspire.Hosting.Redis.Tests/RedisPublicApiTests.cs index fcbbbeb8fd7..ac3663ef07a 100644 --- a/tests/Aspire.Hosting.Redis.Tests/RedisPublicApiTests.cs +++ b/tests/Aspire.Hosting.Redis.Tests/RedisPublicApiTests.cs @@ -133,6 +133,75 @@ public void WithPersistenceShouldThrowWhenBuilderIsNull() Assert.Equal(nameof(builder), exception.ParamName); } + [Fact] + public void WithModuleShouldThrowWhenBuilderIsNull() + { + IResourceBuilder builder = null!; + + var action = () => builder.WithModule(RedisNativeModule.Json); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(builder), exception.ParamName); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void WithModuleShouldThrowWhenPathIsNullOrEmpty(bool isNull) + { + var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(testOutputHelper); + var redis = builder.AddRedis("Redis"); + var path = isNull ? null! : string.Empty; + + var action = () => redis.WithModule(path); + + var exception = isNull + ? Assert.Throws(action) + : Assert.Throws(action); + Assert.Equal(nameof(path), exception.ParamName); + } + + [Theory] + [InlineData("custom-module.so")] + [InlineData("/opt/redis/custom-module")] + [InlineData("/opt/redis/custom-module.txt")] + public void WithModuleShouldThrowWhenPathIsNotAbsoluteSoPath(string path) + { + var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(testOutputHelper); + var redis = builder.AddRedis("Redis"); + + var action = () => redis.WithModule(path); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(path), exception.ParamName); + } + + [Fact] + public void WithModuleUnionShouldThrowWhenModuleIsNull() + { + var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(testOutputHelper); + var redis = builder.AddRedis("Redis"); + object module = null!; + + var action = () => redis.WithModule(module); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(module), exception.ParamName); + } + + [Fact] + public void WithModuleUnionShouldThrowWhenModuleTypeIsUnsupported() + { + var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(testOutputHelper); + var redis = builder.AddRedis("Redis"); + object module = 1; + + var action = () => redis.WithModule(module); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(module), exception.ParamName); + } + [Fact] public void RedisInsightWithDataVolumeShouldThrowWhenBuilderIsNull() { diff --git a/tests/Aspire.Hosting.RemoteHost.Tests/AtsCapabilityScannerTests.cs b/tests/Aspire.Hosting.RemoteHost.Tests/AtsCapabilityScannerTests.cs index 9ceccda0849..573dd4cb2f5 100644 --- a/tests/Aspire.Hosting.RemoteHost.Tests/AtsCapabilityScannerTests.cs +++ b/tests/Aspire.Hosting.RemoteHost.Tests/AtsCapabilityScannerTests.cs @@ -165,6 +165,21 @@ public void ScanAssembly_IEnumerableCapability_UsesArrayTypes() Assert.Equal(AtsTypeCategory.Array, enumerableReturnCapability.ReturnType.Category); } + [Fact] + public void ScanAssembly_UnionCapability_CollectsEnumTypesFromUnionMembers() + { + var result = AtsCapabilityScanner.ScanAssembly(typeof(AtsCapabilityScannerTests).Assembly); + + var capability = Assert.Single(result.Capabilities, + c => c.CapabilityId.EndsWith("/testUnionEnumParameter", StringComparison.Ordinal)); + var parameter = Assert.Single(capability.Parameters); + + Assert.Equal(AtsTypeCategory.Union, parameter.Type?.Category); + Assert.Contains(parameter.Type!.UnionTypes!, type => type.ClrType == typeof(TestUnionEnum)); + Assert.Contains(parameter.Type.UnionTypes!, type => type.TypeId == AtsConstants.String); + Assert.Contains(result.EnumTypes, type => type.ClrType == typeof(TestUnionEnum)); + } + #endregion #region DeriveMethodName Tests @@ -723,6 +738,12 @@ private sealed class ShadowedEnvironmentResource(string name) : Resource(name), private sealed class OtherEnvironmentResource(string name) : Resource(name), IResourceWithEnvironment; + private enum TestUnionEnum + { + First, + Second + } + [AspireDto] private sealed class NullableScalarDto { @@ -786,6 +807,13 @@ public static IResourceBuilder TestMultiParamHandleCallback( return builder; } + [AspireExport] + public static void TestUnionEnumParameter(IDistributedApplicationBuilder builder, [AspireUnion(typeof(TestUnionEnum), typeof(string))] object value) + { + _ = builder; + _ = value; + } + /// The fallback value. [AspireExport("descriptionFallback", Description = "Uses the description as fallback documentation.")] public static void DescriptionFallback(IDistributedApplicationBuilder builder, string value) diff --git a/tests/PolyglotAppHosts/Aspire.Hosting.Redis/Go/apphost.go b/tests/PolyglotAppHosts/Aspire.Hosting.Redis/Go/apphost.go index 685dad0dfca..8f75b258b78 100644 --- a/tests/PolyglotAppHosts/Aspire.Hosting.Redis/Go/apphost.go +++ b/tests/PolyglotAppHosts/Aspire.Hosting.Redis/Go/apphost.go @@ -39,6 +39,13 @@ func main() { log.Fatalf(aspire.FormatError(err)) } + // withModule on RedisResource — AspireUnion supports native modules and custom module paths + cache.WithModule(aspire.RedisNativeModuleJson) + cache.WithModule("/opt/redis/custom-module.so") + if err = cache.Err(); err != nil { + log.Fatalf(aspire.FormatError(err)) + } + // withHostPort on cache — stand-alone cache.WithHostPort(6379) if err = cache.Err(); err != nil { diff --git a/tests/PolyglotAppHosts/Aspire.Hosting.Redis/Java/AppHost.java b/tests/PolyglotAppHosts/Aspire.Hosting.Redis/Java/AppHost.java index fd60eed28b1..906a026d6c7 100644 --- a/tests/PolyglotAppHosts/Aspire.Hosting.Redis/Java/AppHost.java +++ b/tests/PolyglotAppHosts/Aspire.Hosting.Redis/Java/AppHost.java @@ -12,6 +12,9 @@ void main() throws Exception { cache.withPersistence(new WithPersistenceOptions().interval(600000000.0).keysChangedThreshold(5.0)); // withDataBindMount on RedisResource cache2.withDataBindMount("/tmp/redis-data"); + // withModule on RedisResource - AspireUnion supports native modules and custom module paths + cache.withModule(RedisNativeModule.JSON); + cache.withModule("/opt/redis/custom-module.so"); // withHostPort on RedisResource cache.withHostPort(6379.0); // withPassword on RedisResource diff --git a/tests/PolyglotAppHosts/Aspire.Hosting.Redis/Python/apphost.py b/tests/PolyglotAppHosts/Aspire.Hosting.Redis/Python/apphost.py index 04c1a7a1d2e..534603870b8 100644 --- a/tests/PolyglotAppHosts/Aspire.Hosting.Redis/Python/apphost.py +++ b/tests/PolyglotAppHosts/Aspire.Hosting.Redis/Python/apphost.py @@ -15,6 +15,9 @@ cache.with_persistence() # withDataBindMount on RedisResource cache2.with_data_bind_mount() + # withModule on RedisResource - AspireUnion supports native modules and custom module paths + cache.with_module("Json") + cache.with_module("/opt/redis/custom-module.so") # withHostPort on RedisResource cache.with_host_port() # withPassword on RedisResource diff --git a/tests/PolyglotAppHosts/Aspire.Hosting.Redis/TypeScript/apphost.mts b/tests/PolyglotAppHosts/Aspire.Hosting.Redis/TypeScript/apphost.mts index 8900fca4956..cbb814bac84 100644 --- a/tests/PolyglotAppHosts/Aspire.Hosting.Redis/TypeScript/apphost.mts +++ b/tests/PolyglotAppHosts/Aspire.Hosting.Redis/TypeScript/apphost.mts @@ -1,4 +1,4 @@ -import { createBuilder } from './.aspire/modules/aspire.mjs'; +import { createBuilder, RedisNativeModule } from './.aspire/modules/aspire.mjs'; const builder = await createBuilder(); @@ -16,6 +16,10 @@ await cache.withPersistence({ interval: 600000000, keysChangedThreshold: 5 }); // withDataBindMount on RedisResource await cache2.withDataBindMount("/tmp/redis-data"); +// withModule on RedisResource — AspireUnion supports native modules and custom module paths +await cache.withModule(RedisNativeModule.Json); +await cache.withModule("/opt/redis/custom-module.so"); + // withHostPort on RedisResource await cache.withHostPort({ port: 6379 });