Skip to content

Commit

Permalink
Add WithDataVolume to Redis Insights (#6432)
Browse files Browse the repository at this point in the history
## Description

This pull request introduces new features to the `Aspire.Hosting.Redis` project, including methods for adding data volumes and bind mounts to Redis Insight resources, along with corresponding tests to ensure data persistence between usages. The most important changes are detailed below:

### New Features:
* Added `WithDataVolume` and `WithDataBindMount` methods to `RedisBuilderExtensions` to support adding named volumes and bind mounts for data folders in Redis Insight container resources. 

### Testing Enhancements:
* Introduced a new test `RedisInsightWithDataShouldPersistStateBetweenUsages` in `RedisFunctionalTests` to verify that Redis Insight data persists between container restarts, using either volumes or bind mounts. 

Fixes #6299
  • Loading branch information
Alirexaa authored Nov 4, 2024
1 parent ba893fa commit 11c51e7
Show file tree
Hide file tree
Showing 3 changed files with 196 additions and 0 deletions.
2 changes: 2 additions & 0 deletions src/Aspire.Hosting.Redis/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,7 @@
Aspire.Hosting.Redis.RedisInsightResource
Aspire.Hosting.Redis.RedisInsightResource.PrimaryEndpoint.get -> Aspire.Hosting.ApplicationModel.EndpointReference!
Aspire.Hosting.Redis.RedisInsightResource.RedisInsightResource(string! name) -> void
static Aspire.Hosting.RedisBuilderExtensions.WithDataBindMount(this Aspire.Hosting.ApplicationModel.IResourceBuilder<Aspire.Hosting.Redis.RedisInsightResource!>! builder, string! source) -> Aspire.Hosting.ApplicationModel.IResourceBuilder<Aspire.Hosting.Redis.RedisInsightResource!>!
static Aspire.Hosting.RedisBuilderExtensions.WithDataVolume(this Aspire.Hosting.ApplicationModel.IResourceBuilder<Aspire.Hosting.Redis.RedisInsightResource!>! builder, string? name = null) -> Aspire.Hosting.ApplicationModel.IResourceBuilder<Aspire.Hosting.Redis.RedisInsightResource!>!
static Aspire.Hosting.RedisBuilderExtensions.WithHostPort(this Aspire.Hosting.ApplicationModel.IResourceBuilder<Aspire.Hosting.Redis.RedisInsightResource!>! builder, int? port) -> Aspire.Hosting.ApplicationModel.IResourceBuilder<Aspire.Hosting.Redis.RedisInsightResource!>!
static Aspire.Hosting.RedisBuilderExtensions.WithRedisInsight(this Aspire.Hosting.ApplicationModel.IResourceBuilder<Aspire.Hosting.ApplicationModel.RedisResource!>! builder, System.Action<Aspire.Hosting.ApplicationModel.IResourceBuilder<Aspire.Hosting.Redis.RedisInsightResource!>!>? configureContainer = null, string? containerName = null) -> Aspire.Hosting.ApplicationModel.IResourceBuilder<Aspire.Hosting.ApplicationModel.RedisResource!>!
28 changes: 28 additions & 0 deletions src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -438,4 +438,32 @@ public static IResourceBuilder<RedisResource> WithPersistence(this IResourceBuil
return Task.CompletedTask;
}), ResourceAnnotationMutationBehavior.Replace);
}

/// <summary>
/// Adds a named volume for the data folder to a Redis Insight container resource.
/// </summary>
/// <param name="builder">The resource builder.</param>
/// <param name="name">The name of the volume. Defaults to an auto-generated name based on the application and resource names.</param>
/// <returns>The <see cref="IResourceBuilder{T}"/>.</returns>
[System.Diagnostics.CodeAnalysis.SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Each overload targets a different resource builder type, allowing for tailored functionality. Optional volume names enhance usability, enabling users to easily provide custom names while maintaining clear and distinct method signatures.")]
public static IResourceBuilder<RedisInsightResource> WithDataVolume(this IResourceBuilder<RedisInsightResource> builder, string? name = null)
{
ArgumentNullException.ThrowIfNull(builder);

return builder.WithVolume(name ?? VolumeNameGenerator.Generate(builder, "data"), "/data");
}

/// <summary>
/// Adds a bind mount for the data folder to a Redis Insight container resource.
/// </summary>
/// <param name="builder">The resource builder.</param>
/// <param name="source">The source directory on the host to mount into the container.</param>
/// <returns>The <see cref="IResourceBuilder{T}"/>.</returns>
public static IResourceBuilder<RedisInsightResource> WithDataBindMount(this IResourceBuilder<RedisInsightResource> builder, string source)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(source);

return builder.WithBindMount(source, "/data");
}
}
166 changes: 166 additions & 0 deletions tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
using Xunit;
using Xunit.Abstractions;
using Aspire.Hosting.Tests.Dcp;
using System.Text.Json.Nodes;

namespace Aspire.Hosting.Redis.Tests;

