diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d4cb297..8e9a0e52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ Please ADD ALL Changes to the UNRELEASED SECTION and not a specific release - CloudFormation task settings now live in Mappings so every deploy applies them, reducing ECS task memory from 4 GB to 1 GB - On a new stack, ECS tasks no longer fail to start with a 'log group does not exist' error, and their final logs are kept when the stack is deleted, because the ECS application log group is now created before the tasks and removed after them (#421) - Status notifications now pick one branch consistently for the embed title and Branch field (prefer release/*, then main, else first) - #434 +- Added a build-time --self-test switch run in the Docker image so a binary that cannot start fails the build ### Changed - Dependencies - Updated NSubstitute.Analyzers.CSharp to 1.0.17 - Switched to use minimal APIs diff --git a/COVERAGE.md b/COVERAGE.md index 204bd308..81ac7bb0 100644 --- a/COVERAGE.md +++ b/COVERAGE.md @@ -6,7 +6,7 @@ Generated by the AI Coverage phase. Do not edit by hand. | Project | Line Coverage | | --- | --- | -| BuildBot | n/a (no instrumented lines) | +| BuildBot | 100.0% | | BuildBot.CloudFormation | 100.0% | | BuildBot.Discord | 100.0% | | BuildBot.GitHub | 100.0% | @@ -30,4 +30,4 @@ excluded --- -Captured at commit `21531fa8f8a0eaaa7c7981d2b08325401cbd5f8a` on 2026-10-19. +Captured at commit `6d122751d6f5850bbf9742ae0af63f0ee496d620` on 2026-11-13. diff --git a/Dockerfile b/Dockerfile index 886f9ce4..45f64fa7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,6 +8,9 @@ WORKDIR /usr/src/app COPY BuildBot . COPY appsettings.json . +# Fail the image build, rather than production, if the binary cannot start in this runtime base +RUN [ "/usr/src/app/BuildBot", "--self-test" ] + EXPOSE 8080 ENTRYPOINT [ "/usr/src/app/BuildBot" ] diff --git a/src/BuildBot.Tests/ApplicationConfigLocatorTests.cs b/src/BuildBot.Tests/ApplicationConfigLocatorTests.cs new file mode 100644 index 00000000..a6c2c6e1 --- /dev/null +++ b/src/BuildBot.Tests/ApplicationConfigLocatorTests.cs @@ -0,0 +1,50 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using BuildBot.Helpers; +using FunFair.Test.Common; +using Xunit; + +namespace BuildBot.Tests; + +public sealed class ApplicationConfigLocatorTests : TestBase +{ + [Fact] + public async Task UsesTheBaseDirectoryWhenItHoldsTheAppSettingsAsync() + { + using (TestConfigFolder configFolder = new()) + { + await configFolder.WriteAppSettingsAsync( + discordWebhookUrl: "", + cancellationToken: this.CancellationToken() + ); + + string path = ApplicationConfigLocator.LookupConfigurationFilesPath( + configFolder.FullName + Path.DirectorySeparatorChar + ); + + Assert.Equal(expected: configFolder.FullName, actual: path); + } + } + + [Fact] + public void FallsBackToTheCurrentDirectoryWhenTheBaseDirectoryHasNoAppSettings() + { + using (TestConfigFolder configFolder = new()) + { + string path = ApplicationConfigLocator.LookupConfigurationFilesPath( + configFolder.FullName + Path.DirectorySeparatorChar + ); + + Assert.Equal(expected: Environment.CurrentDirectory, actual: path); + } + } + + [Fact] + public void FallsBackToTheCurrentDirectoryWhenTheBaseDirectoryHasNoParent() + { + string path = ApplicationConfigLocator.LookupConfigurationFilesPath(Path.DirectorySeparatorChar.ToString()); + + Assert.Equal(expected: Environment.CurrentDirectory, actual: path); + } +} diff --git a/src/BuildBot.Tests/BuildBot.Tests.csproj b/src/BuildBot.Tests/BuildBot.Tests.csproj index ad16468e..d95062db 100644 --- a/src/BuildBot.Tests/BuildBot.Tests.csproj +++ b/src/BuildBot.Tests/BuildBot.Tests.csproj @@ -41,6 +41,7 @@ + diff --git a/src/BuildBot.Tests/ProgramTests.cs b/src/BuildBot.Tests/ProgramTests.cs new file mode 100644 index 00000000..0f17cb4d --- /dev/null +++ b/src/BuildBot.Tests/ProgramTests.cs @@ -0,0 +1,104 @@ +using System; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using FunFair.Test.Common; +using Xunit; + +namespace BuildBot.Tests; + +[Collection(ThreadPoolCollection.NAME)] +public sealed class ProgramTests : TestBase +{ + private static readonly Uri PingUri = new("http://127.0.0.1:8080/ping"); + + [Fact] + public async Task SelfTestSwitchExitsWithoutStartingServerAsync() + { + int exitCode = await Program.Main(["--self-test"]); + + Assert.Equal(expected: 0, actual: exitCode); + } + + [Fact] + public async Task HealthCheckSwitchFailsWhenNothingIsListeningAsync() + { + int exitCode = await Program.Main(["--health-check", $"http://127.0.0.1:{GetUnusedPort()}/ping"]); + + Assert.NotEqual(expected: 0, actual: exitCode); + } + + [Fact] + public async Task ServerExitsWithErrorWhenStartupFailsAsync() + { + using (TestConfigFolder contentRoot = new()) + { + string missingFolder = Path.Combine(path1: contentRoot.FullName, path2: "missing"); + + int exitCode = await Program.Main(["--contentRoot", missingFolder]); + + Assert.Equal(expected: 1, actual: exitCode); + } + } + + [Fact] + public async Task ServerRunsUntilCancelledAsync() + { + using (TestConfigFolder configFolder = new()) + { + await configFolder.WriteAppSettingsAsync( + discordWebhookUrl: TestConfigFolder.DISCORD_WEBHOOK_URL, + cancellationToken: this.CancellationToken() + ); + + using ( + CancellationTokenSource stop = CancellationTokenSource.CreateLinkedTokenSource(this.CancellationToken()) + ) + { + Task server = Program + .RunServerAsync(args: [], configPath: configFolder.FullName, cancellationToken: stop.Token) + .AsTask(); + + await WaitUntilListeningAsync(server: server, cancellationToken: this.CancellationToken()); + + await stop.CancelAsync(); + + Assert.Equal(expected: 0, actual: await server); + } + } + } + + private static int GetUnusedPort() + { + using (TcpListener listener = new(localaddr: IPAddress.Loopback, port: 0)) + { + listener.Start(); + + return ((IPEndPoint)listener.LocalEndpoint).Port; + } + } + + private static async ValueTask WaitUntilListeningAsync(Task server, CancellationToken cancellationToken) + { + using (HttpClient client = new()) + { + while (!server.IsCompleted) + { + try + { + using (await client.GetAsync(requestUri: PingUri, cancellationToken: cancellationToken)) + { + return; + } + } + catch (HttpRequestException) + { + await Task.Delay(millisecondsDelay: 100, cancellationToken: cancellationToken); + } + } + } + } +} diff --git a/src/BuildBot.Tests/SelfTestTests.cs b/src/BuildBot.Tests/SelfTestTests.cs new file mode 100644 index 00000000..f496674b --- /dev/null +++ b/src/BuildBot.Tests/SelfTestTests.cs @@ -0,0 +1,77 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using BuildBot.Helpers; +using FunFair.Test.Common; +using Xunit; + +namespace BuildBot.Tests; + +public sealed class SelfTestTests : TestBase +{ + [Theory] + [InlineData("--self-test", true)] + [InlineData("--urls http://localhost --self-test", true)] + [InlineData("", false)] + [InlineData("--health-check http://127.0.0.1:8080/ping", false)] + [InlineData("--SELF-TEST", false)] + public static void DetectsSelfTestSwitch(string commandLine, bool expected) + { + string[] args = commandLine.Split(separator: ' ', options: StringSplitOptions.RemoveEmptyEntries); + + Assert.Equal(expected: expected, actual: SelfTest.IsSelfTest(args)); + } + + [Fact] + public async Task SucceedsWhenStartupCompletesAsync() + { + using (TestConfigFolder configFolder = new()) + { + await configFolder.WriteAppSettingsAsync( + discordWebhookUrl: "", + cancellationToken: this.CancellationToken() + ); + + (int exitCode, string output) = await this.RunSelfTestAsync(configFolder: configFolder); + + Assert.Equal(expected: 0, actual: exitCode); + Assert.Equal(expected: "Self-test passed", actual: output); + } + } + + [Fact] + public async Task FailsWithOneLineReasonWhenConfigurationIsMissingAsync() + { + using (TestConfigFolder configFolder = new()) + { + (int exitCode, string output) = await this.RunSelfTestAsync(configFolder: configFolder); + + Assert.Equal(expected: 1, actual: exitCode); + Assert.StartsWith( + expectedStartString: "Self-test failed: ", + actualString: output, + comparisonType: StringComparison.Ordinal + ); + Assert.DoesNotContain( + expectedSubstring: "\n", + actualString: output, + comparisonType: StringComparison.Ordinal + ); + } + } + + private async ValueTask<(int ExitCode, string Output)> RunSelfTestAsync(TestConfigFolder configFolder) + { + await using (StringWriter output = new()) + { + int exitCode = await SelfTest.RunAsync( + args: ["--self-test"], + configPath: configFolder.FullName, + output: output, + cancellationToken: this.CancellationToken() + ); + + return (exitCode, output.ToString().TrimEnd()); + } + } +} diff --git a/src/BuildBot.Tests/ServerStartupTests.cs b/src/BuildBot.Tests/ServerStartupTests.cs new file mode 100644 index 00000000..230363bf --- /dev/null +++ b/src/BuildBot.Tests/ServerStartupTests.cs @@ -0,0 +1,79 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using BuildBot.Helpers; +using BuildBot.Json; +using FunFair.Test.Common; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http.Json; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Xunit; + +namespace BuildBot.Tests; + +[Collection(ThreadPoolCollection.NAME)] +public sealed class ServerStartupTests : TestBase +{ + [Theory] + [InlineData(0, 0, 1, 1, 1)] + [InlineData(0, 2, 1, 1, 2)] + [InlineData(2, 0, 1, 2, 1)] + [InlineData(2, 2, 1, 2, 2)] + public static void RaisesOnlyTheThreadPoolMinimumsBelowTheTarget( + int workerOffset, + int completionPortOffset, + int targetOffset, + int expectedWorkerOffset, + int expectedCompletionPortOffset + ) + { + ThreadPool.GetMinThreads(out int originalWorker, out int originalCompletionPort); + + // Offsets keep every value above the server's own minimum so a server started by another test cannot change them. + int baseline = Math.Max(val1: Math.Max(val1: originalWorker, val2: originalCompletionPort), val2: 32); + + try + { + Assert.True( + ThreadPool.SetMinThreads( + workerThreads: baseline + workerOffset, + completionPortThreads: baseline + completionPortOffset + ), + userMessage: "Could not set the thread pool minimums" + ); + + ServerStartup.SetThreads(baseline + targetOffset); + + ThreadPool.GetMinThreads(out int worker, out int completionPort); + Assert.Equal(expected: baseline + expectedWorkerOffset, actual: worker); + Assert.Equal(expected: baseline + expectedCompletionPortOffset, actual: completionPort); + } + finally + { + ThreadPool.SetMinThreads(workerThreads: originalWorker, completionPortThreads: originalCompletionPort); + } + } + + [Fact] + public async Task UsesTheApplicationSerializationContextForHttpJsonAsync() + { + using (TestConfigFolder configFolder = new()) + { + await configFolder.WriteAppSettingsAsync( + discordWebhookUrl: TestConfigFolder.DISCORD_WEBHOOK_URL, + cancellationToken: this.CancellationToken() + ); + + await using (WebApplication app = ServerStartup.CreateApp(args: [], configPath: configFolder.FullName)) + { + JsonOptions options = app.Services.GetRequiredService>().Value; + + Assert.Same( + expected: AppSerializationContext.Default, + actual: options.SerializerOptions.TypeInfoResolverChain[0] + ); + } + } + } +} diff --git a/src/BuildBot.Tests/TestConfigFolder.cs b/src/BuildBot.Tests/TestConfigFolder.cs new file mode 100644 index 00000000..431e4ca7 --- /dev/null +++ b/src/BuildBot.Tests/TestConfigFolder.cs @@ -0,0 +1,40 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace BuildBot.Tests; + +internal sealed class TestConfigFolder : IDisposable +{ + public const string DISCORD_WEBHOOK_URL = "https://discord.invalid/api/webhooks/test"; + + private readonly DirectoryInfo _folder; + + public TestConfigFolder() + { + this._folder = Directory.CreateTempSubdirectory(); + } + + public string FullName => this._folder.FullName; + + public void Dispose() + { + this._folder.Delete(recursive: true); + } + + public Task WriteAppSettingsAsync(string discordWebhookUrl, CancellationToken cancellationToken) + { + return File.WriteAllTextAsync( + path: Path.Combine(path1: this.FullName, path2: "appsettings.json"), + contents: $$""" + { + "Discord": { "WebhookUrl": "{{discordWebhookUrl}}" }, + "CloudFormation": { "TopicArn": "", "Aws": { "AccessKeyId": "", "SecretKey": "" } }, + "GitHub": { "WebhookSecret": "" } + } + """, + cancellationToken: cancellationToken + ); + } +} diff --git a/src/BuildBot.Tests/ThreadPoolCollection.cs b/src/BuildBot.Tests/ThreadPoolCollection.cs new file mode 100644 index 00000000..cf0a719d --- /dev/null +++ b/src/BuildBot.Tests/ThreadPoolCollection.cs @@ -0,0 +1,7 @@ +namespace BuildBot.Tests; + +internal static class ThreadPoolCollection +{ + // Server startup changes the process-wide thread pool minimums, so tests that start it or assert on them must not run in parallel. + public const string NAME = "Thread pool"; +} diff --git a/src/BuildBot/Helpers/ApplicationConfigLocator.cs b/src/BuildBot/Helpers/ApplicationConfigLocator.cs index d1e6eae9..1503fa63 100644 --- a/src/BuildBot/Helpers/ApplicationConfigLocator.cs +++ b/src/BuildBot/Helpers/ApplicationConfigLocator.cs @@ -4,13 +4,13 @@ namespace BuildBot.Helpers; -internal static class ApplicationConfigLocator +public static class ApplicationConfigLocator { - public static string ConfigurationFilesPath { get; } = LookupConfigurationFilesPath(); + public static string ConfigurationFilesPath { get; } = LookupConfigurationFilesPath(AppContext.BaseDirectory); - private static string LookupConfigurationFilesPath() + public static string LookupConfigurationFilesPath(string baseDirectory) { - string? path = LookupAppSettingsLocationByAssemblyName(); + string? path = LookupAppSettingsLocationByAssemblyName(baseDirectory); if (path is null) { @@ -21,11 +21,9 @@ private static string LookupConfigurationFilesPath() return path; } - private static string? LookupAppSettingsLocationByAssemblyName() + private static string? LookupAppSettingsLocationByAssemblyName(string baseDirectory) { - string location = AppContext.BaseDirectory; - - string? path = Path.GetDirectoryName(location); + string? path = Path.GetDirectoryName(baseDirectory); if (string.IsNullOrWhiteSpace(path)) { diff --git a/src/BuildBot/Helpers/SelfTest.cs b/src/BuildBot/Helpers/SelfTest.cs new file mode 100644 index 00000000..b3525e60 --- /dev/null +++ b/src/BuildBot/Helpers/SelfTest.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Frozen; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace BuildBot.Helpers; + +public static class SelfTest +{ + private const string SWITCH = "--self-test"; + + // Secrets are only supplied when the container is deployed, so the image build has none; nothing is ever sent to it. + private static readonly IReadOnlyDictionary DeploymentSecretPlaceholders = new Dictionary< + string, + string? + >(StringComparer.Ordinal) + { + ["Discord:WebhookUrl"] = "https://discord.invalid/api/webhooks/self-test", + }.ToFrozenDictionary(StringComparer.Ordinal); + + public static bool IsSelfTest(IReadOnlyList args) + { + return args.Contains(value: SWITCH, comparer: StringComparer.Ordinal); + } + + public static async ValueTask RunAsync( + IReadOnlyList args, + string configPath, + TextWriter output, + CancellationToken cancellationToken + ) + { + try + { + await VerifyStartupAsync(args: WithoutSwitch(args), configPath: configPath); + + await WriteLineAsync(output: output, message: "Self-test passed", cancellationToken: cancellationToken); + + return 0; + } + catch (Exception exception) + { + await WriteLineAsync( + output: output, + message: "Self-test failed: " + exception.Message.ReplaceLineEndings(" "), + cancellationToken: cancellationToken + ); + + return 1; + } + } + + private static string[] WithoutSwitch(IReadOnlyList args) + { + return [.. args.Where(static arg => !StringComparer.Ordinal.Equals(x: arg, y: SWITCH))]; + } + + private static async ValueTask VerifyStartupAsync(string[] args, string configPath) + { + await using ( + WebApplication app = ServerStartup.CreateApp( + args: args, + configPath: configPath, + configurationOverrides: DeploymentSecretPlaceholders, + validateServices: true + ) + ) + { + WebApplication configured = app.ConfigureEndpoints(); + + // Resolving the options runs the listener configuration without binding any port. + _ = configured.Services.GetRequiredService>().Value; + + LoadCryptography(); + } + } + + private static void LoadCryptography() + { + // Forces the native crypto library (OpenSSL on Linux) to load, as webhook signature checks depend on it. + _ = HMACSHA256.HashData(key: [1], source: [2]); + } + + private static ValueTask WriteLineAsync(TextWriter output, string message, CancellationToken cancellationToken) + { + return new(output.WriteLineAsync(buffer: message.AsMemory(), cancellationToken: cancellationToken)); + } +} diff --git a/src/BuildBot/Helpers/ServerStartup.cs b/src/BuildBot/Helpers/ServerStartup.cs index dec3339e..6a6932ee 100644 --- a/src/BuildBot/Helpers/ServerStartup.cs +++ b/src/BuildBot/Helpers/ServerStartup.cs @@ -1,7 +1,8 @@ using System; +using System.Collections.Frozen; +using System.Collections.Generic; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; -using System.IO; using System.Net; using System.Threading; using BuildBot.CloudFormation; @@ -23,7 +24,7 @@ namespace BuildBot.Helpers; -internal static class ServerStartup +public static class ServerStartup { public static void SetThreads(int minThreads) { @@ -50,28 +51,34 @@ public static void SetThreads(int minThreads) Console.WriteLine($"Max worker threads {maxWorker}, Max IOC threads {maxIoc}"); } - public static WebApplication CreateApp(string[] args) + public static WebApplication CreateApp( + IReadOnlyList args, + string? configPath = null, + IReadOnlyDictionary? configurationOverrides = null, + bool validateServices = false + ) { const int httpPort = 8080; - const int httpsPort = 0; - const int h2Port = 0; - WebApplicationBuilder builder = WebApplication.CreateSlimBuilder(args); - - string configPath = ApplicationConfigLocator.ConfigurationFilesPath; + WebApplicationBuilder builder = WebApplication.CreateSlimBuilder([.. args]); - IConfigurationRoot config = LoadConfiguration(configPath); + string configFilesPath = configPath ?? ApplicationConfigLocator.ConfigurationFilesPath; + IConfigurationRoot config = LoadConfiguration( + configPath: configFilesPath, + overrides: configurationOverrides ?? FrozenDictionary.Empty + ); builder.Host.UseWindowsService().UseSystemd(); + + if (validateServices) + { + builder.Host.UseDefaultServiceProvider(static options => + { + options.ValidateOnBuild = true; + options.ValidateScopes = true; + }); + } builder - .WebHost.UseKestrel(options: options => - SetKestrelOptions( - options: options, - httpPort: httpPort, - httpsPort: httpsPort, - h2Port: h2Port, - configurationFiledPath: configPath - ) - ) + .WebHost.UseKestrel(options: static options => SetKestrelOptions(options: options, httpPort: httpPort)) .UseSetting(key: WebHostDefaults.SuppressStatusMessagesKey, value: "True") .ConfigureLogging((_, logger) => ConfigureLogging(logger)); @@ -119,13 +126,17 @@ private static LoggerConfiguration WriteToDebuggerAwareOutput(this LoggerConfigu return Debugger.IsAttached ? writeTo.Debug() : writeTo.Console(); } - private static IConfigurationRoot LoadConfiguration(string configPath) + private static IConfigurationRoot LoadConfiguration( + string configPath, + IReadOnlyDictionary overrides + ) { return new ConfigurationBuilder() .SetBasePath(configPath) .AddJsonFile(path: "appsettings.json", optional: false, reloadOnChange: false) .AddJsonFile(path: "appsettings-local.json", optional: true, reloadOnChange: false) .AddEnvironmentVariables() + .AddInMemoryCollection(overrides) .Build(); } @@ -134,34 +145,7 @@ private static void SetH1ListenOptions(ListenOptions listenOptions) listenOptions.Protocols = HttpProtocols.Http1; } - private static void SetH2ListenOptions(ListenOptions listenOptions) - { - listenOptions.Protocols = HttpProtocols.Http2; - } - - private static void SetHttpsListenOptions(ListenOptions listenOptions, string configurationFiledPath) - { - string certFile = Path.Combine(path1: configurationFiledPath, path2: "server.pfx"); - - if (!File.Exists(certFile)) - { - listenOptions.Protocols = HttpProtocols.Http1; - listenOptions.UseHttps(); - } - else - { - listenOptions.Protocols = HttpProtocols.Http1AndHttp2AndHttp3; - listenOptions.UseHttps(fileName: certFile); - } - } - - private static void SetKestrelOptions( - KestrelServerOptions options, - int httpPort, - int httpsPort, - int h2Port, - string configurationFiledPath - ) + private static void SetKestrelOptions(KestrelServerOptions options, int httpPort) { options.DisableStringReuse = false; options.AllowSynchronousIO = false; @@ -170,22 +154,6 @@ string configurationFiledPath options.Limits.MinResponseDataRate = null; options.Limits.MinRequestBodyDataRate = null; - if (httpsPort != 0) - { - Console.WriteLine($"Listening on HTTPS port: {httpsPort}"); - options.Listen( - address: IPAddress.Any, - port: httpsPort, - configure: o => SetHttpsListenOptions(listenOptions: o, configurationFiledPath: configurationFiledPath) - ); - } - - if (h2Port != 0) - { - Console.WriteLine($"Listening on H2 port: {h2Port}"); - options.Listen(address: IPAddress.Any, port: h2Port, configure: SetH2ListenOptions); - } - Console.WriteLine($"Listening on HTTP port: {httpPort}"); options.Listen(address: IPAddress.Any, port: httpPort, configure: SetH1ListenOptions); } diff --git a/src/BuildBot/Program.cs b/src/BuildBot/Program.cs index 45ecb44c..ece014f4 100644 --- a/src/BuildBot/Program.cs +++ b/src/BuildBot/Program.cs @@ -1,10 +1,12 @@ using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Threading; using System.Threading.Tasks; using BuildBot.Helpers; using Credfeto.Docker.HealthCheck.Http.Client; using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; namespace BuildBot; @@ -26,10 +28,28 @@ public static async Task Main(string[] args) ); } - return await RunServerAsync(args); + if (SelfTest.IsSelfTest(args)) + { + return await SelfTest.RunAsync( + args: args, + configPath: ApplicationConfigLocator.ConfigurationFilesPath, + output: Console.Out, + cancellationToken: CancellationToken.None + ); + } + + return await RunServerAsync( + args: args, + configPath: ApplicationConfigLocator.ConfigurationFilesPath, + cancellationToken: CancellationToken.None + ); } - private static async ValueTask RunServerAsync(string[] args) + public static async ValueTask RunServerAsync( + IReadOnlyList args, + string configPath, + CancellationToken cancellationToken + ) { StartupBanner.Show(); @@ -37,12 +57,9 @@ private static async ValueTask RunServerAsync(string[] args) try { - await using (WebApplication app = ServerStartup.CreateApp(args)) - { - await RunAsync(app); + await RunAppAsync(args: args, configPath: configPath, cancellationToken: cancellationToken); - return 0; - } + return 0; } catch (Exception exception) { @@ -54,12 +71,24 @@ private static async ValueTask RunServerAsync(string[] args) } } - private static Task RunAsync(WebApplication application) + private static async ValueTask RunAppAsync( + IReadOnlyList args, + string configPath, + CancellationToken cancellationToken + ) + { + await using (WebApplication app = ServerStartup.CreateApp(args: args, configPath: configPath)) + { + await RunAsync(application: app, cancellationToken: cancellationToken); + } + } + + private static Task RunAsync(WebApplication application, CancellationToken cancellationToken) { Console.WriteLine("App Created"); WebApplication configured = application.ConfigureEndpoints(); Console.WriteLine("Endpoints configured"); - return configured.RunAsync(); + return configured.RunAsync(cancellationToken); } }