diff --git a/playground/Redis/Redis.AppHost/Program.cs b/playground/Redis/Redis.AppHost/Program.cs index a933492a47c..aaec3b64a63 100644 --- a/playground/Redis/Redis.AppHost/Program.cs +++ b/playground/Redis/Redis.AppHost/Program.cs @@ -2,7 +2,8 @@ var redis = builder.AddRedis("redis") .WithDataVolume("redis-data") - .WithRedisCommander(); + .WithRedisCommander() + .WithRedisInsight(c => c.WithAcceptEula(true)); builder.AddProject("apiservice") .WithReference(redis) diff --git a/playground/TestShop/TestShop.AppHost/Program.cs b/playground/TestShop/TestShop.AppHost/Program.cs index d1f1edc50fc..802843364a1 100644 --- a/playground/TestShop/TestShop.AppHost/Program.cs +++ b/playground/TestShop/TestShop.AppHost/Program.cs @@ -12,7 +12,11 @@ basketCache.WithRedisCommander(c => { c.WithHostPort(33801); - }); + }) + .WithRedisInsight(c => + { + c.WithHostPort(33802); + }); #endif var catalogDbApp = builder.AddProject("catalogdbapp") diff --git a/src/Aspire.Hosting.Redis/Aspire.Hosting.Redis.csproj b/src/Aspire.Hosting.Redis/Aspire.Hosting.Redis.csproj index 16dc7aa78a3..0a67848cd7c 100644 --- a/src/Aspire.Hosting.Redis/Aspire.Hosting.Redis.csproj +++ b/src/Aspire.Hosting.Redis/Aspire.Hosting.Redis.csproj @@ -24,4 +24,8 @@ + + + + diff --git a/src/Aspire.Hosting.Redis/PublicAPI.Unshipped.txt b/src/Aspire.Hosting.Redis/PublicAPI.Unshipped.txt index 074c6ad103b..667b9143a78 100644 --- a/src/Aspire.Hosting.Redis/PublicAPI.Unshipped.txt +++ b/src/Aspire.Hosting.Redis/PublicAPI.Unshipped.txt @@ -1,2 +1,7 @@ #nullable enable - +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.WithAcceptEula(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, bool accept = true) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! +static Aspire.Hosting.RedisBuilderExtensions.WithHostPort(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, int? port) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! +static Aspire.Hosting.RedisBuilderExtensions.WithRedisInsight(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, System.Action!>? configureContainer = null, string? containerName = null) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! diff --git a/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs b/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs index d95519100d6..f98b4713b80 100644 --- a/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs +++ b/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs @@ -3,10 +3,13 @@ using System.Globalization; using System.Text; +using System.Text.Json; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Redis; using Aspire.Hosting.Utils; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Polly; namespace Aspire.Hosting; @@ -123,12 +126,189 @@ public static IResourceBuilder WithRedisCommander(this IResourceB } } + /// + /// Configures a container resource for Redis Insight which is pre-configured to connect to the that this method is used on. + /// + /// The for the . + /// Configuration callback for Redis Insight container resource. + /// Override the container name used for Redis Insight. + /// + public static IResourceBuilder WithRedisInsight(this IResourceBuilder builder, Action>? configureContainer = null, string? containerName = null) + { + ArgumentNullException.ThrowIfNull(builder); + + if (builder.ApplicationBuilder.Resources.OfType().SingleOrDefault() is { } existingRedisCommanderResource) + { + var builderForExistingResource = builder.ApplicationBuilder.CreateResourceBuilder(existingRedisCommanderResource); + configureContainer?.Invoke(builderForExistingResource); + return builder; + } + else + { + builder.ApplicationBuilder.Services.AddHttpClient(); + containerName ??= $"{builder.Resource.Name}-insight"; + + var resource = new RedisInsightResource(containerName); + var resourceBuilder = builder.ApplicationBuilder.AddResource(resource) + .WithImage(RedisContainerImageTags.RedisInsightImage, RedisContainerImageTags.RedisInsightTag) + .WithImageRegistry(RedisContainerImageTags.RedisInsightRegistry) + .WithHttpEndpoint(targetPort: 5540, name: "http") + .ExcludeFromManifest(); + + builder.ApplicationBuilder.Eventing.Subscribe(async (e, ct) => + { + var redisInstances = builder.ApplicationBuilder.Resources.OfType(); + + if (!redisInstances.Any()) + { + // No-op if there are no Redis resources present. + return; + } + + var httpClientFactory = e.Services.GetRequiredService(); + + var redisInsightResource = builder.ApplicationBuilder.Resources.OfType().Single(); + var insightEndpoint = redisInsightResource.PrimaryEndpoint; + + var client = httpClientFactory.CreateClient(); + client.BaseAddress = new Uri($"{insightEndpoint.Scheme}://{insightEndpoint.Host}:{insightEndpoint.Port}"); + + var rls = e.Services.GetRequiredService(); + var resourceLogger = rls.GetLogger(resource); + + if (resource.AcceptedEula) + { + await AcceptRedisInsightEula(resourceLogger, client, ct).ConfigureAwait(false); + } + + await ImportRedisDatabases(resourceLogger, redisInstances, client, ct).ConfigureAwait(false); + }); + + configureContainer?.Invoke(resourceBuilder); + + return builder; + } + + static async Task ImportRedisDatabases(ILogger resourceLogger, IEnumerable redisInstances, HttpClient client, CancellationToken ct) + { + using (var stream = new MemoryStream()) + { + using var writer = new Utf8JsonWriter(stream); + + writer.WriteStartArray(); + + foreach (var redisResource in redisInstances) + { + if (redisResource.PrimaryEndpoint.IsAllocated) + { + var endpoint = redisResource.PrimaryEndpoint; + writer.WriteStartObject(); + writer.WriteString("host", redisResource.Name); + writer.WriteNumber("port", endpoint.TargetPort!.Value); + writer.WriteString("name", redisResource.Name); + writer.WriteNumber("db", 0); + //todo: provide username and password when https://github.com/dotnet/aspire/pull/4642 merged. + writer.WriteNull("username"); + writer.WriteNull("password"); + writer.WriteString("connectionType", "STANDALONE"); + writer.WriteEndObject(); + } + } + writer.WriteEndArray(); + await writer.FlushAsync(ct).ConfigureAwait(false); + stream.Seek(0, SeekOrigin.Begin); + + var content = new MultipartFormDataContent(); + + var fileContent = new StreamContent(stream); + + content.Add(fileContent, "file", "RedisInsight_connections.json"); + + var apiUrl = $"/api/databases/import"; + + var pipeline = new ResiliencePipelineBuilder().AddRetry(new Polly.Retry.RetryStrategyOptions + { + Delay = TimeSpan.FromSeconds(2), + MaxRetryAttempts = 5, + }).Build(); + + try + { + await pipeline.ExecuteAsync(async (ctx) => + { + var response = await client.PostAsync(apiUrl, content, ctx) + .ConfigureAwait(false); + + response.EnsureSuccessStatusCode(); + }, ct).ConfigureAwait(false); + + } + catch (Exception ex) + { + resourceLogger.LogError("Could not import Redis databases into RedisInsight. Reason: {reason}", ex.Message); + } + }; + } + + static async Task AcceptRedisInsightEula(ILogger resourceLogger, HttpClient client, CancellationToken ct) + { + using (var stream = new MemoryStream()) + { + using var writer = new Utf8JsonWriter(stream); + + writer.WriteStartObject(); + + writer.WritePropertyName("agreements"); + writer.WriteStartObject(); + writer.WriteBoolean("eula", true); + writer.WriteBoolean("analytics", false); + writer.WriteBoolean("notifications", false); + writer.WriteBoolean("encryption", false); + writer.WriteEndObject(); + + writer.WriteEndObject(); + + await writer.FlushAsync(ct).ConfigureAwait(false); + stream.Seek(0, SeekOrigin.Begin); + string json = Encoding.UTF8.GetString(stream.ToArray()); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + + var apiUrl = $"/api/settings"; + + var pipeline = new ResiliencePipelineBuilder().AddRetry(new Polly.Retry.RetryStrategyOptions + { + Delay = TimeSpan.FromSeconds(2), + MaxRetryAttempts = 5, + }).Build(); + + try + { + await pipeline.ExecuteAsync(async (ctx) => + { + var response = await client.PatchAsync(apiUrl, content, ctx) + .ConfigureAwait(false); + + response.EnsureSuccessStatusCode(); + + var d = await response.Content.ReadAsStringAsync(ctx).ConfigureAwait(false); + + }, ct).ConfigureAwait(false); + + } + catch (Exception ex) + { + resourceLogger.LogError("Could accept RedisInsight eula. Reason: {reason}", ex.Message); + } + }; + } + + } /// /// Configures the host port that the Redis Commander resource is exposed on instead of using randomly assigned port. /// /// The resource builder for Redis Commander. /// The port to bind on the host. If is used random port will be assigned. - /// The resource builder for PGAdmin. + /// The resource builder for RedisCommander. public static IResourceBuilder WithHostPort(this IResourceBuilder builder, int? port) { ArgumentNullException.ThrowIfNull(builder); @@ -139,6 +319,36 @@ public static IResourceBuilder WithHostPort(this IResour }); } + /// + /// Configures the host port that the Redis Insight resource is exposed on instead of using randomly assigned port. + /// + /// The resource builder for Redis Insight. + /// The port to bind on the host. If is used random port will be assigned. + /// The resource builder for RedisInsight. + public static IResourceBuilder WithHostPort(this IResourceBuilder builder, int? port) + { + ArgumentNullException.ThrowIfNull(builder); + + return builder.WithEndpoint("http", endpoint => + { + endpoint.Port = port; + }); + } + + /// + /// Configures the acceptance of the End User License Agreement (EULA) for Redis Insight. + /// + /// The resource builder for Redis Insight. + /// A boolean value indicating whether to accept the EULA. If , the EULA is accepted. + /// The resource builder for Redis Insight. + public static IResourceBuilder WithAcceptEula(this IResourceBuilder builder, bool accept = true) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.Resource.AcceptedEula = accept; + return builder; + } + /// /// Adds a named volume for the data folder to a Redis container resource and enables Redis persistence. /// diff --git a/src/Aspire.Hosting.Redis/RedisContainerImageTags.cs b/src/Aspire.Hosting.Redis/RedisContainerImageTags.cs index 6ef38706cea..5206cbc4226 100644 --- a/src/Aspire.Hosting.Redis/RedisContainerImageTags.cs +++ b/src/Aspire.Hosting.Redis/RedisContainerImageTags.cs @@ -11,4 +11,7 @@ internal static class RedisContainerImageTags public const string RedisCommanderRegistry = "docker.io"; public const string RedisCommanderImage = "rediscommander/redis-commander"; public const string RedisCommanderTag = "latest"; + public const string RedisInsightRegistry = "docker.io"; + public const string RedisInsightImage = "redis/redisinsight"; + public const string RedisInsightTag = "2.54"; } diff --git a/src/Aspire.Hosting.Redis/RedisInsightResource.cs b/src/Aspire.Hosting.Redis/RedisInsightResource.cs new file mode 100644 index 00000000000..d76881257f9 --- /dev/null +++ b/src/Aspire.Hosting.Redis/RedisInsightResource.cs @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Redis; + +/// +/// A resource that represents a Redis Insight container. +/// +/// The name of the resource. +public class RedisInsightResource(string name) : ContainerResource(name) +{ + internal const string PrimaryEndpointName = "http"; + + private EndpointReference? _primaryEndpoint; + + /// + /// Gets the primary endpoint for the Redis Insight. + /// + public EndpointReference PrimaryEndpoint => _primaryEndpoint ??= new(this, PrimaryEndpointName); + + /// + /// A boolean value indicating whether to accept the EULA. If , the EULA is accepted. + /// + internal bool AcceptedEula { get; set; } +} diff --git a/tests/Aspire.Hosting.Redis.Tests/AddRedisTests.cs b/tests/Aspire.Hosting.Redis.Tests/AddRedisTests.cs index f6e07a49bb9..10b47464444 100644 --- a/tests/Aspire.Hosting.Redis.Tests/AddRedisTests.cs +++ b/tests/Aspire.Hosting.Redis.Tests/AddRedisTests.cs @@ -129,6 +129,16 @@ public void WithRedisCommanderAddsRedisCommanderResource() Assert.Single(builder.Resources.OfType()); } + [Fact] + public void WithRedisInsightAddsWithRedisInsightResource() + { + var builder = DistributedApplication.CreateBuilder(); + builder.AddRedis("myredis1").WithRedisInsight(); + builder.AddRedis("myredis2").WithRedisInsight(); + + Assert.Single(builder.Resources.OfType()); + } + [Fact] public void WithRedisCommanderSupportsChangingContainerImageValues() { @@ -147,6 +157,24 @@ public void WithRedisCommanderSupportsChangingContainerImageValues() Assert.Equal("someothertag", containerAnnotation.Tag); } + [Fact] + public void WithRedisInsightSupportsChangingContainerImageValues() + { + var builder = DistributedApplication.CreateBuilder(); + builder.AddRedis("myredis").WithRedisInsight(c => + { + c.WithImageRegistry("example.mycompany.com"); + c.WithImage("customrediscommander"); + c.WithImageTag("someothertag"); + }); + + var resource = Assert.Single(builder.Resources.OfType()); + var containerAnnotation = Assert.Single(resource.Annotations.OfType()); + Assert.Equal("example.mycompany.com", containerAnnotation.Registry); + Assert.Equal("customrediscommander", containerAnnotation.Image); + Assert.Equal("someothertag", containerAnnotation.Tag); + } + [Fact] public void WithRedisCommanderSupportsChangingHostPort() { @@ -161,6 +189,20 @@ public void WithRedisCommanderSupportsChangingHostPort() Assert.Equal(1000, endpoint.Port); } + [Fact] + public void WithRedisInsightSupportsChangingHostPort() + { + var builder = DistributedApplication.CreateBuilder(); + builder.AddRedis("myredis").WithRedisInsight(c => + { + c.WithHostPort(1000); + }); + + var resource = Assert.Single(builder.Resources.OfType()); + var endpoint = Assert.Single(resource.Annotations.OfType()); + Assert.Equal(1000, endpoint.Port); + } + [Fact] public async Task SingleRedisInstanceProducesCorrectRedisHostsVariable() { @@ -349,4 +391,19 @@ public void WithPersistenceAddsCommandLineArgsAnnotation() Assert.True(redis.Resource.TryGetAnnotationsOfType(out var argsAnnotations)); Assert.NotNull(argsAnnotations.SingleOrDefault()); } + + [Fact] + public void RedisInsightAcceptedEulaIsFalseByDefault() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + IResourceBuilder? redisInsightBuilder = null; + var redis = builder.AddRedis("redis").WithRedisInsight(c => + { + redisInsightBuilder = c; + }); + + Assert.NotNull(redisInsightBuilder); + Assert.False(redisInsightBuilder.Resource.AcceptedEula); + } } diff --git a/tests/Aspire.Hosting.Redis.Tests/Aspire.Hosting.Redis.Tests.csproj b/tests/Aspire.Hosting.Redis.Tests/Aspire.Hosting.Redis.Tests.csproj index 92a8906d1dd..c6a98cbc167 100644 --- a/tests/Aspire.Hosting.Redis.Tests/Aspire.Hosting.Redis.Tests.csproj +++ b/tests/Aspire.Hosting.Redis.Tests/Aspire.Hosting.Redis.Tests.csproj @@ -13,9 +13,4 @@ - - - - - diff --git a/tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs b/tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs index 7e0a9d84471..308a5503541 100644 --- a/tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs +++ b/tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Net.Http.Json; using System.Net; using Aspire.Components.Common.Tests; using Aspire.Hosting.ApplicationModel; @@ -14,6 +15,7 @@ using StackExchange.Redis; using Xunit; using Xunit.Abstractions; +using Newtonsoft.Json.Linq; namespace Aspire.Hosting.Redis.Tests; @@ -130,6 +132,112 @@ public async Task VerifyRedisResource() Assert.Equal("value", value); } + [Fact] + [RequiresDocker] + public async Task VerifyWithRedisInsightImportDatabases() + { + var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5)); + + using var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(testOutputHelper); + + var redis1 = builder.AddRedis("redis-1"); + IResourceBuilder? redisInsightBuilder = null; + var redis2 = builder.AddRedis("redis-2").WithRedisInsight(c => redisInsightBuilder = c); + Assert.NotNull(redisInsightBuilder); + using var app = builder.Build(); + + await app.StartAsync(); + + var rns = app.Services.GetRequiredService(); + + await rns.WaitForResourceAsync(redisInsightBuilder.Resource.Name, KnownResourceStates.Running, cts.Token); + + var client = app.CreateHttpClient(redisInsightBuilder.Resource.Name, "http"); + + var response = await client.GetAsync("/api/databases", cts.Token); + response.EnsureSuccessStatusCode(); + + var databases = await response.Content.ReadFromJsonAsync>(cts.Token); + + Assert.NotNull(databases); + Assert.Collection(databases, + db => + { + Assert.Equal(redis1.Resource.Name, db.Name); + Assert.Equal(redis1.Resource.Name, db.Host); + Assert.Equal(redis1.Resource.PrimaryEndpoint.TargetPort, db.Port); + Assert.Equal("STANDALONE", db.ConnectionType); + Assert.Equal(0, db.Db); + }, + db => + { + Assert.Equal(redis2.Resource.Name, db.Name); + Assert.Equal(redis2.Resource.Name, db.Host); + Assert.Equal(redis2.Resource.PrimaryEndpoint.TargetPort, db.Port); + Assert.Equal("STANDALONE", db.ConnectionType); + Assert.Equal(0, db.Db); + }); + + foreach (var db in databases) + { + var cts2 = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + var testConnectionResponse = await client.GetAsync($"/api/databases/test/{db.Id}", cts2.Token); + response.EnsureSuccessStatusCode(); + } + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + [RequiresDocker] + public async Task VerifyWithRedisInsightAcceptEula(bool accept) + { + var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5)); + + using var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(testOutputHelper); + + IResourceBuilder? redisInsightBuilder = null; + var redis = builder.AddRedis("redis").WithRedisInsight(c => + { + c.WithAcceptEula(accept); + redisInsightBuilder = c; + }); + + Assert.NotNull(redisInsightBuilder); + Assert.Equal(accept, redisInsightBuilder.Resource.AcceptedEula); + + using var app = builder.Build(); + + await app.StartAsync(); + + var rns = app.Services.GetRequiredService(); + + await rns.WaitForResourceAsync(redisInsightBuilder.Resource.Name, KnownResourceStates.Running, cts.Token); + + var client = app.CreateHttpClient(redisInsightBuilder.Resource.Name, "http"); + + var response = await client.GetAsync("/api/settings", cts.Token); + response.EnsureSuccessStatusCode(); + + var content = await response.Content.ReadAsStringAsync(); + + var jo = JObject.Parse(content); + var agreements = jo["agreements"]; + if (accept) + { + Assert.NotNull(agreements); + Assert.False(agreements["analytics"]!.Value()); + Assert.False(agreements["notifications"]!.Value()); + Assert.False(agreements["encryption"]!.Value()); + Assert.True(agreements["eula"]!.Value()); + } + else + { + Assert.NotNull(agreements); + Assert.False(agreements.HasValues); + } + } + [Fact] [RequiresDocker] public async Task WithDataVolumeShouldPersistStateBetweenUsages() @@ -375,4 +483,14 @@ public async Task PersistenceIsDisabledByDefault() await app.StopAsync(); } } + + internal sealed class RedisInsightDatabaseModel + { + public string? Id { get; set; } + public string? Host { get; set; } + public int? Port { get; set; } + public string? Name { get; set; } + public int? Db { get; set; } + public string? ConnectionType { get; set; } + } } diff --git a/tests/Aspire.Hosting.Redis.Tests/RedisPublicApiTests.cs b/tests/Aspire.Hosting.Redis.Tests/RedisPublicApiTests.cs index 22c1ee42e6e..d92fbff375c 100644 --- a/tests/Aspire.Hosting.Redis.Tests/RedisPublicApiTests.cs +++ b/tests/Aspire.Hosting.Redis.Tests/RedisPublicApiTests.cs @@ -47,7 +47,18 @@ public void WithRedisCommanderShouldThrowWhenBuilderIsNull() } [Fact] - public void WithHostPortShouldThrowWhenBuilderIsNull() + public void WithRedisInsightResourceShouldThrowWhenBuilderIsNull() + { + IResourceBuilder builder = null!; + + var action = () => builder.WithRedisInsight(); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(builder), exception.ParamName); + } + + [Fact] + public void RedisCommanderWithHostPortShouldThrowWhenBuilderIsNull() { IResourceBuilder builder = null!; const int port = 777; @@ -58,6 +69,18 @@ public void WithHostPortShouldThrowWhenBuilderIsNull() Assert.Equal(nameof(builder), exception.ParamName); } + [Fact] + public void RedisInsightResourceWithHostPortShouldThrowWhenBuilderIsNull() + { + IResourceBuilder builder = null!; + const int port = 777; + + var action = () => builder.WithHostPort(port); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(builder), exception.ParamName); + } + [Fact] public void WithDataVolumeShouldThrowWhenBuilderIsNull() {