diff --git a/playground/AzureContainerApps/AzureContainerApps.AppHost/api.module.bicep b/playground/AzureContainerApps/AzureContainerApps.AppHost/api.module.bicep index fddffaab0d6..468a6b446b0 100644 --- a/playground/AzureContainerApps/AzureContainerApps.AppHost/api.module.bicep +++ b/playground/AzureContainerApps/AzureContainerApps.AppHost/api.module.bicep @@ -5,6 +5,9 @@ param api_containerport string param storage_outputs_blobendpoint string +@secure() +param cache_password_value string + param account_outputs_connectionstring string @secure() @@ -30,6 +33,10 @@ resource api 'Microsoft.App/containerApps@2024-03-01' = { properties: { configuration: { secrets: [ + { + name: 'connectionstrings--cache' + value: 'cache:6379,password=${cache_password_value}' + } { name: 'value' value: secretparam_value @@ -88,7 +95,7 @@ resource api 'Microsoft.App/containerApps@2024-03-01' = { } { name: 'ConnectionStrings__cache' - value: 'cache:6379' + secretRef: 'connectionstrings--cache' } { name: 'ConnectionStrings__account' diff --git a/playground/AzureContainerApps/AzureContainerApps.AppHost/aspire-manifest.json b/playground/AzureContainerApps/AzureContainerApps.AppHost/aspire-manifest.json index 8bfbe816dba..07df304ddb8 100644 --- a/playground/AzureContainerApps/AzureContainerApps.AppHost/aspire-manifest.json +++ b/playground/AzureContainerApps/AzureContainerApps.AppHost/aspire-manifest.json @@ -31,22 +31,23 @@ }, "cache": { "type": "container.v1", - "connectionString": "{cache.bindings.tcp.host}:{cache.bindings.tcp.port}", + "connectionString": "{cache.bindings.tcp.host}:{cache.bindings.tcp.port},password={cache-password.value}", "image": "docker.io/library/redis:7.4", "deployment": { "type": "azure.bicep.v0", "path": "cache.module.bicep", "params": { "cache_volumes_0_storage": "{cache.volumes.0.storage}", + "cache_password_value": "{cache-password.value}", "outputs_azure_container_registry_managed_identity_id": "{.outputs.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID}", "outputs_managed_identity_client_id": "{.outputs.MANAGED_IDENTITY_CLIENT_ID}", "outputs_azure_container_apps_environment_id": "{.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_ID}" } }, + "entrypoint": "/bin/sh", "args": [ - "--save", - "60", - "1" + "-c", + "redis-server --requirepass $REDIS_PASSWORD --save 60 1" ], "volumes": [ { @@ -55,6 +56,9 @@ "readOnly": false } ], + "env": { + "REDIS_PASSWORD": "{cache-password.value}" + }, "bindings": { "tcp": { "scheme": "tcp", @@ -116,6 +120,7 @@ "params": { "api_containerport": "{api.containerPort}", "storage_outputs_blobendpoint": "{storage.outputs.blobEndpoint}", + "cache_password_value": "{cache-password.value}", "account_outputs_connectionstring": "{account.outputs.connectionString}", "secretparam_value": "{secretparam.value}", "outputs_azure_container_registry_managed_identity_id": "{.outputs.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID}", @@ -152,6 +157,22 @@ "external": true } } + }, + "cache-password": { + "type": "parameter.v0", + "value": "{cache-password.inputs.value}", + "inputs": { + "value": { + "type": "string", + "secret": true, + "default": { + "generate": { + "minLength": 22, + "special": false + } + } + } + } } } } \ No newline at end of file diff --git a/playground/AzureContainerApps/AzureContainerApps.AppHost/cache.module.bicep b/playground/AzureContainerApps/AzureContainerApps.AppHost/cache.module.bicep index 40c90e8d434..10045f22d38 100644 --- a/playground/AzureContainerApps/AzureContainerApps.AppHost/cache.module.bicep +++ b/playground/AzureContainerApps/AzureContainerApps.AppHost/cache.module.bicep @@ -3,6 +3,9 @@ param location string = resourceGroup().location param cache_volumes_0_storage string +@secure() +param cache_password_value string + param outputs_azure_container_registry_managed_identity_id string param outputs_managed_identity_client_id string @@ -14,6 +17,12 @@ resource cache 'Microsoft.App/containerApps@2024-03-01' = { location: location properties: { configuration: { + secrets: [ + { + name: 'redis-password' + value: cache_password_value + } + ] activeRevisionsMode: 'Single' ingress: { external: false @@ -27,12 +36,18 @@ resource cache 'Microsoft.App/containerApps@2024-03-01' = { { image: 'docker.io/library/redis:7.4' name: 'cache' + command: [ + '/bin/sh' + ] args: [ - '--save' - '60' - '1' + '-c' + 'redis-server --requirepass \$REDIS_PASSWORD --save 60 1' ] env: [ + { + name: 'REDIS_PASSWORD' + secretRef: 'redis-password' + } { name: 'AZURE_CLIENT_ID' value: outputs_managed_identity_client_id diff --git a/playground/ProxylessEndToEnd/ProxylessEndToEnd.AppHost/aspire-manifest.json b/playground/ProxylessEndToEnd/ProxylessEndToEnd.AppHost/aspire-manifest.json index 9829077ecca..cce302c6318 100644 --- a/playground/ProxylessEndToEnd/ProxylessEndToEnd.AppHost/aspire-manifest.json +++ b/playground/ProxylessEndToEnd/ProxylessEndToEnd.AppHost/aspire-manifest.json @@ -3,8 +3,16 @@ "resources": { "redis": { "type": "container.v0", - "connectionString": "{redis.bindings.tcp.host}:{redis.bindings.tcp.port}", + "connectionString": "{redis.bindings.tcp.host}:{redis.bindings.tcp.port},password={redis-password.value}", "image": "docker.io/library/redis:7.4", + "entrypoint": "/bin/sh", + "args": [ + "-c", + "redis-server --requirepass $REDIS_PASSWORD" + ], + "env": { + "REDIS_PASSWORD": "{redis-password.value}" + }, "bindings": { "tcp": { "scheme": "tcp", @@ -59,6 +67,22 @@ "port": 13456 } } + }, + "redis-password": { + "type": "parameter.v0", + "value": "{redis-password.inputs.value}", + "inputs": { + "value": { + "type": "string", + "secret": true, + "default": { + "generate": { + "minLength": 22, + "special": false + } + } + } + } } } } \ No newline at end of file diff --git a/playground/Redis/Redis.AppHost/Program.cs b/playground/Redis/Redis.AppHost/Program.cs index 1466fb3cb27..caa7d08b6fb 100644 --- a/playground/Redis/Redis.AppHost/Program.cs +++ b/playground/Redis/Redis.AppHost/Program.cs @@ -12,6 +12,7 @@ .WithDataVolume("valkey-data"); builder.AddProject("apiservice") + .WithExternalHttpEndpoints() .WithReference(redis).WaitFor(redis) .WithReference(garnet).WaitFor(garnet) .WithReference(valkey).WaitFor(valkey); diff --git a/playground/Redis/Redis.AppHost/aspire-manifest.json b/playground/Redis/Redis.AppHost/aspire-manifest.json index aae9cf53447..0ddf822b615 100644 --- a/playground/Redis/Redis.AppHost/aspire-manifest.json +++ b/playground/Redis/Redis.AppHost/aspire-manifest.json @@ -3,12 +3,12 @@ "resources": { "redis": { "type": "container.v0", - "connectionString": "{redis.bindings.tcp.host}:{redis.bindings.tcp.port}", + "connectionString": "{redis.bindings.tcp.host}:{redis.bindings.tcp.port},password={redis-password.value}", "image": "docker.io/library/redis:7.4", + "entrypoint": "/bin/sh", "args": [ - "--save", - "60", - "1" + "-c", + "redis-server --requirepass $REDIS_PASSWORD --save 60 1" ], "volumes": [ { @@ -17,6 +17,9 @@ "readOnly": false } ], + "env": { + "REDIS_PASSWORD": "{redis-password.value}" + }, "bindings": { "tcp": { "scheme": "tcp", @@ -96,12 +99,30 @@ "http": { "scheme": "http", "protocol": "tcp", - "transport": "http" + "transport": "http", + "external": true }, "https": { "scheme": "https", "protocol": "tcp", - "transport": "http" + "transport": "http", + "external": true + } + } + }, + "redis-password": { + "type": "parameter.v0", + "value": "{redis-password.inputs.value}", + "inputs": { + "value": { + "type": "string", + "secret": true, + "default": { + "generate": { + "minLength": 22, + "special": false + } + } } } } diff --git a/playground/TestShop/TestShop.AppHost/aspire-manifest.json b/playground/TestShop/TestShop.AppHost/aspire-manifest.json index e820a18c6fa..43434a16965 100644 --- a/playground/TestShop/TestShop.AppHost/aspire-manifest.json +++ b/playground/TestShop/TestShop.AppHost/aspire-manifest.json @@ -33,12 +33,12 @@ }, "basketcache": { "type": "container.v0", - "connectionString": "{basketcache.bindings.tcp.host}:{basketcache.bindings.tcp.port}", + "connectionString": "{basketcache.bindings.tcp.host}:{basketcache.bindings.tcp.port},password={basketcache-password.value}", "image": "docker.io/library/redis:7.4", + "entrypoint": "/bin/sh", "args": [ - "--save", - "60", - "1" + "-c", + "redis-server --requirepass $REDIS_PASSWORD --save 60 1" ], "volumes": [ { @@ -47,6 +47,9 @@ "readOnly": false } ], + "env": { + "REDIS_PASSWORD": "{basketcache-password.value}" + }, "bindings": { "tcp": { "scheme": "tcp", @@ -240,6 +243,22 @@ } } }, + "basketcache-password": { + "type": "parameter.v0", + "value": "{basketcache-password.inputs.value}", + "inputs": { + "value": { + "type": "string", + "secret": true, + "default": { + "generate": { + "minLength": 22, + "special": false + } + } + } + } + }, "messaging-password": { "type": "parameter.v0", "value": "{messaging-password.inputs.value}", diff --git a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppsInfrastructure.cs b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppsInfrastructure.cs index 1089ad7bb4e..465663dced8 100644 --- a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppsInfrastructure.cs +++ b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppsInfrastructure.cs @@ -608,6 +608,7 @@ BicepValue GetHostValue(string? prefix = null, string? suffix = null) EndpointProperty.Url => GetHostValue($"{scheme}://", suffix: isHttpIngress ? null : $":{port}"), EndpointProperty.Host or EndpointProperty.IPV4Host => GetHostValue(), EndpointProperty.Port => port.ToString(CultureInfo.InvariantCulture), + EndpointProperty.HostAndPort => GetHostValue(suffix: $":{port}"), EndpointProperty.TargetPort => targetPort is null ? AllocateContainerPortParameter() : targetPort, EndpointProperty.Scheme => scheme, _ => throw new NotSupportedException(), diff --git a/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs b/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs index 9ae180aa3bf..9995f03f18d 100644 --- a/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs +++ b/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs @@ -5,6 +5,7 @@ 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; @@ -36,11 +37,43 @@ public static class RedisBuilderExtensions /// /// This version of the package defaults to the tag of the container image. /// - public static IResourceBuilder AddRedis(this IDistributedApplicationBuilder builder, [ResourceName] string name, int? port = null) + public static IResourceBuilder AddRedis(this IDistributedApplicationBuilder builder, [ResourceName] string name, int? port) + { + return builder.AddRedis(name, port, null); + } + + /// + /// Adds a Redis container to the application model. + /// + /// The . + /// The name of the resource. This name will be used as the connection string name when referenced in a dependency. + /// The host port to bind the underlying container to. + /// The parameter used to provide the password for the Redis resource. If a random password will be generated. + /// A reference to the . + /// + /// + /// This resource includes built-in health checks. When this resource is referenced as a dependency + /// using the + /// extension method then the dependent resource will wait until the Redis resource is able to service + /// requests. + /// + /// This version of the package defaults to the tag of the container image. + /// + public static IResourceBuilder AddRedis( + this IDistributedApplicationBuilder builder, + [ResourceName] string name, + int? port = null, + IResourceBuilder? password = null) { ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(name); + + // StackExchange.Redis doesn't support passwords with commas. + // See https://github.com/StackExchange/StackExchange.Redis/issues/680 and + // https://github.com/Azure/azure-dev/issues/4848 + var passwordParameter = password?.Resource ?? ParameterResourceBuilderExtensions.CreateDefaultPasswordParameter(builder, $"{name}-password", special: false); - var redis = new RedisResource(name); + var redis = new RedisResource(name, passwordParameter); string? connectionString = null; @@ -61,7 +94,43 @@ public static IResourceBuilder AddRedis(this IDistributedApplicat .WithEndpoint(port: port, targetPort: 6379, name: RedisResource.PrimaryEndpointName) .WithImage(RedisContainerImageTags.Image, RedisContainerImageTags.Tag) .WithImageRegistry(RedisContainerImageTags.Registry) - .WithHealthCheck(healthCheckKey); + .WithHealthCheck(healthCheckKey) + // see https://github.com/dotnet/aspire/issues/3838 for why the password is passed this way + .WithEntrypoint("/bin/sh") + .WithEnvironment(context => + { + if (redis.PasswordParameter is { } password) + { + context.EnvironmentVariables["REDIS_PASSWORD"] = password; + } + }) + .WithArgs(context => + { + var redisCommand = new List + { + "redis-server" + }; + + if (redis.PasswordParameter is not null) + { + redisCommand.Add("--requirepass"); + redisCommand.Add("$REDIS_PASSWORD"); + } + + if (redis.TryGetLastAnnotation(out var persistenceAnnotation)) + { + var interval = (persistenceAnnotation.Interval ?? TimeSpan.FromSeconds(60)).TotalSeconds.ToString(CultureInfo.InvariantCulture); + + redisCommand.Add("--save"); + redisCommand.Add(interval); + redisCommand.Add(persistenceAnnotation.KeysChangedThreshold.ToString(CultureInfo.InvariantCulture)); + } + + context.Args.Add("-c"); + context.Args.Add(string.Join(' ', redisCommand)); + + return Task.CompletedTask; + }); } /// @@ -114,6 +183,10 @@ public static IResourceBuilder WithRedisCommander(this IResourceB // Redis Commander 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 var hostString = $"{(hostsVariableBuilder.Length > 0 ? "," : string.Empty)}{redisInstance.Name}:{redisInstance.Name}:{redisInstance.PrimaryEndpoint.TargetPort}:0"; + if (redisInstance.PasswordParameter is not null) + { + hostString += $":{redisInstance.PasswordParameter.Value}"; + } hostsVariableBuilder.Append(hostString); } } @@ -211,6 +284,11 @@ static async Task ImportRedisDatabases(ILogger resourceLogger, IEnumerable + { + 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 @@ -249,9 +327,15 @@ static async Task ImportRedisDatabases(ILogger resourceLogger, IEnumerable } } + /// + /// 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; + } + + var jsonContent = JsonContent.Create(new + { + agreements = new + { + // all 4 are required to be set + eula = false, + analytics = false, + notifications = false, + encryption = false, + } + }); + + 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)); + } + + 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")] @@ -431,14 +560,14 @@ public static IResourceBuilder WithPersistence(this IResourceBuil { ArgumentNullException.ThrowIfNull(builder); - return builder.WithAnnotation(new CommandLineArgsCallbackAnnotation(context => - { - context.Args.Add("--save"); - context.Args.Add( - (interval ?? TimeSpan.FromSeconds(60)).TotalSeconds.ToString(CultureInfo.InvariantCulture)); - context.Args.Add(keysChangedThreshold.ToString(CultureInfo.InvariantCulture)); - return Task.CompletedTask; - }), ResourceAnnotationMutationBehavior.Replace); + return builder.WithAnnotation( + new PersistenceAnnotation(interval, keysChangedThreshold), ResourceAnnotationMutationBehavior.Replace); + } + + private sealed class PersistenceAnnotation(TimeSpan? interval, long keysChangedThreshold) : IResourceAnnotation + { + public TimeSpan? Interval => interval; + public long KeysChangedThreshold => keysChangedThreshold; } /// diff --git a/src/Aspire.Hosting.Redis/RedisResource.cs b/src/Aspire.Hosting.Redis/RedisResource.cs index cb91caf391c..fc9ea4b2843 100644 --- a/src/Aspire.Hosting.Redis/RedisResource.cs +++ b/src/Aspire.Hosting.Redis/RedisResource.cs @@ -9,6 +9,16 @@ namespace Aspire.Hosting.ApplicationModel; /// The name of the resource. public class RedisResource(string name) : ContainerResource(name), IResourceWithConnectionString { + /// + /// Initializes a new instance of the class. + /// + /// The name of the resource. + /// A parameter that contains the Redis server password. + public RedisResource(string name, ParameterResource password) : this(name) + { + PasswordParameter = password; + } + internal const string PrimaryEndpointName = "tcp"; private EndpointReference? _primaryEndpoint; @@ -18,8 +28,23 @@ public class RedisResource(string name) : ContainerResource(name), IResourceWith /// public EndpointReference PrimaryEndpoint => _primaryEndpoint ??= new(this, PrimaryEndpointName); - private ReferenceExpression ConnectionString => - ReferenceExpression.Create($"{PrimaryEndpoint.Property(EndpointProperty.HostAndPort)}"); + /// + /// Gets the parameter that contains the Redis server password. + /// + public ParameterResource? PasswordParameter { get; } + + private ReferenceExpression BuildConnectionString() + { + var builder = new ReferenceExpressionBuilder(); + builder.Append($"{PrimaryEndpoint.Property(EndpointProperty.HostAndPort)}"); + + if (PasswordParameter is not null) + { + builder.Append($",password={PasswordParameter}"); + } + + return builder.Build(); + } /// /// Gets the connection string expression for the Redis server. @@ -33,7 +58,7 @@ public ReferenceExpression ConnectionStringExpression return connectionStringAnnotation.Resource.ConnectionStringExpression; } - return ConnectionString; + return BuildConnectionString(); } } @@ -49,6 +74,6 @@ public ReferenceExpression ConnectionStringExpression return connectionStringAnnotation.Resource.GetConnectionStringAsync(cancellationToken); } - return ConnectionString.GetValueAsync(cancellationToken); + return BuildConnectionString().GetValueAsync(cancellationToken); } } diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureBicepResourceTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureBicepResourceTests.cs index 68825be4980..3463c66a568 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureBicepResourceTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureBicepResourceTests.cs @@ -1119,8 +1119,9 @@ public async Task PublishAsRedisPublishesRedisAsAzureRedisInfrastructure() #pragma warning restore CS0618 // Type or member is obsolete Assert.True(redis.Resource.IsContainer()); + Assert.NotNull(redis.Resource.PasswordParameter); - Assert.Equal("localhost:12455", await redis.Resource.GetConnectionStringAsync()); + Assert.Equal($"localhost:12455,password={redis.Resource.PasswordParameter.Value}", await redis.Resource.GetConnectionStringAsync()); var manifest = await ManifestUtils.GetManifestWithBicep(redis.Resource); diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs index 7cb599c9f0a..3f32ed73ef5 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs @@ -820,7 +820,9 @@ public async Task ProjectWithManyReferenceTypes() context.EnvironmentVariables["TARGET_PORT"] = httpEp.Property(EndpointProperty.TargetPort); context.EnvironmentVariables["PORT"] = httpEp.Property(EndpointProperty.Port); context.EnvironmentVariables["HOST"] = httpEp.Property(EndpointProperty.Host); + context.EnvironmentVariables["HOSTANDPORT"] = httpEp.Property(EndpointProperty.HostAndPort); context.EnvironmentVariables["SCHEME"] = httpEp.Property(EndpointProperty.Scheme); + context.EnvironmentVariables["INTERNAL_HOSTANDPORT"] = internalEp.Property(EndpointProperty.HostAndPort); }); using var app = builder.Build(); @@ -1034,10 +1036,18 @@ param api_containerimage string name: 'HOST' value: 'api.internal.${outputs_azure_container_apps_environment_default_domain}' } + { + name: 'HOSTANDPORT' + value: 'api.internal.${outputs_azure_container_apps_environment_default_domain}:80' + } { name: 'SCHEME' value: 'http' } + { + name: 'INTERNAL_HOSTANDPORT' + value: 'api:8000' + } { name: 'AZURE_CLIENT_ID' value: outputs_managed_identity_client_id diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureRedisExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureRedisExtensionsTests.cs index 9ec34838203..b6cc52d1de4 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureRedisExtensionsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureRedisExtensionsTests.cs @@ -143,15 +143,19 @@ public async Task AddAzureRedisRunAsContainerProducesCorrectConnectionString() { using var builder = TestDistributedApplicationBuilder.Create(); + RedisResource? redisResource = null; var redis = builder.AddAzureRedis("cache") .RunAsContainer(c => { + redisResource = c.Resource; + c.WithEndpoint("tcp", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 12455)); }); Assert.True(redis.Resource.IsContainer(), "The resource should now be a container resource."); - Assert.Equal("localhost:12455", await redis.Resource.ConnectionStringExpression.GetValueAsync(CancellationToken.None)); + Assert.NotNull(redisResource?.PasswordParameter); + Assert.Equal($"localhost:12455,password={redisResource.PasswordParameter.Value}", await redis.Resource.ConnectionStringExpression.GetValueAsync(CancellationToken.None)); } [Theory] diff --git a/tests/Aspire.Hosting.Redis.Tests/AddRedisTests.cs b/tests/Aspire.Hosting.Redis.Tests/AddRedisTests.cs index 88fd795171f..37f4a0a3d85 100644 --- a/tests/Aspire.Hosting.Redis.Tests/AddRedisTests.cs +++ b/tests/Aspire.Hosting.Redis.Tests/AddRedisTests.cs @@ -77,7 +77,41 @@ public void AddRedisContainerAddsAnnotationMetadata() } [Fact] - public async Task RedisCreatesConnectionString() + public void RedisCreatesConnectionStringWithPassword() + { + var appBuilder = DistributedApplication.CreateBuilder(); + + var password = "p@ssw0rd1"; + var pass = appBuilder.AddParameter("pass", password); + appBuilder.AddRedis("myRedis", password: pass); + + using var app = appBuilder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var connectionStringResource = Assert.Single(appModel.Resources.OfType()); + Assert.Equal("{myRedis.bindings.tcp.host}:{myRedis.bindings.tcp.port},password={pass.value}", connectionStringResource.ConnectionStringExpression.ValueExpression); + } + + [Fact] + public void RedisCreatesConnectionStringWithPasswordAndPort() + { + var appBuilder = DistributedApplication.CreateBuilder(); + + var password = "p@ssw0rd1"; + var pass = appBuilder.AddParameter("pass", password); + appBuilder.AddRedis("myRedis", port: 3000, password: pass); + + using var app = appBuilder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var connectionStringResource = Assert.Single(appModel.Resources.OfType()); + Assert.Equal("{myRedis.bindings.tcp.host}:{myRedis.bindings.tcp.port},password={pass.value}", connectionStringResource.ConnectionStringExpression.ValueExpression); + } + + [Fact] + public async Task RedisCreatesConnectionStringWithDefaultPassword() { var appBuilder = DistributedApplication.CreateBuilder(); appBuilder.AddRedis("myRedis") @@ -89,12 +123,12 @@ public async Task RedisCreatesConnectionString() var connectionStringResource = Assert.Single(appModel.Resources.OfType()); var connectionString = await connectionStringResource.GetConnectionStringAsync(default); - Assert.Equal("{myRedis.bindings.tcp.host}:{myRedis.bindings.tcp.port}", connectionStringResource.ConnectionStringExpression.ValueExpression); + Assert.Equal("{myRedis.bindings.tcp.host}:{myRedis.bindings.tcp.port},password={myRedis-password.value}", connectionStringResource.ConnectionStringExpression.ValueExpression); Assert.StartsWith("localhost:2000", connectionString); } [Fact] - public async Task VerifyManifest() + public async Task VerifyWithoutPasswordManifest() { using var builder = TestDistributedApplicationBuilder.Create(); var redis = builder.AddRedis("redis"); @@ -104,8 +138,89 @@ public async Task VerifyManifest() var expectedManifest = $$""" { "type": "container.v0", - "connectionString": "{redis.bindings.tcp.host}:{redis.bindings.tcp.port}", + "connectionString": "{redis.bindings.tcp.host}:{redis.bindings.tcp.port},password={redis-password.value}", + "image": "{{RedisContainerImageTags.Registry}}/{{RedisContainerImageTags.Image}}:{{RedisContainerImageTags.Tag}}", + "entrypoint": "/bin/sh", + "args": [ + "-c", + "redis-server --requirepass $REDIS_PASSWORD" + ], + "env": { + "REDIS_PASSWORD": "{redis-password.value}" + }, + "bindings": { + "tcp": { + "scheme": "tcp", + "protocol": "tcp", + "transport": "tcp", + "targetPort": 6379 + } + } + } + """; + Assert.Equal(expectedManifest, manifest.ToString()); + } + + [Fact] + public async Task VerifyWithPasswordManifest() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var password = "p@ssw0rd1"; + builder.Configuration["Parameters:pass"] = password; + + var pass = builder.AddParameter("pass"); + var redis = builder.AddRedis("redis", password: pass); + var manifest = await ManifestUtils.GetManifest(redis.Resource); + + var expectedManifest = $$""" + { + "type": "container.v0", + "connectionString": "{redis.bindings.tcp.host}:{redis.bindings.tcp.port},password={pass.value}", + "image": "{{RedisContainerImageTags.Registry}}/{{RedisContainerImageTags.Image}}:{{RedisContainerImageTags.Tag}}", + "entrypoint": "/bin/sh", + "args": [ + "-c", + "redis-server --requirepass $REDIS_PASSWORD" + ], + "env": { + "REDIS_PASSWORD": "{pass.value}" + }, + "bindings": { + "tcp": { + "scheme": "tcp", + "protocol": "tcp", + "transport": "tcp", + "targetPort": 6379 + } + } + } + """; + Assert.Equal(expectedManifest, manifest.ToString()); + } + + [Fact] + public async Task VerifyWithPasswordValueNotProvidedManifest() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var pass = builder.AddParameter("pass"); + var redis = builder.AddRedis("redis", password: pass); + var manifest = await ManifestUtils.GetManifest(redis.Resource); + + var expectedManifest = $$""" + { + "type": "container.v0", + "connectionString": "{redis.bindings.tcp.host}:{redis.bindings.tcp.port},password={pass.value}", "image": "{{RedisContainerImageTags.Registry}}/{{RedisContainerImageTags.Image}}:{{RedisContainerImageTags.Tag}}", + "entrypoint": "/bin/sh", + "args": [ + "-c", + "redis-server --requirepass $REDIS_PASSWORD" + ], + "env": { + "REDIS_PASSWORD": "{pass.value}" + }, "bindings": { "tcp": { "scheme": "tcp", @@ -204,7 +319,7 @@ public void WithRedisInsightSupportsChangingHostPort() } [Fact] - public async Task SingleRedisInstanceProducesCorrectRedisHostsVariable() + public async Task SingleRedisInstanceWithoutPasswordProducesCorrectRedisHostsVariable() { var builder = DistributedApplication.CreateBuilder(); var redis = builder.AddRedis("myredis1").WithRedisCommander(); @@ -222,7 +337,28 @@ public async Task SingleRedisInstanceProducesCorrectRedisHostsVariable() DistributedApplicationOperation.Run, TestServiceProvider.Instance); - Assert.Equal($"myredis1:{redis.Resource.Name}:6379:0", config["REDIS_HOSTS"]); + Assert.Equal($"myredis1:{redis.Resource.Name}:6379:0:{redis.Resource.PasswordParameter?.Value}", config["REDIS_HOSTS"]); + } + + [Fact] + public async Task SingleRedisInstanceWithPasswordProducesCorrectRedisHostsVariable() + { + var builder = DistributedApplication.CreateBuilder(); + var password = "p@ssw0rd1"; + var pass = builder.AddParameter("pass", password); + var redis = builder.AddRedis("myredis1", password: pass).WithRedisCommander(); + using var app = builder.Build(); + + // Add fake allocated endpoints. + redis.WithEndpoint("tcp", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 5001)); + + await builder.Eventing.PublishAsync(new(app.Services, app.Services.GetRequiredService())); + + var commander = builder.Resources.Single(r => r.Name.EndsWith("-commander")); + + var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(commander); + + Assert.Equal($"myredis1:{redis.Resource.Name}:6379:0:{password}", config["REDIS_HOSTS"]); } [Fact] @@ -246,7 +382,7 @@ public async Task MultipleRedisInstanceProducesCorrectRedisHostsVariable() DistributedApplicationOperation.Run, TestServiceProvider.Instance); - Assert.Equal($"myredis1:{redis1.Resource.Name}:6379:0,myredis2:myredis2:6379:0", config["REDIS_HOSTS"]); + Assert.Equal($"myredis1:{redis1.Resource.Name}:6379:0:{redis1.Resource.PasswordParameter?.Value},myredis2:myredis2:6379:0:{redis2.Resource.PasswordParameter?.Value}", config["REDIS_HOSTS"]); } [Theory] @@ -307,7 +443,7 @@ public async Task WithDataVolumeAddsPersistenceAnnotation() .WithDataVolume(); var args = await GetCommandLineArgs(redis); - Assert.Equal("--save 60 1", args); + Assert.Contains("--save 60 1", args); } [Fact] @@ -318,7 +454,7 @@ public async Task WithDataVolumeDoesNotAddPersistenceAnnotationIfIsReadOnly() .WithDataVolume(isReadOnly: true); var args = await GetCommandLineArgs(redis); - Assert.Empty(args); + Assert.DoesNotContain("--save", args); } [Fact] @@ -329,7 +465,7 @@ public async Task WithDataBindMountAddsPersistenceAnnotation() .WithDataBindMount("myredisdata"); var args = await GetCommandLineArgs(redis); - Assert.Equal("--save 60 1", args); + Assert.Contains("--save 60 1", args); } [Fact] @@ -340,7 +476,7 @@ public async Task WithDataBindMountDoesNotAddPersistenceAnnotationIfIsReadOnly() .WithDataBindMount("myredisdata", isReadOnly: true); var args = await GetCommandLineArgs(redis); - Assert.Empty(args); + Assert.DoesNotContain("--save", args); } [Fact] @@ -352,7 +488,11 @@ public async Task WithPersistenceReplacesPreviousAnnotationInstances() .WithPersistence(TimeSpan.FromSeconds(10), 2); var args = await GetCommandLineArgs(redis); - Assert.Equal("--save 10 2", args); + Assert.Contains("--save 10 2", args); + + // ensure `--save` is not added twice + var saveIndex = args.IndexOf("--save"); + Assert.DoesNotContain("--save", args.Substring(saveIndex + 1)); } private static async Task GetCommandLineArgs(IResourceBuilder builder) @@ -371,4 +511,27 @@ public void WithPersistenceAddsCommandLineArgsAnnotation() Assert.True(redis.Resource.TryGetAnnotationsOfType(out var argsAnnotations)); Assert.NotNull(argsAnnotations.SingleOrDefault()); } + + [Fact] + public async Task AddRedisContainerWithPasswordAnnotationMetadata() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var password = "p@ssw0rd1"; + var pass = builder.AddParameter("pass", password); + var redis = builder. + AddRedis("myRedis", password: pass) + .WithEndpoint("tcp", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 5001)); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var containerResource = Assert.Single(appModel.Resources.OfType()); + + var connectionStringResource = Assert.Single(appModel.Resources.OfType()); + var connectionString = await connectionStringResource.GetConnectionStringAsync(default); + Assert.Equal("{myRedis.bindings.tcp.host}:{myRedis.bindings.tcp.port},password={pass.value}", connectionStringResource.ConnectionStringExpression.ValueExpression); + Assert.StartsWith($"localhost:5001,password={password}", connectionString); + } } diff --git a/tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs b/tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs index fd83a9b06b2..5783f89b8b7 100644 --- a/tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs +++ b/tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs @@ -1,13 +1,11 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Net; using System.Net.Http.Json; -using System.Text.Json.Nodes; +using System.Net; using Aspire.Components.Common.Tests; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Testing; -using Aspire.Hosting.Tests.Dcp; using Aspire.Hosting.Tests.Utils; using Aspire.Hosting.Utils; using Microsoft.Extensions.Configuration; @@ -17,6 +15,9 @@ using StackExchange.Redis; using Xunit; using Xunit.Abstractions; +using Aspire.Hosting.Tests.Dcp; +using System.Text.Json.Nodes; +using Aspire.Hosting; namespace Aspire.Hosting.Redis.Tests; @@ -710,4 +711,39 @@ internal sealed class RedisInsightDatabaseModel public int? Db { get; set; } public string? ConnectionType { get; set; } } + + [Fact] + [RequiresDocker] + public async Task WithRedisCommanderShouldWorkWithPassword() + { + var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5)); + + using var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(testOutputHelper); + + var passwordParameter = builder.AddParameter("pass", "p@ssw0rd1"); + + var redis = builder.AddRedis("redis", password: passwordParameter) + .WithRedisCommander(); + + builder.Services.AddHttpClient(); + using var app = builder.Build(); + + await app.StartAsync(); + + var appModel = app.Services.GetRequiredService(); + var redisCommander = Assert.Single(appModel.Resources.OfType()); + + await app.ResourceNotifications.WaitForResourceHealthyAsync(redis.Resource.Name, cts.Token); + await app.ResourceNotifications.WaitForResourceHealthyAsync(redisCommander.Name, cts.Token); + + var endpoint = redisCommander.GetEndpoint("http"); + var redisCommanderUrl = endpoint.Url; + Assert.NotNull(redisCommanderUrl); + + var clientFactory = app.Services.GetRequiredService(); + var client = clientFactory.CreateClient(); + + var httpResponse = await client.GetAsync(redisCommanderUrl!); + httpResponse.EnsureSuccessStatusCode(); + } } diff --git a/tests/Aspire.Hosting.Tests/DistributedApplicationTests.cs b/tests/Aspire.Hosting.Tests/DistributedApplicationTests.cs index 473b2e375ab..5f2a71bcb0d 100644 --- a/tests/Aspire.Hosting.Tests/DistributedApplicationTests.cs +++ b/tests/Aspire.Hosting.Tests/DistributedApplicationTests.cs @@ -933,14 +933,14 @@ public async Task ProxylessContainerCanBeReferenced() var service = Assert.Single(exeList.Where(c => $"{testName}-servicea".Equals(c.AppModelResourceName))); var env = Assert.Single(service.Spec.Env!.Where(e => e.Name == $"ConnectionStrings__{testName}-redis")); - Assert.Equal("localhost:1234", env.Value); + Assert.Equal($"localhost:1234,password={redis.Resource.PasswordParameter?.Value}", env.Value); var list = await s.ListAsync().DefaultTimeout(); var redisContainer = Assert.Single(list.Where(c => Regex.IsMatch(c.Name(), $"{testName}-redis-{ReplicaIdRegex}"))); Assert.Equal(1234, Assert.Single(redisContainer.Spec.Ports!).HostPort); var otherRedisEnv = Assert.Single(service.Spec.Env!.Where(e => e.Name == $"ConnectionStrings__{testName}-redisNoPort")); - Assert.Equal("localhost:6379", otherRedisEnv.Value); + Assert.Equal($"localhost:6379,password={redisNoPort.Resource.PasswordParameter?.Value}", otherRedisEnv.Value); var otherRedisContainer = Assert.Single(list.Where(c => Regex.IsMatch(c.Name(), $"{testName}-redisNoPort-{ReplicaIdRegex}"))); Assert.Equal(6379, Assert.Single(otherRedisContainer.Spec.Ports!).HostPort); @@ -987,14 +987,14 @@ public async Task WithEndpointProxySupportDisablesProxies() var service = Assert.Single(exeList.Where(c => $"{testName}-servicea".Equals(c.AppModelResourceName))); var env = Assert.Single(service.Spec.Env!.Where(e => e.Name == $"ConnectionStrings__{testName}-redis")); - Assert.Equal("localhost:1234", env.Value); + Assert.Equal($"localhost:1234,password={redis.Resource.PasswordParameter!.Value}", env.Value); var list = await s.ListAsync().DefaultTimeout(); var redisContainer = Assert.Single(list.Where(c => Regex.IsMatch(c.Name(), $"{testName}-redis-{ReplicaIdRegex}"))); Assert.Equal(1234, Assert.Single(redisContainer.Spec.Ports!).HostPort); var otherRedisEnv = Assert.Single(service.Spec.Env!.Where(e => e.Name == $"ConnectionStrings__{testName}-redisNoPort")); - Assert.Equal("localhost:6379", otherRedisEnv.Value); + Assert.Equal($"localhost:6379,password={redisNoPort.Resource.PasswordParameter!.Value}", otherRedisEnv.Value); var otherRedisContainer = Assert.Single(list.Where(c => Regex.IsMatch(c.Name(), $"{testName}-redisNoPort-{ReplicaIdRegex}"))); Assert.Equal(6379, Assert.Single(otherRedisContainer.Spec.Ports!).HostPort); diff --git a/tests/Aspire.Hosting.Tests/ManifestGenerationTests.cs b/tests/Aspire.Hosting.Tests/ManifestGenerationTests.cs index 20308cce28a..27963752502 100644 --- a/tests/Aspire.Hosting.Tests/ManifestGenerationTests.cs +++ b/tests/Aspire.Hosting.Tests/ManifestGenerationTests.cs @@ -225,7 +225,7 @@ public void PublishingRedisResourceAsContainerResultsInConnectionStringProperty( var container = resources.GetProperty("rediscontainer"); Assert.Equal("container.v0", container.GetProperty("type").GetString()); - Assert.Equal("{rediscontainer.bindings.tcp.host}:{rediscontainer.bindings.tcp.port}", container.GetProperty("connectionString").GetString()); + Assert.Equal("{rediscontainer.bindings.tcp.host}:{rediscontainer.bindings.tcp.port},password={rediscontainer-password.value}", container.GetProperty("connectionString").GetString()); } [Fact] @@ -398,8 +398,16 @@ public void VerifyTestProgramFullManifest() }, "redis": { "type": "container.v0", - "connectionString": "{redis.bindings.tcp.host}:{redis.bindings.tcp.port}", + "connectionString": "{redis.bindings.tcp.host}:{redis.bindings.tcp.port},password={redis-password.value}", "image": "{{ComponentTestConstants.AspireTestContainerRegistry}}/{{RedisContainerImageTags.Image}}:{{RedisContainerImageTags.Tag}}", + "entrypoint": "/bin/sh", + "args": [ + "-c", + "redis-server --requirepass $REDIS_PASSWORD" + ], + "env": { + "REDIS_PASSWORD": "{redis-password.value}" + }, "bindings": { "tcp": { "scheme": "tcp", @@ -433,6 +441,22 @@ public void VerifyTestProgramFullManifest() "type": "value.v0", "connectionString": "{postgres.connectionString};Database=postgresdb" }, + "redis-password": { + "type": "parameter.v0", + "value": "{redis-password.inputs.value}", + "inputs": { + "value": { + "type": "string", + "secret": true, + "default": { + "generate": { + "minLength": 22, + "special": false + } + } + } + } + }, "postgres-password": { "type": "parameter.v0", "value": "{postgres-password.inputs.value}",