Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve writing dashboard startup config failure messages #3243

Merged
merged 1 commit into from
Apr 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading