diff --git a/playground/mysql/MySqlDb.AppHost/Program.cs b/playground/mysql/MySqlDb.AppHost/Program.cs index 78e6aaa23aa..dd89bdfbb48 100644 --- a/playground/mysql/MySqlDb.AppHost/Program.cs +++ b/playground/mysql/MySqlDb.AppHost/Program.cs @@ -12,6 +12,6 @@ builder.AddProject("apiservice") .WithExternalHttpEndpoints() - .WithReference(catalogDb); + .WithReference(catalogDb).WaitFor(catalogDb); builder.Build().Run(); diff --git a/src/Aspire.Hosting.MySql/Aspire.Hosting.MySql.csproj b/src/Aspire.Hosting.MySql/Aspire.Hosting.MySql.csproj index dc6cb3aaf0c..f9ea708cb4a 100644 --- a/src/Aspire.Hosting.MySql/Aspire.Hosting.MySql.csproj +++ b/src/Aspire.Hosting.MySql/Aspire.Hosting.MySql.csproj @@ -22,6 +22,10 @@ + + + + diff --git a/src/Aspire.Hosting.MySql/MySqlBuilderExtensions.cs b/src/Aspire.Hosting.MySql/MySqlBuilderExtensions.cs index ccbe01b0e40..d2ef5975606 100644 --- a/src/Aspire.Hosting.MySql/MySqlBuilderExtensions.cs +++ b/src/Aspire.Hosting.MySql/MySqlBuilderExtensions.cs @@ -4,6 +4,7 @@ using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.MySql; using Aspire.Hosting.Utils; +using Microsoft.Extensions.DependencyInjection; namespace Aspire.Hosting; @@ -30,6 +31,38 @@ public static IResourceBuilder AddMySql(this IDistributedAp var passwordParameter = password?.Resource ?? ParameterResourceBuilderExtensions.CreateDefaultPasswordParameter(builder, $"{name}-password"); var resource = new MySqlServerResource(name, passwordParameter); + + string? connectionString = null; + + builder.Eventing.Subscribe(resource, async (@event, ct) => + { + connectionString = await resource.ConnectionStringExpression.GetValueAsync(ct).ConfigureAwait(false); + + if (connectionString == null) + { + throw new DistributedApplicationException($"ConnectionStringAvailableEvent was published for the '{resource.Name}' resource but the connection string was null."); + } + + var lookup = builder.Resources.OfType().ToDictionary(d => d.Name); + + foreach (var databaseName in resource.Databases) + { + if (!lookup.TryGetValue(databaseName.Key, out var databaseResource)) + { + throw new DistributedApplicationException($"Database resource '{databaseName}' under SQL Server resource '{resource.Name}' was not found in the model."); + } + + var connectionStringAvailableEvent = new ConnectionStringAvailableEvent(databaseResource, @event.Services); + await builder.Eventing.PublishAsync(connectionStringAvailableEvent, ct).ConfigureAwait(false); + + var beforeResourceStartedEvent = new BeforeResourceStartedEvent(databaseResource, @event.Services); + await builder.Eventing.PublishAsync(beforeResourceStartedEvent, ct).ConfigureAwait(false); + } + }); + + var healthCheckKey = $"{name}_check"; + builder.Services.AddHealthChecks().AddMySql(sp => connectionString ?? throw new InvalidOperationException("Connection string is unavailable"), name: healthCheckKey); + return builder.AddResource(resource) .WithEndpoint(port: port, targetPort: 3306, name: MySqlServerResource.PrimaryEndpointName) // Internal port is always 3306. .WithImage(MySqlContainerImageTags.Image, MySqlContainerImageTags.Tag) @@ -37,7 +70,8 @@ public static IResourceBuilder AddMySql(this IDistributedAp .WithEnvironment(context => { context.EnvironmentVariables[PasswordEnvVarName] = resource.PasswordParameter; - }); + }) + .WithHealthCheck(healthCheckKey); } /// @@ -57,7 +91,24 @@ public static IResourceBuilder AddDatabase(this IResource builder.Resource.AddDatabase(name, databaseName); var mySqlDatabase = new MySqlDatabaseResource(name, databaseName, builder.Resource); - return builder.ApplicationBuilder.AddResource(mySqlDatabase); + + string? connectionString = null; + + builder.ApplicationBuilder.Eventing.Subscribe(mySqlDatabase, async (@event, ct) => + { + connectionString = await mySqlDatabase.ConnectionStringExpression.GetValueAsync(ct).ConfigureAwait(false); + + if (connectionString == null) + { + throw new DistributedApplicationException($"ConnectionStringAvailableEvent was published for the '{mySqlDatabase.Name}' resource but the connection string was null."); + } + }); + + var healthCheckKey = $"{name}_check"; + builder.ApplicationBuilder.Services.AddHealthChecks().AddMySql(sp => connectionString!, name: healthCheckKey); + + return builder.ApplicationBuilder.AddResource(mySqlDatabase) + .WithHealthCheck(healthCheckKey); } /// diff --git a/tests/Aspire.Hosting.MySql.Tests/MySqlFunctionalTests.cs b/tests/Aspire.Hosting.MySql.Tests/MySqlFunctionalTests.cs index 17fc0aea8ed..78f09ae73d3 100644 --- a/tests/Aspire.Hosting.MySql.Tests/MySqlFunctionalTests.cs +++ b/tests/Aspire.Hosting.MySql.Tests/MySqlFunctionalTests.cs @@ -3,6 +3,7 @@ using System.Data; using Aspire.Components.Common.Tests; +using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Tests.Utils; using Aspire.Hosting.Utils; using Microsoft.EntityFrameworkCore; @@ -10,6 +11,7 @@ using Microsoft.EntityFrameworkCore.Storage; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Hosting; using MySqlConnector; using Polly; @@ -22,6 +24,101 @@ public class MySqlFunctionalTests(ITestOutputHelper testOutputHelper) { private static readonly Predicate s_mySqlReadyText = log => log.Contains("ready for connections") && log.Contains("port: 3306"); + [Fact] + [RequiresDocker] + public async Task VerifyWaitForOnMySqlBlocksDependentResources() + { + var cts = new CancellationTokenSource(TimeSpan.FromMinutes(3)); + using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper); + + var healthCheckTcs = new TaskCompletionSource(); + builder.Services.AddHealthChecks().AddAsyncCheck("blocking_check", () => + { + return healthCheckTcs.Task; + }); + + var resource = builder.AddMySql("resource") + .WithHealthCheck("blocking_check"); + + var dependentResource = builder.AddMySql("dependentresource") + .WaitFor(resource); + + using var app = builder.Build(); + + var pendingStart = app.StartAsync(cts.Token); + + var rns = app.Services.GetRequiredService(); + + await rns.WaitForResourceAsync(resource.Resource.Name, KnownResourceStates.Running, cts.Token); + + await rns.WaitForResourceAsync(dependentResource.Resource.Name, KnownResourceStates.Waiting, cts.Token); + + healthCheckTcs.SetResult(HealthCheckResult.Healthy()); + + await rns.WaitForResourceAsync(resource.Resource.Name, (re => re.Snapshot.HealthStatus == HealthStatus.Healthy), cts.Token); + + await rns.WaitForResourceAsync(dependentResource.Resource.Name, KnownResourceStates.Running, cts.Token); + + await pendingStart; + + await app.StopAsync(); + } + + [Fact] + [RequiresDocker] + public async Task VerifyWaitForOnMySqlDatabaseBlocksDependentResources() + { + var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5)); + using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper); + + var healthCheckTcs = new TaskCompletionSource(); + builder.Services.AddHealthChecks().AddAsyncCheck("blocking_check", () => + { + return healthCheckTcs.Task; + }); + + var resource = builder.AddMySql("resource") + .WithHealthCheck("blocking_check"); + + var db = resource.AddDatabase("db"); + + var dependentResource = builder.AddMySql("dependentresource") + .WaitFor(db); + + using var app = builder.Build(); + + var pendingStart = app.StartAsync(cts.Token); + + var rns = app.Services.GetRequiredService(); + + await rns.WaitForResourceAsync(resource.Resource.Name, KnownResourceStates.Running, cts.Token); + + await rns.WaitForResourceAsync(db.Resource.Name, KnownResourceStates.Running, cts.Token); + + await rns.WaitForResourceAsync(dependentResource.Resource.Name, KnownResourceStates.Waiting, cts.Token); + + healthCheckTcs.SetResult(HealthCheckResult.Healthy()); + + await rns.WaitForResourceAsync(resource.Resource.Name, (re => re.Snapshot.HealthStatus == HealthStatus.Healthy), cts.Token); + + // Create the database. + var connectionString = await resource.Resource.ConnectionStringExpression.GetValueAsync(cts.Token); + using var connection = new MySqlConnection(connectionString); + await connection.OpenAsync(cts.Token); + + var command = connection.CreateCommand(); + command.CommandText = "CREATE DATABASE db;"; + await command.ExecuteNonQueryAsync(cts.Token); + + await rns.WaitForResourceAsync(db.Resource.Name, re => re.Snapshot.HealthStatus == HealthStatus.Healthy, cts.Token); + + await rns.WaitForResourceAsync(dependentResource.Resource.Name, KnownResourceStates.Running, cts.Token); + + await pendingStart; + + await app.StopAsync(); + } + [Fact] [RequiresDocker] public async Task VerifyMySqlResource() diff --git a/tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs b/tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs index 502abd218c6..8e1cc234212 100644 --- a/tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs +++ b/tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs @@ -35,7 +35,7 @@ public async Task VerifyWaitForOnRedisBlocksDependentResources() }); var redis = builder.AddRedis("redis") - .WithHealthCheck("blocking_check"); + .WithHealthCheck("blocking_check"); var dependentResource = builder.AddRedis("dependentresource") .WaitFor(redis); // Just using another redis instance as a dependent resource.