diff --git a/Shared.EventStoreContext.Tests/EventStoreContextRetryIntegrationTests.cs b/Shared.EventStoreContext.Tests/EventStoreContextRetryIntegrationTests.cs index aaa2e1a8..0b68b4bb 100644 --- a/Shared.EventStoreContext.Tests/EventStoreContextRetryIntegrationTests.cs +++ b/Shared.EventStoreContext.Tests/EventStoreContextRetryIntegrationTests.cs @@ -42,7 +42,7 @@ public EventStoreContextRetryIntegrationTests() public async Task EventStoreContext_InsertEvents_RetriesAfterDockerOutage() { this.InitialiseRetryConfiguration(); - await this.EventStoreDockerHelper.StartContainers(false, nameof(this.EventStoreContext_InsertEvents_RetriesAfterDockerOutage)); + await this.EventStoreDockerHelper.StartContainers(false, nameof(this.EventStoreContext_InsertEvents_RetriesAfterDockerOutage), CancellationToken.None); TimeSpan deadline = TimeSpan.FromSeconds(5); IEventStoreContext context = this.CreateContext(false, deadline); diff --git a/Shared.EventStoreContext.Tests/EventStoreContextTests.cs b/Shared.EventStoreContext.Tests/EventStoreContextTests.cs index 9f342ff1..0b1b6330 100644 --- a/Shared.EventStoreContext.Tests/EventStoreContextTests.cs +++ b/Shared.EventStoreContext.Tests/EventStoreContextTests.cs @@ -103,7 +103,7 @@ public void EventStoreContext_GetStatusFrom_ProjectionDetailsIsNull_CorrectValue [TestCase(false)] public async Task EventStoreContext_InsertEvents_EventsAreWritten(Boolean secureEventStore){ - await this.EventStoreDockerHelper.StartContainers(secureEventStore, $"EventStoreContext_InsertEvents_EventsAreWritten_{secureEventStore}"); + await this.EventStoreDockerHelper.StartContainers(secureEventStore, $"EventStoreContext_InsertEvents_EventsAreWritten_{secureEventStore}", CancellationToken.None); TimeSpan deadline = TimeSpan.FromMinutes(2); @@ -132,7 +132,7 @@ public async Task EventStoreContext_ReadEvents_EventsAreRead(Boolean secureEvent TypeMap.AddType("EstateCreatedEvent"); TimeSpan deadline = TimeSpan.FromMinutes(2); - await this.EventStoreDockerHelper.StartContainers(secureEventStore, $"EventStoreContext_ReadEvents_EventsAreRead{secureEventStore}"); + await this.EventStoreDockerHelper.StartContainers(secureEventStore, $"EventStoreContext_ReadEvents_EventsAreRead{secureEventStore}", CancellationToken.None); IEventStoreContext context = this.CreateContext(secureEventStore, deadline); @@ -175,7 +175,7 @@ public async Task EventStoreContext_ReadEvents_EventsAreRead(Boolean secureEvent public async Task EventStoreContext_ReadEventsBackwards_EventsAreRead(Boolean secureEventStore){ TimeSpan deadline = TimeSpan.FromMinutes(2); - await this.EventStoreDockerHelper.StartContainers(secureEventStore, $"EventStoreContext_ReadEventsBackwards_EventsAreRead{secureEventStore}"); + await this.EventStoreDockerHelper.StartContainers(secureEventStore, $"EventStoreContext_ReadEventsBackwards_EventsAreRead{secureEventStore}", CancellationToken.None); IEventStoreContext context = this.CreateContext(secureEventStore, deadline); @@ -206,7 +206,7 @@ public async Task EventStoreContext_ReadEventsBackwards_EventsAreRead(Boolean se public async Task EventStoreContext_ReadEventsBackwards_StreamNotFound_EventsAreRead(Boolean secureEventStore){ TimeSpan deadline = TimeSpan.FromMinutes(2); - await this.EventStoreDockerHelper.StartContainers(secureEventStore, $"EventStoreContext_ReadEventsBackwards_StreamNotFound_EventsAreRead{secureEventStore}"); + await this.EventStoreDockerHelper.StartContainers(secureEventStore, $"EventStoreContext_ReadEventsBackwards_StreamNotFound_EventsAreRead{secureEventStore}", CancellationToken.None); IEventStoreContext context = this.CreateContext(secureEventStore, deadline); @@ -225,7 +225,7 @@ public async Task EventStoreContext_ReadEventsBackwards_StreamNotFound_EventsAre public async Task EventStoreContext_RunTransientQuery_Faulted_ErrorThrown(Boolean secureEventStore){ TimeSpan deadline = TimeSpan.FromMinutes(2); - await this.EventStoreDockerHelper.StartContainers(secureEventStore, $"EventStoreContext_RunTransientQuery_Faulted_ErrorThrown{secureEventStore}"); + await this.EventStoreDockerHelper.StartContainers(secureEventStore, $"EventStoreContext_RunTransientQuery_Faulted_ErrorThrown{secureEventStore}", CancellationToken.None); IEventStoreContext context = this.CreateContext(secureEventStore, deadline); @@ -274,7 +274,7 @@ public async Task EventStoreContext_RunTransientQuery_Faulted_ErrorThrown(Boolea public async Task EventStoreContext_RunTransientQuery_QueryIsRun(Boolean secureEventStore){ TimeSpan deadline = TimeSpan.FromMinutes(2); - await this.EventStoreDockerHelper.StartContainers(secureEventStore, $"EventStoreContext_RunTransientQuery_QueryIsRun{secureEventStore}"); + await this.EventStoreDockerHelper.StartContainers(secureEventStore, $"EventStoreContext_RunTransientQuery_QueryIsRun{secureEventStore}", CancellationToken.None); IEventStoreContext context = this.CreateContext(secureEventStore, deadline); @@ -320,7 +320,7 @@ public async Task EventStoreContext_RunTransientQuery_QueryIsRun(Boolean secureE public async Task EventStoreContext_RunTransientQuery_ResultIsEmpty_ErrorThrown(Boolean secureEventStore){ TimeSpan deadline = TimeSpan.FromMinutes(2); - await this.EventStoreDockerHelper.StartContainers(secureEventStore, $"EventStoreContext_RunTransientQuery_ResultIsEmpty_ErrorThrown{secureEventStore}"); + await this.EventStoreDockerHelper.StartContainers(secureEventStore, $"EventStoreContext_RunTransientQuery_ResultIsEmpty_ErrorThrown{secureEventStore}", CancellationToken.None); IEventStoreContext context = this.CreateContext(secureEventStore, deadline); diff --git a/Shared.EventStoreContext.Tests/EventStoreDockerHelper.cs b/Shared.EventStoreContext.Tests/EventStoreDockerHelper.cs index b872e5a7..e2acc3cf 100644 --- a/Shared.EventStoreContext.Tests/EventStoreDockerHelper.cs +++ b/Shared.EventStoreContext.Tests/EventStoreDockerHelper.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Threading; using KurrentDB.Client; using Shared.IntegrationTesting; using Shared.IntegrationTesting.TestContainers; @@ -10,11 +11,14 @@ namespace Shared.EventStoreContext.Tests; public class EventStoreDockerHelper : Shared.IntegrationTesting.TestContainers.DockerHelper { - public async Task StartContainers(Boolean isSecureEventStore, String testName) + public async Task StartContainers( + Boolean isSecureEventStore, + String testName, + CancellationToken cancellationToken) { this.SetHostTraceFolder(testName); this.ScenarioName = testName; - await this.StartContainersForScenarioRun(testName, DockerServices.EventStore); + await this.StartContainersForScenarioRun(testName, DockerServices.EventStore, cancellationToken); } public override async Task StopContainersForScenarioRun(DockerServices sharedDockerServices) @@ -42,13 +46,17 @@ public override async Task StopContainersForScenarioRun(DockerServices sharedDoc } } - public override async Task CreateSubscriptions() + public override async Task CreateSubscriptions(CancellationToken cancellationToken) { // Nothing actually needed here } - public override async Task StartContainersForScenarioRun(String scenarioName, DockerServices services) + public override async Task StartContainersForScenarioRun( + String scenarioName, + DockerServices services, + CancellationToken cancellationToken) { + cancellationToken.ThrowIfCancellationRequested(); this.TestId = Guid.NewGuid(); String networkName = $"eventstoretestnetwork{this.TestId:N}"; INetwork networkService = await this.SetupTestNetwork(networkName, true); @@ -78,13 +86,15 @@ await this.StartContainer2(SetupSecureEventStoreContainerLocal, new List { networkService }, - DockerServices.EventStore); + DockerServices.EventStore, + cancellationToken); await this.StartContainer2(SetupInsecureEventStoreContainerLocal, new List { networkService }, - DockerServices.EventStore); + DockerServices.EventStore, + cancellationToken); } public KurrentDBClientSettings CreateEventStoreClientSettings(Boolean secureEventStore, TimeSpan? deadline = null, String userName = "admin", String password = "changeit") diff --git a/Shared.IntegrationTesting.Tests/DockerHelperStartupPlanTests.cs b/Shared.IntegrationTesting.Tests/DockerHelperStartupPlanTests.cs index d4a46210..81f8576f 100644 --- a/Shared.IntegrationTesting.Tests/DockerHelperStartupPlanTests.cs +++ b/Shared.IntegrationTesting.Tests/DockerHelperStartupPlanTests.cs @@ -1,7 +1,17 @@ using System.Collections.Generic; +using System.IO; using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Containers; +using EventStore.Client; +using NLog; using NUnit.Framework; +using Shared.Logger; using Shared.IntegrationTesting; +using Shared.IntegrationTesting.TestContainers; using Shouldly; namespace Shared.IntegrationTesting.Tests; @@ -10,14 +20,65 @@ public class DockerHelperStartupPlanTests { private sealed class InspectableDockerHelper : TestDockerHelper { - public void UseLinuxPlatform() + public IReadOnlyList> GetStartupGroupsForTesting() { - this.DockerPlatform = DockerEnginePlatform.Linux; + return base.GetStartupGroups(); } - public IReadOnlyList> GetStartupGroupsForTesting() + public HttpClient GetEventStoreHealthCheckClientForTesting() { - return base.GetStartupGroups(); + return BaseDockerHelper.EventStoreHealthCheckClient; + } + + public Task CaptureContainerLogsForTesting(IContainer container) + { + return base.CaptureContainerLogs(container, CancellationToken.None); + } + + public static IReadOnlyList<(DockerServices service, IContainer container)> GetRetainedSharedContainers( + IEnumerable<(DockerServices service, IContainer container)> containers, + DockerServices sharedDockerServices) + { + return DockerHelper.RetainSharedContainers(containers, sharedDockerServices); + } + + } + + private sealed class FailingProjectionDockerHelper : TestDockerHelper + { + private readonly FileInfo projectionFile; + + public FailingProjectionDockerHelper(FileInfo projectionFile) + { + this.projectionFile = projectionFile; + + NlogLogger logger = new(); + logger.Initialise(LogManager.GetLogger(nameof(FailingProjectionDockerHelper)), nameof(FailingProjectionDockerHelper)); + this.Logger = logger; + } + + public Task LoadProjectionsForTesting() + { + return base.LoadEventStoreProjections(CancellationToken.None); + } + + protected override IReadOnlyList GetProjectionFiles() + { + return [this.projectionFile]; + } + + protected override List GetRequiredProjections() + { + return [this.projectionFile.Name]; + } + + protected override Task CreateContinuousProjection( + EventStoreProjectionManagementClient projectionClient, + string projectionName, + string projection, + CancellationToken cancellationToken) + { + throw new InvalidOperationException("projection creation failed"); } } @@ -25,7 +86,6 @@ public IReadOnlyList> GetStartupGroupsForTesting() public void StartupGroups_place_foundation_services_first_and_estate_management_ui_last_on_linux() { InspectableDockerHelper helper = new(); - helper.UseLinuxPlatform(); IReadOnlyList> startupGroups = helper.GetStartupGroupsForTesting(); @@ -46,4 +106,73 @@ public void StartupGroups_place_foundation_services_first_and_estate_management_ [DockerServices.EstateManagementUI] ]); } + + [Test] + public void Shared_containers_are_retained_for_later_cleanup() + { + List<(DockerServices service, IContainer container)> containers = + [ + (DockerServices.SqlServer, null!), + (DockerServices.EventStore, null!) + ]; + + IReadOnlyList<(DockerServices service, IContainer container)> remaining = + InspectableDockerHelper.GetRetainedSharedContainers(containers, DockerServices.SqlServer); + + remaining.Select(container => container.service).ShouldBe([DockerServices.SqlServer]); + } + + [Test] + public void EventStore_health_checks_reuse_their_http_client() + { + InspectableDockerHelper helper = new(); + + HttpClient firstClient = helper.GetEventStoreHealthCheckClientForTesting(); + HttpClient secondClient = helper.GetEventStoreHealthCheckClientForTesting(); + + secondClient.ShouldBeSameAs(firstClient); + } + + [Test] + public void Host_folder_mount_does_not_require_docker_platform() + { + ContainerBuilder builder = new(); + + ContainerBuilder mountedBuilder = builder.MountHostFolder("trace"); + + mountedBuilder.ShouldNotBeNull(); + } + + [Test] + public async Task Startup_honours_cancellation_before_creating_resources() + { + using CancellationTokenSource cancellationTokenSource = new(); + cancellationTokenSource.Cancel(); + InspectableDockerHelper helper = new(); + + await Should.ThrowAsync(() => + helper.StartContainersForScenarioRun( + "cancelled-startup", + DockerServices.None, + cancellationTokenSource.Token)); + } + + [Test] + public async Task Projection_creation_failure_is_propagated() + { + string path = Path.Combine(Path.GetTempPath(), $"projection-{Guid.NewGuid():N}.js"); + await File.WriteAllTextAsync(path, "fromAll().when({});"); + + try + { + FailingProjectionDockerHelper helper = new(new FileInfo(path)); + + await Should.ThrowAsync( + () => helper.LoadProjectionsForTesting()); + } + finally + { + File.Delete(path); + } + } } diff --git a/Shared.IntegrationTesting.Tests/GenericSteps.cs b/Shared.IntegrationTesting.Tests/GenericSteps.cs index cc6eec1c..c606e355 100644 --- a/Shared.IntegrationTesting.Tests/GenericSteps.cs +++ b/Shared.IntegrationTesting.Tests/GenericSteps.cs @@ -11,7 +11,7 @@ namespace Shared.IntegrationTesting.Tests; using Reqnroll; public class TestDockerHelper : DockerHelper{ - public override async Task CreateSubscriptions(){ + public override async Task CreateSubscriptions(CancellationToken cancellationToken){ // Nothing here } } @@ -50,7 +50,10 @@ public async Task StartSystem() { this.TestingContext.Logger = logger; this.TestingContext.Logger.LogInformation("About to Start Containers for Scenario Run"); this.TestingContext.DockerHelper.ScenarioName = scenarioName; - await this.TestingContext.DockerHelper.StartContainersForScenarioRun(scenarioName, services).ConfigureAwait(false); + await this.TestingContext.DockerHelper.StartContainersForScenarioRun( + scenarioName, + services, + CancellationToken.None).ConfigureAwait(false); this.TestingContext.Logger.LogInformation("Containers for Scenario Run Started"); } diff --git a/Shared.IntegrationTesting.UnitTests/RetryTests.cs b/Shared.IntegrationTesting.UnitTests/RetryTests.cs index dbc70ab0..ad8df1d1 100644 --- a/Shared.IntegrationTesting.UnitTests/RetryTests.cs +++ b/Shared.IntegrationTesting.UnitTests/RetryTests.cs @@ -1,6 +1,7 @@ namespace Shared.IntegrationTesting.UnitTests; using System; +using System.Threading; using System.Threading.Tasks; using Shared.IntegrationTesting; using Shouldly; @@ -28,4 +29,36 @@ await Retry.For(async () => attempts.ShouldBe(2); } + + [Fact] + public async Task Retry_For_stops_immediately_when_cancelled() + { + using CancellationTokenSource cancellationTokenSource = new(); + cancellationTokenSource.Cancel(); + + await Should.ThrowAsync(() => Retry.For( + _ => Task.CompletedTask, + cancellationTokenSource.Token, + TimeSpan.FromMinutes(1), + TimeSpan.FromSeconds(1))); + } + + [Fact] + public async Task Retry_For_does_not_retry_when_exception_is_not_retryable() + { + Int32 attempts = 0; + + await Should.ThrowAsync(() => Retry.For( + _ => + { + attempts++; + throw new InvalidOperationException("terminal failure"); + }, + CancellationToken.None, + _ => false, + TimeSpan.FromMinutes(1), + TimeSpan.FromSeconds(1))); + + attempts.ShouldBe(1); + } } diff --git a/Shared.IntegrationTesting/ContainerType.cs b/Shared.IntegrationTesting/ContainerType.cs index 7444bd92..9adf8a93 100644 --- a/Shared.IntegrationTesting/ContainerType.cs +++ b/Shared.IntegrationTesting/ContainerType.cs @@ -36,10 +36,3 @@ public enum DockerServices EstateManagementUI = 1024, EstateReporting = 2048 } - -public enum DockerEnginePlatform -{ - Unknown, - Linux, - Windows -} \ No newline at end of file diff --git a/Shared.IntegrationTesting/Retry.cs b/Shared.IntegrationTesting/Retry.cs index 4f782562..45091532 100644 --- a/Shared.IntegrationTesting/Retry.cs +++ b/Shared.IntegrationTesting/Retry.cs @@ -36,6 +36,35 @@ public static async Task For(Func action, TimeSpan? retryFor = null, TimeSpan? retryInterval = null) { + await For( + _ => action(), + CancellationToken.None, + retryFor, + retryInterval).ConfigureAwait(false); + } + + public static async Task For( + Func action, + CancellationToken cancellationToken, + TimeSpan? retryFor = null, + TimeSpan? retryInterval = null) + { + await For( + action, + cancellationToken, + _ => true, + retryFor, + retryInterval).ConfigureAwait(false); + } + + public static async Task For( + Func action, + CancellationToken cancellationToken, + Func shouldRetry, + TimeSpan? retryFor = null, + TimeSpan? retryInterval = null) + { + cancellationToken.ThrowIfCancellationRequested(); DateTime startTime = DateTime.Now; Exception lastException = null; @@ -48,16 +77,25 @@ public static async Task For(Func action, { try { - await action().ConfigureAwait(false); + await action(cancellationToken).ConfigureAwait(false); lastException = null; break; } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } catch (Exception e) { + if (shouldRetry(e) == false) + { + throw; + } + lastException = e; // wait before retrying - await Task.Delay(retryInterval ?? Retry.DefaultRetryInterval); + await Task.Delay(retryInterval ?? Retry.DefaultRetryInterval, cancellationToken); } } @@ -68,4 +106,4 @@ public static async Task For(Func action, } #endregion -} \ No newline at end of file +} diff --git a/Shared.IntegrationTesting/TestContainers/BaseDockerHelper.cs b/Shared.IntegrationTesting/TestContainers/BaseDockerHelper.cs index 900754d2..c230bb4b 100644 --- a/Shared.IntegrationTesting/TestContainers/BaseDockerHelper.cs +++ b/Shared.IntegrationTesting/TestContainers/BaseDockerHelper.cs @@ -72,6 +72,8 @@ public abstract class BaseDockerHelper{ protected readonly IHealthCheckClient HealthCheckClient; + protected static readonly HttpClient EventStoreHealthCheckClient = CreateEventStoreHealthCheckClient(); + protected Dictionary HostPorts = new(); protected String HostTraceFolder; @@ -125,13 +127,6 @@ protected BaseDockerHelper(Boolean skipHealthChecks=false) { } })); - // Linux-only integration containers. - this.DockerPlatform = BaseDockerHelper.GetDockerEnginePlatform().Result.Data; - if (this.DockerPlatform != DockerEnginePlatform.Linux) - { - throw new NotSupportedException("Integration test containers are only supported on Linux Docker engines."); - } - this.ImageDetails.Add(ContainerType.SqlServer, ("mcr.microsoft.com/mssql/server:2022-latest", true)); this.ImageDetails.Add(ContainerType.EventStore, ("kurrentplatform/kurrentdb:26.1", true)); this.ImageDetails.Add(ContainerType.MessagingService, ("stuartferguson/messagingservice:master", true)); @@ -221,23 +216,6 @@ protected virtual IReadOnlyList> GetStartupGroups( return startupGroups; } - public static async Task> GetDockerEnginePlatform(){ - try{ - Docker.DotNet.DockerClientBuilder b = new DockerClientBuilder(); - DockerClient docker = b.Build(); - - SystemInfoResponse? info = await docker.System.GetSystemInfoAsync(); - - return info.OSType switch { - "linux" => Result.Success(DockerEnginePlatform.Linux), - _ => Result.Success(DockerEnginePlatform.Unknown) - }; - } - catch(Exception e){ - return Result.Failure($"Unable to determine docker Engine Platform. Exception [{e.Message}]"); - } - } - public static DockerClient GetDockerHost() { Docker.DotNet.DockerClientBuilder b = new DockerClientBuilder(); DockerClient docker = b.Build(); @@ -273,8 +251,6 @@ public static DockerClient GetDockerHost() { return Result.Success(details.Value); } - public DockerEnginePlatform DockerPlatform { get; protected set; } - public void SetHostPort(ContainerType key, Int32 hostPort){ KeyValuePair details = this.HostPorts.SingleOrDefault(c => c.Key == key); if (!details.Equals(default(KeyValuePair))){ @@ -313,7 +289,7 @@ public virtual ContainerBuilder SetupCallbackHandlerContainer(){ .WithName(this.CallbackHandlerContainerName) // similar to WithName() .WithImage(imageDetails.imageName) .WithEnvironment(environmentVariables) - .MountHostFolder(this.DockerPlatform, this.HostTraceFolder) + .MountHostFolder(this.HostTraceFolder) .WithPortBinding(DockerPorts.CallbackHandlerDockerPort, true); return callbackHandlerContainer; @@ -341,7 +317,7 @@ public virtual void SetupContainerNames(){ protected String EstateManagementUiContainerName; public virtual ContainerBuilder SetupEventStoreContainer(){ - this.Trace($"About to Start Event Store Container [{this.DockerPlatform}]"); + this.Trace("About to Start Event Store Container"); Dictionary environmentVariables = new(){ {"EVENTSTORE_RUN_PROJECTIONS","all"}, @@ -360,7 +336,7 @@ public virtual ContainerBuilder SetupEventStoreContainer(){ // Copy these to the container String path = Path.Combine(Directory.GetCurrentDirectory(), "certs"); - eventStoreContainer = eventStoreContainer.MountHostFolder(this.DockerPlatform, path, certsPath); + eventStoreContainer = eventStoreContainer.MountHostFolder(path, certsPath); // Certificates configuration environmentVariables.Add("EVENTSTORE_CertificateFile",$"{certsPath}/node1/node.crt"); @@ -369,11 +345,8 @@ public virtual ContainerBuilder SetupEventStoreContainer(){ environmentVariables.Add("EVENTSTORE_INSECURE","false"); } - if (this.DockerPlatform == DockerEnginePlatform.Linux) { - String logfilePath = "/var/log/kurrentdb"; - - eventStoreContainer = eventStoreContainer.MountHostFolder(this.DockerPlatform, this.HostTraceFolder, logfilePath); - } + String logfilePath = "/var/log/kurrentdb"; + eventStoreContainer = eventStoreContainer.MountHostFolder(this.HostTraceFolder, logfilePath); (String imageName, Boolean useLatest) imageDetails = this.GetImageDetails(ContainerType.EventStore).Data; this.Trace($"About to Start Event Store Container using image [{imageDetails.imageName}]"); @@ -419,7 +392,7 @@ public virtual ContainerBuilder SetupFileProcessorContainer(){ .WithName(this.FileProcessorContainerName) // similar to WithName() .WithImage(imageDetails.imageName) .WithEnvironment(environmentVariables) - .MountHostFolder(this.DockerPlatform, this.HostTraceFolder) + .MountHostFolder(this.HostTraceFolder) .WithPortBinding(DockerPorts.FileProcessorDockerPort, true); // Mount the folder to upload files @@ -456,6 +429,9 @@ protected virtual ContainerBuilder SetupEstateManagementUiContainer() environmentVariables.Add($"AppSettings:BackEndClientId", "serviceClient"); environmentVariables.Add($"AppSettings:BackEndClientSecret", "Secret1"); environmentVariables.Add($"DataReloadConfig:DefaultInSeconds", "1"); + environmentVariables.Add( + "AppSettings:EstateReportingApi", + $"http://{this.EstateReportingContainerName}:{DockerPorts.EstateReportingDockerPort}"); environmentVariables.Add("ConnectionStrings:TransactionProcessorReadModel", this.SetConnectionString("TransactionProcessorReadModel", this.UseSecureSqlServerDatabase)); (String imageName, Boolean useLatest) imageDetails = this.GetImageDetails(ContainerType.EstateManagementUI).Data; @@ -464,7 +440,7 @@ protected virtual ContainerBuilder SetupEstateManagementUiContainer() .WithName(this.EstateManagementUiContainerName) .WithImage(imageDetails.imageName) .WithEnvironment(environmentVariables) - .MountHostFolder(this.DockerPlatform, this.HostTraceFolder) + .MountHostFolder(this.HostTraceFolder) .WithPortBinding(DockerPorts.EstateManagementUIDockerPort,true); return containerBuilder; @@ -475,13 +451,16 @@ public virtual ContainerBuilder SetupConfigHostContainer() this.Trace("About to Start Config Host Container"); Dictionary environmentVariables = new(); environmentVariables.Add("AppSettings:InMemoryDatabase", "true"); + environmentVariables.Add( + "ConnectionStrings:ConfigurationDatabase", + this.SetConnectionString("master", this.UseSecureSqlServerDatabase)); (String imageName, Boolean useLatest) imageDetails = this.GetImageDetails(ContainerType.ConfigurationHost).Data; ContainerBuilder configHostContainer = new ContainerBuilder().WithName(this.ConfigHostContainerName) .WithImage(imageDetails.imageName) .WithEnvironment(environmentVariables) - .MountHostFolder(this.DockerPlatform, this.HostTraceFolder) + .MountHostFolder(this.HostTraceFolder) .WithPortBinding(DockerPorts.ConfigHostDockerPort, true); return configHostContainer; @@ -508,7 +487,7 @@ public virtual ContainerBuilder SetupMessagingServiceContainer(){ ContainerBuilder messagingServiceContainer = new ContainerBuilder().WithName(this.MessagingServiceContainerName) // similar to WithName() .WithImage(imageDetails.imageName) .WithEnvironment(environmentVariables) - .MountHostFolder(this.DockerPlatform, this.HostTraceFolder); + .MountHostFolder(this.HostTraceFolder); messagingServiceContainer = messagingServiceContainer .WithPortBinding(DockerPorts.MessagingServiceDockerPort, true); @@ -631,7 +610,7 @@ public virtual ContainerBuilder SetupTestHostContainer(){ .WithName(this.TestHostContainerName) // similar to WithName() .WithImage(imageDetails.imageName) .WithEnvironment(environmentVariables) - .MountHostFolder(this.DockerPlatform, this.HostTraceFolder) + .MountHostFolder(this.HostTraceFolder) .WithPortBinding(DockerPorts.TestHostPort, true); return testHostContainer; @@ -656,7 +635,7 @@ public virtual ContainerBuilder SetupEstateReportingContainer() .WithName(this.EstateReportingContainerName) // similar to WithName() .WithImage(imageDetails.imageName) .WithEnvironment(environmentVariables) - .MountHostFolder(this.DockerPlatform, this.HostTraceFolder) + .MountHostFolder(this.HostTraceFolder) .WithPortBinding(DockerPorts.EstateReportingDockerPort, true); return estateReportingContainer; @@ -688,7 +667,7 @@ public virtual ContainerBuilder SetupTransactionProcessorAclContainer(){ .WithName(this.TransactionProcessorAclContainerName) // similar to WithName() .WithImage(imageDetails.imageName) .WithEnvironment(environmentVariables) - .MountHostFolder(this.DockerPlatform, this.HostTraceFolder) + .MountHostFolder(this.HostTraceFolder) .WithPortBinding(DockerPorts.TransactionProcessorAclDockerPort, true); return transactionProcessorACLContainer; @@ -718,7 +697,7 @@ public virtual ContainerBuilder SetupTransactionProcessorContainer(){ .WithName(this.TransactionProcessorContainerName) // similar to WithName() .WithImage(imageDetails.imageName) .WithEnvironment(environmentVariables) - .MountHostFolder(this.DockerPlatform, this.HostTraceFolder) + .MountHostFolder(this.HostTraceFolder) .WithPortBinding(DockerPorts.TransactionProcessorDockerPort, true); // TODO: Extension for multiple networks @@ -729,7 +708,10 @@ public virtual ContainerBuilder SetupTransactionProcessorContainer(){ return transactionProcessorContainer; } - public abstract Task StartContainersForScenarioRun(String scenarioName, DockerServices dockerServices); + public abstract Task StartContainersForScenarioRun( + String scenarioName, + DockerServices dockerServices, + CancellationToken cancellationToken); public abstract Task StopContainersForScenarioRun(DockerServices sharedDockerServices); @@ -792,7 +774,9 @@ protected virtual async Task CreatePersistentSubscription((String streamName, St this.Trace($"Subscription Group [{subscription.groupName}] Stream [{subscription.streamName}] created"); } - protected async Task DoSqlServerHealthCheck(IContainer containerService){ + protected async Task DoSqlServerHealthCheck( + IContainer containerService, + CancellationToken cancellationToken){ // Try opening a connection const int maxRetries = 10; bool connected = false; @@ -813,7 +797,7 @@ protected async Task DoSqlServerHealthCheck(IContainer containerService){ if (attempt < maxRetries) { - await Task.Delay(TimeSpan.FromSeconds(30)); + await Task.Delay(TimeSpan.FromSeconds(30), cancellationToken); } } } @@ -824,54 +808,58 @@ protected async Task DoSqlServerHealthCheck(IContainer containerService){ $"Database container not started after {maxRetries} attempts"); } } - protected async Task DoEventStoreHealthCheck(){ + protected async Task DoEventStoreHealthCheck(CancellationToken cancellationToken){ String scheme = this.IsSecureEventStore switch { true => "https", _ => "http" }; this.Trace("About to do event store ping"); - await Retry.For(async () => + await Retry.For(async cancellationToken => { + cancellationToken.ThrowIfCancellationRequested(); String url = $"{scheme}://127.0.0.1:{this.EventStoreHttpPort}/ping"; - using HttpClientHandler httpClientHandler = new(); - httpClientHandler.ServerCertificateCustomValidationCallback = (message, cert, chain, errors) => true; - using HttpClient client = new(httpClientHandler); - String authenticationString = "admin:changeit"; - String base64EncodedAuthenticationString = Convert.ToBase64String(Encoding.UTF8.GetBytes(authenticationString)); - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", base64EncodedAuthenticationString); - - HttpResponseMessage pingResponse = await client.GetAsync(url).ConfigureAwait(false); + using HttpResponseMessage pingResponse = await EventStoreHealthCheckClient.GetAsync(url, cancellationToken).ConfigureAwait(false); pingResponse.StatusCode.ShouldBe(HttpStatusCode.OK); }, - + cancellationToken, TimeSpan.FromSeconds(300), TimeSpan.FromSeconds(30)).ConfigureAwait(false); this.Trace("About to do event store info"); - await Retry.For(async () => + await Retry.For(async cancellationToken => { + cancellationToken.ThrowIfCancellationRequested(); String url = $"{scheme}://127.0.0.1:{this.EventStoreHttpPort}/info"; - using HttpClientHandler httpClientHandler = new(); - httpClientHandler.ServerCertificateCustomValidationCallback = (message, cert, chain, errors) => true; - using HttpClient client = new(httpClientHandler); - String authenticationString = "admin:changeit"; - String base64EncodedAuthenticationString = Convert.ToBase64String(Encoding.UTF8.GetBytes(authenticationString)); - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", base64EncodedAuthenticationString); - - HttpResponseMessage infoResponse = await client.GetAsync(url).ConfigureAwait(false); + using HttpResponseMessage infoResponse = await EventStoreHealthCheckClient.GetAsync(url, cancellationToken).ConfigureAwait(false); infoResponse.StatusCode.ShouldBe(HttpStatusCode.OK); String infoData = await infoResponse.Content.ReadAsStringAsync().ConfigureAwait(false); this.Trace(infoData); - }); + }, cancellationToken); } - protected async Task DoHealthCheck(ContainerType containerType) { + private static HttpClient CreateEventStoreHealthCheckClient() + { + HttpClientHandler httpClientHandler = new() + { + ServerCertificateCustomValidationCallback = (message, cert, chain, errors) => true + }; + + HttpClient client = new(httpClientHandler); + String authenticationString = "admin:changeit"; + String base64EncodedAuthenticationString = Convert.ToBase64String(Encoding.UTF8.GetBytes(authenticationString)); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", base64EncodedAuthenticationString); + return client; + } + + protected async Task DoHealthCheck( + ContainerType containerType, + CancellationToken cancellationToken) { (String, Int32) containerDetails = containerType switch { ContainerType.CallbackHandler => ("http", this.CallbackHandlerPort), ContainerType.FileProcessor => ("http", this.FileProcessorPort), @@ -880,8 +868,8 @@ protected async Task DoHealthCheck(ContainerType containerType) { ContainerType.TransactionProcessor => ("http", this.TransactionProcessorPort), ContainerType.SecurityService => ("https", this.SecurityServicePort), ContainerType.TransactionProcessorAcl => ("http", this.TransactionProcessorAclPort), - //ContainerType.ConfigurationHost => ("http", this.ConfigHostPort), - //ContainerType.EstateManagementUI => ("https", this.EstateManagementUiPort), + ContainerType.ConfigurationHost => ("http", this.ConfigHostPort), + ContainerType.EstateManagementUI => ("https", this.EstateManagementUiPort), ContainerType.EstateReporting => ("http", this.EstateReportingPort), _ => (null, 0) }; @@ -889,10 +877,11 @@ protected async Task DoHealthCheck(ContainerType containerType) { if (containerDetails.Item1 == null) return; - await Retry.For(async () => { + await Retry.For(async cancellationToken => { + cancellationToken.ThrowIfCancellationRequested(); this.Trace($"About to do health check for {containerType}"); - SimpleResults.Result healthCheckResult = await this.HealthCheckClient.PerformHealthCheck(containerDetails.Item1, "127.0.0.1", containerDetails.Item2, CancellationToken.None); + SimpleResults.Result healthCheckResult = await this.HealthCheckClient.PerformHealthCheck(containerDetails.Item1, "127.0.0.1", containerDetails.Item2, cancellationToken); if (healthCheckResult.IsSuccess) { HealthChecks.HealthCheckResult result = StringSerialiser.Deserialise(healthCheckResult.Data, new SerialiserOptions(SerialiserPropertyFormat.CamelCase)); @@ -906,7 +895,7 @@ await Retry.For(async () => { this.Trace($"health check failed for {containerType}"); throw new InvalidOperationException($"Health check failed for {containerType} [{healthCheckResult.Message}]"); } - }, TimeSpan.FromMinutes(3), TimeSpan.FromSeconds(20)); + }, cancellationToken, TimeSpan.FromMinutes(3), TimeSpan.FromSeconds(20)); } protected void Error(String message, Exception ex){ @@ -935,7 +924,7 @@ protected virtual List GetRequiredProjections(){ return requiredProjections; } - protected virtual async Task LoadEventStoreProjections(){ + protected virtual async Task LoadEventStoreProjections(CancellationToken cancellationToken){ //Start our Continuous Projections - we might decide to do this at a different stage, but now lets try here String projectionsFolder = "projections/continuous"; @@ -945,7 +934,7 @@ protected virtual async Task LoadEventStoreProjections(){ if (di.Exists) { - FileInfo[] files = di.GetFiles(); + IReadOnlyList files = this.GetProjectionFiles(); var requiredProjections = this.GetRequiredProjections(); EventStoreProjectionManagementClient projectionClient = new(this.ConfigureEventStoreSettings()); List projectionNames = new(); @@ -960,15 +949,8 @@ protected virtual async Task LoadEventStoreProjections(){ this.Trace($"Creating projection [{projectionName}] from file [{file.FullName}]"); - try - { - await projectionClient.CreateContinuousAsync(projectionName, projection, trackEmittedStreams: true).ConfigureAwait(false); - } - catch (Exception ex) - { - // ignored - this.Trace($"Projection [{projectionName}] create error {ex.Message}"); - } + await this.CreateContinuousProjection(projectionClient, projectionName, projection, cancellationToken) + .ConfigureAwait(false); projectionNames.Add(projectionName); this.Trace($"Projection [{projectionName}] created"); @@ -977,9 +959,28 @@ protected virtual async Task LoadEventStoreProjections(){ // Now check the create status of each foreach (String projectionName in projectionNames) { - ProjectionDetails projectionDetails = await projectionClient.GetStatusAsync(projectionName); - projectionDetails.Status.ShouldBe("Running", $"Projection [{projectionName}] is {projectionDetails.Status}"); - this.Trace($"Projection [{projectionName}] running"); + await Retry.For(async cancellationToken => + { + cancellationToken.ThrowIfCancellationRequested(); + ProjectionDetails projectionDetails = await this.GetProjectionStatus( + projectionClient, + projectionName, + cancellationToken) + .ConfigureAwait(false); + + if (IsTerminalProjectionStatus(projectionDetails.Status)) + { + throw new TerminalProjectionException( + $"Projection [{projectionName}] entered terminal state [{projectionDetails.Status}]"); + } + + projectionDetails.Status.ShouldBe("Running", $"Projection [{projectionName}] is {projectionDetails.Status}"); + this.Trace($"Projection [{projectionName}] running"); + }, + cancellationToken, + exception => exception is not TerminalProjectionException, + TimeSpan.FromMinutes(3), + TimeSpan.FromSeconds(5)); } } } @@ -987,6 +988,50 @@ protected virtual async Task LoadEventStoreProjections(){ this.Trace("Loaded projections"); } + protected virtual IReadOnlyList GetProjectionFiles() + { + return new DirectoryInfo("projections/continuous").GetFiles(); + } + + protected virtual Task CreateContinuousProjection( + EventStoreProjectionManagementClient projectionClient, + String projectionName, + String projection, + CancellationToken cancellationToken) + { + return projectionClient.CreateContinuousAsync( + projectionName, + projection, + trackEmittedStreams: true, + cancellationToken: cancellationToken); + } + + protected virtual Task GetProjectionStatus( + EventStoreProjectionManagementClient projectionClient, + String projectionName, + CancellationToken cancellationToken) + { + return projectionClient.GetStatusAsync( + projectionName, + cancellationToken: cancellationToken); + } + + private static Boolean IsTerminalProjectionStatus(String status) + { + return String.Equals(status, "Faulted", StringComparison.OrdinalIgnoreCase) + || String.Equals(status, "Stopped", StringComparison.OrdinalIgnoreCase) + || String.Equals(status, "Completed", StringComparison.OrdinalIgnoreCase) + || String.Equals(status, "Aborted", StringComparison.OrdinalIgnoreCase); + } + + private sealed class TerminalProjectionException : InvalidOperationException + { + public TerminalProjectionException(String message) + : base(message) + { + } + } + //protected virtual void SetAdditionalVariables(ContainerType containerType, List variableList){ // this.AdditionalVariables.SingleOrDefault(a => a.Key == containerType).Value.AddRange(variableList); //} @@ -1005,17 +1050,24 @@ protected virtual String SetConnectionString(String databaseName, } - protected async Task StartContainer2(Func buildContainerFunc, List networkServices, DockerServices dockerService){ + protected async Task StartContainer2( + Func buildContainerFunc, + List networkServices, + DockerServices dockerService, + CancellationToken cancellationToken){ if ((this.RequiredDockerServices & dockerService) != dockerService) { return default; } + IContainer builtContainer = default; + Boolean containerStarted = false; + try{ - IContainer builtContainer = default; - await Retry.For(async () => + await Retry.For(async cancellationToken => { + containerStarted = false; ContainerBuilder containerBuilder = buildContainerFunc(); foreach (INetwork networkService in networkServices) { @@ -1025,14 +1077,21 @@ await Retry.For(async () => builtContainer = containerBuilder.Build(); try { - await builtContainer.StartAsync(); + await builtContainer.StartAsync(cancellationToken); + containerStarted = true; + + lock (this.ContainerStateLock) + { + this.Containers.Add((dockerService, builtContainer)); + } } catch { + await this.CaptureContainerLogs(builtContainer, cancellationToken); await builtContainer.DisposeAsync(); throw; } - }); + }, cancellationToken); this.Trace($"{dockerService} Container Started"); @@ -1067,40 +1126,60 @@ await Retry.For(async () => switch (type) { case ContainerType.EventStore: - await DoEventStoreHealthCheck(); + await DoEventStoreHealthCheck(cancellationToken); break; case ContainerType.SqlServer: - await DoSqlServerHealthCheck(builtContainer); + await DoSqlServerHealthCheck(builtContainer, cancellationToken); break; default: - await this.DoHealthCheck(type); + await this.DoHealthCheck(type, cancellationToken); break; } } - lock (this.ContainerStateLock) - { - this.Containers.Add((dockerService, builtContainer)); - } - this.Trace($"Container [{buildContainerFunc.Method.Name}] started"); return builtContainer; } catch (Exception ex){ - // TODO: read console logs - //if (consoleLogs != null){ - // while (!consoleLogs.IsFinished){ - // String s = consoleLogs.TryRead(10000); - // this.Trace(s); - // } - //} + if (builtContainer is not null && containerStarted) + { + await this.CaptureContainerLogs(builtContainer, cancellationToken); + } this.Error($"Error starting container [{buildContainerFunc.Method.Name}]", ex); throw; } } + protected virtual async Task CaptureContainerLogs( + IContainer container, + CancellationToken cancellationToken) + { + try + { + (String stdout, String stderr) logs = await container.GetLogsAsync( + DateTime.UtcNow.AddMinutes(-5), + DateTime.UtcNow, + timestampsEnabled: true, + cancellationToken).ConfigureAwait(false); + + if (String.IsNullOrWhiteSpace(logs.stdout) == false) + { + this.Trace($"Container [{container.Name}] stdout:\n{logs.stdout}"); + } + + if (String.IsNullOrWhiteSpace(logs.stderr) == false) + { + this.Trace($"Container [{container.Name}] stderr:\n{logs.stderr}"); + } + } + catch (Exception logException) + { + this.Trace($"Unable to capture logs for container: {logException.Message}"); + } + } + private void SetHostPortForService(ContainerType type, IContainer startedContainer){ UInt16 GetPort(Int32 dockerPort) => startedContainer.GetMappedPublicPort(dockerPort); diff --git a/Shared.IntegrationTesting/TestContainers/DockerHelper.cs b/Shared.IntegrationTesting/TestContainers/DockerHelper.cs index 24f47282..b17bb642 100644 --- a/Shared.IntegrationTesting/TestContainers/DockerHelper.cs +++ b/Shared.IntegrationTesting/TestContainers/DockerHelper.cs @@ -42,14 +42,15 @@ protected virtual void SetHostTraceFolder(String scenarioName) { } - public override async Task StartContainersForScenarioRun(String scenarioName, DockerServices dockerServices){ + public override async Task StartContainersForScenarioRun( + String scenarioName, + DockerServices dockerServices, + CancellationToken cancellationToken){ + cancellationToken.ThrowIfCancellationRequested(); this.IntegrationTestCertificate = IntegrationTestCertificate.Create(); try { - Result result = await BaseDockerHelper.GetDockerEnginePlatform(); - this.DockerPlatform = result.Data; - this.RequiredDockerServices = dockerServices; this.IsSecureEventStore = Environment.GetEnvironmentVariable("IsSecureEventStore") switch @@ -80,7 +81,7 @@ async Task StartWithTrace(Func { DateTimeOffset startedAt = DateTimeOffset.UtcNow; this.Trace($"Starting container [{service}]"); - await StartContainer2(setupContainer, networks, service); + await StartContainer2(setupContainer, networks, service, cancellationToken); TimeSpan elapsed = DateTimeOffset.UtcNow - startedAt; this.Trace($"Container [{service}] started in {elapsed.TotalSeconds:N1}s"); } @@ -104,12 +105,14 @@ async Task StartWithTrace(Func foreach (IReadOnlyList startupGroup in this.GetStartupGroups()) { + cancellationToken.ThrowIfCancellationRequested(); await Task.WhenAll(startupGroup.Select(service => StartWithTrace(setupContainerForService(service), service))); } - await this.LoadEventStoreProjections(); + await this.LoadEventStoreProjections(cancellationToken); - await this.CreateSubscriptions(); + cancellationToken.ThrowIfCancellationRequested(); + await this.CreateSubscriptions(cancellationToken); } catch { @@ -128,6 +131,9 @@ public override async Task StopContainersForScenarioRun( DockerServices sharedDockerServices) { List cleanupErrors = []; + List<(DockerServices service, IContainer container)> remainingContainers = + RetainSharedContainers(this.Containers, sharedDockerServices).ToList(); + List remainingNetworks = []; try { @@ -138,9 +144,20 @@ in this.Containers.AsEnumerable().Reverse()) { continue; } - String containerName = container.Name; + + String containerName; try { + containerName = container.Name; + } + catch (InvalidOperationException ex) + { + this.Trace($"Skipping unavailable container [{service}] ({ex.Message})"); + continue; + } + + try + { this.Trace($"Stopping container [{containerName}]"); await container.StopAsync(CancellationToken.None); @@ -152,28 +169,36 @@ in this.Containers.AsEnumerable().Reverse()) { this.Trace($"Failed to stop container [{containerName}]"); cleanupErrors.Add(ex); + remainingContainers.Add((service, container)); } } - foreach (INetwork network in this.TestNetworks.AsEnumerable().Reverse()) + if (remainingContainers.Count > 0) { - try + remainingNetworks.AddRange(this.TestNetworks); + } + else + { + foreach (INetwork network in this.TestNetworks.AsEnumerable().Reverse()) { - await network.DeleteAsync(CancellationToken.None); - await network.DisposeAsync(); - } - catch (Exception ex) - { - this.Trace("Failed to remove Docker network"); - cleanupErrors.Add(ex); + try + { + await network.DeleteAsync(CancellationToken.None); + await network.DisposeAsync(); + } + catch (Exception ex) + { + this.Trace("Failed to remove Docker network"); + cleanupErrors.Add(ex); + remainingNetworks.Add(network); + } } } } finally { - // Prevent a second cleanup attempt from using disposed objects. - this.Containers.Clear(); - this.TestNetworks.Clear(); + this.Containers = remainingContainers; + this.TestNetworks = remainingNetworks; if ((sharedDockerServices & DockerServices.SecurityService) != DockerServices.SecurityService) @@ -190,5 +215,14 @@ in this.Containers.AsEnumerable().Reverse()) } } - public abstract Task CreateSubscriptions(); + protected static IReadOnlyList<(DockerServices service, IContainer container)> RetainSharedContainers( + IEnumerable<(DockerServices service, IContainer container)> containers, + DockerServices sharedDockerServices) + { + return containers + .Where(container => (sharedDockerServices & container.service) == container.service) + .ToList(); + } + + public abstract Task CreateSubscriptions(CancellationToken cancellationToken); } diff --git a/Shared.IntegrationTesting/TestContainers/Extensions.cs b/Shared.IntegrationTesting/TestContainers/Extensions.cs index 7afa757f..6b4eaf03 100644 --- a/Shared.IntegrationTesting/TestContainers/Extensions.cs +++ b/Shared.IntegrationTesting/TestContainers/Extensions.cs @@ -9,7 +9,6 @@ namespace Shared.IntegrationTesting.TestContainers; public static class Extensions { public static ContainerBuilder MountHostFolder(this ContainerBuilder containerBuilder, - DockerEnginePlatform dockerEnginePlatform, String hostTraceFolder, String containerPath = null) { diff --git a/Shared.Tests/HealthCheckClientTests.cs b/Shared.Tests/HealthCheckClientTests.cs index 8d61922d..03155622 100644 --- a/Shared.Tests/HealthCheckClientTests.cs +++ b/Shared.Tests/HealthCheckClientTests.cs @@ -23,7 +23,7 @@ public async Task HealthCheckClient_PerformHealthCheck_HealthCheckIsReturned(){ MockHttpMessageHandler mockHttp = new(); String expectedResponse = "{'name' : 'Test'}"; - mockHttp.When(HttpMethod.Get, "http://127.0.0.1:5000/health").Respond("application/json", expectedResponse); + mockHttp.When(HttpMethod.Get, "http://127.0.0.1:5000/healthui").Respond("application/json", expectedResponse); HealthCheckClient healthCheckClient = new(mockHttp.ToHttpClient()); @@ -45,7 +45,7 @@ public async Task HealthCheckClient_PerformHealthCheck_FailedResponses_HealthChe MockHttpMessageHandler mockHttp = new(); //String expectedResponse = "{'name' : 'Test'}"; - mockHttp.When(HttpMethod.Get, "http://127.0.0.1:5000/health").Respond(req => new HttpResponseMessage(statusCode)); + mockHttp.When(HttpMethod.Get, "http://127.0.0.1:5000/healthui").Respond(req => new HttpResponseMessage(statusCode)); HealthCheckClient healthCheckClient = new(mockHttp.ToHttpClient()); @@ -55,4 +55,4 @@ public async Task HealthCheckClient_PerformHealthCheck_FailedResponses_HealthChe result.Status.ShouldBe(resultStatus); } -} \ No newline at end of file +} diff --git a/Shared/HealthChecks/HealthCheckClient.cs b/Shared/HealthChecks/HealthCheckClient.cs index 42e497ed..5e5b29da 100644 --- a/Shared/HealthChecks/HealthCheckClient.cs +++ b/Shared/HealthChecks/HealthCheckClient.cs @@ -53,6 +53,9 @@ protected virtual async Task> HandleResponse(HttpResponseMessage private String BuildRequestUri(String scheme, String uri, Int32 port) { - return $"{scheme}://{uri}:{port}/health"; + // The standard health endpoint is allowed to use a plain-text response. + // Container startup deserialises the response, so use the JSON health + // check UI endpoint consistently across the services. + return $"{scheme}://{uri}:{port}/healthui"; } -} \ No newline at end of file +}