Skip to content

Commit

Permalink
Improve writing dashboard startup config failure messages (dotnet#3243)
Browse files Browse the repository at this point in the history
  • Loading branch information
JamesNK authored and radical committed Apr 3, 2024
1 parent b940c0c commit 727ec02
Show file tree
Hide file tree
Showing 3 changed files with 94 additions and 40 deletions.
99 changes: 80 additions & 19 deletions src/Aspire.Dashboard/DashboardWebApplication.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -33,7 +35,9 @@ public sealed class DashboardWebApplication : IAsyncDisposable
internal const string DashboardUrlDefaultValue = "http://localhost:18888";

private readonly WebApplication _app;
private readonly ILogger<DashboardWebApplication> _logger;
private readonly IOptionsMonitor<DashboardOptions> _dashboardOptionsMonitor;
private readonly IReadOnlyList<string> _validationFailures;
private Func<EndpointInfo>? _frontendEndPointAccessor;
private Func<EndpointInfo>? _otlpServiceEndPointAccessor;

Expand All @@ -49,6 +53,8 @@ public Func<EndpointInfo> OtlpServiceEndPointAccessor

public IOptionsMonitor<DashboardOptions> DashboardOptionsMonitor => _dashboardOptionsMonitor;

public IReadOnlyList<string> ValidationFailures => _validationFailures;

/// <summary>
/// Create a new instance of the <see cref="DashboardWebApplication"/> class.
/// </summary>
Expand Down Expand Up @@ -79,7 +85,22 @@ public DashboardWebApplication(Action<WebApplicationBuilder>? configureBuilder =
builder.Services.AddSingleton<IPostConfigureOptions<DashboardOptions>, PostConfigureDashboardOptions>();
builder.Services.AddSingleton<IValidateOptions<DashboardOptions>, 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<IOptionsMonitor<DashboardOptions>>();
_validationFailures = failureMessages.ToList();
_logger = GetLogger();
WriteVersion(_logger);
WriteValidationFailures(_logger, _validationFailures);
return;
}
else
{
_validationFailures = Array.Empty<string>();
}

ConfigureKestrelEndpoints(builder, dashboardOptions);

Expand Down Expand Up @@ -125,7 +146,7 @@ public DashboardWebApplication(Action<WebApplicationBuilder>? configureBuilder =

_dashboardOptionsMonitor = _app.Services.GetRequiredService<IOptionsMonitor<DashboardOptions>>();

var logger = _app.Services.GetRequiredService<ILoggerFactory>().CreateLogger<DashboardWebApplication>();
_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
Expand All @@ -138,31 +159,26 @@ public DashboardWebApplication(Action<WebApplicationBuilder>? configureBuilder =
.AddSupportedCultures(supportedLanguages)
.AddSupportedUICultures(supportedLanguages));

if (GetType().Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.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<IOptions<DashboardOptions>>().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}";
Expand Down Expand Up @@ -252,22 +268,50 @@ await Microsoft.AspNetCore.Authentication.AuthenticationHttpContextExtensions.Si
}
}

private ILogger<DashboardWebApplication> GetLogger()
{
return _app.Services.GetRequiredService<ILoggerFactory>().CreateLogger<DashboardWebApplication>();
}

private static void WriteValidationFailures(ILogger<DashboardWebApplication> logger, IReadOnlyList<string> 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<DashboardWebApplication> logger)
{
if (typeof(DashboardWebApplication).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.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);
}
}

/// <summary>
/// Load <see cref="DashboardOptions"/> from configuration without using DI. This performs
/// the same steps as getting the options from DI but without the need for a service provider.
/// </summary>
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<string>? 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
Expand Down Expand Up @@ -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()
{
Expand Down
4 changes: 2 additions & 2 deletions src/Aspire.Dashboard/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@

using Aspire.Dashboard;

// TODO potentially inline DashboardWebApplication in this file
new DashboardWebApplication().Run();
var app = new DashboardWebApplication();
return app.Run();
31 changes: 12 additions & 19 deletions tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -33,17 +32,14 @@ public async Task EndPointAccessors_AppStarted_EndPointPortsAssigned()
public async Task Configuration_NoExtraConfig_Error()
{
// Arrange & Act
var ex = await Assert.ThrowsAsync<OptionsValidationException>(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"),
Expand Down Expand Up @@ -194,17 +190,14 @@ await ServerRetryHelper.BindPortsWithRetry(async port =>
public async Task Configuration_NoOtlpAuthMode_Error()
{
// Arrange & Act
var ex = await Assert.ThrowsAsync<OptionsValidationException>(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]
Expand Down

0 comments on commit 727ec02

Please sign in to comment.