diff --git a/src/Aspire.Hosting/Aspire.Hosting.csproj b/src/Aspire.Hosting/Aspire.Hosting.csproj index 5b47ff4cdc4..76868939098 100644 --- a/src/Aspire.Hosting/Aspire.Hosting.csproj +++ b/src/Aspire.Hosting/Aspire.Hosting.csproj @@ -21,6 +21,7 @@ + diff --git a/src/Aspire.Hosting/Backchannel/AppHostRpcTarget.cs b/src/Aspire.Hosting/Backchannel/AppHostRpcTarget.cs index fe50ca47efb..b481e2861f0 100644 --- a/src/Aspire.Hosting/Backchannel/AppHostRpcTarget.cs +++ b/src/Aspire.Hosting/Backchannel/AppHostRpcTarget.cs @@ -9,6 +9,7 @@ using Aspire.Hosting.Publishing; using Aspire.Hosting.Utils; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -21,7 +22,8 @@ internal class AppHostRpcTarget( IServiceProvider serviceProvider, IDistributedApplicationEventing eventing, PublishingActivityProgressReporter activityReporter, - IHostApplicationLifetime lifetime + IHostApplicationLifetime lifetime, + DistributedApplicationOptions options ) { public async IAsyncEnumerable<(string Id, string StatusText, bool IsComplete, bool IsError)> GetPublishingActivitiesAsync([EnumeratorCancellation]CancellationToken cancellationToken) @@ -101,6 +103,29 @@ public Task PingAsync(long timestamp, CancellationToken cancellationToken) public Task<(string BaseUrlWithLoginToken, string? CodespacesUrlWithLoginToken)> GetDashboardUrlsAsync() { + return GetDashboardUrlsAsync(CancellationToken.None); + } + + public async Task<(string BaseUrlWithLoginToken, string? CodespacesUrlWithLoginToken)> GetDashboardUrlsAsync(CancellationToken cancellationToken) + { + if (!options.DashboardEnabled) + { + logger.LogError("Dashboard URL requested but dashboard is disabled."); + throw new InvalidOperationException("Dashboard URL requested but dashboard is disabled."); + } + + // Wait for the dashboard to be healthy before returning the URL. This next statement has several + // layers of hacks. Some to work around devcontainer/codespaces port forwarding behavior, and one to + // temporarily work around the fact that resource events abuse the state to mark the resource as + // hidden instead of having another field. There is a corresponding modification in the ResourceHealthService + // which allows the dashboard resource to trigger health reports even though it never enters + // the Running state. This is a hack. The reason we can't just check HealthStatus is because + // the current implementation of HealthStatus depends on the state of the resource as well. + await resourceNotificationService.WaitForResourceAsync( + KnownResourceNames.AspireDashboard, + re => re.Snapshot.HealthReports.All(h => h.Status == HealthStatus.Healthy), + cancellationToken).ConfigureAwait(false); + var dashboardOptions = serviceProvider.GetService>(); if (dashboardOptions is null) @@ -122,11 +147,11 @@ public Task PingAsync(long timestamp, CancellationToken cancellationToken) if (baseUrlWithLoginToken == codespacesUrlWithLoginToken) { - return Task.FromResult<(string, string?)>((baseUrlWithLoginToken, null)); + return (baseUrlWithLoginToken, null); } else { - return Task.FromResult((baseUrlWithLoginToken, codespacesUrlWithLoginToken)); + return (baseUrlWithLoginToken, codespacesUrlWithLoginToken); } } diff --git a/src/Aspire.Hosting/Dashboard/DashboardLifecycleHook.cs b/src/Aspire.Hosting/Dashboard/DashboardLifecycleHook.cs index 2d22a5db2d1..bd9e337b06f 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardLifecycleHook.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardLifecycleHook.cs @@ -127,7 +127,6 @@ private void AddDashboardResource(DistributedApplicationModel model) nameGenerator.EnsureDcpInstancesPopulated(dashboardResource); ConfigureAspireDashboardResource(dashboardResource); - // Make the dashboard first in the list so it starts as fast as possible. model.Resources.Insert(0, dashboardResource); } @@ -179,6 +178,7 @@ private void ConfigureAspireDashboardResource(IResource dashboardResource) dashboardResource.Annotations.Add(new ResourceSnapshotAnnotation(snapshot)); dashboardResource.Annotations.Add(new EnvironmentCallbackAnnotation(ConfigureEnvironmentVariables)); + dashboardResource.Annotations.Add(new HealthCheckAnnotation(KnownHealthCheckNames.DasboardHealthCheck)); } internal async Task ConfigureEnvironmentVariables(EnvironmentCallbackContext context) diff --git a/src/Aspire.Hosting/DistributedApplicationBuilder.cs b/src/Aspire.Hosting/DistributedApplicationBuilder.cs index 9eb7c04ebc4..fcab487bad2 100644 --- a/src/Aspire.Hosting/DistributedApplicationBuilder.cs +++ b/src/Aspire.Hosting/DistributedApplicationBuilder.cs @@ -19,6 +19,7 @@ using Aspire.Hosting.Lifecycle; using Aspire.Hosting.Orchestrator; using Aspire.Hosting.Publishing; +using Aspire.Hosting.Utils; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -331,6 +332,20 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options) _innerBuilder.Services.AddLifecycleHook(); _innerBuilder.Services.TryAddEnumerable(ServiceDescriptor.Singleton, ConfigureDefaultDashboardOptions>()); _innerBuilder.Services.TryAddEnumerable(ServiceDescriptor.Singleton, ValidateDashboardOptions>()); + + // Dashboard health check. + _innerBuilder.Services.AddHealthChecks().AddUrlGroup(sp => { + + var dashboardOptions = sp.GetRequiredService>().Value; + if (StringUtils.TryGetUriFromDelimitedString(dashboardOptions.DashboardUrl, ";", out var firstDashboardUrl)) + { + return firstDashboardUrl; + } + else + { + throw new DistributedApplicationException($"The dashboard resource '{KnownResourceNames.AspireDashboard}' does not have endpoints."); + } + }, KnownHealthCheckNames.DasboardHealthCheck); } if (options.EnableResourceLogging) diff --git a/src/Aspire.Hosting/Health/ResourceHealthCheckService.cs b/src/Aspire.Hosting/Health/ResourceHealthCheckService.cs index bf831314053..cefe7f32102 100644 --- a/src/Aspire.Hosting/Health/ResourceHealthCheckService.cs +++ b/src/Aspire.Hosting/Health/ResourceHealthCheckService.cs @@ -39,7 +39,10 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) } } - if (resourceEvent.Snapshot.State?.Text == KnownResourceStates.Running) + // HACK: We are special casing the Aspire dashboard here until we address the issue of the Hidden state + // making it impossible to determine whether a hidden resource is running or not. When that change + // is made we can remove the special case logic here for the dashboard. + if (resourceEvent.Snapshot.State?.Text == KnownResourceStates.Running || resourceEvent.Resource.Name == KnownResourceNames.AspireDashboard) { if (state == null) { diff --git a/src/Shared/KnownHealthCheckNames.cs b/src/Shared/KnownHealthCheckNames.cs new file mode 100644 index 00000000000..5bf0276584c --- /dev/null +++ b/src/Shared/KnownHealthCheckNames.cs @@ -0,0 +1,12 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire; + +internal static class KnownHealthCheckNames +{ + /// + /// Common name for dashboard health check. + /// + public const string DasboardHealthCheck = "aspire_dashboard_check"; +}