diff --git a/examples-Aspire/AspireApp1.AppHost/Program.cs b/examples-Aspire/AspireApp1.AppHost/Program.cs index 854c7e06..451cf92e 100644 --- a/examples-Aspire/AspireApp1.AppHost/Program.cs +++ b/examples-Aspire/AspireApp1.AppHost/Program.cs @@ -45,6 +45,7 @@ IResourceBuilder apiService = builder builder.AddProject("webfrontend") .WithExternalHttpEndpoints() - .WithReference(apiService); + .WithReference(apiService) + .WaitFor(apiService); builder.Build().Run(); \ No newline at end of file diff --git a/src/WireMock.Net.Aspire/WireMockHealthCheck.cs b/src/WireMock.Net.Aspire/WireMockHealthCheck.cs new file mode 100644 index 00000000..2b51cbee --- /dev/null +++ b/src/WireMock.Net.Aspire/WireMockHealthCheck.cs @@ -0,0 +1,44 @@ +// Copyright © WireMock.Net + +using Aspire.Hosting.ApplicationModel; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using WireMock.Client; + +namespace WireMock.Net.Aspire; + +/// +/// WireMockHealthCheck +/// +public class WireMockHealthCheck(WireMockServerResource resource) : IHealthCheck +{ + private const string HealthStatusHealthy = "Healthy"; + + /// + public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + if (!await IsHealthyAsync(resource.AdminApi.Value, cancellationToken)) + { + return HealthCheckResult.Unhealthy("WireMock.Net is not healthy"); + } + + if (resource.ApiMappingState == WireMockMappingState.NotSubmitted) + { + return HealthCheckResult.Unhealthy("WireMock.Net has not received mappings"); + } + + return HealthCheckResult.Healthy(); + } + + private static async Task IsHealthyAsync(IWireMockAdminApi adminApi, CancellationToken cancellationToken) + { + try + { + var status = await adminApi.GetHealthAsync(cancellationToken); + return string.Equals(status, HealthStatusHealthy, StringComparison.OrdinalIgnoreCase); + } + catch + { + return false; + } + } +} diff --git a/src/WireMock.Net.Aspire/WireMockMappingState.cs b/src/WireMock.Net.Aspire/WireMockMappingState.cs new file mode 100644 index 00000000..2bb82108 --- /dev/null +++ b/src/WireMock.Net.Aspire/WireMockMappingState.cs @@ -0,0 +1,10 @@ +// Copyright © WireMock.Net + +namespace WireMock.Net.Aspire; + +internal enum WireMockMappingState +{ + NoMappings, + NotSubmitted, + Submitted, +} diff --git a/src/WireMock.Net.Aspire/WireMockServerBuilderExtensions.cs b/src/WireMock.Net.Aspire/WireMockServerBuilderExtensions.cs index c522f674..b19e192e 100644 --- a/src/WireMock.Net.Aspire/WireMockServerBuilderExtensions.cs +++ b/src/WireMock.Net.Aspire/WireMockServerBuilderExtensions.cs @@ -3,6 +3,7 @@ using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Lifecycle; using Aspire.Hosting.WireMock; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.HealthChecks; using Stef.Validation; using WireMock.Client.Builders; @@ -53,11 +54,21 @@ public static class WireMockServerBuilderExtensions Guard.NotNull(arguments); var wireMockContainerResource = new WireMockServerResource(name, arguments); + + var healthCheckKey = $"{name}_check"; + var healthCheckRegistration = new HealthCheckRegistration( + healthCheckKey, + _ => new WireMockHealthCheck(wireMockContainerResource), + failureStatus: null, + tags: null); + builder.Services.AddHealthChecks().Add(healthCheckRegistration); + var resourceBuilder = builder .AddResource(wireMockContainerResource) .WithImage(DefaultLinuxImage) .WithEnvironment(ctx => ctx.EnvironmentVariables.Add("DOTNET_USE_POLLING_FILE_WATCHER", "1")) // https://khalidabuhakmeh.com/aspnet-docker-gotchas-and-workarounds#configuration-reloads-and-filesystemwatcher .WithHttpEndpoint(port: arguments.HttpPort, targetPort: WireMockServerArguments.HttpContainerPort) + .WithHealthCheck(healthCheckKey) .WithWireMockInspectorCommand(); if (!string.IsNullOrEmpty(arguments.MappingsPath)) @@ -172,6 +183,7 @@ public static class WireMockServerBuilderExtensions wiremock.ApplicationBuilder.Services.TryAddLifecycleHook(); wiremock.Resource.Arguments.ApiMappingBuilder = configure; + wiremock.Resource.ApiMappingState = WireMockMappingState.NotSubmitted; return wiremock; } diff --git a/src/WireMock.Net.Aspire/WireMockServerLifecycleHook.cs b/src/WireMock.Net.Aspire/WireMockServerLifecycleHook.cs index d97a4113..57df9ee8 100644 --- a/src/WireMock.Net.Aspire/WireMockServerLifecycleHook.cs +++ b/src/WireMock.Net.Aspire/WireMockServerLifecycleHook.cs @@ -10,32 +10,47 @@ internal class WireMockServerLifecycleHook(ILoggerFactory loggerFactory) : IDist { private readonly CancellationTokenSource _shutdownCts = new(); - public async Task AfterResourcesCreatedAsync(DistributedApplicationModel appModel, CancellationToken cancellationToken = default) + private CancellationTokenSource? _linkedCts; + private Task? _mappingTask; + + public Task AfterEndpointsAllocatedAsync(DistributedApplicationModel appModel, CancellationToken cancellationToken = default) { - var cts = CancellationTokenSource.CreateLinkedTokenSource(_shutdownCts.Token, cancellationToken); + _linkedCts = CancellationTokenSource.CreateLinkedTokenSource(_shutdownCts.Token, cancellationToken); - var wireMockServerResources = appModel.Resources - .OfType() - .ToArray(); - - foreach (var wireMockServerResource in wireMockServerResources) + _mappingTask = Task.Run(async () => { - wireMockServerResource.SetLogger(loggerFactory.CreateLogger()); + var wireMockServerResources = appModel.Resources + .OfType() + .ToArray(); - var endpoint = wireMockServerResource.GetEndpoint(); - if (endpoint.IsAllocated) + foreach (var wireMockServerResource in wireMockServerResources) { - await wireMockServerResource.WaitForHealthAsync(cts.Token); + wireMockServerResource.SetLogger(loggerFactory.CreateLogger()); - await wireMockServerResource.CallApiMappingBuilderActionAsync(cts.Token); + var endpoint = wireMockServerResource.GetEndpoint(); + System.Diagnostics.Debug.Assert(endpoint.IsAllocated); - wireMockServerResource.StartWatchingStaticMappings(cts.Token); + await wireMockServerResource.WaitForHealthAsync(_linkedCts.Token); + + await wireMockServerResource.CallApiMappingBuilderActionAsync(_linkedCts.Token); + + wireMockServerResource.StartWatchingStaticMappings(_linkedCts.Token); } - } + }, _linkedCts.Token); + + return Task.CompletedTask; } public async ValueTask DisposeAsync() { await _shutdownCts.CancelAsync(); + + _linkedCts?.Dispose(); + _shutdownCts.Dispose(); + + if (_mappingTask is not null) + { + await _mappingTask; + } } } \ No newline at end of file diff --git a/src/WireMock.Net.Aspire/WireMockServerResource.cs b/src/WireMock.Net.Aspire/WireMockServerResource.cs index e2eac519..528b2100 100644 --- a/src/WireMock.Net.Aspire/WireMockServerResource.cs +++ b/src/WireMock.Net.Aspire/WireMockServerResource.cs @@ -5,6 +5,7 @@ using RestEase; using Stef.Validation; using WireMock.Client; using WireMock.Client.Extensions; +using WireMock.Net.Aspire; using WireMock.Util; // ReSharper disable once CheckNamespace @@ -19,6 +20,7 @@ public class WireMockServerResource : ContainerResource, IResourceWithServiceDis internal WireMockServerArguments Arguments { get; } internal Lazy AdminApi => new(CreateWireMockAdminApi); + internal WireMockMappingState ApiMappingState { get; set; } = WireMockMappingState.NoMappings; private ILogger? _logger; private EnhancedFileSystemWatcher? _enhancedFileSystemWatcher; @@ -64,6 +66,8 @@ public class WireMockServerResource : ContainerResource, IResourceWithServiceDis var mappingBuilder = AdminApi.Value.GetMappingBuilder(); await Arguments.ApiMappingBuilder.Invoke(mappingBuilder, cancellationToken); + + ApiMappingState = WireMockMappingState.Submitted; } internal void StartWatchingStaticMappings(CancellationToken cancellationToken) diff --git a/test/WireMock.Net.Aspire.Tests/IntegrationTests.cs b/test/WireMock.Net.Aspire.Tests/IntegrationTests.cs index e51c2d9b..dc086201 100644 --- a/test/WireMock.Net.Aspire.Tests/IntegrationTests.cs +++ b/test/WireMock.Net.Aspire.Tests/IntegrationTests.cs @@ -19,6 +19,7 @@ public class IntegrationTests(ITestOutputHelper output) var appHostBuilder = await DistributedApplicationTestingBuilder.CreateAsync(); await using var app = await appHostBuilder.BuildAsync(); await app.StartAsync(); + await app.ResourceNotifications.WaitForResourceHealthyAsync("wiremock-service"); using var httpClient = app.CreateHttpClient("wiremock-service"); @@ -46,6 +47,7 @@ public class IntegrationTests(ITestOutputHelper output) var appHostBuilder = await DistributedApplicationTestingBuilder.CreateAsync(); await using var app = await appHostBuilder.BuildAsync(); await app.StartAsync(); + await app.ResourceNotifications.WaitForResourceHealthyAsync("wiremock-service"); var adminClient = app.CreateWireMockAdminClient("wiremock-service"); diff --git a/test/WireMock.Net.Aspire.Tests/WireMockServerBuilderExtensionsTests.cs b/test/WireMock.Net.Aspire.Tests/WireMockServerBuilderExtensionsTests.cs index a2c1f15d..88ea28a0 100644 --- a/test/WireMock.Net.Aspire.Tests/WireMockServerBuilderExtensionsTests.cs +++ b/test/WireMock.Net.Aspire.Tests/WireMockServerBuilderExtensionsTests.cs @@ -67,7 +67,7 @@ public class WireMockServerBuilderExtensionsTests MappingsPath = null, HttpPort = port }); - wiremock.Resource.Annotations.Should().HaveCount(5); + wiremock.Resource.Annotations.Should().HaveCount(6); var containerImageAnnotation = wiremock.Resource.Annotations.OfType().FirstOrDefault(); containerImageAnnotation.Should().BeEquivalentTo(new ContainerImageAnnotation