Skip to content
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
6 changes: 6 additions & 0 deletions src/Aspire.Hosting.Redis/AssemblyInfo.cs
Original file line number Diff line number Diff line change
@@ -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")]
81 changes: 81 additions & 0 deletions src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,15 @@ public static IResourceBuilder<RedisResource> AddRedis(
additionalArgs.Add(persistenceAnnotation.KeysChangedThreshold.ToString(CultureInfo.InvariantCulture));
}

if (redis.TryGetAnnotationsOfType<RedisModuleAnnotation>(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)
{
Expand Down Expand Up @@ -520,6 +529,78 @@ private sealed class PersistenceAnnotation(TimeSpan? interval, long keysChangedT
public long KeysChangedThreshold => keysChangedThreshold;
}

/// <summary>
/// Configures the Redis resource to use the specified native Redis module.
/// </summary>
/// <param name="builder">The resource builder.</param>
/// <param name="module">The well-known, pre-installed Redis module to load, or the path to the Redis module <c>.so</c> file on the container.</param>
/// <returns>The <see cref="IResourceBuilder{T}"/>.</returns>
/// <ats-returns>The resource builder.</ats-returns>
[AspireExport]
internal static IResourceBuilder<RedisResource> WithModule(this IResourceBuilder<RedisResource> 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)),
};
}

/// <summary>
/// Configures the Redis resource to use the specified native Redis module.
/// </summary>
/// <param name="builder">The resource builder.</param>
/// <param name="nativeModule">The well-known, pre-installed Redis module to load.</param>
/// <returns>The <see cref="IResourceBuilder{T}"/>.</returns>
[AspireExportIgnore(Reason = "Polyglot app hosts use the canonical withModule export with a RedisNativeModule|string union.")]
public static IResourceBuilder<RedisResource> WithModule(this IResourceBuilder<RedisResource> 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."),
};
}

/// <summary>
/// Configures the Redis resource to use the specified Redis module by providing the path to the module's <c>.so</c> file on the container.
/// </summary>
/// <param name="builder">The resource builder.</param>
/// <param name="path">The path to the Redis module <c>.so</c> file on the container. This should be an absolute path.</param>
/// <returns>The <see cref="IResourceBuilder{T}"/>.</returns>
[AspireExportIgnore(Reason = "Polyglot app hosts use the canonical withModule export with a RedisNativeModule|string union.")]
public static IResourceBuilder<RedisResource> WithModule(this IResourceBuilder<RedisResource> 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;

/// <summary>
/// Adds a named volume for the data folder to a Redis Insight container resource.
/// </summary>
Expand Down
33 changes: 33 additions & 0 deletions src/Aspire.Hosting.Redis/RedisNativeModule.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Well-known Redis modules that are included in the Redis container image from version 8 onward.
/// </summary>
/// <remarks>
/// See https://redis.io/blog/redis-8-ga/.
/// </remarks>
public enum RedisNativeModule
{
/// <summary>
/// Redis JSON module for storing, updating, and querying JSON documents in Redis.
/// </summary>
Json,

/// <summary>
/// Redis Search module for secondary indexing and querying of data stored in Redis.
/// </summary>
Search,

/// <summary>
/// Redis Bloom Filter module for probabilistic data structures including Bloom filters, Cuckoo filters, Count-Min Sketches, and Top-K filters.
/// </summary>
BloomFilter,

/// <summary>
/// Redis TimeSeries module for efficient storage and querying of time series data in Redis.
/// </summary>
TimeSeries,
}
8 changes: 8 additions & 0 deletions src/Aspire.Hosting.RemoteHost/AtsCapabilityScanner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -616,6 +616,14 @@ private static void CollectEnumClrTypes(AtsTypeRef? typeRef, Dictionary<string,
CollectEnumClrTypes(typeRef.ElementType, enumTypes);
CollectEnumClrTypes(typeRef.KeyType, enumTypes);
CollectEnumClrTypes(typeRef.ValueType, enumTypes);

if (typeRef.UnionTypes is not null)
{
foreach (var unionType in typeRef.UnionTypes)
{
CollectEnumClrTypes(unionType, enumTypes);
}
}
}

/// <summary>
Expand Down
37 changes: 37 additions & 0 deletions tests/Aspire.Hosting.Redis.Tests/AddRedisTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> GetCommandLineArgs(IResourceBuilder<RedisResource> builder)
{
var args = await ArgumentEvaluator.GetArgumentListAsync(builder.Resource);
Expand Down
69 changes: 69 additions & 0 deletions tests/Aspire.Hosting.Redis.Tests/RedisPublicApiTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,75 @@ public void WithPersistenceShouldThrowWhenBuilderIsNull()
Assert.Equal(nameof(builder), exception.ParamName);
}

[Fact]
public void WithModuleShouldThrowWhenBuilderIsNull()
{
IResourceBuilder<RedisResource> builder = null!;

var action = () => builder.WithModule(RedisNativeModule.Json);

var exception = Assert.Throws<ArgumentNullException>(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<ArgumentNullException>(action)
: Assert.Throws<ArgumentException>(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<ArgumentException>(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<ArgumentNullException>(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<ArgumentException>(action);
Assert.Equal(nameof(module), exception.ParamName);
}

[Fact]
public void RedisInsightWithDataVolumeShouldThrowWhenBuilderIsNull()
{
Expand Down
28 changes: 28 additions & 0 deletions tests/Aspire.Hosting.RemoteHost.Tests/AtsCapabilityScannerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
{
Expand Down Expand Up @@ -786,6 +807,13 @@ public static IResourceBuilder<TestResource> TestMultiParamHandleCallback(
return builder;
}

[AspireExport]
public static void TestUnionEnumParameter(IDistributedApplicationBuilder builder, [AspireUnion(typeof(TestUnionEnum), typeof(string))] object value)
{
_ = builder;
_ = value;
}

/// <param name="value">The fallback value.</param>
[AspireExport("descriptionFallback", Description = "Uses the description as fallback documentation.")]
public static void DescriptionFallback(IDistributedApplicationBuilder builder, string value)
Expand Down
7 changes: 7 additions & 0 deletions tests/PolyglotAppHosts/Aspire.Hosting.Redis/Go/apphost.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions tests/PolyglotAppHosts/Aspire.Hosting.Redis/Java/AppHost.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions tests/PolyglotAppHosts/Aspire.Hosting.Redis/Python/apphost.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createBuilder } from './.aspire/modules/aspire.mjs';
import { createBuilder, RedisNativeModule } from './.aspire/modules/aspire.mjs';

const builder = await createBuilder();

Expand All @@ -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 });

Expand Down
Loading