File: Health\ResourceHealthCheckServiceTests.cs
Web Access
Project: src\tests\Aspire.Hosting.Tests\Aspire.Hosting.Tests.csproj (Aspire.Hosting.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using Aspire.Hosting.Utils;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Xunit;
using Xunit.Abstractions;
 
namespace Aspire.Hosting.Tests.Health;
 
public class ResourceHealthCheckServiceTests(ITestOutputHelper testOutputHelper)
{
    [Fact]
    public async Task ResourcesWithoutHealthCheckAnnotationsGetReadyEventFired()
    {
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
        var resource = builder.AddResource(new ParentResource("resource"));
 
        var blockAssert = new TaskCompletionSource<ResourceReadyEvent>();
        builder.Eventing.Subscribe<ResourceReadyEvent>(resource.Resource, (@event, ct) =>
        {
            blockAssert.SetResult(@event);
            return Task.CompletedTask;
        });
 
        using var app = builder.Build();
        var rns = app.Services.GetRequiredService<ResourceNotificationService>();
        var pendingStart = app.StartAsync();
 
        await rns.PublishUpdateAsync(resource.Resource, s => s with
        {
            State = new ResourceStateSnapshot(KnownResourceStates.Running, null)
        });
 
        var @event = await blockAssert.Task;
        Assert.Equal(resource.Resource, @event.Resource);
 
        await pendingStart;
        await app.StopAsync();
    }
 
    [Fact]
    public async Task PoorlyImplementedHealthChecksDontCauseMonitoringLoopToCrashout()
    {
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
 
        var hitCount = 0;
        builder.Services.AddHealthChecks().AddCheck("resource_check", (check) =>
        {
            hitCount++;
            throw new InvalidOperationException("Random failure instead of result!");
        });
 
        var resource = builder.AddResource(new ParentResource("resource"))
                              .WithHealthCheck("resource_check");
 
        using var app = builder.Build();
        var rns = app.Services.GetRequiredService<ResourceNotificationService>();
 
        var abortTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(120));
        var pendingStart = app.StartAsync(abortTokenSource.Token);
 
        await rns.PublishUpdateAsync(resource.Resource, s => s with
        {
            State = new ResourceStateSnapshot(KnownResourceStates.Running, null)
        });
 
        while (!abortTokenSource.Token.IsCancellationRequested)
        {
            if (hitCount > 2)
            {
                break;
            }
            await Task.Delay(100);
        }
 
        await pendingStart;
        await app.StopAsync();
    }
 
    [Fact]
    public async Task ResourceHealthCheckServiceDoesNotRunHealthChecksUnlessResourceIsRunning()
    {
        using var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(testOutputHelper);
 
        // The custom resource we are using for our test.
        var hitCount = 0;
        var checkStatus = HealthCheckResult.Unhealthy();
        builder.Services.AddHealthChecks().AddCheck("parent_test", () =>
        {
            hitCount++;
            return checkStatus;
        });
 
        var parent = builder.AddResource(new ParentResource("parent"))
                            .WithHealthCheck("parent_test");
 
        // Handle ResourceReadyEvent and use it to control when we drop through to do our assert
        // on the health test being executed.
        var resourceReadyEventFired = new TaskCompletionSource<ResourceReadyEvent>();
        builder.Eventing.Subscribe<ResourceReadyEvent>(parent.Resource, (@event, ct) =>
        {
            resourceReadyEventFired.SetResult(@event);
            return Task.CompletedTask;
        });
 
        // Make sure that this test doesn't run longer than a minute (should finish in a second or two)
        // but allow enough time to debug things without having to adjust timings.
        var abortTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(120));
 
        using var app = builder.Build();
        var pendingStart = app.StartAsync(abortTokenSource.Token);
        var rns = app.Services.GetRequiredService<ResourceNotificationService>();
 
        // Verify that the health check does not get run before we move the resource into the
        // the running state. There isn't a great way to do this using a completition source
        // so I'm just going to spin for up to ten seconds to be sure that no local perf
        // issues lead to a false pass here.
        var giveUpAfter = DateTime.Now.AddSeconds(10);
        while (!abortTokenSource.Token.IsCancellationRequested)
        {
            Assert.Equal(0, hitCount);
            await Task.Delay(100);
 
            if (DateTime.Now > giveUpAfter)
            {
                break;
            }
        }
        Assert.False(abortTokenSource.IsCancellationRequested);
 
        await rns.PublishUpdateAsync(parent.Resource, s => s with
        {
            State = new ResourceStateSnapshot(KnownResourceStates.Running, null)
        });
 
        // Wait for the ResourceReadyEvent
        checkStatus = HealthCheckResult.Healthy();
        await Task.WhenAll([resourceReadyEventFired.Task]);
        Assert.True(hitCount > 0);
 
        await pendingStart;
        await app.StopAsync();
    }
 
    [Fact]
    public async Task ResourceHealthCheckServiceOnlyRaisesResourceReadyOnce()
    {
        using var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(testOutputHelper);
 
        // The custom resource we are using for our test.
        var healthCheckHits = 0;
        builder.Services.AddHealthChecks().AddCheck("parent_test", () =>
        {
            healthCheckHits++;
            return HealthCheckResult.Healthy();
        });
 
        var parent = builder.AddResource(new ParentResource("parent"))
                            .WithHealthCheck("parent_test");
 
        // Handle ResourceReadyEvent and use it to control when we drop through to do our assert
        // on the health test being executed.
        var eventHits = 0;
        var resourceReadyEventFired = new TaskCompletionSource<ResourceReadyEvent>();
        builder.Eventing.Subscribe<ResourceReadyEvent>(parent.Resource, (@event, ct) =>
        {
            eventHits++;
            return Task.CompletedTask;
        });
 
        // Make sure that this test doesn't run longer than a minute (should finish in a second or two)
        // but allow enough time to debug things without having to adjust timings.
        var abortTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(120));
 
        using var app = builder.Build();
        var pendingStart = app.StartAsync(abortTokenSource.Token);
        var rns = app.Services.GetRequiredService<ResourceNotificationService>();
 
        // Get the custom resource to a running state.
        await rns.PublishUpdateAsync(parent.Resource, s => s with
        {
            State = new ResourceStateSnapshot(KnownResourceStates.Running, null)
        });
 
        while (!abortTokenSource.Token.IsCancellationRequested)
        {
            // We wait for this hit count to reach 3
            // because it means that we've had a chance
            // to fire the ready event twice.
            if (healthCheckHits > 2)
            {
                break;
            }
            await Task.Delay(100);
        }
 
        Assert.False(abortTokenSource.IsCancellationRequested);
        Assert.Equal(1, eventHits);
 
        await pendingStart;
        await app.StopAsync();
    }
 
    [Fact]
    public async Task VerifyThatChildResourceWillBecomeHealthyOnceParentBecomesHealthy()
    {
        using var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(testOutputHelper);
 
        builder.Services.AddHealthChecks().AddCheck("parent_test", () => HealthCheckResult.Healthy());
        var parent = builder.AddResource(new ParentResource("parent"))
                            .WithHealthCheck("parent_test");
 
        var parentReady = new TaskCompletionSource<ResourceReadyEvent>();
        builder.Eventing.Subscribe<ResourceReadyEvent>(parent.Resource, (@event, ct) =>
        {
            parentReady.SetResult(@event);
            return Task.CompletedTask;
        });
 
        var child = builder.AddResource(new ChildResource("child", parent.Resource));
 
        var childReady = new TaskCompletionSource<ResourceReadyEvent>();
        builder.Eventing.Subscribe<ResourceReadyEvent>(child.Resource, (@event, ct) =>
        {
            childReady.SetResult(@event);
            return Task.CompletedTask;
        });
 
        using var app = builder.Build();
        var pendingStart = app.StartAsync();
        var rns = app.Services.GetRequiredService<ResourceNotificationService>();
 
        // Get the custom resource to a running state.
        await rns.PublishUpdateAsync(parent.Resource, s => s with
        {
            State = new ResourceStateSnapshot(KnownResourceStates.Running, null)
        });
 
        // ... only need to do this with custom resources, for containers this
        // is handled by app executor. When we get operators we won't need to do
        // this at all.
        await rns.PublishUpdateAsync(child.Resource, s => s with
        {
            State = new ResourceStateSnapshot(KnownResourceStates.Running, null)
        });
 
        var parentReadyEvent = await parentReady.Task;
        Assert.Equal(parentReadyEvent.Resource, parent.Resource);
 
        var childReadyEvent = await childReady.Task;
        Assert.Equal(childReadyEvent.Resource, child.Resource);
 
        await pendingStart;
        await app.StopAsync();
    }
 
    private sealed class ParentResource(string name) : Resource(name)
    {
    }
 
    private sealed class ChildResource(string name, ParentResource parent) : Resource(name), IResourceWithParent<ParentResource>
    {
        public ParentResource Parent => parent;
    }
}