From b18a246f2ba936997490c716264195715b2d8b1c Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Wed, 3 Apr 2024 16:45:16 +0800 Subject: [PATCH] Improve writing dashboard startup config failure messages (#3243) --- .../DashboardWebApplication.cs | 99 +++++++++++++++---- src/Aspire.Dashboard/Program.cs | 4 +- .../Integration/StartupTests.cs | 31 +++--- 3 files changed, 94 insertions(+), 40 deletions(-) diff --git a/src/Aspire.Dashboard/DashboardWebApplication.cs b/src/Aspire.Dashboard/DashboardWebApplication.cs index 26c8bde74c..beac86ec22 100644 --- a/src/Aspire.Dashboard/DashboardWebApplication.cs +++ b/src/Aspire.Dashboard/DashboardWebApplication.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Net; using System.Reflection; using System.Security.Claims; @@ -33,7 +35,9 @@ public sealed class DashboardWebApplication : IAsyncDisposable internal const string DashboardUrlDefaultValue = "http://localhost:18888"; private readonly WebApplication _app; + private readonly ILogger _logger; private readonly IOptionsMonitor _dashboardOptionsMonitor; + private readonly IReadOnlyList _validationFailures; private Func? _frontendEndPointAccessor; private Func? _otlpServiceEndPointAccessor; @@ -49,6 +53,8 @@ public Func OtlpServiceEndPointAccessor public IOptionsMonitor DashboardOptionsMonitor => _dashboardOptionsMonitor; + public IReadOnlyList ValidationFailures => _validationFailures; + /// /// Create a new instance of the class. /// @@ -79,7 +85,22 @@ public DashboardWebApplication(Action? configureBuilder = builder.Services.AddSingleton, PostConfigureDashboardOptions>(); builder.Services.AddSingleton, ValidateDashboardOptions>(); - var dashboardOptions = GetDashboardOptions(builder, dashboardConfigSection); + if (!TryGetDashboardOptions(builder, dashboardConfigSection, out var dashboardOptions, out var failureMessages)) + { + // The options have validation failures. Write them out to the user and return a non-zero exit code. + // We don't want to start the app, but we need to build the app to access the logger to log the errors. + _app = builder.Build(); + _dashboardOptionsMonitor = _app.Services.GetRequiredService>(); + _validationFailures = failureMessages.ToList(); + _logger = GetLogger(); + WriteVersion(_logger); + WriteValidationFailures(_logger, _validationFailures); + return; + } + else + { + _validationFailures = Array.Empty(); + } ConfigureKestrelEndpoints(builder, dashboardOptions); @@ -125,7 +146,7 @@ public DashboardWebApplication(Action? configureBuilder = _dashboardOptionsMonitor = _app.Services.GetRequiredService>(); - var logger = _app.Services.GetRequiredService().CreateLogger(); + _logger = GetLogger(); // this needs to be explicitly enumerated for each supported language // our language list comes from https://github.com/dotnet/arcade/blob/89008f339a79931cc49c739e9dbc1a27c608b379/src/Microsoft.DotNet.XliffTasks/build/Microsoft.DotNet.XliffTasks.props#L22 @@ -138,31 +159,26 @@ public DashboardWebApplication(Action? configureBuilder = .AddSupportedCultures(supportedLanguages) .AddSupportedUICultures(supportedLanguages)); - if (GetType().Assembly.GetCustomAttribute()?.InformationalVersion is string informationalVersion) - { - // Write version at info level so it's written to the console by default. Help us debug user issues. - // Display version and commit like 8.0.0-preview.2.23619.3+17dd83f67c6822954ec9a918ef2d048a78ad4697 - logger.LogInformation("Aspire version: {Version}", informationalVersion); - } + WriteVersion(_logger); _app.Lifetime.ApplicationStarted.Register(() => { if (_frontendEndPointAccessor != null) { var url = GetEndpointUrl(_frontendEndPointAccessor()); - logger.LogInformation("Now listening on: {DashboardUri}", url); + _logger.LogInformation("Now listening on: {DashboardUri}", url); var options = _app.Services.GetRequiredService>().Value; if (options.Frontend.AuthMode == FrontendAuthMode.BrowserToken) { - LoggingHelpers.WriteDashboardUrl(logger, url, options.Frontend.BrowserToken); + LoggingHelpers.WriteDashboardUrl(_logger, url, options.Frontend.BrowserToken); } } if (_otlpServiceEndPointAccessor != null) { // This isn't used by dotnet watch but still useful to have for debugging - logger.LogInformation("OTLP server running at: {OtlpEndpointUri}", GetEndpointUrl(_otlpServiceEndPointAccessor())); + _logger.LogInformation("OTLP server running at: {OtlpEndpointUri}", GetEndpointUrl(_otlpServiceEndPointAccessor())); } static string GetEndpointUrl(EndpointInfo info) => $"{(info.isHttps ? "https" : "http")}://{info.EndPoint}"; @@ -252,22 +268,50 @@ await Microsoft.AspNetCore.Authentication.AuthenticationHttpContextExtensions.Si } } + private ILogger GetLogger() + { + return _app.Services.GetRequiredService().CreateLogger(); + } + + private static void WriteValidationFailures(ILogger logger, IReadOnlyList validationFailures) + { + logger.LogError("Failed to start the dashboard due to {Count} configuration error(s).", validationFailures.Count); + foreach (var message in validationFailures) + { + logger.LogError("{ErrorMessage}", message); + } + } + + private static void WriteVersion(ILogger logger) + { + if (typeof(DashboardWebApplication).Assembly.GetCustomAttribute()?.InformationalVersion is string informationalVersion) + { + // Write version at info level so it's written to the console by default. Help us debug user issues. + // Display version and commit like 8.0.0-preview.2.23619.3+17dd83f67c6822954ec9a918ef2d048a78ad4697 + logger.LogInformation("Aspire version: {Version}", informationalVersion); + } + } + /// /// Load from configuration without using DI. This performs /// the same steps as getting the options from DI but without the need for a service provider. /// - private static DashboardOptions GetDashboardOptions(WebApplicationBuilder builder, IConfigurationSection dashboardConfigSection) + private static bool TryGetDashboardOptions(WebApplicationBuilder builder, IConfigurationSection dashboardConfigSection, [NotNullWhen(true)] out DashboardOptions? dashboardOptions, [NotNullWhen(false)] out IEnumerable? failureMessages) { - var dashboardOptions = new DashboardOptions(); + dashboardOptions = new DashboardOptions(); dashboardConfigSection.Bind(dashboardOptions); new PostConfigureDashboardOptions(builder.Configuration).PostConfigure(name: string.Empty, dashboardOptions); var result = new ValidateDashboardOptions().Validate(name: string.Empty, dashboardOptions); if (result.Failed) { - throw new OptionsValidationException(optionsName: string.Empty, typeof(DashboardOptions), result.Failures); + failureMessages = result.Failures; + return false; + } + else + { + failureMessages = null; + return true; } - - return dashboardOptions; } // Kestrel endpoints are loaded from configuration. This is done so that advanced configuration of endpoints is @@ -521,11 +565,28 @@ private static void ConfigureAuthentication(WebApplicationBuilder builder, Dashb }); } - public void Run() => _app.Run(); + public int Run() + { + if (_validationFailures.Count > 0) + { + return -1; + } + + _app.Run(); + return 0; + } - public Task StartAsync(CancellationToken cancellationToken = default) => _app.StartAsync(cancellationToken); + public Task StartAsync(CancellationToken cancellationToken = default) + { + Debug.Assert(_validationFailures.Count == 0); + return _app.StartAsync(cancellationToken); + } - public Task StopAsync(CancellationToken cancellationToken = default) => _app.StopAsync(cancellationToken); + public Task StopAsync(CancellationToken cancellationToken = default) + { + Debug.Assert(_validationFailures.Count == 0); + return _app.StopAsync(cancellationToken); + } public ValueTask DisposeAsync() { diff --git a/src/Aspire.Dashboard/Program.cs b/src/Aspire.Dashboard/Program.cs index cbee57b827..fe4866c7e6 100644 --- a/src/Aspire.Dashboard/Program.cs +++ b/src/Aspire.Dashboard/Program.cs @@ -3,5 +3,5 @@ using Aspire.Dashboard; -// TODO potentially inline DashboardWebApplication in this file -new DashboardWebApplication().Run(); +var app = new DashboardWebApplication(); +return app.Run(); diff --git a/tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs b/tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs index fe5cad0626..3c0ce2cd2d 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs @@ -6,7 +6,6 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Testing; -using Microsoft.Extensions.Options; using OpenTelemetry.Proto.Collector.Logs.V1; using Xunit; using Xunit.Abstractions; @@ -33,17 +32,14 @@ public async Task EndPointAccessors_AppStarted_EndPointPortsAssigned() public async Task Configuration_NoExtraConfig_Error() { // Arrange & Act - var ex = await Assert.ThrowsAsync(async () => - { - await using var app = IntegrationTestHelpers.CreateDashboardWebApplication(testOutputHelper, - additionalConfiguration: data => - { - data.Clear(); - }); - }); + await using var app = IntegrationTestHelpers.CreateDashboardWebApplication(testOutputHelper, + additionalConfiguration: data => + { + data.Clear(); + }); // Assert - Assert.Collection(ex.Failures, + Assert.Collection(app.ValidationFailures, s => s.Contains("Dashboard:Frontend:EndpointUrls"), s => s.Contains("Dashboard:Frontend:AuthMode"), s => s.Contains("Dashboard:Otlp:EndpointUrl"), @@ -194,17 +190,14 @@ await ServerRetryHelper.BindPortsWithRetry(async port => public async Task Configuration_NoOtlpAuthMode_Error() { // Arrange & Act - var ex = await Assert.ThrowsAsync(async () => - { - await using var app = IntegrationTestHelpers.CreateDashboardWebApplication(testOutputHelper, - additionalConfiguration: data => - { - data.Remove(DashboardConfigNames.DashboardOtlpAuthModeName.ConfigKey); - }); - }); + await using var app = IntegrationTestHelpers.CreateDashboardWebApplication(testOutputHelper, + additionalConfiguration: data => + { + data.Remove(DashboardConfigNames.DashboardOtlpAuthModeName.ConfigKey); + }); // Assert - Assert.Contains("Dashboard:Otlp:AuthMode", ex.Message); + Assert.Contains("Dashboard:Otlp:AuthMode", app.ValidationFailures.Single()); } [Fact]