diff --git a/src/Aspire.Hosting/IDistributedApplicationComponentBuilder.cs b/src/Aspire.Hosting/IDistributedApplicationComponentBuilder.cs index 9ade7c63af..fcfdecbc38 100644 --- a/src/Aspire.Hosting/IDistributedApplicationComponentBuilder.cs +++ b/src/Aspire.Hosting/IDistributedApplicationComponentBuilder.cs @@ -5,7 +5,7 @@ namespace Aspire.Hosting; -public interface IDistributedApplicationComponentBuilder where T : IDistributedApplicationComponent +public interface IDistributedApplicationComponentBuilder where T : IDistributedApplicationComponent { IDistributedApplicationBuilder ApplicationBuilder { get; } T Component { get; } diff --git a/src/Aspire.Hosting/Postgres/IPostgresComponent.cs b/src/Aspire.Hosting/Postgres/IPostgresComponent.cs new file mode 100644 index 0000000000..e78824f997 --- /dev/null +++ b/src/Aspire.Hosting/Postgres/IPostgresComponent.cs @@ -0,0 +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 Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Postgres; + +public interface IPostgresComponent : IDistributedApplicationComponent +{ + string? GetConnectionString(string? databaseName = null); +} diff --git a/src/Aspire.Hosting/Postgres/PostgresContainerBuilderExtensions.cs b/src/Aspire.Hosting/Postgres/PostgresBuilderExtensions.cs similarity index 60% rename from src/Aspire.Hosting/Postgres/PostgresContainerBuilderExtensions.cs rename to src/Aspire.Hosting/Postgres/PostgresBuilderExtensions.cs index 5c6ab360be..830b6dde38 100644 --- a/src/Aspire.Hosting/Postgres/PostgresContainerBuilderExtensions.cs +++ b/src/Aspire.Hosting/Postgres/PostgresBuilderExtensions.cs @@ -7,7 +7,7 @@ namespace Aspire.Hosting.Postgres; -public static class PostgresContainerBuilderExtensions +public static class PostgresBuilderExtensions { private const string PasswordEnvVarName = "POSTGRES_PASSWORD"; private const string ConnectionStringEnvironmentName = "ConnectionStrings__"; @@ -32,50 +32,53 @@ public static IDistributedApplicationComponentBuilder AddPostgres(this IDistributedApplicationBuilder builder, string name, string? connectionString) + { + var postgres = new PostgresComponent(name, connectionString); + + return builder.AddComponent(postgres) + .WithAnnotation(new ManifestPublishingCallbackAnnotation((jsonWriter, cancellationToken) => + WritePostgresComponentToManifest(jsonWriter, postgres.GetConnectionString(), cancellationToken))); + } + + private static Task WritePostgresComponentToManifest(Utf8JsonWriter jsonWriter, CancellationToken cancellationToken) => + WritePostgresComponentToManifest(jsonWriter, null, cancellationToken); + + private static async Task WritePostgresComponentToManifest(Utf8JsonWriter jsonWriter, string? connectionString, CancellationToken cancellationToken) { jsonWriter.WriteString("type", "postgres.v1"); + if (!string.IsNullOrEmpty(connectionString)) + { + jsonWriter.WriteString("connectionString", connectionString); + } await jsonWriter.FlushAsync(cancellationToken).ConfigureAwait(false); } /// /// Sets a connection string for this service. The connection string will be available in the service's environment. /// - public static IDistributedApplicationComponentBuilder WithPostgresDatabase(this IDistributedApplicationComponentBuilder builder, IDistributedApplicationComponentBuilder postgresBuilder, string? databaseName = null, string? connectionName = null) + public static IDistributedApplicationComponentBuilder WithPostgresDatabase(this IDistributedApplicationComponentBuilder builder, IDistributedApplicationComponentBuilder postgresBuilder, string? databaseName = null, string? connectionName = null) where T : IDistributedApplicationComponentWithEnvironment { + var postgres = postgresBuilder.Component; connectionName = connectionName ?? postgresBuilder.Component.Name; return builder.WithEnvironment((context) => { + var connectionStringName = $"{ConnectionStringEnvironmentName}{connectionName}"; + if (context.PublisherName == "manifest") { - context.EnvironmentVariables[$"{ConnectionStringEnvironmentName}{connectionName}"] = $"{{{postgresBuilder.Component.Name}.connectionString}}"; + context.EnvironmentVariables[connectionStringName] = $"{{{postgres.Name}.connectionString}}"; return; } - if (!postgresBuilder.Component.TryGetAllocatedEndPoints(out var allocatedEndpoints)) + var connectionString = postgres.GetConnectionString(databaseName); + if (string.IsNullOrEmpty(connectionString)) { - throw new InvalidOperationException("Expected allocated endpoints!"); + throw new DistributedApplicationException($"A connection string for Postgres '{postgres.Name}' could not be retrieved."); } - - if (!postgresBuilder.Component.TryGetLastAnnotation(out var passwordAnnotation)) - { - throw new InvalidOperationException($"Postgres does not have a password set!"); - } - - var allocatedEndpoint = allocatedEndpoints.Single(); // We should only have one endpoint for Postgres. - - var baseConnectionString = $"Host={allocatedEndpoint.Address};Port={allocatedEndpoint.Port};Username=postgres;Password={passwordAnnotation.Password};"; - var connectionString = databaseName == null ? baseConnectionString : $"{baseConnectionString}Database={databaseName};"; - - context.EnvironmentVariables[$"{ConnectionStringEnvironmentName}{connectionName}"] = connectionString; + context.EnvironmentVariables[connectionStringName] = connectionString; }); } - - public static IDistributedApplicationComponentBuilder WithPostgresDatabase(this IDistributedApplicationComponentBuilder builder, string connectionName, string connectionString) - where T : IDistributedApplicationComponentWithEnvironment - { - return builder.WithEnvironment(ConnectionStringEnvironmentName + connectionName, connectionString); - } } diff --git a/src/Aspire.Hosting/Postgres/PostgresComponent.cs b/src/Aspire.Hosting/Postgres/PostgresComponent.cs new file mode 100644 index 0000000000..1be64494e2 --- /dev/null +++ b/src/Aspire.Hosting/Postgres/PostgresComponent.cs @@ -0,0 +1,18 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Postgres; + +public class PostgresComponent(string name, string? connectionString) : DistributedApplicationComponent(name), IPostgresComponent +{ + public string? GetConnectionString(string? databaseName = null) => + connectionString is null ? null : + databaseName is null ? + connectionString : + connectionString.EndsWith(';') ? + $"{connectionString}Database={databaseName}" : + $"{connectionString};Database={databaseName}"; + +} diff --git a/src/Aspire.Hosting/Postgres/PostgresContainerComponent.cs b/src/Aspire.Hosting/Postgres/PostgresContainerComponent.cs index a86bb1609e..ea517fed2e 100644 --- a/src/Aspire.Hosting/Postgres/PostgresContainerComponent.cs +++ b/src/Aspire.Hosting/Postgres/PostgresContainerComponent.cs @@ -5,6 +5,24 @@ namespace Aspire.Hosting.Postgres; -public class PostgresContainerComponent(string name) : ContainerComponent(name) +public class PostgresContainerComponent(string name) : ContainerComponent(name), IPostgresComponent { + public string GetConnectionString(string? databaseName = null) + { + if (!this.TryGetAllocatedEndPoints(out var allocatedEndpoints)) + { + throw new DistributedApplicationException("Expected allocated endpoints!"); + } + + if (!this.TryGetLastAnnotation(out var passwordAnnotation)) + { + throw new DistributedApplicationException($"Postgres does not have a password set!"); + } + + var allocatedEndpoint = allocatedEndpoints.Single(); // We should only have one endpoint for Postgres. + + var baseConnectionString = $"Host={allocatedEndpoint.Address};Port={allocatedEndpoint.Port};Username=postgres;Password={passwordAnnotation.Password};"; + var connectionString = databaseName is null ? baseConnectionString : $"{baseConnectionString}Database={databaseName};"; + return connectionString; + } } diff --git a/src/Aspire.Hosting/Redis/IRedisComponent.cs b/src/Aspire.Hosting/Redis/IRedisComponent.cs new file mode 100644 index 0000000000..bbc3f41ba5 --- /dev/null +++ b/src/Aspire.Hosting/Redis/IRedisComponent.cs @@ -0,0 +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 Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Redis; + +public interface IRedisComponent : IDistributedApplicationComponent +{ + string? GetConnectionString(); +} diff --git a/src/Aspire.Hosting/Redis/RedisContainerBuilderExtensions.cs b/src/Aspire.Hosting/Redis/RedisBuilderExtensions.cs similarity index 56% rename from src/Aspire.Hosting/Redis/RedisContainerBuilderExtensions.cs rename to src/Aspire.Hosting/Redis/RedisBuilderExtensions.cs index 20d69bd0f3..9dbcf9eb87 100644 --- a/src/Aspire.Hosting/Redis/RedisContainerBuilderExtensions.cs +++ b/src/Aspire.Hosting/Redis/RedisBuilderExtensions.cs @@ -7,7 +7,7 @@ namespace Aspire.Hosting.Redis; -public static class RedisContainerBuilderExtensions +public static class RedisBuilderExtensions { private const string ConnectionStringEnvironmentName = "ConnectionStrings__"; @@ -22,16 +22,33 @@ public static IDistributedApplicationComponentBuilder A return componentBuilder; } - private static async Task WriteRedisComponentToManifest(Utf8JsonWriter jsonWriter, CancellationToken cancellationToken) + public static IDistributedApplicationComponentBuilder AddRedis(this IDistributedApplicationBuilder builder, string name, string? connectionString) + { + var redis = new RedisComponent(name, connectionString); + + return builder.AddComponent(redis) + .WithAnnotation(new ManifestPublishingCallbackAnnotation((jsonWriter, cancellationToken) => + WriteRedisComponentToManifest(jsonWriter, redis.GetConnectionString(), cancellationToken))); + } + + private static Task WriteRedisComponentToManifest(Utf8JsonWriter jsonWriter, CancellationToken cancellationToken) => + WriteRedisComponentToManifest(jsonWriter, null, cancellationToken); + + private static async Task WriteRedisComponentToManifest(Utf8JsonWriter jsonWriter, string? connectionString, CancellationToken cancellationToken) { jsonWriter.WriteString("type", "redis.v1"); + if (!string.IsNullOrEmpty(connectionString)) + { + jsonWriter.WriteString("connectionString", connectionString); + } await jsonWriter.FlushAsync(cancellationToken).ConfigureAwait(false); } - public static IDistributedApplicationComponentBuilder WithRedis(this IDistributedApplicationComponentBuilder builder, IDistributedApplicationComponentBuilder redisBuilder, string? connectionName = null) + public static IDistributedApplicationComponentBuilder WithRedis(this IDistributedApplicationComponentBuilder builder, IDistributedApplicationComponentBuilder redisBuilder, string? connectionName = null) where T : IDistributedApplicationComponentWithEnvironment { - connectionName = connectionName ?? redisBuilder.Component.Name; + var redis = redisBuilder.Component; + connectionName = connectionName ?? redis.Name; return builder.WithEnvironment((context) => { @@ -39,24 +56,16 @@ public static IDistributedApplicationComponentBuilder WithRedis(this IDist if (context.PublisherName == "manifest") { - context.EnvironmentVariables[connectionStringName] = $"{{{redisBuilder.Component.Name}.connectionString}}"; + context.EnvironmentVariables[connectionStringName] = $"{{{redis.Name}.connectionString}}"; return; } - if (!redisBuilder.Component.TryGetAnnotationsOfType(out var allocatedEndpoints)) + var connectionString = redis.GetConnectionString(); + if (string.IsNullOrEmpty(connectionString)) { - throw new DistributedApplicationException("Redis component does not have endpoint annotation."); + throw new DistributedApplicationException($"A connection string for Redis '{redis.Name}' could not be retrieved."); } - - // We should only have one endpoint for Redis for local scenarios. - var endpoint = allocatedEndpoints.Single(); - context.EnvironmentVariables[connectionStringName] = endpoint.EndPointString; + context.EnvironmentVariables[connectionStringName] = connectionString; }); } - - public static IDistributedApplicationComponentBuilder WithRedis(this IDistributedApplicationComponentBuilder projectBuilder, string connectionName, string connectionString) - where T : IDistributedApplicationComponentWithEnvironment - { - return projectBuilder.WithEnvironment(ConnectionStringEnvironmentName + connectionName, connectionString); - } } diff --git a/src/Aspire.Hosting/Redis/RedisComponent.cs b/src/Aspire.Hosting/Redis/RedisComponent.cs new file mode 100644 index 0000000000..67b1ee2bd5 --- /dev/null +++ b/src/Aspire.Hosting/Redis/RedisComponent.cs @@ -0,0 +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 Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Redis; + +public class RedisComponent(string name, string? connectionString) : DistributedApplicationComponent(name), IRedisComponent +{ + public string? GetConnectionString() => connectionString; +} diff --git a/src/Aspire.Hosting/Redis/RedisContainerComponent.cs b/src/Aspire.Hosting/Redis/RedisContainerComponent.cs index 46a318d919..236957b160 100644 --- a/src/Aspire.Hosting/Redis/RedisContainerComponent.cs +++ b/src/Aspire.Hosting/Redis/RedisContainerComponent.cs @@ -5,6 +5,17 @@ namespace Aspire.Hosting.Redis; -public class RedisContainerComponent(string name) : ContainerComponent(name) +public class RedisContainerComponent(string name) : ContainerComponent(name), IRedisComponent { + public string GetConnectionString() + { + if (!this.TryGetAnnotationsOfType(out var allocatedEndpoints)) + { + throw new DistributedApplicationException("Redis component does not have endpoint annotation."); + } + + // We should only have one endpoint for Redis for local scenarios. + var endpoint = allocatedEndpoints.Single(); + return endpoint.EndPointString; + } } diff --git a/src/Aspire.Hosting/SqlServer/ISqlServerComponent.cs b/src/Aspire.Hosting/SqlServer/ISqlServerComponent.cs new file mode 100644 index 0000000000..971cebf09e --- /dev/null +++ b/src/Aspire.Hosting/SqlServer/ISqlServerComponent.cs @@ -0,0 +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 Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.SqlServer; + +public interface ISqlServerComponent : IDistributedApplicationComponent +{ + string? GetConnectionString(string? databaseName = null); +} diff --git a/src/Aspire.Hosting/SqlServer/SqlServerCloudApplicationBuilderExtensions.cs b/src/Aspire.Hosting/SqlServer/SqlServerBuilderExtensions.cs similarity index 63% rename from src/Aspire.Hosting/SqlServer/SqlServerCloudApplicationBuilderExtensions.cs rename to src/Aspire.Hosting/SqlServer/SqlServerBuilderExtensions.cs index b44f69922a..bc70ce5c5f 100644 --- a/src/Aspire.Hosting/SqlServer/SqlServerCloudApplicationBuilderExtensions.cs +++ b/src/Aspire.Hosting/SqlServer/SqlServerBuilderExtensions.cs @@ -7,7 +7,7 @@ namespace Aspire.Hosting.SqlServer; -public static class SqlServerCloudApplicationBuilderExtensions +public static class SqlServerBuilderExtensions { private const string ConnectionStringEnvironmentName = "ConnectionStrings__"; @@ -24,15 +24,32 @@ public static IDistributedApplicationComponentBuilder AddSqlServer(this IDistributedApplicationBuilder builder, string name, string? connectionString) + { + var sqlServer = new SqlServerComponent(name, connectionString); + + return builder.AddComponent(sqlServer) + .WithAnnotation(new ManifestPublishingCallbackAnnotation((jsonWriter, cancellationToken) => + WriteSqlServerComponentToManifest(jsonWriter, sqlServer.GetConnectionString(), cancellationToken))); + } + + private static Task WriteSqlServerComponentToManifest(Utf8JsonWriter jsonWriter, CancellationToken cancellationToken) => + WriteSqlServerComponentToManifest(jsonWriter, null, cancellationToken); + + private static async Task WriteSqlServerComponentToManifest(Utf8JsonWriter jsonWriter, string? connectionString, CancellationToken cancellationToken) { jsonWriter.WriteString("type", "sqlserver.v1"); + if (!string.IsNullOrEmpty(connectionString)) + { + jsonWriter.WriteString("connectionString", connectionString); + } await jsonWriter.FlushAsync(cancellationToken).ConfigureAwait(false); } public static IDistributedApplicationComponentBuilder WithSqlServer(this IDistributedApplicationComponentBuilder builder, IDistributedApplicationComponentBuilder sqlBuilder, string? databaseName, string? connectionName = null) where T : IDistributedApplicationComponentWithEnvironment { + var sql = sqlBuilder.Component; connectionName = connectionName ?? sqlBuilder.Component.Name; return builder.WithEnvironment((context) => @@ -41,26 +58,16 @@ public static IDistributedApplicationComponentBuilder WithSqlServer(this I if (context.PublisherName == "manifest") { - context.EnvironmentVariables[connectionStringName] = $"{{{sqlBuilder.Component.Name}.connectionString}}"; + context.EnvironmentVariables[connectionStringName] = $"{{{sql.Name}.connectionString}}"; return; } - if (!sqlBuilder.Component.TryGetAnnotationsOfType(out var allocatedEndpoints)) + var connectionString = sql.GetConnectionString(databaseName); + if (string.IsNullOrEmpty(connectionString)) { - throw new DistributedApplicationException("Sql component does not have endpoint annotation."); + throw new DistributedApplicationException($"A connection string for SqlServer '{sql.Name}' could not be retrieved."); } - - var endpoint = allocatedEndpoints.Single(); - - // HACK: Use the 127.0.0.1 address because localhost is resolving to [::1] following - // up with DCP on this issue. - context.EnvironmentVariables[connectionStringName] = $"Server=127.0.0.1,{endpoint.Port};Database={databaseName ?? "master"};User ID=sa;Password={sqlBuilder.Component.GeneratedPassword};TrustServerCertificate=true;"; + context.EnvironmentVariables[connectionStringName] = connectionString; }); } - - public static IDistributedApplicationComponentBuilder WithSqlServer(this IDistributedApplicationComponentBuilder projectBuilder, string connectionName, string connectionString) - where T : IDistributedApplicationComponentWithEnvironment - { - return projectBuilder.WithEnvironment(ConnectionStringEnvironmentName + connectionName, connectionString); - } } diff --git a/src/Aspire.Hosting/SqlServer/SqlServerComponent.cs b/src/Aspire.Hosting/SqlServer/SqlServerComponent.cs new file mode 100644 index 0000000000..6ddebf7675 --- /dev/null +++ b/src/Aspire.Hosting/SqlServer/SqlServerComponent.cs @@ -0,0 +1,17 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.SqlServer; + +public class SqlServerComponent(string name, string? connectionString) : DistributedApplicationComponent(name), ISqlServerComponent +{ + public string? GetConnectionString(string? databaseName = null) => + connectionString is null ? null : + databaseName is null ? + connectionString : + connectionString.EndsWith(';') ? + $"{connectionString}Database={databaseName}" : + $"{connectionString};Database={databaseName}"; +} diff --git a/src/Aspire.Hosting/SqlServer/SqlServerContainerComponent.cs b/src/Aspire.Hosting/SqlServer/SqlServerContainerComponent.cs index 04d2d3e5f4..9709ed18e8 100644 --- a/src/Aspire.Hosting/SqlServer/SqlServerContainerComponent.cs +++ b/src/Aspire.Hosting/SqlServer/SqlServerContainerComponent.cs @@ -5,7 +5,7 @@ namespace Aspire.Hosting.SqlServer; -public class SqlServerContainerComponent : ContainerComponent +public class SqlServerContainerComponent : ContainerComponent, ISqlServerComponent { public SqlServerContainerComponent(string name) : base(name) { @@ -13,4 +13,18 @@ public SqlServerContainerComponent(string name) : base(name) } public string GeneratedPassword { get; } + + public string GetConnectionString(string? databaseName = null) + { + if (!this.TryGetAnnotationsOfType(out var allocatedEndpoints)) + { + throw new DistributedApplicationException("Sql component does not have endpoint annotation."); + } + + var endpoint = allocatedEndpoints.Single(); + + // HACK: Use the 127.0.0.1 address because localhost is resolving to [::1] following + // up with DCP on this issue. + return $"Server=127.0.0.1,{endpoint.Port};Database={databaseName ?? "master"};User ID=sa;Password={GeneratedPassword};TrustServerCertificate=true;"; + } }