Skip to content
233 changes: 23 additions & 210 deletions src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -230,223 +224,42 @@ public static IResourceBuilder<RedisResource> 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();

// 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<AfterEndpointsAllocatedEvent>((e, ct) =>
{
endpointsAllocatedTcs.TrySetResult();
return Task.CompletedTask;
});

builder.ApplicationBuilder.Eventing.Subscribe<ResourceReadyEvent>(resource, async (e, ct) =>
{
var redisInstances = builder.ApplicationBuilder.Resources.OfType<RedisResource>();

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;
}

// Wait for all endpoints to be allocated before attempting to import databases
await endpointsAllocatedTcs.Task.ConfigureAwait(false);

var redisInsightResource = builder.ApplicationBuilder.Resources.OfType<RedisInsightResource>().Single();
var insightEndpoint = redisInsightResource.PrimaryEndpoint;

using var client = new HttpClient();
client.BaseAddress = new Uri($"{insightEndpoint.Scheme}://{insightEndpoint.Host}:{insightEndpoint.Port}");
var redisInstances = builder.ApplicationBuilder.Resources.OfType<RedisResource>();

var rls = e.Services.GetRequiredService<ResourceLoggerService>();
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<RedisResource> 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<RedisDatabaseDto[]>(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<Guid>();

using var writer = new Utf8JsonWriter(stream);

writer.WriteStartArray();

foreach (var redisResource in redisInstances)
{
if (lookup is { } && lookup.Contains(redisResource.Name))
if (!redisInstances.Any())
{
// 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]);
// No-op if there are no Redis resources present.
return;
}

if (redisResource.PrimaryEndpoint.IsAllocated)
var counter = 1;

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

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

/// <summary>
/// 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.
/// </summary>
private static async Task InitializeRedisInsightSettings(HttpClient client, ILogger resourceLogger, CancellationToken ct)
{
if (await AreSettingsInitialized(client, ct).ConfigureAwait(false))
{
return;
}

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<bool> 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; }
}

/// <summary>
Expand Down
4 changes: 2 additions & 2 deletions src/Aspire.Hosting.Redis/RedisContainerImageTags.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,6 @@ internal static class RedisContainerImageTags
/// <remarks>redis/redisinsight</remarks>
public const string RedisInsightImage = "redis/redisinsight";

/// <remarks>2.66</remarks>
public const string RedisInsightTag = "2.66";
/// <remarks>2.68</remarks>
public const string RedisInsightTag = "2.68";
}
63 changes: 62 additions & 1 deletion tests/Aspire.Hosting.Redis.Tests/AddRedisTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,67 @@ public void WithRedisInsightAddsWithRedisInsightResource()
Assert.Single(builder.Resources.OfType<RedisInsightResource>());
}

[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<AfterEndpointsAllocatedEvent>(new(app.Services, app.Services.GetRequiredService<DistributedApplicationModel>()));

var redisInsight = Assert.Single(builder.Resources.OfType<RedisInsightResource>());
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()
{
Expand Down Expand Up @@ -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<AfterEndpointsAllocatedEvent>(new (app.Services, app.Services.GetRequiredService<DistributedApplicationModel>()));
await builder.Eventing.PublishAsync<AfterEndpointsAllocatedEvent>(new(app.Services, app.Services.GetRequiredService<DistributedApplicationModel>()));

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

Expand Down
Loading
Loading