Skip to content

Commit c09db91

Browse files
Alirexaaeerhardt
andauthored
Change Redis Insights to use environment variables for preconfigured database connections (#8524)
This pull request includes significant changes to the Aspire.Hosting.Redis library, focusing on simplifying the WithRedisInsight method, updating the RedisInsight image tag, and improving test coverage. The most important changes are grouped into codebase simplification, image tag updates, and test improvements. Updated the RedisInsight image tag from `2.66` to `2.68`. * Change Redis Insights to use environments variable for preconfigure database connections * Remove no need tests * revert playground app * Fix test * Remove unused class * Add test * Address Copilot feedback * Add resilience pipline to test * Address PR feedback * Wait for Running state in tests * Set permissions for bind mount * Address PR feedback. - Remove IsAllocated check - Minor code clean up. Fixes #8506 Fixes #7291 Fixes #6099 Fixes #7176 --------- Co-authored-by: Eric Erhardt <[email protected]>
1 parent 85567dc commit c09db91

File tree

4 files changed

+136
-378
lines changed

4 files changed

+136
-378
lines changed

src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs

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

44
using System.Globalization;
5-
using System.Net.Http.Json;
65
using System.Text;
7-
using System.Text.Json;
8-
using System.Text.Json.Nodes;
9-
using System.Text.Json.Serialization;
106
using Aspire.Hosting;
117
using Aspire.Hosting.ApplicationModel;
128
using Aspire.Hosting.Redis;
139
using Microsoft.Extensions.DependencyInjection;
14-
using Microsoft.Extensions.Logging;
15-
using Polly;
1610

1711
namespace Aspire.Hosting;
1812

@@ -230,223 +224,42 @@ public static IResourceBuilder<RedisResource> WithRedisInsight(this IResourceBui
230224

231225
var resource = new RedisInsightResource(containerName);
232226
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 =>
252231
{
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>();
265233

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())
316235
{
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;
321238
}
322239

323-
if (redisResource.PrimaryEndpoint.IsAllocated)
240+
var counter = 1;
241+
242+
foreach (var redisInstance in redisInstances)
324243
{
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)
338249
{
339-
writer.WriteNull("password");
250+
context.EnvironmentVariables.Add($"RI_REDIS_PASSWORD{counter}", redisInstance.PasswordParameter.Value);
340251
}
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";
356252

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++;
379254
}
255+
})
256+
.WithRelationship(builder.Resource, "RedisInsight")
257+
.ExcludeFromManifest();
380258

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);
420260

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;
425262
}
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; }
450263
}
451264

452265
/// <summary>

src/Aspire.Hosting.Redis/RedisContainerImageTags.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,6 @@ internal static class RedisContainerImageTags
2929
/// <remarks>redis/redisinsight</remarks>
3030
public const string RedisInsightImage = "redis/redisinsight";
3131

32-
/// <remarks>2.66</remarks>
33-
public const string RedisInsightTag = "2.66";
32+
/// <remarks>2.68</remarks>
33+
public const string RedisInsightTag = "2.68";
3434
}

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

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,67 @@ public void WithRedisInsightAddsWithRedisInsightResource()
254254
Assert.Single(builder.Resources.OfType<RedisInsightResource>());
255255
}
256256

257+
[Fact]
258+
public async Task WithRedisInsightProducesCorrectEnvironmentVariables()
259+
{
260+
var builder = DistributedApplication.CreateBuilder();
261+
var redis1 = builder.AddRedis("myredis1").WithRedisInsight();
262+
var redis2 = builder.AddRedis("myredis2").WithRedisInsight();
263+
using var app = builder.Build();
264+
265+
// Add fake allocated endpoints.
266+
redis1.WithEndpoint("tcp", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 5001));
267+
redis2.WithEndpoint("tcp", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 5002));
268+
269+
await builder.Eventing.PublishAsync<AfterEndpointsAllocatedEvent>(new(app.Services, app.Services.GetRequiredService<DistributedApplicationModel>()));
270+
271+
var redisInsight = Assert.Single(builder.Resources.OfType<RedisInsightResource>());
272+
var envs = await redisInsight.GetEnvironmentVariableValuesAsync();
273+
274+
Assert.Collection(envs,
275+
(item) =>
276+
{
277+
Assert.Equal("RI_REDIS_HOST1", item.Key);
278+
Assert.Equal(redis1.Resource.Name, item.Value);
279+
},
280+
(item) =>
281+
{
282+
Assert.Equal("RI_REDIS_PORT1", item.Key);
283+
Assert.Equal($"{redis1.Resource.PrimaryEndpoint.TargetPort!.Value}", item.Value);
284+
},
285+
(item) =>
286+
{
287+
Assert.Equal("RI_REDIS_ALIAS1", item.Key);
288+
Assert.Equal(redis1.Resource.Name, item.Value);
289+
},
290+
(item) =>
291+
{
292+
Assert.Equal("RI_REDIS_PASSWORD1", item.Key);
293+
Assert.Equal(redis1.Resource.PasswordParameter!.Value, item.Value);
294+
},
295+
(item) =>
296+
{
297+
Assert.Equal("RI_REDIS_HOST2", item.Key);
298+
Assert.Equal(redis2.Resource.Name, item.Value);
299+
},
300+
(item) =>
301+
{
302+
Assert.Equal("RI_REDIS_PORT2", item.Key);
303+
Assert.Equal($"{redis2.Resource.PrimaryEndpoint.TargetPort!.Value}", item.Value);
304+
},
305+
(item) =>
306+
{
307+
Assert.Equal("RI_REDIS_ALIAS2", item.Key);
308+
Assert.Equal(redis2.Resource.Name, item.Value);
309+
},
310+
(item) =>
311+
{
312+
Assert.Equal("RI_REDIS_PASSWORD2", item.Key);
313+
Assert.Equal(redis2.Resource.PasswordParameter!.Value, item.Value);
314+
});
315+
316+
}
317+
257318
[Fact]
258319
public void WithRedisCommanderSupportsChangingContainerImageValues()
259320
{
@@ -373,7 +434,7 @@ public async Task MultipleRedisInstanceProducesCorrectRedisHostsVariable()
373434
redis1.WithEndpoint("tcp", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 5001));
374435
redis2.WithEndpoint("tcp", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 5002, "host2"));
375436

376-
await builder.Eventing.PublishAsync<AfterEndpointsAllocatedEvent>(new (app.Services, app.Services.GetRequiredService<DistributedApplicationModel>()));
437+
await builder.Eventing.PublishAsync<AfterEndpointsAllocatedEvent>(new(app.Services, app.Services.GetRequiredService<DistributedApplicationModel>()));
377438

378439
var commander = builder.Resources.Single(r => r.Name.EndsWith("-commander"));
379440

0 commit comments

Comments
 (0)