Expand Down Expand Up @@ -534,6 +535,171 @@ public async Task PersistenceIsDisabledByDefault()
}
}

[Theory]
[InlineData(false)]
[InlineData(true)]
[RequiresDocker]
public async Task RedisInsightWithDataShouldPersistStateBetweenUsages(bool useVolume)
{
var cts = new CancellationTokenSource(TimeSpan.FromMinutes(10));

string? volumeName = null;
string? bindMountPath = null;

try
{
using var builder1 = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(testOutputHelper);
IResourceBuilder<RedisInsightResource>? redisInsightBuilder1 = null;
var redis1 = builder1.AddRedis("redis")
.WithRedisInsight(c => { redisInsightBuilder1 = c; });
Assert.NotNull(redisInsightBuilder1);

if (useVolume)
{
// Use a deterministic volume name to prevent them from exhausting the machines if deletion fails
volumeName = VolumeNameGenerator.Generate(redisInsightBuilder1, nameof(RedisInsightWithDataShouldPersistStateBetweenUsages));

// if the volume already exists (because of a crashing previous run), delete it
DockerUtils.AttemptDeleteDockerVolume(volumeName, throwOnFailure: true);
redisInsightBuilder1.WithDataVolume(volumeName);
}
else
{
bindMountPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
redisInsightBuilder1.WithDataBindMount(bindMountPath);
}

using (var app = builder1.Build())
{
await app.StartAsync();

// RedisInsight will import databases when it is ready, this task will run after the initial databases import
// so we will use that to know when the databases have been successfully imported
var redisInsightsReady = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
builder1.Eventing.Subscribe<ResourceReadyEvent>(redisInsightBuilder1.Resource, (evt, ct) =>
{
redisInsightsReady.TrySetResult();
return Task.CompletedTask;
});

await redisInsightsReady.Task.WaitAsync(cts.Token);

try
{
var httpClient = app.CreateHttpClient(redisInsightBuilder1.Resource.Name, "http");
await AcceptRedisInsightEula(httpClient, cts.Token);
}
finally
{
// Stops the container, or the Volume would still be in use
await app.StopAsync();
}
}

using var builder2 = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(testOutputHelper);

IResourceBuilder<RedisInsightResource>? redisInsightBuilder2 = null;
var redis2 = builder2.AddRedis("redis")
.WithRedisInsight(c => { redisInsightBuilder2 = c; });
Assert.NotNull(redisInsightBuilder2);

if (useVolume)
{
redisInsightBuilder2.WithDataVolume(volumeName);
}
else
{
redisInsightBuilder2.WithDataBindMount(bindMountPath!);
}

using (var app = builder2.Build())
{
await app.StartAsync();

// RedisInsight will import databases when it is ready, this task will run after the initial databases import
// so we will use that to know when the databases have been successfully imported
var redisInsightsReady = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
builder2.Eventing.Subscribe<ResourceReadyEvent>(redisInsightBuilder2.Resource, (evt, ct) =>
{
redisInsightsReady.TrySetResult();
return Task.CompletedTask;
});

await redisInsightsReady.Task.WaitAsync(cts.Token);

try
{
var httpClient = app.CreateHttpClient(redisInsightBuilder2.Resource.Name, "http");
await EnsureRedisInsightEulaAccepted(httpClient, cts.Token);
}
finally
{
// Stops the container, or the Volume would still be in use
await app.StopAsync();
}
}
}
finally
{
if (volumeName is not null)
{
DockerUtils.AttemptDeleteDockerVolume(volumeName);
}

if (bindMountPath is not null)
{
try
{
Directory.Delete(bindMountPath, recursive: true);
}
catch
{
// Don't fail test if we can't clean the temporary folder
}
}
}
}

private static async Task EnsureRedisInsightEulaAccepted(HttpClient httpClient, CancellationToken ct)
{
var response = await httpClient.GetAsync("/api/settings", ct);
response.EnsureSuccessStatusCode();

var content = await response.Content.ReadAsStringAsync(ct);

var jo = JsonObject.Parse(content);
Assert.NotNull(jo);
var agreements = jo["agreements"];

Assert.NotNull(agreements);
Assert.False(agreements["analytics"]!.GetValue<bool>());
Assert.False(agreements["notifications"]!.GetValue<bool>());
Assert.False(agreements["encryption"]!.GetValue<bool>());
Assert.True(agreements["eula"]!.GetValue<bool>());
}

static async Task AcceptRedisInsightEula(HttpClient client, CancellationToken ct)
{
var jsonContent = JsonContent.Create(new
{
agreements = new
{
eula = true,
analytics = false,
notifications = false,
encryption = false,
}
});

var apiUrl = $"/api/settings";

var response = await client.PatchAsync(apiUrl, jsonContent, ct);

response.EnsureSuccessStatusCode();

await EnsureRedisInsightEulaAccepted(client, ct);
}

internal sealed class RedisInsightDatabaseModel
{
public string? Id { get; set; }
Expand Down

0 comments on commit 11c51e7

Please sign in to comment.