|
2 | 2 | // The .NET Foundation licenses this file to you under the MIT license. |
3 | 3 |
|
4 | 4 | using System.Globalization; |
5 | | -using System.Net.Http.Json; |
6 | 5 | using System.Text; |
7 | | -using System.Text.Json; |
8 | | -using System.Text.Json.Nodes; |
9 | | -using System.Text.Json.Serialization; |
10 | 6 | using Aspire.Hosting; |
11 | 7 | using Aspire.Hosting.ApplicationModel; |
12 | 8 | using Aspire.Hosting.Redis; |
13 | 9 | using Microsoft.Extensions.DependencyInjection; |
14 | | -using Microsoft.Extensions.Logging; |
15 | | -using Polly; |
16 | 10 |
|
17 | 11 | namespace Aspire.Hosting; |
18 | 12 |
|
@@ -230,223 +224,42 @@ public static IResourceBuilder<RedisResource> WithRedisInsight(this IResourceBui |
230 | 224 |
|
231 | 225 | var resource = new RedisInsightResource(containerName); |
232 | 226 | var resourceBuilder = builder.ApplicationBuilder.AddResource(resource) |
233 | | - .WithImage(RedisContainerImageTags.RedisInsightImage, RedisContainerImageTags.RedisInsightTag) |
234 | | - .WithImageRegistry(RedisContainerImageTags.RedisInsightRegistry) |
235 | | - .WithHttpEndpoint(targetPort: 5540, name: "http") |
236 | | - .ExcludeFromManifest(); |
237 | | - |
238 | | - // We need to wait for all endpoints to be allocated before attempting to import databases |
239 | | - var endpointsAllocatedTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); |
240 | | - |
241 | | - builder.ApplicationBuilder.Eventing.Subscribe<AfterEndpointsAllocatedEvent>((e, ct) => |
242 | | - { |
243 | | - endpointsAllocatedTcs.TrySetResult(); |
244 | | - return Task.CompletedTask; |
245 | | - }); |
246 | | - |
247 | | - builder.ApplicationBuilder.Eventing.Subscribe<ResourceReadyEvent>(resource, async (e, ct) => |
248 | | - { |
249 | | - var redisInstances = builder.ApplicationBuilder.Resources.OfType<RedisResource>(); |
250 | | - |
251 | | - if (!redisInstances.Any()) |
| 227 | + .WithImage(RedisContainerImageTags.RedisInsightImage, RedisContainerImageTags.RedisInsightTag) |
| 228 | + .WithImageRegistry(RedisContainerImageTags.RedisInsightRegistry) |
| 229 | + .WithHttpEndpoint(targetPort: 5540, name: "http") |
| 230 | + .WithEnvironment(context => |
252 | 231 | { |
253 | | - // No-op if there are no Redis resources present. |
254 | | - return; |
255 | | - } |
256 | | - |
257 | | - // Wait for all endpoints to be allocated before attempting to import databases |
258 | | - await endpointsAllocatedTcs.Task.ConfigureAwait(false); |
259 | | - |
260 | | - var redisInsightResource = builder.ApplicationBuilder.Resources.OfType<RedisInsightResource>().Single(); |
261 | | - var insightEndpoint = redisInsightResource.PrimaryEndpoint; |
262 | | - |
263 | | - using var client = new HttpClient(); |
264 | | - client.BaseAddress = new Uri($"{insightEndpoint.Scheme}://{insightEndpoint.Host}:{insightEndpoint.Port}"); |
| 232 | + var redisInstances = builder.ApplicationBuilder.Resources.OfType<RedisResource>(); |
265 | 233 |
|
266 | | - var rls = e.Services.GetRequiredService<ResourceLoggerService>(); |
267 | | - var resourceLogger = rls.GetLogger(resource); |
268 | | - |
269 | | - await ImportRedisDatabases(resourceLogger, redisInstances, client, ct).ConfigureAwait(false); |
270 | | - }); |
271 | | - |
272 | | - resourceBuilder.WithRelationship(builder.Resource, "RedisInsight"); |
273 | | - |
274 | | - configureContainer?.Invoke(resourceBuilder); |
275 | | - |
276 | | - return builder; |
277 | | - } |
278 | | - |
279 | | - static async Task ImportRedisDatabases(ILogger resourceLogger, IEnumerable<RedisResource> redisInstances, HttpClient client, CancellationToken cancellationToken) |
280 | | - { |
281 | | - var databasesPath = "/api/databases"; |
282 | | - |
283 | | - var pipeline = new ResiliencePipelineBuilder().AddRetry(new Polly.Retry.RetryStrategyOptions |
284 | | - { |
285 | | - Delay = TimeSpan.FromSeconds(2), |
286 | | - MaxRetryAttempts = 5, |
287 | | - }).Build(); |
288 | | - |
289 | | - await pipeline.ExecuteAsync(async (ctx) => |
290 | | - { |
291 | | - await InitializeRedisInsightSettings(client, resourceLogger, ctx).ConfigureAwait(false); |
292 | | - }, cancellationToken).ConfigureAwait(false); |
293 | | - |
294 | | - using (var stream = new MemoryStream()) |
295 | | - { |
296 | | - // As part of configuring RedisInsight we need to factor in the possibility that the |
297 | | - // container resource is being run with persistence turned on. In this case we need |
298 | | - // to get the list of existing databases because we might need to delete some. |
299 | | - var lookup = await pipeline.ExecuteAsync(async (ctx) => |
300 | | - { |
301 | | - var getDatabasesResponse = await client.GetFromJsonAsync<RedisDatabaseDto[]>(databasesPath, cancellationToken).ConfigureAwait(false); |
302 | | - return getDatabasesResponse?.ToLookup( |
303 | | - i => i.Name ?? throw new InvalidDataException("Database name is missing."), |
304 | | - i => i.Id ?? throw new InvalidDataException("Database ID is missing.")); |
305 | | - }, cancellationToken).ConfigureAwait(false); |
306 | | - |
307 | | - var databasesToDelete = new List<Guid>(); |
308 | | - |
309 | | - using var writer = new Utf8JsonWriter(stream); |
310 | | - |
311 | | - writer.WriteStartArray(); |
312 | | - |
313 | | - foreach (var redisResource in redisInstances) |
314 | | - { |
315 | | - if (lookup is { } && lookup.Contains(redisResource.Name)) |
| 234 | + if (!redisInstances.Any()) |
316 | 235 | { |
317 | | - // It is possible that there are multiple databases with |
318 | | - // a conflicting name so we delete them all. This just keeps |
319 | | - // track of the specific ID that we need to delete. |
320 | | - databasesToDelete.AddRange(lookup[redisResource.Name]); |
| 236 | + // No-op if there are no Redis resources present. |
| 237 | + return; |
321 | 238 | } |
322 | 239 |
|
323 | | - if (redisResource.PrimaryEndpoint.IsAllocated) |
| 240 | + var counter = 1; |
| 241 | + |
| 242 | + foreach (var redisInstance in redisInstances) |
324 | 243 | { |
325 | | - var endpoint = redisResource.PrimaryEndpoint; |
326 | | - writer.WriteStartObject(); |
327 | | - |
328 | | - writer.WriteString("host", redisResource.Name); |
329 | | - writer.WriteNumber("port", endpoint.TargetPort!.Value); |
330 | | - writer.WriteString("name", redisResource.Name); |
331 | | - writer.WriteNumber("db", 0); |
332 | | - writer.WriteNull("username"); |
333 | | - if (redisResource.PasswordParameter is { } passwordParam) |
334 | | - { |
335 | | - writer.WriteString("password", passwordParam.Value); |
336 | | - } |
337 | | - else |
| 244 | + // RedisInsight assumes Redis is being accessed over a default Aspire container network and hardcodes the resource address |
| 245 | + context.EnvironmentVariables.Add($"RI_REDIS_HOST{counter}", redisInstance.Name); |
| 246 | + context.EnvironmentVariables.Add($"RI_REDIS_PORT{counter}", redisInstance.PrimaryEndpoint.TargetPort!.Value); |
| 247 | + context.EnvironmentVariables.Add($"RI_REDIS_ALIAS{counter}", redisInstance.Name); |
| 248 | + if (redisInstance.PasswordParameter is not null) |
338 | 249 | { |
339 | | - writer.WriteNull("password"); |
| 250 | + context.EnvironmentVariables.Add($"RI_REDIS_PASSWORD{counter}", redisInstance.PasswordParameter.Value); |
340 | 251 | } |
341 | | - writer.WriteString("connectionType", "STANDALONE"); |
342 | | - writer.WriteEndObject(); |
343 | | - } |
344 | | - } |
345 | | - writer.WriteEndArray(); |
346 | | - await writer.FlushAsync(cancellationToken).ConfigureAwait(false); |
347 | | - stream.Seek(0, SeekOrigin.Begin); |
348 | | - |
349 | | - var content = new MultipartFormDataContent(); |
350 | | - |
351 | | - var fileContent = new StreamContent(stream); |
352 | | - |
353 | | - content.Add(fileContent, "file", "RedisInsight_connections.json"); |
354 | | - |
355 | | - var apiUrl = $"{databasesPath}/import"; |
356 | 252 |
|
357 | | - try |
358 | | - { |
359 | | - if (databasesToDelete.Any()) |
360 | | - { |
361 | | - await pipeline.ExecuteAsync(async (ctx) => |
362 | | - { |
363 | | - // Create a DELETE request to send to the existing instance of |
364 | | - // RedisInsight with the IDs of the database to delete. |
365 | | - var deleteContent = JsonContent.Create(new |
366 | | - { |
367 | | - ids = databasesToDelete |
368 | | - }); |
369 | | - |
370 | | - var deleteRequest = new HttpRequestMessage(HttpMethod.Delete, databasesPath) |
371 | | - { |
372 | | - Content = deleteContent |
373 | | - }; |
374 | | - |
375 | | - var deleteResponse = await client.SendAsync(deleteRequest, cancellationToken).ConfigureAwait(false); |
376 | | - deleteResponse.EnsureSuccessStatusCode(); |
377 | | - |
378 | | - }, cancellationToken).ConfigureAwait(false); |
| 253 | + counter++; |
379 | 254 | } |
| 255 | + }) |
| 256 | + .WithRelationship(builder.Resource, "RedisInsight") |
| 257 | + .ExcludeFromManifest(); |
380 | 258 |
|
381 | | - await pipeline.ExecuteAsync(async (ctx) => |
382 | | - { |
383 | | - var response = await client.PostAsync(apiUrl, content, ctx) |
384 | | - .ConfigureAwait(false); |
385 | | - |
386 | | - response.EnsureSuccessStatusCode(); |
387 | | - }, cancellationToken).ConfigureAwait(false); |
388 | | - |
389 | | - } |
390 | | - catch (Exception ex) |
391 | | - { |
392 | | - resourceLogger.LogError("Could not import Redis databases into RedisInsight. Reason: {reason}", ex.Message); |
393 | | - } |
394 | | - } |
395 | | - } |
396 | | - } |
397 | | - |
398 | | - /// <summary> |
399 | | - /// Initializes the Redis Insight settings to work around https://github.com/RedisInsight/RedisInsight/issues/3452. |
400 | | - /// Redis Insight requires the encryption property to be set if the Redis database connection contains a password. |
401 | | - /// </summary> |
402 | | - private static async Task InitializeRedisInsightSettings(HttpClient client, ILogger resourceLogger, CancellationToken ct) |
403 | | - { |
404 | | - if (await AreSettingsInitialized(client, ct).ConfigureAwait(false)) |
405 | | - { |
406 | | - return; |
407 | | - } |
408 | | - |
409 | | - var jsonContent = JsonContent.Create(new |
410 | | - { |
411 | | - agreements = new |
412 | | - { |
413 | | - // all 4 are required to be set |
414 | | - eula = false, |
415 | | - analytics = false, |
416 | | - notifications = false, |
417 | | - encryption = false, |
418 | | - } |
419 | | - }); |
| 259 | + configureContainer?.Invoke(resourceBuilder); |
420 | 260 |
|
421 | | - var response = await client.PatchAsync("/api/settings", jsonContent, ct).ConfigureAwait(false); |
422 | | - if (!response.IsSuccessStatusCode) |
423 | | - { |
424 | | - resourceLogger.LogDebug("Could not initialize RedisInsight settings. Reason: {reason}", await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false)); |
| 261 | + return builder; |
425 | 262 | } |
426 | | - |
427 | | - response.EnsureSuccessStatusCode(); |
428 | | - } |
429 | | - |
430 | | - private static async Task<bool> AreSettingsInitialized(HttpClient client, CancellationToken ct) |
431 | | - { |
432 | | - var response = await client.GetAsync("/api/settings", ct).ConfigureAwait(false); |
433 | | - response.EnsureSuccessStatusCode(); |
434 | | - |
435 | | - var content = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); |
436 | | - |
437 | | - var jsonResponse = JsonNode.Parse(content); |
438 | | - var agreements = jsonResponse?["agreements"]; |
439 | | - |
440 | | - return agreements is not null; |
441 | | - } |
442 | | - |
443 | | - private class RedisDatabaseDto |
444 | | - { |
445 | | - [JsonPropertyName("id")] |
446 | | - public Guid? Id { get; set; } |
447 | | - |
448 | | - [JsonPropertyName("name")] |
449 | | - public string? Name { get; set; } |
450 | 263 | } |
451 | 264 |
|
452 | 265 | /// <summary> |
|
0 commit comments