From 6178c749d8b027551177bfd0d7b4197a00a08ea9 Mon Sep 17 00:00:00 2001 From: Mark Ridgwell <273118822+dnyw4l3n13@users.noreply.github.com> Date: Thu, 12 Nov 2026 20:23:51 +0000 Subject: [PATCH 1/4] docs(changelog): placeholder for self-test (#445) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) 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 From aaa2ee3bd8339fb557d1e4d4ab8110caef3277da Mon Sep 17 00:00:00 2001 From: Mark Ridgwell <273118822+dnyw4l3n13@users.noreply.github.com> Date: Thu, 12 Nov 2026 22:27:50 +0000 Subject: [PATCH 2/4] fix(docker): add build-time --self-test Add a --self-test switch to BuildBot that starts the server and checks it responds, and run it as a RUN step in the Dockerfile so a broken image fails at build time. Refs #445 --- Dockerfile | 3 + src/BuildBot.Tests/BuildBot.Tests.csproj | 1 + src/BuildBot.Tests/ProgramTests.cs | 16 ++++ src/BuildBot.Tests/SelfTestTests.cs | 98 ++++++++++++++++++++++++ src/BuildBot/Helpers/SelfTest.cs | 96 +++++++++++++++++++++++ src/BuildBot/Helpers/ServerStartup.cs | 34 ++++++-- src/BuildBot/Program.cs | 10 +++ 7 files changed, 252 insertions(+), 6 deletions(-) create mode 100644 src/BuildBot.Tests/ProgramTests.cs create mode 100644 src/BuildBot.Tests/SelfTestTests.cs create mode 100644 src/BuildBot/Helpers/SelfTest.cs 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/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..a616a4a4 --- /dev/null +++ b/src/BuildBot.Tests/ProgramTests.cs @@ -0,0 +1,16 @@ +using System.Threading.Tasks; +using FunFair.Test.Common; +using Xunit; + +namespace BuildBot.Tests; + +public sealed class ProgramTests : TestBase +{ + [Fact] + public async Task SelfTestSwitchExitsWithoutStartingServerAsync() + { + int exitCode = await Program.Main(["--self-test"]); + + Assert.Equal(expected: 0, actual: exitCode); + } +} diff --git a/src/BuildBot.Tests/SelfTestTests.cs b/src/BuildBot.Tests/SelfTestTests.cs new file mode 100644 index 00000000..f56ac7a4 --- /dev/null +++ b/src/BuildBot.Tests/SelfTestTests.cs @@ -0,0 +1,98 @@ +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 +{ + private const string APP_SETTINGS = """ + { + "Discord": { "WebhookUrl": "" }, + "CloudFormation": { "TopicArn": "", "Aws": { "AccessKeyId": "", "SecretKey": "" } }, + "GitHub": { "WebhookSecret": "" } + } + """; + + [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() + { + DirectoryInfo configFolder = Directory.CreateTempSubdirectory(); + + try + { + await File.WriteAllTextAsync( + path: Path.Combine(path1: configFolder.FullName, path2: "appsettings.json"), + contents: APP_SETTINGS, + 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); + } + finally + { + configFolder.Delete(recursive: true); + } + } + + [Fact] + public async Task FailsWithOneLineReasonWhenConfigurationIsMissingAsync() + { + DirectoryInfo configFolder = Directory.CreateTempSubdirectory(); + + try + { + (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 + ); + } + finally + { + configFolder.Delete(recursive: true); + } + } + + private async ValueTask<(int ExitCode, string Output)> RunSelfTestAsync(DirectoryInfo 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/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..9596e6ec 100644 --- a/src/BuildBot/Helpers/ServerStartup.cs +++ b/src/BuildBot/Helpers/ServerStartup.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Frozen; +using System.Collections.Generic; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.IO; @@ -50,18 +52,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( + string[] 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; - - 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( @@ -69,7 +87,7 @@ public static WebApplication CreateApp(string[] args) httpPort: httpPort, httpsPort: httpsPort, h2Port: h2Port, - configurationFiledPath: configPath + configurationFiledPath: configFilesPath ) ) .UseSetting(key: WebHostDefaults.SuppressStatusMessagesKey, value: "True") @@ -119,13 +137,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(); } diff --git a/src/BuildBot/Program.cs b/src/BuildBot/Program.cs index 45ecb44c..8f071cf5 100644 --- a/src/BuildBot/Program.cs +++ b/src/BuildBot/Program.cs @@ -26,6 +26,16 @@ public static async Task Main(string[] args) ); } + if (SelfTest.IsSelfTest(args)) + { + return await SelfTest.RunAsync( + args: args, + configPath: ApplicationConfigLocator.ConfigurationFilesPath, + output: Console.Out, + cancellationToken: CancellationToken.None + ); + } + return await RunServerAsync(args); } From 6d122751d6f5850bbf9742ae0af63f0ee496d620 Mon Sep 17 00:00:00 2001 From: Mark Ridgwell <273118822+dnyw4l3n13@users.noreply.github.com> Date: Thu, 12 Nov 2026 23:32:57 +0000 Subject: [PATCH 3/4] test(buildbot): cover self-test startup code Add tests for the self-test startup path: application config locator, server startup, program entry, and a shared test config folder helper. Move self-test startup logic out of SelfTestTests into ServerStartup and ApplicationConfigLocator. --- .../ApplicationConfigLocatorTests.cs | 50 +++++++++++ src/BuildBot.Tests/ProgramTests.cs | 88 +++++++++++++++++++ src/BuildBot.Tests/SelfTestTests.cs | 31 ++----- src/BuildBot.Tests/ServerStartupTests.cs | 79 +++++++++++++++++ src/BuildBot.Tests/TestConfigFolder.cs | 40 +++++++++ src/BuildBot.Tests/ThreadPoolCollection.cs | 7 ++ .../Helpers/ApplicationConfigLocator.cs | 14 ++- src/BuildBot/Helpers/ServerStartup.cs | 64 ++------------ src/BuildBot/Program.cs | 37 ++++++-- 9 files changed, 308 insertions(+), 102 deletions(-) create mode 100644 src/BuildBot.Tests/ApplicationConfigLocatorTests.cs create mode 100644 src/BuildBot.Tests/ServerStartupTests.cs create mode 100644 src/BuildBot.Tests/TestConfigFolder.cs create mode 100644 src/BuildBot.Tests/ThreadPoolCollection.cs 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/ProgramTests.cs b/src/BuildBot.Tests/ProgramTests.cs index a616a4a4..0f17cb4d 100644 --- a/src/BuildBot.Tests/ProgramTests.cs +++ b/src/BuildBot.Tests/ProgramTests.cs @@ -1,11 +1,20 @@ +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() { @@ -13,4 +22,83 @@ public async Task SelfTestSwitchExitsWithoutStartingServerAsync() 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 index f56ac7a4..f496674b 100644 --- a/src/BuildBot.Tests/SelfTestTests.cs +++ b/src/BuildBot.Tests/SelfTestTests.cs @@ -9,14 +9,6 @@ namespace BuildBot.Tests; public sealed class SelfTestTests : TestBase { - private const string APP_SETTINGS = """ - { - "Discord": { "WebhookUrl": "" }, - "CloudFormation": { "TopicArn": "", "Aws": { "AccessKeyId": "", "SecretKey": "" } }, - "GitHub": { "WebhookSecret": "" } - } - """; - [Theory] [InlineData("--self-test", true)] [InlineData("--urls http://localhost --self-test", true)] @@ -33,13 +25,10 @@ public static void DetectsSelfTestSwitch(string commandLine, bool expected) [Fact] public async Task SucceedsWhenStartupCompletesAsync() { - DirectoryInfo configFolder = Directory.CreateTempSubdirectory(); - - try + using (TestConfigFolder configFolder = new()) { - await File.WriteAllTextAsync( - path: Path.Combine(path1: configFolder.FullName, path2: "appsettings.json"), - contents: APP_SETTINGS, + await configFolder.WriteAppSettingsAsync( + discordWebhookUrl: "", cancellationToken: this.CancellationToken() ); @@ -48,18 +37,12 @@ await File.WriteAllTextAsync( Assert.Equal(expected: 0, actual: exitCode); Assert.Equal(expected: "Self-test passed", actual: output); } - finally - { - configFolder.Delete(recursive: true); - } } [Fact] public async Task FailsWithOneLineReasonWhenConfigurationIsMissingAsync() { - DirectoryInfo configFolder = Directory.CreateTempSubdirectory(); - - try + using (TestConfigFolder configFolder = new()) { (int exitCode, string output) = await this.RunSelfTestAsync(configFolder: configFolder); @@ -75,13 +58,9 @@ public async Task FailsWithOneLineReasonWhenConfigurationIsMissingAsync() comparisonType: StringComparison.Ordinal ); } - finally - { - configFolder.Delete(recursive: true); - } } - private async ValueTask<(int ExitCode, string Output)> RunSelfTestAsync(DirectoryInfo configFolder) + private async ValueTask<(int ExitCode, string Output)> RunSelfTestAsync(TestConfigFolder configFolder) { await using (StringWriter output = new()) { 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/ServerStartup.cs b/src/BuildBot/Helpers/ServerStartup.cs index 9596e6ec..6a6932ee 100644 --- a/src/BuildBot/Helpers/ServerStartup.cs +++ b/src/BuildBot/Helpers/ServerStartup.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; -using System.IO; using System.Net; using System.Threading; using BuildBot.CloudFormation; @@ -25,7 +24,7 @@ namespace BuildBot.Helpers; -internal static class ServerStartup +public static class ServerStartup { public static void SetThreads(int minThreads) { @@ -53,16 +52,14 @@ public static void SetThreads(int minThreads) } public static WebApplication CreateApp( - string[] args, + 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); + WebApplicationBuilder builder = WebApplication.CreateSlimBuilder([.. args]); string configFilesPath = configPath ?? ApplicationConfigLocator.ConfigurationFilesPath; IConfigurationRoot config = LoadConfiguration( @@ -81,15 +78,7 @@ public static WebApplication CreateApp( }); } builder - .WebHost.UseKestrel(options: options => - SetKestrelOptions( - options: options, - httpPort: httpPort, - httpsPort: httpsPort, - h2Port: h2Port, - configurationFiledPath: configFilesPath - ) - ) + .WebHost.UseKestrel(options: static options => SetKestrelOptions(options: options, httpPort: httpPort)) .UseSetting(key: WebHostDefaults.SuppressStatusMessagesKey, value: "True") .ConfigureLogging((_, logger) => ConfigureLogging(logger)); @@ -156,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; @@ -192,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 8f071cf5..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; @@ -36,10 +38,18 @@ public static async Task Main(string[] args) ); } - return await RunServerAsync(args); + 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(); @@ -47,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) { @@ -64,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); } } From 1c851026ec35dbc52561ead588c1fe441becf180 Mon Sep 17 00:00:00 2001 From: Mark Ridgwell <273118822+dnyw4l3n13@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:15:17 +0000 Subject: [PATCH 4/4] docs(coverage): update COVERAGE.md for self-test --- COVERAGE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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.