File: Utils\PersistentContainerTestHelpers.cs
Web Access
Project: src\tests\Aspire.Hosting.TestUtilities\Aspire.Hosting.TestUtilities.csproj (Aspire.Hosting.TestUtilities)
// 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.Testing;
using Aspire.Shared.UserSecrets;
using Microsoft.Extensions.DependencyInjection;
 
namespace Aspire.Hosting.Utils;
 
public static class PersistentContainerTestHelpers
{
    private const string ContainerIdPropertyName = "container.id";
    private const string ContainerLifetimePropertyName = "container.lifetime";
 
    /// <summary>
    /// Verifies that a resource configured with a persistent lifetime uses the same Docker container across AppHost runs.
    /// </summary>
    /// <param name="testOutputHelper">The xUnit output helper used for test and resource logging.</param>
    /// <param name="configureResource">Configures the persistent resource on each AppHost run.</param>
    /// <param name="resourceName">The resource name whose persistent Docker container identity should be compared.</param>
    /// <param name="useTestContainerRegistry">Whether to apply the test container registry override for integrations that require CI-mirrored images.</param>
    /// <param name="randomizePorts">Whether to force DCP to randomize ports for the AppHost runs.</param>
    /// <param name="timeout">The timeout for starting, stopping, and observing the resource. Defaults to 10 minutes because some container integrations have slow cold starts.</param>
    public static async Task AssertResourceReusesContainerAsync(
        ITestOutputHelper testOutputHelper,
        Action<IDistributedApplicationTestingBuilder> configureResource,
        string resourceName,
        bool useTestContainerRegistry = false,
        bool randomizePorts = false,
        TimeSpan? timeout = null)
    {
        await AssertResourcesReuseContainersAsync(
            testOutputHelper,
            configureResource,
            [resourceName],
            useTestContainerRegistry,
            randomizePorts,
            compareUrls: false,
            timeout);
    }
 
    /// <summary>
    /// Verifies that resources configured with persistent lifetimes use the same Docker containers across AppHost runs.
    /// </summary>
    /// <param name="testOutputHelper">The xUnit output helper used for test and resource logging.</param>
    /// <param name="configureResources">Configures the persistent resources on each AppHost run.</param>
    /// <param name="resourceNames">The resource names whose persistent Docker container identities should be compared.</param>
    /// <param name="useTestContainerRegistry">Whether to apply the test container registry override for integrations that require CI-mirrored images.</param>
    /// <param name="randomizePorts">Whether to force DCP to randomize ports for the AppHost runs.</param>
    /// <param name="compareUrls">Whether to compare the resource URLs across runs. This also verifies stable public ports.</param>
    /// <param name="timeout">The timeout for starting, stopping, and observing the resources. Defaults to 10 minutes because some container integrations have slow cold starts.</param>
    public static async Task AssertResourcesReuseContainersAsync(
        ITestOutputHelper testOutputHelper,
        Action<IDistributedApplicationTestingBuilder> configureResources,
        string[] resourceNames,
        bool useTestContainerRegistry = false,
        bool randomizePorts = false,
        bool compareUrls = false,
        TimeSpan? timeout = null)
    {
        using var cts = new CancellationTokenSource(timeout ?? TimeSpan.FromMinutes(10));
        using var workspace = TemporaryWorkspace.Create(testOutputHelper);
        var userSecretsId = Guid.NewGuid().ToString("N");
 
        try
        {
            var before = await RunContainerAsync();
            var after = await RunContainerAsync();
 
            Assert.Equal(before, after);
        }
        finally
        {
            var userSecretsPath = UserSecretsPathHelper.GetSecretsPathFromSecretsId(userSecretsId);
            if (Path.GetDirectoryName(userSecretsPath) is { } userSecretsDirectory && Directory.Exists(userSecretsDirectory))
            {
                try
                {
                    Directory.Delete(userSecretsDirectory, recursive: true);
                }
                catch (IOException)
                {
                    // Best-effort cleanup only. A locked secrets file should not fail an otherwise successful test.
                }
            }
        }
 
        async Task<ResourceRunSnapshot[]> RunContainerAsync()
        {
            var args = new[]
            {
                "--environment=Development",
                $"{KnownConfigNames.AspireUserSecretsId}={userSecretsId}",
                // Persistent-container reuse tests default to normal run behavior. Individual
                // tests opt into randomization when the integration doesn't publish the public
                // proxy port into container configuration that participates in the lifecycle key.
                $"DcpPublisher:RandomizePorts={(randomizePorts ? "true" : "false")}"
            };
 
            using var builder = (useTestContainerRegistry
                    ? TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(testOutputHelper, args)
                    : TestDistributedApplicationBuilder.Create(testOutputHelper, args))
                .WithTempAspireStore(workspace.WorkspaceRoot.FullName)
                .WithResourceCleanUp(false);
 
            Assert.True(builder.UserSecretsManager.IsAvailable);
 
            configureResources(builder);
 
            using var app = builder.Build();
            await app.StartAsync(cts.Token);
 
            var resourceNotificationService = app.Services.GetRequiredService<ResourceNotificationService>();
            var resourceSnapshots = await Task.WhenAll(
                resourceNames.Select(resourceName => GetContainerIdentityAsync(resourceNotificationService, resourceName, compareUrls, cts.Token)));
 
            await app.StopAsync(cts.Token).WaitAsync(cts.Token);
 
            return resourceSnapshots.OrderBy(snapshot => snapshot.ResourceName, StringComparer.Ordinal).ToArray();
        }
    }
 
    /// <summary>
    /// Gets the Docker container identity for a persistent resource after it becomes healthy.
    /// </summary>
    private static async Task<ResourceRunSnapshot> GetContainerIdentityAsync(ResourceNotificationService resourceNotificationService, string resourceName, bool includeUrls, CancellationToken cancellationToken)
    {
        await resourceNotificationService.WaitForResourceHealthyAsync(resourceName, cancellationToken);
        var resourceEvent = await resourceNotificationService.WaitForResourceAsync(resourceName, evt =>
        {
            return GetPropertyValue(evt, ContainerLifetimePropertyName) is ContainerLifetime.Persistent &&
                GetPropertyValue(evt, ContainerIdPropertyName) is string { Length: > 0 } &&
                (!includeUrls || evt.Snapshot.Urls.Length > 0);
        }, cancellationToken);
 
        var containerLifetime = GetPropertyValue(resourceEvent, ContainerLifetimePropertyName);
        Assert.Equal(ContainerLifetime.Persistent, containerLifetime);
 
        var containerId = Assert.IsType<string>(GetPropertyValue(resourceEvent, ContainerIdPropertyName));
        Assert.NotEmpty(containerId);
 
        var urls = includeUrls
            ? string.Join(Environment.NewLine, resourceEvent.Snapshot.Urls
                .OrderBy(url => url.Name, StringComparer.Ordinal)
                .ThenBy(url => url.Url, StringComparer.Ordinal)
                .Select(url => $"{url.Name}:{url.Url}"))
            : string.Empty;
 
        if (includeUrls)
        {
            Assert.NotEmpty(urls);
        }
 
        return new(resourceName, containerId, urls);
    }
 
    private static object? GetPropertyValue(ResourceEvent resourceEvent, string propertyName) =>
        resourceEvent.Snapshot.Properties.FirstOrDefault(x => x.Name == propertyName)?.Value;
 
    private sealed record ResourceRunSnapshot(string ResourceName, string ContainerId, string Urls);
}