Skip to content

Commit 9cfbdca

Browse files
authored
Make RedisInsight work with WithLifetime(...). (#6425)
* Make RedisInsight work with WithLifetime(...).
1 parent f3a410d commit 9cfbdca

File tree

4 files changed

+197
-10
lines changed

4 files changed

+197
-10
lines changed

src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs

Lines changed: 67 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33

44
using System.Globalization;
5+
using System.Net.Http.Json;
56
using System.Text;
67
using System.Text.Json;
8+
using System.Text.Json.Serialization;
79
using Aspire.Hosting.ApplicationModel;
810
using Aspire.Hosting.Redis;
911
using Aspire.Hosting.Utils;
@@ -197,20 +199,50 @@ public static IResourceBuilder<RedisResource> WithRedisInsight(this IResourceBui
197199
return builder;
198200
}
199201

200-
static async Task ImportRedisDatabases(ILogger resourceLogger, IEnumerable<RedisResource> redisInstances, HttpClient client, CancellationToken ct)
202+
static async Task ImportRedisDatabases(ILogger resourceLogger, IEnumerable<RedisResource> redisInstances, HttpClient client, CancellationToken cancellationToken)
201203
{
204+
var databasesPath = "/api/databases";
205+
206+
var pipeline = new ResiliencePipelineBuilder().AddRetry(new Polly.Retry.RetryStrategyOptions
207+
{
208+
Delay = TimeSpan.FromSeconds(2),
209+
MaxRetryAttempts = 5,
210+
}).Build();
211+
202212
using (var stream = new MemoryStream())
203213
{
214+
// As part of configuring RedisInsight we need to factor in the possibility that the
215+
// container resource is being run with persistence turned on. In this case we need
216+
// to get the list of existing databases because we might need to delete some.
217+
var lookup = await pipeline.ExecuteAsync(async (ctx) =>
218+
{
219+
var getDatabasesResponse = await client.GetFromJsonAsync<RedisDatabaseDto[]>(databasesPath, cancellationToken).ConfigureAwait(false);
220+
return getDatabasesResponse?.ToLookup(
221+
i => i.Name ?? throw new InvalidDataException("Database name is missing."),
222+
i => i.Id ?? throw new InvalidDataException("Database ID is missing."));
223+
}, cancellationToken).ConfigureAwait(false);
224+
225+
var databasesToDelete = new List<Guid>();
226+
204227
using var writer = new Utf8JsonWriter(stream);
205228

206229
writer.WriteStartArray();
207230

208231
foreach (var redisResource in redisInstances)
209232
{
233+
if (lookup is { } && lookup.Contains(redisResource.Name))
234+
{
235+
// It is possible that there are multiple databases with
236+
// a conflicting name so we delete them all. This just keeps
237+
// track of the specific ID that we need to delete.
238+
databasesToDelete.AddRange(lookup[redisResource.Name]);
239+
}
240+
210241
if (redisResource.PrimaryEndpoint.IsAllocated)
211242
{
212243
var endpoint = redisResource.PrimaryEndpoint;
213244
writer.WriteStartObject();
245+
214246
writer.WriteString("host", redisResource.Name);
215247
writer.WriteNumber("port", endpoint.TargetPort!.Value);
216248
writer.WriteString("name", redisResource.Name);
@@ -223,7 +255,7 @@ static async Task ImportRedisDatabases(ILogger resourceLogger, IEnumerable<Redis
223255
}
224256
}
225257
writer.WriteEndArray();
226-
await writer.FlushAsync(ct).ConfigureAwait(false);
258+
await writer.FlushAsync(cancellationToken).ConfigureAwait(false);
227259
stream.Seek(0, SeekOrigin.Begin);
228260

229261
var content = new MultipartFormDataContent();
@@ -232,23 +264,39 @@ static async Task ImportRedisDatabases(ILogger resourceLogger, IEnumerable<Redis
232264

233265
content.Add(fileContent, "file", "RedisInsight_connections.json");
234266

235-
var apiUrl = $"/api/databases/import";
236-
237-
var pipeline = new ResiliencePipelineBuilder().AddRetry(new Polly.Retry.RetryStrategyOptions
238-
{
239-
Delay = TimeSpan.FromSeconds(2),
240-
MaxRetryAttempts = 5,
241-
}).Build();
267+
var apiUrl = $"{databasesPath}/import";
242268

243269
try
244270
{
271+
if (databasesToDelete.Any())
272+
{
273+
await pipeline.ExecuteAsync(async (ctx) =>
274+
{
275+
// Create a DELETE request to send to the existing instance of
276+
// RedisInsight with the IDs of the database to delete.
277+
var deleteContent = JsonContent.Create(new
278+
{
279+
ids = databasesToDelete
280+
});
281+
282+
var deleteRequest = new HttpRequestMessage(HttpMethod.Delete, databasesPath)
283+
{
284+
Content = deleteContent
285+
};
286+
287+
var deleteResponse = await client.SendAsync(deleteRequest, cancellationToken).ConfigureAwait(false);
288+
deleteResponse.EnsureSuccessStatusCode();
289+
290+
}, cancellationToken).ConfigureAwait(false);
291+
}
292+
245293
await pipeline.ExecuteAsync(async (ctx) =>
246294
{
247295
var response = await client.PostAsync(apiUrl, content, ctx)
248296
.ConfigureAwait(false);
249297

250298
response.EnsureSuccessStatusCode();
251-
}, ct).ConfigureAwait(false);
299+
}, cancellationToken).ConfigureAwait(false);
252300

253301
}
254302
catch (Exception ex)
@@ -259,6 +307,15 @@ await pipeline.ExecuteAsync(async (ctx) =>
259307
}
260308
}
261309

310+
private class RedisDatabaseDto
311+
{
312+
[JsonPropertyName("id")]
313+
public Guid? Id { get; set; }
314+
315+
[JsonPropertyName("name")]
316+
public string? Name { get; set; }
317+
}
318+
262319
/// <summary>
263320
/// Configures the host port that the Redis Commander resource is exposed on instead of using randomly assigned port.
264321
/// </summary>

tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
using StackExchange.Redis;
1616
using Xunit;
1717
using Xunit.Abstractions;
18+
using Aspire.Hosting.Tests.Dcp;
1819

1920
namespace Aspire.Hosting.Redis.Tests;
2021

@@ -120,6 +121,111 @@ public async Task VerifyRedisResource()
120121
Assert.Equal("value", value);
121122
}
122123

124+
[Fact]
125+
[RequiresDocker]
126+
public async Task VerifyDatabasesAreNotDuplicatedForPersistentRedisInsightContainer()
127+
{
128+
var randomResourceSuffix = Random.Shared.Next(10000).ToString();
129+
var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5));
130+
131+
var configure = (DistributedApplicationOptions options) =>
132+
{
133+
options.ContainerRegistryOverride = TestConstants.AspireTestContainerRegistry;
134+
};
135+
136+
using var builder1 = TestDistributedApplicationBuilder.Create(configure, testOutputHelper);
137+
builder1.Configuration[$"DcpPublisher:ResourceNameSuffix"] = randomResourceSuffix;
138+
139+
IResourceBuilder<RedisInsightResource>? redisInsightBuilder = null;
140+
var redis1 = builder1.AddRedis("redisForInsightPersistence")
141+
.WithRedisInsight(c =>
142+
{
143+
redisInsightBuilder = c;
144+
c.WithLifetime(ContainerLifetime.Persistent);
145+
});
146+
147+
// Wire up an additional event subcription to ResourceReadyEvent on the RedisInsightResource
148+
// instance. This works because the ResourceReadyEvent fires non-blocking sequential so the
149+
// wire-up that WithRedisInsight does is guaranteed to execute before this one does. So we then
150+
// use this to block pulling the list of databases until we know they've been updated. This
151+
// will repeated below for the second app.
152+
//
153+
// Issue: https://github.com/dotnet/aspire/issues/6455
154+
Assert.NotNull(redisInsightBuilder);
155+
var redisInsightsReady = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
156+
builder1.Eventing.Subscribe<ResourceReadyEvent>(redisInsightBuilder.Resource, (evt, ct) =>
157+
{
158+
redisInsightsReady.TrySetResult();
159+
return Task.CompletedTask;
160+
});
161+
162+
using var app1 = builder1.Build();
163+
164+
await app1.StartAsync(cts.Token);
165+
166+
await redisInsightsReady.Task.WaitAsync(cts.Token);
167+
168+
using var client1 = app1.CreateHttpClient($"{redis1.Resource.Name}-insight", "http");
169+
var firstRunDatabases = await client1.GetFromJsonAsync<RedisInsightDatabaseModel[]>("/api/databases", cts.Token);
170+
171+
Assert.NotNull(firstRunDatabases);
172+
Assert.Single(firstRunDatabases);
173+
Assert.Equal($"{redis1.Resource.Name}", firstRunDatabases[0].Name);
174+
175+
await app1.StopAsync(cts.Token);
176+
177+
using var builder2 = TestDistributedApplicationBuilder.Create(configure, testOutputHelper);
178+
builder2.Configuration[$"DcpPublisher:ResourceNameSuffix"] = randomResourceSuffix;
179+
180+
var redis2 = builder2.AddRedis("redisForInsightPersistence")
181+
.WithRedisInsight(c =>
182+
{
183+
redisInsightBuilder = c;
184+
c.WithLifetime(ContainerLifetime.Persistent);
185+
});
186+
187+
// Wire up an additional event subcription to ResourceReadyEvent on the RedisInsightResource
188+
// instance. This works because the ResourceReadyEvent fires non-blocking sequential so the
189+
// wire-up that WithRedisInsight does is guaranteed to execute before this one does. So we then
190+
// use this to block pulling the list of databases until we know they've been updated. This
191+
// will repeated below for the second app.
192+
Assert.NotNull(redisInsightBuilder);
193+
redisInsightsReady = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
194+
builder2.Eventing.Subscribe<ResourceReadyEvent>(redisInsightBuilder.Resource, (evt, ct) =>
195+
{
196+
redisInsightsReady.TrySetResult();
197+
return Task.CompletedTask;
198+
});
199+
200+
using var app2 = builder2.Build();
201+
await app2.StartAsync(cts.Token);
202+
203+
await redisInsightsReady.Task.WaitAsync(cts.Token);
204+
205+
using var client2 = app2.CreateHttpClient($"{redisInsightBuilder.Resource.Name}", "http");
206+
var secondRunDatabases = await client2.GetFromJsonAsync<RedisInsightDatabaseModel[]>("/api/databases", cts.Token);
207+
208+
Assert.NotNull(secondRunDatabases);
209+
Assert.Single(secondRunDatabases);
210+
Assert.Equal($"{redis2.Resource.Name}", secondRunDatabases[0].Name);
211+
Assert.NotEqual(secondRunDatabases.Single().Id, firstRunDatabases.Single().Id);
212+
213+
// HACK: This is a workaround for the fact that ApplicationExecutor is not a public type. What I have
214+
// done here is I get the latest event from RNS for the insights instance which gives me the resource
215+
// name as known from a DCP perspective. I then use the ApplicationExecutorProxy (introduced with this
216+
// change to call the ApplicationExecutor stop method. The proxy is a public type with an internal
217+
// constructor inside the Aspire.Hosting.Tests package. This is a short term solution for 9.0 to
218+
// make sure that we have good test coverage for WithRedisInsight behavior, but we need a better
219+
// long term solution in 9.x for folks that will want to do things like execute commands against
220+
// resources to stop specific containers.
221+
var rns = app2.Services.GetRequiredService<ResourceNotificationService>();
222+
var latestEvent = await rns.WaitForResourceHealthyAsync(redisInsightBuilder.Resource.Name, cts.Token);
223+
var executorProxy = app2.Services.GetRequiredService<ApplicationExecutorProxy>();
224+
await executorProxy.StopResourceAsync(latestEvent.ResourceId, cts.Token);
225+
226+
await app2.StopAsync(cts.Token);
227+
}
228+
123229
[Fact]
124230
[RequiresDocker]
125231
public async Task VerifyWithRedisInsightImportDatabases()
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using Aspire.Hosting.Dcp;
5+
6+
namespace Aspire.Hosting.Tests.Dcp;
7+
8+
public class ApplicationExecutorProxy
9+
{
10+
internal ApplicationExecutorProxy(ApplicationExecutor executor)
11+
{
12+
_executor = executor;
13+
}
14+
15+
private readonly ApplicationExecutor _executor;
16+
17+
public Task StartResourceAsync(string resourceName, CancellationToken cancellationToken) => _executor.StartResourceAsync(resourceName, cancellationToken);
18+
19+
public Task StopResourceAsync(string resourceName, CancellationToken cancellationToken) => _executor.StopResourceAsync(resourceName, cancellationToken);
20+
}

tests/Aspire.Hosting.Tests/Utils/TestDistributedApplicationBuilder.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@
66
using System.Reflection;
77
using Aspire.Components.Common.Tests;
88
using Aspire.Hosting.Dashboard;
9+
using Aspire.Hosting.Dcp;
910
using Aspire.Hosting.Eventing;
1011
using Aspire.Hosting.Testing;
12+
using Aspire.Hosting.Tests.Dcp;
1113
using Microsoft.Extensions.Configuration;
1214
using Microsoft.Extensions.DependencyInjection;
1315
using Microsoft.Extensions.Hosting;
@@ -76,6 +78,8 @@ private TestDistributedApplicationBuilder(Action<DistributedApplicationOptions>?
7678
o.OtlpGrpcEndpointUrl ??= "http://localhost:4317";
7779
});
7880

81+
_innerBuilder.Services.AddSingleton<ApplicationExecutorProxy>(sp => new ApplicationExecutorProxy(sp.GetRequiredService<ApplicationExecutor>()));
82+
7983
_innerBuilder.Services.AddHttpClient();
8084
_innerBuilder.Services.ConfigureHttpClientDefaults(http => http.AddStandardResilienceHandler());
8185
if (testOutputHelper is not null)

0 commit comments

Comments
 (0)