From 614aa4fc47f19fd0cb9894238a7955b65821a889 Mon Sep 17 00:00:00 2001 From: Alireza Baloochi Date: Thu, 3 Apr 2025 18:47:28 +0330 Subject: [PATCH 01/12] Change Redis Insights to use environments variable for preconfigure database connections --- .../RedisBuilderExtensions.cs | 214 ++---------------- .../RedisContainerImageTags.cs | 4 +- .../RedisFunctionalTests.cs | 6 +- 3 files changed, 23 insertions(+), 201 deletions(-) diff --git a/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs b/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs index 35b3a167956..378d9b29e08 100644 --- a/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs +++ b/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs @@ -2,17 +2,11 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Globalization; -using System.Net.Http.Json; using System.Text; -using System.Text.Json; -using System.Text.Json.Nodes; -using System.Text.Json.Serialization; using Aspire.Hosting; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Redis; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Polly; namespace Aspire.Hosting; @@ -235,218 +229,46 @@ public static IResourceBuilder WithRedisInsight(this IResourceBui .WithHttpEndpoint(targetPort: 5540, name: "http") .ExcludeFromManifest(); - // We need to wait for all endpoints to be allocated before attempting to import databases - var endpointsAllocatedTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - builder.ApplicationBuilder.Eventing.Subscribe((e, ct) => - { - endpointsAllocatedTcs.TrySetResult(); - return Task.CompletedTask; - }); - - builder.ApplicationBuilder.Eventing.Subscribe(resource, async (e, ct) => { var redisInstances = builder.ApplicationBuilder.Resources.OfType(); if (!redisInstances.Any()) { // No-op if there are no Redis resources present. - return; + return Task.CompletedTask; } - // Wait for all endpoints to be allocated before attempting to import databases - await endpointsAllocatedTcs.Task.ConfigureAwait(false); - - var redisInsightResource = builder.ApplicationBuilder.Resources.OfType().Single(); - var insightEndpoint = redisInsightResource.PrimaryEndpoint; - - using var client = new HttpClient(); - client.BaseAddress = new Uri($"{insightEndpoint.Scheme}://{insightEndpoint.Host}:{insightEndpoint.Port}"); - - var rls = e.Services.GetRequiredService(); - var resourceLogger = rls.GetLogger(resource); - - await ImportRedisDatabases(resourceLogger, redisInstances, client, ct).ConfigureAwait(false); - }); - - resourceBuilder.WithRelationship(builder.Resource, "RedisInsight"); - - configureContainer?.Invoke(resourceBuilder); - - return builder; - } - - static async Task ImportRedisDatabases(ILogger resourceLogger, IEnumerable redisInstances, HttpClient client, CancellationToken cancellationToken) - { - var databasesPath = "/api/databases"; - - var pipeline = new ResiliencePipelineBuilder().AddRetry(new Polly.Retry.RetryStrategyOptions - { - Delay = TimeSpan.FromSeconds(2), - MaxRetryAttempts = 5, - }).Build(); - - await pipeline.ExecuteAsync(async (ctx) => - { - await InitializeRedisInsightSettings(client, resourceLogger, ctx).ConfigureAwait(false); - }, cancellationToken).ConfigureAwait(false); - - using (var stream = new MemoryStream()) - { - // As part of configuring RedisInsight we need to factor in the possibility that the - // container resource is being run with persistence turned on. In this case we need - // to get the list of existing databases because we might need to delete some. - var lookup = await pipeline.ExecuteAsync(async (ctx) => - { - var getDatabasesResponse = await client.GetFromJsonAsync(databasesPath, cancellationToken).ConfigureAwait(false); - return getDatabasesResponse?.ToLookup( - i => i.Name ?? throw new InvalidDataException("Database name is missing."), - i => i.Id ?? throw new InvalidDataException("Database ID is missing.")); - }, cancellationToken).ConfigureAwait(false); - - var databasesToDelete = new List(); - - using var writer = new Utf8JsonWriter(stream); - - writer.WriteStartArray(); - - foreach (var redisResource in redisInstances) + resourceBuilder.WithEnvironment(context => { - if (lookup is { } && lookup.Contains(redisResource.Name)) - { - // It is possible that there are multiple databases with - // a conflicting name so we delete them all. This just keeps - // track of the specific ID that we need to delete. - databasesToDelete.AddRange(lookup[redisResource.Name]); - } + var counter = 1; - if (redisResource.PrimaryEndpoint.IsAllocated) + foreach (var redisInstance in redisInstances) { - 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); - writer.WriteNull("username"); - if (redisResource.PasswordParameter is { } passwordParam) - { - writer.WriteString("password", passwordParam.Value); - } - else + // RedisInsight assumes Redis is being accessed over a default Aspire container network and hardcodes the resource address + // This will need to be refactored once updated service discovery APIs are available + context.EnvironmentVariables.Add($"RI_REDIS_HOST{counter}", redisInstance.Name); + context.EnvironmentVariables.Add($"RI_REDIS_PORT{counter}", redisInstance.PrimaryEndpoint.TargetPort!.Value); + context.EnvironmentVariables.Add($"RI_REDIS_ALIAS{counter}", redisInstance.Name); + if (redisInstance.PasswordParameter is not null) { - writer.WriteNull("password"); + context.EnvironmentVariables.Add($"RI_REDIS_PASSWORD{counter}", redisInstance.PasswordParameter.Value); } - writer.WriteString("connectionType", "STANDALONE"); - writer.WriteEndObject(); - } - } - writer.WriteEndArray(); - await writer.FlushAsync(cancellationToken).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 = $"{databasesPath}/import"; - - try - { - if (databasesToDelete.Any()) - { - await pipeline.ExecuteAsync(async (ctx) => - { - // Create a DELETE request to send to the existing instance of - // RedisInsight with the IDs of the database to delete. - var deleteContent = JsonContent.Create(new - { - ids = databasesToDelete - }); - - var deleteRequest = new HttpRequestMessage(HttpMethod.Delete, databasesPath) - { - Content = deleteContent - }; - - var deleteResponse = await client.SendAsync(deleteRequest, cancellationToken).ConfigureAwait(false); - deleteResponse.EnsureSuccessStatusCode(); - - }, cancellationToken).ConfigureAwait(false); + counter++; } + }); - await pipeline.ExecuteAsync(async (ctx) => - { - var response = await client.PostAsync(apiUrl, content, ctx) - .ConfigureAwait(false); - - response.EnsureSuccessStatusCode(); - }, cancellationToken).ConfigureAwait(false); - - } - catch (Exception ex) - { - resourceLogger.LogError("Could not import Redis databases into RedisInsight. Reason: {reason}", ex.Message); - } - } - } - } + return Task.CompletedTask; + }); - /// - /// Initializes the Redis Insight settings to work around https://github.com/RedisInsight/RedisInsight/issues/3452. - /// Redis Insight requires the encryption property to be set if the Redis database connection contains a password. - /// - private static async Task InitializeRedisInsightSettings(HttpClient client, ILogger resourceLogger, CancellationToken ct) - { - if (await AreSettingsInitialized(client, ct).ConfigureAwait(false)) - { - return; - } + resourceBuilder.WithRelationship(builder.Resource, "RedisInsight"); - var jsonContent = JsonContent.Create(new - { - agreements = new - { - // all 4 are required to be set - eula = false, - analytics = false, - notifications = false, - encryption = false, - } - }); + configureContainer?.Invoke(resourceBuilder); - var response = await client.PatchAsync("/api/settings", jsonContent, ct).ConfigureAwait(false); - if (!response.IsSuccessStatusCode) - { - resourceLogger.LogDebug("Could not initialize RedisInsight settings. Reason: {reason}", await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false)); + return builder; } - response.EnsureSuccessStatusCode(); - } - - private static async Task AreSettingsInitialized(HttpClient client, CancellationToken ct) - { - var response = await client.GetAsync("/api/settings", ct).ConfigureAwait(false); - response.EnsureSuccessStatusCode(); - - var content = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); - - var jsonResponse = JsonNode.Parse(content); - var agreements = jsonResponse?["agreements"]; - - return agreements is not null; - } - - private class RedisDatabaseDto - { - [JsonPropertyName("id")] - public Guid? Id { get; set; } - - [JsonPropertyName("name")] - public string? Name { get; set; } } /// diff --git a/src/Aspire.Hosting.Redis/RedisContainerImageTags.cs b/src/Aspire.Hosting.Redis/RedisContainerImageTags.cs index 0c1e1516c5b..65d8a1a70b5 100644 --- a/src/Aspire.Hosting.Redis/RedisContainerImageTags.cs +++ b/src/Aspire.Hosting.Redis/RedisContainerImageTags.cs @@ -29,6 +29,6 @@ internal static class RedisContainerImageTags /// redis/redisinsight public const string RedisInsightImage = "redis/redisinsight"; - /// 2.66 - public const string RedisInsightTag = "2.66"; + /// 2.68 + public const string RedisInsightTag = "2.68"; } diff --git a/tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs b/tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs index 8af9d48a47b..3c5e74a2493 100644 --- a/tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs +++ b/tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs @@ -124,7 +124,7 @@ public async Task VerifyRedisResource() [Fact] [RequiresDocker] - [QuarantinedTest("https://github.com/dotnet/aspire/issues/7291")] + //[QuarantinedTest("https://github.com/dotnet/aspire/issues/7291")] public async Task VerifyDatabasesAreNotDuplicatedForPersistentRedisInsightContainer() { var randomResourceSuffix = Random.Shared.Next(10000).ToString(); @@ -229,7 +229,7 @@ public async Task VerifyDatabasesAreNotDuplicatedForPersistentRedisInsightContai [Fact] [RequiresDocker] - [QuarantinedTest("https://github.com/dotnet/aspire/issues/6099")] + //[QuarantinedTest("https://github.com/dotnet/aspire/issues/6099")] public async Task VerifyWithRedisInsightImportDatabases() { var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5)); @@ -537,7 +537,7 @@ public async Task PersistenceIsDisabledByDefault() [InlineData(false)] [InlineData(true)] [RequiresDocker] - [QuarantinedTest("https://github.com/dotnet/aspire/issues/7176")] + //[QuarantinedTest("https://github.com/dotnet/aspire/issues/7176")] public async Task RedisInsightWithDataShouldPersistStateBetweenUsages(bool useVolume) { var cts = new CancellationTokenSource(TimeSpan.FromMinutes(10)); From fa0b3947274364063955a02b487c6ebfedca7952 Mon Sep 17 00:00:00 2001 From: Alireza Baloochi Date: Thu, 3 Apr 2025 19:50:28 +0330 Subject: [PATCH 02/12] Remove no need tests --- playground/Redis/Redis.AppHost/Program.cs | 2 +- .../RedisFunctionalTests.cs | 170 ------------------ 2 files changed, 1 insertion(+), 171 deletions(-) diff --git a/playground/Redis/Redis.AppHost/Program.cs b/playground/Redis/Redis.AppHost/Program.cs index caa7d08b6fb..4f23ba38ec9 100644 --- a/playground/Redis/Redis.AppHost/Program.cs +++ b/playground/Redis/Redis.AppHost/Program.cs @@ -3,7 +3,7 @@ var redis = builder.AddRedis("redis"); redis.WithDataVolume() .WithRedisCommander(c => c.WithHostPort(33803).WithParentRelationship(redis)) - .WithRedisInsight(c => c.WithHostPort(41567).WithParentRelationship(redis)); + .WithRedisInsight(c => c.WithHostPort(41567).WithParentRelationship(redis).WithLifetime(ContainerLifetime.Persistent)); var garnet = builder.AddGarnet("garnet") .WithDataVolume(); diff --git a/tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs b/tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs index 3c5e74a2493..be7f989cdc1 100644 --- a/tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs +++ b/tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs @@ -14,10 +14,8 @@ using Microsoft.Extensions.Hosting; using StackExchange.Redis; using Xunit; -using Aspire.Hosting.Tests.Dcp; using System.Text.Json.Nodes; using Aspire.Hosting; -using Aspire.Components.Common.Tests; namespace Aspire.Hosting.Redis.Tests; @@ -122,174 +120,6 @@ public async Task VerifyRedisResource() Assert.Equal("value", value); } - [Fact] - [RequiresDocker] - //[QuarantinedTest("https://github.com/dotnet/aspire/issues/7291")] - public async Task VerifyDatabasesAreNotDuplicatedForPersistentRedisInsightContainer() - { - var randomResourceSuffix = Random.Shared.Next(10000).ToString(); - var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5)); - - var configure = (DistributedApplicationOptions options) => - { - options.ContainerRegistryOverride = ComponentTestConstants.AspireTestContainerRegistry; - }; - - using var builder1 = TestDistributedApplicationBuilder.Create(configure, testOutputHelper); - builder1.Configuration[$"DcpPublisher:ResourceNameSuffix"] = randomResourceSuffix; - - IResourceBuilder? redisInsightBuilder = null; - var redis1 = builder1.AddRedis("redisForInsightPersistence") - .WithRedisInsight(c => - { - redisInsightBuilder = c; - c.WithLifetime(ContainerLifetime.Persistent); - }); - - // Wire up an additional event subcription to ResourceReadyEvent on the RedisInsightResource - // instance. This works because the ResourceReadyEvent fires non-blocking sequential so the - // wire-up that WithRedisInsight does is guaranteed to execute before this one does. So we then - // use this to block pulling the list of databases until we know they've been updated. This - // will repeated below for the second app. - // - // Issue: https://github.com/dotnet/aspire/issues/6455 - Assert.NotNull(redisInsightBuilder); - var redisInsightsReady = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - builder1.Eventing.Subscribe(redisInsightBuilder.Resource, (evt, ct) => - { - redisInsightsReady.TrySetResult(); - return Task.CompletedTask; - }); - - using var app1 = builder1.Build(); - - await app1.StartAsync(cts.Token); - - await redisInsightsReady.Task.WaitAsync(cts.Token); - - using var client1 = app1.CreateHttpClient($"{redis1.Resource.Name}-insight", "http"); - var firstRunDatabases = await client1.GetFromJsonAsync("/api/databases", cts.Token); - - Assert.NotNull(firstRunDatabases); - Assert.Single(firstRunDatabases); - Assert.Equal($"{redis1.Resource.Name}", firstRunDatabases[0].Name); - - await app1.StopAsync(cts.Token); - - using var builder2 = TestDistributedApplicationBuilder.Create(configure, testOutputHelper); - builder2.Configuration[$"DcpPublisher:ResourceNameSuffix"] = randomResourceSuffix; - - var redis2 = builder2.AddRedis("redisForInsightPersistence") - .WithRedisInsight(c => - { - redisInsightBuilder = c; - c.WithLifetime(ContainerLifetime.Persistent); - }); - - // Wire up an additional event subcription to ResourceReadyEvent on the RedisInsightResource - // instance. This works because the ResourceReadyEvent fires non-blocking sequential so the - // wire-up that WithRedisInsight does is guaranteed to execute before this one does. So we then - // use this to block pulling the list of databases until we know they've been updated. This - // will repeated below for the second app. - Assert.NotNull(redisInsightBuilder); - redisInsightsReady = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - builder2.Eventing.Subscribe(redisInsightBuilder.Resource, (evt, ct) => - { - redisInsightsReady.TrySetResult(); - return Task.CompletedTask; - }); - - using var app2 = builder2.Build(); - await app2.StartAsync(cts.Token); - - await redisInsightsReady.Task.WaitAsync(cts.Token); - - using var client2 = app2.CreateHttpClient($"{redisInsightBuilder.Resource.Name}", "http"); - var secondRunDatabases = await client2.GetFromJsonAsync("/api/databases", cts.Token); - - Assert.NotNull(secondRunDatabases); - Assert.Single(secondRunDatabases); - Assert.Equal($"{redis2.Resource.Name}", secondRunDatabases[0].Name); - Assert.NotEqual(secondRunDatabases.Single().Id, firstRunDatabases.Single().Id); - - // HACK: This is a workaround for the fact that ApplicationExecutor is not a public type. What I have - // done here is I get the latest event from RNS for the insights instance which gives me the resource - // name as known from a DCP perspective. I then use the ApplicationExecutorProxy (introduced with this - // change to call the ApplicationExecutor stop method. The proxy is a public type with an internal - // constructor inside the Aspire.Hosting.Tests package. This is a short term solution for 9.0 to - // make sure that we have good test coverage for WithRedisInsight behavior, but we need a better - // long term solution in 9.x for folks that will want to do things like execute commands against - // resources to stop specific containers. - var latestEvent = await app2.ResourceNotifications.WaitForResourceHealthyAsync(redisInsightBuilder.Resource.Name, cts.Token); - var executorProxy = app2.Services.GetRequiredService(); - await executorProxy.StopResourceAsync(latestEvent.ResourceId, cts.Token); - - await app2.StopAsync(cts.Token); - } - - [Fact] - [RequiresDocker] - //[QuarantinedTest("https://github.com/dotnet/aspire/issues/6099")] - 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); - - // 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); - builder.Eventing.Subscribe(redisInsightBuilder.Resource, (evt, ct) => - { - redisInsightsReady.TrySetResult(); - return Task.CompletedTask; - }); - - using var app = builder.Build(); - - await app.StartAsync(); - - await redisInsightsReady.Task.WaitAsync(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(); - } - } - [Fact] [RequiresDocker] public async Task WithDataVolumeShouldPersistStateBetweenUsages() From 06ca21f8232e2d2765e7c8de64a2b8314c21a6a4 Mon Sep 17 00:00:00 2001 From: Alireza Baloochi Date: Thu, 3 Apr 2025 19:51:36 +0330 Subject: [PATCH 03/12] revert playground app --- playground/Redis/Redis.AppHost/Program.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/playground/Redis/Redis.AppHost/Program.cs b/playground/Redis/Redis.AppHost/Program.cs index 4f23ba38ec9..caa7d08b6fb 100644 --- a/playground/Redis/Redis.AppHost/Program.cs +++ b/playground/Redis/Redis.AppHost/Program.cs @@ -3,7 +3,7 @@ var redis = builder.AddRedis("redis"); redis.WithDataVolume() .WithRedisCommander(c => c.WithHostPort(33803).WithParentRelationship(redis)) - .WithRedisInsight(c => c.WithHostPort(41567).WithParentRelationship(redis).WithLifetime(ContainerLifetime.Persistent)); + .WithRedisInsight(c => c.WithHostPort(41567).WithParentRelationship(redis)); var garnet = builder.AddGarnet("garnet") .WithDataVolume(); From 8f68faa2c02a0671f31867ce6203865ab88d65a7 Mon Sep 17 00:00:00 2001 From: Alireza Baloochi Date: Thu, 3 Apr 2025 20:28:31 +0330 Subject: [PATCH 04/12] Fix test --- .../RedisFunctionalTests.cs | 23 ++----------------- 1 file changed, 2 insertions(+), 21 deletions(-) diff --git a/tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs b/tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs index be7f989cdc1..df631c09408 100644 --- a/tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs +++ b/tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs @@ -367,7 +367,6 @@ public async Task PersistenceIsDisabledByDefault() [InlineData(false)] [InlineData(true)] [RequiresDocker] - //[QuarantinedTest("https://github.com/dotnet/aspire/issues/7176")] public async Task RedisInsightWithDataShouldPersistStateBetweenUsages(bool useVolume) { var cts = new CancellationTokenSource(TimeSpan.FromMinutes(10)); @@ -403,16 +402,7 @@ public async Task RedisInsightWithDataShouldPersistStateBetweenUsages(bool useVo { 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(redisInsightBuilder1.Resource, (evt, ct) => - { - redisInsightsReady.TrySetResult(); - return Task.CompletedTask; - }); - - await redisInsightsReady.Task.WaitAsync(cts.Token); + await app.WaitForHealthyAsync(redisInsightBuilder1).WaitAsync(cts.Token); try { @@ -446,16 +436,7 @@ public async Task RedisInsightWithDataShouldPersistStateBetweenUsages(bool useVo { 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(redisInsightBuilder2.Resource, (evt, ct) => - { - redisInsightsReady.TrySetResult(); - return Task.CompletedTask; - }); - - await redisInsightsReady.Task.WaitAsync(cts.Token); + await app.WaitForHealthyAsync(redisInsightBuilder2).WaitAsync(cts.Token); try { From c3354d18bdb0e9f114b20887c4294112aa027193 Mon Sep 17 00:00:00 2001 From: Alireza Baloochi Date: Thu, 3 Apr 2025 20:29:43 +0330 Subject: [PATCH 05/12] Remove unused class --- .../Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs b/tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs index df631c09408..b2aea7953af 100644 --- a/tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs +++ b/tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs @@ -511,16 +511,6 @@ static async Task AcceptRedisInsightEula(HttpClient client, CancellationToken ct await EnsureRedisInsightEulaAccepted(client, ct); } - 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; } - } - [Fact] [RequiresDocker] public async Task WithRedisCommanderShouldWorkWithPassword() From 283e2aacac4af7d662d97f82f36a572bf224c3de Mon Sep 17 00:00:00 2001 From: Alireza Baloochi Date: Thu, 3 Apr 2025 21:04:04 +0330 Subject: [PATCH 06/12] Add test --- .../AddRedisTests.cs | 63 ++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/tests/Aspire.Hosting.Redis.Tests/AddRedisTests.cs b/tests/Aspire.Hosting.Redis.Tests/AddRedisTests.cs index 37f4a0a3d85..f33b259cf49 100644 --- a/tests/Aspire.Hosting.Redis.Tests/AddRedisTests.cs +++ b/tests/Aspire.Hosting.Redis.Tests/AddRedisTests.cs @@ -254,6 +254,67 @@ public void WithRedisInsightAddsWithRedisInsightResource() Assert.Single(builder.Resources.OfType()); } + [Fact] + public async Task WithRedisInsightProducesCorrectEnvironmentVariables() + { + var builder = DistributedApplication.CreateBuilder(); + var redis1 = builder.AddRedis("myredis1").WithRedisInsight(); + var redis2 = builder.AddRedis("myredis2").WithRedisInsight(); + using var app = builder.Build(); + + // Add fake allocated endpoints. + redis1.WithEndpoint("tcp", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 5001)); + redis2.WithEndpoint("tcp", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 5002)); + + await builder.Eventing.PublishAsync(new(app.Services, app.Services.GetRequiredService())); + + var redisInsight = Assert.Single(builder.Resources.OfType()); + var envs = await redisInsight.GetEnvironmentVariableValuesAsync(); + + Assert.Collection(envs, + (item) => + { + Assert.Equal("RI_REDIS_HOST1", item.Key); + Assert.Equal(redis1.Resource.Name, item.Value); + }, + (item) => + { + Assert.Equal("RI_REDIS_PORT1", item.Key); + Assert.Equal($"{redis1.Resource.PrimaryEndpoint.TargetPort!.Value}", item.Value); + }, + (item) => + { + Assert.Equal("RI_REDIS_ALIAS1", item.Key); + Assert.Equal(redis1.Resource.Name, item.Value); + }, + (item) => + { + Assert.Equal("RI_REDIS_PASSWORD1", item.Key); + Assert.Equal(redis1.Resource.PasswordParameter!.Value, item.Value); + }, + (item) => + { + Assert.Equal("RI_REDIS_HOST2", item.Key); + Assert.Equal(redis2.Resource.Name, item.Value); + }, + (item) => + { + Assert.Equal("RI_REDIS_PORT2", item.Key); + Assert.Equal($"{redis2.Resource.PrimaryEndpoint.TargetPort!.Value}", item.Value); + }, + (item) => + { + Assert.Equal("RI_REDIS_ALIAS2", item.Key); + Assert.Equal(redis2.Resource.Name, item.Value); + }, + (item) => + { + Assert.Equal("RI_REDIS_PASSWORD2", item.Key); + Assert.Equal(redis2.Resource.PasswordParameter!.Value, item.Value); + }); + + } + [Fact] public void WithRedisCommanderSupportsChangingContainerImageValues() { @@ -373,7 +434,7 @@ public async Task MultipleRedisInstanceProducesCorrectRedisHostsVariable() redis1.WithEndpoint("tcp", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 5001)); redis2.WithEndpoint("tcp", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 5002, "host2")); - await builder.Eventing.PublishAsync(new (app.Services, app.Services.GetRequiredService())); + await builder.Eventing.PublishAsync(new(app.Services, app.Services.GetRequiredService())); var commander = builder.Resources.Single(r => r.Name.EndsWith("-commander")); From 007382e7b916fbf8fef1803e09587c82a1954e32 Mon Sep 17 00:00:00 2001 From: Alireza Baloochi Date: Thu, 3 Apr 2025 22:06:03 +0330 Subject: [PATCH 07/12] Address Copilot feedback --- .../RedisBuilderExtensions.cs | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs b/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs index 378d9b29e08..25db48e3320 100644 --- a/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs +++ b/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs @@ -245,17 +245,20 @@ public static IResourceBuilder WithRedisInsight(this IResourceBui foreach (var redisInstance in redisInstances) { - // RedisInsight assumes Redis is being accessed over a default Aspire container network and hardcodes the resource address - // This will need to be refactored once updated service discovery APIs are available - context.EnvironmentVariables.Add($"RI_REDIS_HOST{counter}", redisInstance.Name); - context.EnvironmentVariables.Add($"RI_REDIS_PORT{counter}", redisInstance.PrimaryEndpoint.TargetPort!.Value); - context.EnvironmentVariables.Add($"RI_REDIS_ALIAS{counter}", redisInstance.Name); - if (redisInstance.PasswordParameter is not null) + if (redisInstance.PrimaryEndpoint.IsAllocated) { - context.EnvironmentVariables.Add($"RI_REDIS_PASSWORD{counter}", redisInstance.PasswordParameter.Value); + // RedisInsight assumes Redis is being accessed over a default Aspire container network and hardcodes the resource address + // This will need to be refactored once updated service discovery APIs are available + context.EnvironmentVariables.Add($"RI_REDIS_HOST{counter}", redisInstance.Name); + context.EnvironmentVariables.Add($"RI_REDIS_PORT{counter}", redisInstance.PrimaryEndpoint.TargetPort!.Value); + context.EnvironmentVariables.Add($"RI_REDIS_ALIAS{counter}", redisInstance.Name); + if (redisInstance.PasswordParameter is not null) + { + context.EnvironmentVariables.Add($"RI_REDIS_PASSWORD{counter}", redisInstance.PasswordParameter.Value); + } + + counter++; } - - counter++; } }); From f54771515f176e9cb8c22fe9d3fd63525c4f7a0c Mon Sep 17 00:00:00 2001 From: Alireza Baloochi Date: Thu, 3 Apr 2025 22:44:45 +0330 Subject: [PATCH 08/12] Add resilience pipline to test --- .../RedisFunctionalTests.cs | 31 ++++++++++++------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs b/tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs index b2aea7953af..0babb5b84b4 100644 --- a/tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs +++ b/tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs @@ -16,6 +16,7 @@ using Xunit; using System.Text.Json.Nodes; using Aspire.Hosting; +using Polly; namespace Aspire.Hosting.Redis.Tests; @@ -473,20 +474,28 @@ public async Task RedisInsightWithDataShouldPersistStateBetweenUsages(bool useVo private static async Task EnsureRedisInsightEulaAccepted(HttpClient httpClient, CancellationToken ct) { - var response = await httpClient.GetAsync("/api/settings", ct); - response.EnsureSuccessStatusCode(); + var pipeline = new ResiliencePipelineBuilder() + .AddRetry(new() { MaxRetryAttempts = 10, BackoffType = DelayBackoffType.Linear, Delay = TimeSpan.FromSeconds(2) }) + .Build(); + + await pipeline.ExecuteAsync(async ct => + { + var response = await httpClient.GetAsync("/api/settings", ct); + response.EnsureSuccessStatusCode(); + + var content = await response.Content.ReadAsStringAsync(ct); - var content = await response.Content.ReadAsStringAsync(ct); + var jo = JsonObject.Parse(content); + Assert.NotNull(jo); + var agreements = jo["agreements"]; - var jo = JsonObject.Parse(content); - Assert.NotNull(jo); - var agreements = jo["agreements"]; + Assert.NotNull(agreements); + Assert.False(agreements["analytics"]!.GetValue()); + Assert.False(agreements["notifications"]!.GetValue()); + Assert.False(agreements["encryption"]!.GetValue()); + Assert.True(agreements["eula"]!.GetValue()); + }, ct); - Assert.NotNull(agreements); - Assert.False(agreements["analytics"]!.GetValue()); - Assert.False(agreements["notifications"]!.GetValue()); - Assert.False(agreements["encryption"]!.GetValue()); - Assert.True(agreements["eula"]!.GetValue()); } static async Task AcceptRedisInsightEula(HttpClient client, CancellationToken ct) From 77f25c0da818ab12f918baa3d8b6058ca3fc6d28 Mon Sep 17 00:00:00 2001 From: Alireza Baloochi Date: Thu, 3 Apr 2025 23:49:34 +0330 Subject: [PATCH 09/12] Address PR feedback --- .../RedisBuilderExtensions.cs | 36 +++++------ .../RedisFunctionalTests.cs | 62 +++++++++++++++++++ 2 files changed, 77 insertions(+), 21 deletions(-) diff --git a/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs b/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs index 25db48e3320..b4e274f23b5 100644 --- a/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs +++ b/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs @@ -229,40 +229,34 @@ public static IResourceBuilder WithRedisInsight(this IResourceBui .WithHttpEndpoint(targetPort: 5540, name: "http") .ExcludeFromManifest(); - builder.ApplicationBuilder.Eventing.Subscribe((e, ct) => + resourceBuilder.WithEnvironment(context => { var redisInstances = builder.ApplicationBuilder.Resources.OfType(); if (!redisInstances.Any()) { // No-op if there are no Redis resources present. - return Task.CompletedTask; + return; } - resourceBuilder.WithEnvironment(context => - { - var counter = 1; + var counter = 1; - foreach (var redisInstance in redisInstances) + foreach (var redisInstance in redisInstances) + { + if (redisInstance.PrimaryEndpoint.IsAllocated) { - if (redisInstance.PrimaryEndpoint.IsAllocated) + // RedisInsight assumes Redis is being accessed over a default Aspire container network and hardcodes the resource address + context.EnvironmentVariables.Add($"RI_REDIS_HOST{counter}", redisInstance.Name); + context.EnvironmentVariables.Add($"RI_REDIS_PORT{counter}", redisInstance.PrimaryEndpoint.TargetPort!.Value); + context.EnvironmentVariables.Add($"RI_REDIS_ALIAS{counter}", redisInstance.Name); + if (redisInstance.PasswordParameter is not null) { - // RedisInsight assumes Redis is being accessed over a default Aspire container network and hardcodes the resource address - // This will need to be refactored once updated service discovery APIs are available - context.EnvironmentVariables.Add($"RI_REDIS_HOST{counter}", redisInstance.Name); - context.EnvironmentVariables.Add($"RI_REDIS_PORT{counter}", redisInstance.PrimaryEndpoint.TargetPort!.Value); - context.EnvironmentVariables.Add($"RI_REDIS_ALIAS{counter}", redisInstance.Name); - if (redisInstance.PasswordParameter is not null) - { - context.EnvironmentVariables.Add($"RI_REDIS_PASSWORD{counter}", redisInstance.PasswordParameter.Value); - } - - counter++; + context.EnvironmentVariables.Add($"RI_REDIS_PASSWORD{counter}", redisInstance.PasswordParameter.Value); } - } - }); - return Task.CompletedTask; + counter++; + } + } }); resourceBuilder.WithRelationship(builder.Resource, "RedisInsight"); diff --git a/tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs b/tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs index 0babb5b84b4..89b32e3a886 100644 --- a/tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs +++ b/tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs @@ -121,6 +121,60 @@ 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(); + + await app.WaitForHealthyAsync(redisInsightBuilder).WaitAsync(cts.Token); + + var client = app.CreateHttpClient(redisInsightBuilder.Resource.Name, "http"); + + // Accept EULA first; otherwise, /api/databases will not work properly. + await AcceptRedisInsightEula(client, cts.Token); + + var response = await client.GetAsync("/api/databases", cts.Token); + response.EnsureSuccessStatusCode(); + + var databases = await response.Content.ReadFromJsonAsync>(cts.Token); + Assert.NotNull(databases); + + databases = [.. databases.OrderBy(d => d.Id)]; + 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); + }, + db => + { + Assert.Equal(redis2.Resource.Name, db.Name); + Assert.Equal(redis2.Resource.Name, db.Host); + Assert.Equal(redis2.Resource.PrimaryEndpoint.TargetPort, db.Port); + }); + + 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(); + } + } + [Fact] [RequiresDocker] public async Task WithDataVolumeShouldPersistStateBetweenUsages() @@ -554,4 +608,12 @@ public async Task WithRedisCommanderShouldWorkWithPassword() var httpResponse = await client.GetAsync(redisCommanderUrl!); httpResponse.EnsureSuccessStatusCode(); } + + internal sealed class RedisInsightDatabaseModel + { + public string? Id { get; set; } + public string? Host { get; set; } + public int? Port { get; set; } + public string? Name { get; set; } + } } From a30ea56a99e1732254b77e847bedd2ae449b5d3c Mon Sep 17 00:00:00 2001 From: Alireza Baloochi Date: Fri, 4 Apr 2025 00:35:46 +0330 Subject: [PATCH 10/12] Wait for Running state in tests --- tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs b/tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs index 89b32e3a886..f1581a69089 100644 --- a/tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs +++ b/tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs @@ -138,7 +138,8 @@ public async Task VerifyWithRedisInsightImportDatabases() await app.StartAsync(); - await app.WaitForHealthyAsync(redisInsightBuilder).WaitAsync(cts.Token); + var rns = app.Services.GetRequiredService(); + await rns.WaitForResourceAsync(redisInsightBuilder.Resource.Name, KnownResourceStates.Running).WaitAsync(cts.Token); var client = app.CreateHttpClient(redisInsightBuilder.Resource.Name, "http"); @@ -457,7 +458,8 @@ public async Task RedisInsightWithDataShouldPersistStateBetweenUsages(bool useVo { await app.StartAsync(); - await app.WaitForHealthyAsync(redisInsightBuilder1).WaitAsync(cts.Token); + var rns = app.Services.GetRequiredService(); + await rns.WaitForResourceAsync(redisInsightBuilder1.Resource.Name, KnownResourceStates.Running).WaitAsync(cts.Token); try { @@ -491,7 +493,8 @@ public async Task RedisInsightWithDataShouldPersistStateBetweenUsages(bool useVo { await app.StartAsync(); - await app.WaitForHealthyAsync(redisInsightBuilder2).WaitAsync(cts.Token); + var rns = app.Services.GetRequiredService(); + await rns.WaitForResourceAsync(redisInsightBuilder2.Resource.Name,KnownResourceStates.Running).WaitAsync(cts.Token); try { From 945998ce38d7e21f7747a2af6039ed0b98e96944 Mon Sep 17 00:00:00 2001 From: Alireza Baloochi Date: Fri, 4 Apr 2025 00:46:09 +0330 Subject: [PATCH 11/12] Set permissions for bind mount --- .../RedisFunctionalTests.cs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs b/tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs index f1581a69089..cc4f5adc271 100644 --- a/tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs +++ b/tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs @@ -22,6 +22,10 @@ namespace Aspire.Hosting.Redis.Tests; public class RedisFunctionalTests(ITestOutputHelper testOutputHelper) { + private const UnixFileMode MountFilePermissions = + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | + UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute; + [Fact] [RequiresDocker] [QuarantinedTest("https://github.com/dotnet/aspire/issues/7177")] @@ -449,7 +453,13 @@ public async Task RedisInsightWithDataShouldPersistStateBetweenUsages(bool useVo } else { - bindMountPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + bindMountPath = Directory.CreateTempSubdirectory().FullName; + + if (!OperatingSystem.IsWindows()) + { + // Change permissions for non-root accounts (container user account) + File.SetUnixFileMode(bindMountPath, MountFilePermissions); + } redisInsightBuilder1.WithDataBindMount(bindMountPath); } @@ -494,7 +504,7 @@ public async Task RedisInsightWithDataShouldPersistStateBetweenUsages(bool useVo await app.StartAsync(); var rns = app.Services.GetRequiredService(); - await rns.WaitForResourceAsync(redisInsightBuilder2.Resource.Name,KnownResourceStates.Running).WaitAsync(cts.Token); + await rns.WaitForResourceAsync(redisInsightBuilder2.Resource.Name, KnownResourceStates.Running).WaitAsync(cts.Token); try { From 25c347112462f7541447c90307871a51fcfb941b Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Fri, 4 Apr 2025 10:34:47 -0500 Subject: [PATCH 12/12] Address PR feedback. - Remove IsAllocated check - Minor code clean up. --- .../RedisBuilderExtensions.cs | 38 ++++++++----------- .../RedisFunctionalTests.cs | 27 +++++++------ 2 files changed, 29 insertions(+), 36 deletions(-) diff --git a/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs b/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs index b4e274f23b5..88dd2625e0d 100644 --- a/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs +++ b/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs @@ -224,26 +224,22 @@ public static IResourceBuilder WithRedisInsight(this IResourceBui 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(); - - resourceBuilder.WithEnvironment(context => - { - var redisInstances = builder.ApplicationBuilder.Resources.OfType(); - - if (!redisInstances.Any()) + .WithImage(RedisContainerImageTags.RedisInsightImage, RedisContainerImageTags.RedisInsightTag) + .WithImageRegistry(RedisContainerImageTags.RedisInsightRegistry) + .WithHttpEndpoint(targetPort: 5540, name: "http") + .WithEnvironment(context => { - // No-op if there are no Redis resources present. - return; - } + var redisInstances = builder.ApplicationBuilder.Resources.OfType(); + + if (!redisInstances.Any()) + { + // No-op if there are no Redis resources present. + return; + } - var counter = 1; + var counter = 1; - foreach (var redisInstance in redisInstances) - { - if (redisInstance.PrimaryEndpoint.IsAllocated) + foreach (var redisInstance in redisInstances) { // RedisInsight assumes Redis is being accessed over a default Aspire container network and hardcodes the resource address context.EnvironmentVariables.Add($"RI_REDIS_HOST{counter}", redisInstance.Name); @@ -256,16 +252,14 @@ public static IResourceBuilder WithRedisInsight(this IResourceBui counter++; } - } - }); - - resourceBuilder.WithRelationship(builder.Resource, "RedisInsight"); + }) + .WithRelationship(builder.Resource, "RedisInsight") + .ExcludeFromManifest(); configureContainer?.Invoke(resourceBuilder); return builder; } - } /// diff --git a/tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs b/tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs index cc4f5adc271..b1e36bc1afd 100644 --- a/tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs +++ b/tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs @@ -546,23 +546,22 @@ private static async Task EnsureRedisInsightEulaAccepted(HttpClient httpClient, .Build(); await pipeline.ExecuteAsync(async ct => - { - var response = await httpClient.GetAsync("/api/settings", ct); - response.EnsureSuccessStatusCode(); - - var content = await response.Content.ReadAsStringAsync(ct); + { + var response = await httpClient.GetAsync("/api/settings", ct); + response.EnsureSuccessStatusCode(); - var jo = JsonObject.Parse(content); - Assert.NotNull(jo); - var agreements = jo["agreements"]; + var content = await response.Content.ReadAsStringAsync(ct); - Assert.NotNull(agreements); - Assert.False(agreements["analytics"]!.GetValue()); - Assert.False(agreements["notifications"]!.GetValue()); - Assert.False(agreements["encryption"]!.GetValue()); - Assert.True(agreements["eula"]!.GetValue()); - }, ct); + var jo = JsonNode.Parse(content); + Assert.NotNull(jo); + var agreements = jo["agreements"]; + Assert.NotNull(agreements); + Assert.False(agreements["analytics"]!.GetValue()); + Assert.False(agreements["notifications"]!.GetValue()); + Assert.False(agreements["encryption"]!.GetValue()); + Assert.True(agreements["eula"]!.GetValue()); + }, ct); } static async Task AcceptRedisInsightEula(HttpClient client, CancellationToken ct)