diff --git a/playground/TestShop/CatalogDb/Program.cs b/playground/TestShop/CatalogDb/Program.cs index f2cd200790e..50cc70cc12c 100644 --- a/playground/TestShop/CatalogDb/Program.cs +++ b/playground/TestShop/CatalogDb/Program.cs @@ -18,4 +18,3 @@ app.MapDefaultEndpoints(); await app.RunAsync(); - diff --git a/playground/TestShop/TestShop.AppHost/Program.cs b/playground/TestShop/TestShop.AppHost/Program.cs index 8e3f5d256f2..9fc964bed04 100644 --- a/playground/TestShop/TestShop.AppHost/Program.cs +++ b/playground/TestShop/TestShop.AppHost/Program.cs @@ -15,6 +15,9 @@ }); #endif +var catalogDbApp = builder.AddProject("catalogdbapp") + .WithReference(catalogDb); + var catalogService = builder.AddProject("catalogservice") .WithReference(catalogDb) .WithReplicas(2); @@ -40,9 +43,6 @@ .WithReference(basketService) .WithReference(catalogService); -builder.AddProject("catalogdbapp") - .WithReference(catalogDb); - #if !SKIP_DASHBOARD_REFERENCE // This project is only added in playground projects to support development/debugging // of the dashboard. It is not required in end developer code. Comment out this code diff --git a/src/Aspire.Dashboard/Components/ResourcesGridColumns/StateColumnDisplay.razor b/src/Aspire.Dashboard/Components/ResourcesGridColumns/StateColumnDisplay.razor index 8e4564af903..14b7672f88b 100644 --- a/src/Aspire.Dashboard/Components/ResourcesGridColumns/StateColumnDisplay.razor +++ b/src/Aspire.Dashboard/Components/ResourcesGridColumns/StateColumnDisplay.razor @@ -32,7 +32,7 @@ Class="severity-icon" /> } } -else if (Resource.IsStartingOrBuilding()) +else if (Resource.IsStartingOrBuildingOrWaiting()) { string.IsNullOrEmpty(resource.State); diff --git a/src/Aspire.Dashboard/Model/KnownResourceState.cs b/src/Aspire.Dashboard/Model/KnownResourceState.cs index 3903364e0ab..cb62a66a89b 100644 --- a/src/Aspire.Dashboard/Model/KnownResourceState.cs +++ b/src/Aspire.Dashboard/Model/KnownResourceState.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. namespace Aspire.Dashboard.Model; @@ -11,5 +11,6 @@ public enum KnownResourceState Starting, Running, Building, - Hidden + Hidden, + Waiting } diff --git a/src/Aspire.Hosting/ApplicationModel/CustomResourceSnapshot.cs b/src/Aspire.Hosting/ApplicationModel/CustomResourceSnapshot.cs index 6cde3ae94ee..84d0e54d834 100644 --- a/src/Aspire.Hosting/ApplicationModel/CustomResourceSnapshot.cs +++ b/src/Aspire.Hosting/ApplicationModel/CustomResourceSnapshot.cs @@ -149,4 +149,14 @@ public static class KnownResourceStates /// The finished state. Useful for showing the resource has finished. /// public static readonly string Finished = nameof(Finished); + + /// + /// The waiting state. Useful for showing the resource is waiting for a dependency. + /// + public static readonly string Waiting = nameof(Waiting); + + /// + /// List of terminal states. + /// + public static readonly string[] TerminalStates = [Finished, FailedToStart, Exited]; } diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceNotificationService.cs b/src/Aspire.Hosting/ApplicationModel/ResourceNotificationService.cs index 7091369cbaa..f0456b64279 100644 --- a/src/Aspire.Hosting/ApplicationModel/ResourceNotificationService.cs +++ b/src/Aspire.Hosting/ApplicationModel/ResourceNotificationService.cs @@ -103,6 +103,38 @@ public async Task WaitForResourceAsync(string resourceName, IEnumerable< throw new OperationCanceledException($"The operation was cancelled before the resource reached one of the target states: [{string.Join(", ", targetStates)}]"); } + /// + /// Waits for a resource to reach one of the specified states. See for common states. + /// + /// + /// This method returns a task that will complete when the resource reaches one of the specified target states. If the resource + /// is already in the target state, the method will return immediately.
+ /// If the resource doesn't reach one of the target states before is signaled, this method + /// will throw . + ///
+ /// The name of the resource. + /// A predicate which is evaluated for each for the selected resource. + /// A cancellation token that cancels the wait operation when signaled. + /// A representing the wait operation and which of the target states the resource reached. + [System.Diagnostics.CodeAnalysis.SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", + Justification = "targetState(s) parameters are mutually exclusive.")] + public async Task WaitForResourceAsync(string resourceName, Func predicate, CancellationToken cancellationToken = default) + { + using var watchCts = CancellationTokenSource.CreateLinkedTokenSource(_applicationStopping, cancellationToken); + var watchToken = watchCts.Token; + await foreach (var resourceEvent in WatchAsync(watchToken).ConfigureAwait(false)) + { + if (string.Equals(resourceName, resourceEvent.Resource.Name, StringComparisons.ResourceName) + && resourceEvent.Snapshot.State?.Text is { Length: > 0 } statusText + && predicate(resourceEvent)) + { + return resourceEvent; + } + } + + throw new OperationCanceledException($"The operation was cancelled before the resource met the predicate condition."); + } + /// /// Watch for changes to the state for all resources. /// diff --git a/src/Aspire.Hosting/PublicAPI.Unshipped.txt b/src/Aspire.Hosting/PublicAPI.Unshipped.txt index 85e0d9002cd..782d494db05 100644 --- a/src/Aspire.Hosting/PublicAPI.Unshipped.txt +++ b/src/Aspire.Hosting/PublicAPI.Unshipped.txt @@ -16,6 +16,7 @@ Aspire.Hosting.ApplicationModel.BeforeStartEvent.BeforeStartEvent(System.IServic Aspire.Hosting.ApplicationModel.BeforeStartEvent.Model.get -> Aspire.Hosting.ApplicationModel.DistributedApplicationModel! Aspire.Hosting.ApplicationModel.BeforeStartEvent.Services.get -> System.IServiceProvider! Aspire.Hosting.ApplicationModel.ResourceNotificationService.ResourceNotificationService(Microsoft.Extensions.Logging.ILogger! logger, Microsoft.Extensions.Hosting.IHostApplicationLifetime! hostApplicationLifetime) -> void +Aspire.Hosting.ApplicationModel.ResourceNotificationService.WaitForResourceAsync(string! resourceName, System.Func! predicate, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Aspire.Hosting.DistributedApplicationBuilder.Eventing.get -> Aspire.Hosting.Eventing.IDistributedApplicationEventing! Aspire.Hosting.Eventing.DistributedApplicationEventing Aspire.Hosting.Eventing.DistributedApplicationEventing.DistributedApplicationEventing() -> void @@ -49,6 +50,8 @@ Aspire.Hosting.DistributedApplicationExecutionContextOptions.DistributedApplicat Aspire.Hosting.DistributedApplicationExecutionContextOptions.Operation.get -> Aspire.Hosting.DistributedApplicationOperation Aspire.Hosting.DistributedApplicationExecutionContextOptions.ServiceProvider.get -> System.IServiceProvider? Aspire.Hosting.DistributedApplicationExecutionContextOptions.ServiceProvider.set -> void +static Aspire.Hosting.ResourceBuilderExtensions.WaitFor(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, Aspire.Hosting.ApplicationModel.IResourceBuilder! dependency) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! +static Aspire.Hosting.ResourceBuilderExtensions.WaitForCompletion(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, Aspire.Hosting.ApplicationModel.IResourceBuilder! dependency, int exitCode = 0) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! static readonly Aspire.Hosting.ApplicationModel.KnownResourceStates.Exited -> string! static readonly Aspire.Hosting.ApplicationModel.KnownResourceStates.FailedToStart -> string! static readonly Aspire.Hosting.ApplicationModel.KnownResourceStates.Finished -> string! @@ -72,3 +75,5 @@ static Aspire.Hosting.ContainerResourceBuilderExtensions.WithBuildArg(this As static Aspire.Hosting.ContainerResourceBuilderExtensions.WithBuildSecret(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, string! name, Aspire.Hosting.ApplicationModel.IResourceBuilder! value) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! static Aspire.Hosting.ProjectResourceBuilderExtensions.AddProject(this Aspire.Hosting.IDistributedApplicationBuilder! builder, string! name, string! projectPath, System.Action! configure) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! static Aspire.Hosting.ProjectResourceBuilderExtensions.AddProject(this Aspire.Hosting.IDistributedApplicationBuilder! builder, string! name, System.Action! configure) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! +static readonly Aspire.Hosting.ApplicationModel.KnownResourceStates.TerminalStates -> string![]! +static readonly Aspire.Hosting.ApplicationModel.KnownResourceStates.Waiting -> string! diff --git a/src/Aspire.Hosting/ResourceBuilderExtensions.cs b/src/Aspire.Hosting/ResourceBuilderExtensions.cs index 1dd8905f077..b4f85ee9a5a 100644 --- a/src/Aspire.Hosting/ResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ResourceBuilderExtensions.cs @@ -4,6 +4,8 @@ using System.Net.Sockets; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Publishing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; namespace Aspire.Hosting; @@ -549,4 +551,145 @@ public static IResourceBuilder ExcludeFromManifest(this IResourceBuilder + /// Waits for the dependency resource to enter the Running state before starting the resource. + /// + /// The type of the resource. + /// The resource builder for the resource that will be waiting. + /// The resource builder for the dependency resource. + /// The resource builder. + /// + /// This method is useful when a resource should wait until another has started running. This can help + /// reduce errors in logs during local development where dependency resources. + /// + /// + /// Start message queue before starting the worker service. + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// var messaging = builder.AddRabbitMQ("messaging"); + /// builder.AddProject<Projects.MyApp>("myapp") + /// .WithReference(messaging) + /// .WaitFor(messaging); + /// + /// + public static IResourceBuilder WaitFor(this IResourceBuilder builder, IResourceBuilder dependency) where T : IResource + { + builder.ApplicationBuilder.Eventing.Subscribe(builder.Resource, async (e, ct) => + { + var rls = e.Services.GetRequiredService(); + var resourceLogger = rls.GetLogger(builder.Resource); + resourceLogger.LogInformation("Waiting for resource '{Name}' to enter the '{State}' state.", dependency.Resource.Name, KnownResourceStates.Running); + + var rns = e.Services.GetRequiredService(); + await rns.PublishUpdateAsync(builder.Resource, s => s with { State = KnownResourceStates.Waiting }).ConfigureAwait(false); + var resourceEvent = await rns.WaitForResourceAsync(dependency.Resource.Name, re => IsContinuableState(re.Snapshot), cancellationToken: ct).ConfigureAwait(false); + var snapshot = resourceEvent.Snapshot; + + if (snapshot.State == KnownResourceStates.FailedToStart) + { + resourceLogger.LogError( + "Dependency resource '{ResourceName}' failed to start.", + dependency.Resource.Name + ); + + throw new DistributedApplicationException($"Dependency resource '{dependency.Resource.Name}' failed to start."); + } + else if (snapshot.State!.Text == KnownResourceStates.Finished || snapshot.State!.Text == KnownResourceStates.Exited) + { + resourceLogger.LogError( + "Resource '{ResourceName}' has entered the '{State}' state prematurely.", + dependency.Resource.Name, + snapshot.State.Text + ); + + throw new DistributedApplicationException( + $"Resource '{dependency.Resource.Name}' has entered the '{snapshot.State.Text}' state prematurely." + ); + } + }); + + return builder; + + static bool IsContinuableState(CustomResourceSnapshot snapshot) => + snapshot.State?.Text == KnownResourceStates.Running || + snapshot.State?.Text == KnownResourceStates.Finished || + snapshot.State?.Text == KnownResourceStates.Exited || + snapshot.State?.Text == KnownResourceStates.FailedToStart; + } + + /// + /// Waits for the dependency resource to enter the Exited or Finished state before starting the resource. + /// + /// The type of the resource. + /// The resource builder for the resource that will be waiting. + /// The resource builder for the dependency resource. + /// The exit code which is interpretted as successful. + /// The resource builder. + /// + /// This method is useful when a resource should wait until another has completed. A common usage pattern + /// would be to include a console application that initializes the database schema or performs other one off + /// initialization tasks. + /// Note that this method has no impact at deployment time and only works for local development. + /// + /// + /// Wait for database initialization app to complete running. + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// var pgsql = builder.AddPostgres("postgres"); + /// var dbprep = builder.AddProject<Projects.DbPrepApp>("dbprep") + /// .WithReference(pgsql); + /// builder.AddProject<Projects.DatabasePrepTool>("dbprep") + /// .WithReference(pgsql) + /// .WaitForCompletion(dbprep); + /// + /// + public static IResourceBuilder WaitForCompletion(this IResourceBuilder builder, IResourceBuilder dependency, int exitCode = 0) where T : IResource + { + builder.ApplicationBuilder.Eventing.Subscribe(builder.Resource, async (e, ct) => + { + if (dependency.Resource.TryGetLastAnnotation(out var replicaAnnotation) && replicaAnnotation.Replicas > 1) + { + throw new DistributedApplicationException("WaitForCompletion cannot be used with resources that have replicas."); + } + + var rls = e.Services.GetRequiredService(); + var resourceLogger = rls.GetLogger(builder.Resource); + resourceLogger.LogInformation($"Waiting for resource '{dependency.Resource.Name}' to complete."); + + var rns = e.Services.GetRequiredService(); + await rns.PublishUpdateAsync(builder.Resource, s => s with { State = KnownResourceStates.Waiting }).ConfigureAwait(false); + var resourceEvent = await rns.WaitForResourceAsync(dependency.Resource.Name, re => IsKnownTerminalState(re.Snapshot), cancellationToken: ct).ConfigureAwait(false); + var snapshot = resourceEvent.Snapshot; + + if (snapshot.State == KnownResourceStates.FailedToStart) + { + resourceLogger.LogError( + "Dependency resource '{ResourceName}' failed to start.", + dependency.Resource.Name + ); + + throw new DistributedApplicationException($"Dependency resource '{dependency.Resource.Name}' failed to start."); + } + else if ((snapshot.State!.Text == KnownResourceStates.Finished || snapshot.State!.Text == KnownResourceStates.Exited) && snapshot.ExitCode != exitCode) + { + resourceLogger.LogError( + "Resource '{ResourceName}' has entered the '{State}' state with exit code '{ExitCode}'", + dependency.Resource.Name, + snapshot.State.Text, + snapshot.ExitCode + ); + + throw new DistributedApplicationException( + $"Resource '{dependency.Resource.Name}' has entered the '{snapshot.State.Text}' state with exit code '{snapshot.ExitCode}'" + ); + } + }); + + return builder; + + static bool IsKnownTerminalState(CustomResourceSnapshot snapshot) => + KnownResourceStates.TerminalStates.Contains(snapshot.State?.Text) && + snapshot.ExitCode is not null; + } } diff --git a/tests/Aspire.Hosting.Tests/WaitForTests.cs b/tests/Aspire.Hosting.Tests/WaitForTests.cs new file mode 100644 index 00000000000..278764e2317 --- /dev/null +++ b/tests/Aspire.Hosting.Tests/WaitForTests.cs @@ -0,0 +1,235 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Components.Common.Tests; +using Aspire.Hosting.Utils; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Aspire.Hosting.Tests; + +public class WaitForTests +{ + [Fact] + [RequiresDocker] + public async Task EnsureDependentResourceMovesIntoWaitingState() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var dependency = builder.AddResource(new CustomResource("test")); + var nginx = builder.AddContainer("nginx", "mcr.microsoft.com/cbl-mariner/base/nginx", "1.22") + .WithReference(dependency) + .WaitFor(dependency); + + using var app = builder.Build(); + + // StartAsync will currently block until the dependency resource moves + // into a Running state, so rather than awaiting it we'll hold onto the + // task so we can inspect the state of the Nginx resource which should + // be in a waiting state if everything is working correctly. + var startupCts = new CancellationTokenSource(TimeSpan.FromSeconds(60)); + var startTask = app.StartAsync(startupCts.Token); + + // We don't want to wait forever for Nginx to move into a waiting state, + // it should be super quick, but we'll allow 60 seconds just in case the + // CI machine is chugging (also useful when collecting code coverage). + var waitingStateCts = new CancellationTokenSource(TimeSpan.FromSeconds(60)); + + var rns = app.Services.GetRequiredService(); + await rns.WaitForResourceAsync(nginx.Resource.Name, "Waiting", waitingStateCts.Token); + + // Now that we know we successfully entered the Waiting state, we can swap + // the dependency into a running state which will unblock startup and + // we can continue executing. + await rns.PublishUpdateAsync(dependency.Resource, s => s with { + State = KnownResourceStates.Running + }); + + await startTask; + + await app.StopAsync(); + } + + [Fact] + [RequiresDocker] + public async Task WaitForCompletionWaitsForTerminalStateOfDependencyResource() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var dependency = builder.AddResource(new CustomResource("test")); + var nginx = builder.AddContainer("nginx", "mcr.microsoft.com/cbl-mariner/base/nginx", "1.22") + .WithReference(dependency) + .WaitForCompletion(dependency); + + using var app = builder.Build(); + + // StartAsync will currently block until the dependency resource moves + // into a Finished state, so rather than awaiting it we'll hold onto the + // task so we can inspect the state of the Nginx resource which should + // be in a waiting state if everything is working correctly. + var startupCts = new CancellationTokenSource(TimeSpan.FromSeconds(120)); + var startTask = app.StartAsync(startupCts.Token); + + // We don't want to wait forever for Nginx to move into a waiting state, + // it should be super quick, but we'll allow 60 seconds just in case the + // CI machine is chugging (also useful when collecting code coverage). + var waitingStateCts = new CancellationTokenSource(TimeSpan.FromSeconds(120)); + + var rns = app.Services.GetRequiredService(); + await rns.WaitForResourceAsync(nginx.Resource.Name, "Waiting", waitingStateCts.Token); + + // Now that we know we successfully entered the Waiting state, we can swap + // the dependency into a running state which will unblock startup and + // we can continue executing. + await rns.PublishUpdateAsync(dependency.Resource, s => s with + { + State = KnownResourceStates.Finished, + ExitCode = 0 + }); + + // This time we want to wait for Nginx to move into a Running state to verify that + // it successfully started after we moved the dependency resource into the Finished, but + // we need to give it more time since we have to download the image in CI. + var runningStateCts = new CancellationTokenSource(TimeSpan.FromSeconds(60)); + await rns.WaitForResourceAsync(nginx.Resource.Name, KnownResourceStates.Running, runningStateCts.Token); + + await startTask; + + await app.StopAsync(); + } + + [Fact] + [RequiresDocker] + public async Task WaitForThrowsIfResourceMovesToTerminalStateBeforeRunning() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var dependency = builder.AddResource(new CustomResource("test")); + var nginx = builder.AddContainer("nginx", "mcr.microsoft.com/cbl-mariner/base/nginx", "1.22") + .WithReference(dependency) + .WaitFor(dependency); + + using var app = builder.Build(); + + // StartAsync will currently block until the dependency resource moves + // into a Finished state, so rather than awaiting it we'll hold onto the + // task so we can inspect the state of the Nginx resource which should + // be in a waiting state if everything is working correctly. + var startupCts = new CancellationTokenSource(TimeSpan.FromSeconds(120)); + var startTask = app.StartAsync(startupCts.Token); + + // We don't want to wait forever for Nginx to move into a waiting state, + // it should be super quick, but we'll allow 60 seconds just in case the + // CI machine is chugging (also useful when collecting code coverage). + var waitingStateCts = new CancellationTokenSource(TimeSpan.FromSeconds(120)); + + var rns = app.Services.GetRequiredService(); + await rns.WaitForResourceAsync(nginx.Resource.Name, "Waiting", waitingStateCts.Token); + + // Now that we know we successfully entered the Waiting state, we can swap + // the dependency into a running state which will unblock startup and + // we can continue executing. + await rns.PublishUpdateAsync(dependency.Resource, s => s with + { + State = KnownResourceStates.Finished, + ExitCode = 0 + }); + + // This time we want to wait for Nginx to move into a Running state to verify that + // it successfully started after we moved the dependency resource into the Finished, but + // we need to give it more time since we have to download the image in CI. + var runningStateCts = new CancellationTokenSource(TimeSpan.FromSeconds(60)); + await rns.WaitForResourceAsync(nginx.Resource.Name, KnownResourceStates.FailedToStart, runningStateCts.Token); + + await startTask; + + await app.StopAsync(); + } + + [Fact] + [RequiresDocker] + public async Task EnsureDependencyResourceThatReturnsNonMatchingExitCodeResultsInDependentResourceFailingToStart() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var dependency = builder.AddResource(new CustomResource("test")); + var nginx = builder.AddContainer("nginx", "mcr.microsoft.com/cbl-mariner/base/nginx", "1.22") + .WithReference(dependency) + .WaitForCompletion(dependency, exitCode: 2); + + using var app = builder.Build(); + + // StartAsync will currently block until the dependency resource moves + // into a Finished state, so rather than awaiting it we'll hold onto the + // task so we can inspect the state of the Nginx resource which should + // be in a waiting state if everything is working correctly. + var startupCts = new CancellationTokenSource(TimeSpan.FromSeconds(60)); + var startTask = app.StartAsync(startupCts.Token); + + // We don't want to wait forever for Nginx to move into a waiting state, + // it should be super quick, but we'll allow 60 seconds just in case the + // CI machine is chugging (also useful when collecting code coverage). + var waitingStateCts = new CancellationTokenSource(TimeSpan.FromSeconds(60)); + + var rns = app.Services.GetRequiredService(); + await rns.WaitForResourceAsync(nginx.Resource.Name, "Waiting", waitingStateCts.Token); + + // Now that we know we successfully entered the Waiting state, we can swap + // the dependency into a finished state which will unblock startup and + // we can continue executing. + await rns.PublishUpdateAsync(dependency.Resource, s => s with + { + State = KnownResourceStates.Finished, + ExitCode = 3 // Exit code does not match expected exit code above intentionally. + }); + + // This time we want to wait for Nginx to move into a FailedToStart state to verify that + // it didn't start if the dependency resource didn't finish with the correct exit code. + var runningStateCts = new CancellationTokenSource(TimeSpan.FromSeconds(60)); + await rns.WaitForResourceAsync(nginx.Resource.Name, KnownResourceStates.FailedToStart, runningStateCts.Token); + + await startTask; + + await app.StopAsync(); + } + + [Fact] + [RequiresDocker] + public async Task DependencyWithGreaterThan1ReplicaAnnotationCausesDependentResourceToFailToStart() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var dependency = builder.AddResource(new CustomResource("test")) + .WithAnnotation(new ReplicaAnnotation(2)); + + var nginx = builder.AddContainer("nginx", "mcr.microsoft.com/cbl-mariner/base/nginx", "1.22") + .WithReference(dependency) + .WaitForCompletion(dependency); + + using var app = builder.Build(); + + // StartAsync will currently block until the dependency resource moves + // into a Finished state, so rather than awaiting it we'll hold onto the + // task so we can inspect the state of the Nginx resource which should + // be in a waiting state if everything is working correctly. + var startupCts = new CancellationTokenSource(TimeSpan.FromSeconds(60)); + var startTask = app.StartAsync(startupCts.Token); + + // We don't want to wait forever for Nginx to move into a waiting state, + // it should be super quick, but we'll allow 60 seconds just in case the + // CI machine is chugging (also useful when collecting code coverage). + var waitingStateCts = new CancellationTokenSource(TimeSpan.FromSeconds(60)); + + var rns = app.Services.GetRequiredService(); + await rns.WaitForResourceAsync(nginx.Resource.Name, "FailedToStart", waitingStateCts.Token); + + await startTask; + + await app.StopAsync(); + } + + private sealed class CustomResource(string name) : Resource(name), IResourceWithConnectionString + { + public ReferenceExpression ConnectionStringExpression => ReferenceExpression.Create($"foo"); + } +} diff --git a/tests/Aspire.Playground.Tests/ProjectSpecificTests.cs b/tests/Aspire.Playground.Tests/ProjectSpecificTests.cs index f662e152479..acac9054981 100644 --- a/tests/Aspire.Playground.Tests/ProjectSpecificTests.cs +++ b/tests/Aspire.Playground.Tests/ProjectSpecificTests.cs @@ -29,7 +29,7 @@ await app.WaitForTextAsync($"I'm Batman. - Batman") await app.StopAsync(); } - [Fact] + [Fact(Skip = "https://github.com/dotnet/aspire/issues/5489")] public async Task KafkaTest() { var appHostPath = Directory.GetFiles(AppContext.BaseDirectory, "KafkaBasic.AppHost.dll").Single();