File: PersistentContainerEndToEndTests.cs
Web Access
Project: src\tests\Aspire.Cli.EndToEnd.Tests\Aspire.Cli.EndToEnd.Tests.csproj (Aspire.Cli.EndToEnd.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.Cli.EndToEnd.Tests.Helpers;
using Aspire.TestUtilities;
using Hex1b.Automation;
using Xunit;
 
namespace Aspire.Cli.EndToEnd.Tests;
 
public sealed class PersistentContainerEndToEndTests(ITestOutputHelper output)
{
    private const string ProjectName = "PersistenceE2E";
 
    [Fact]
    [QuarantinedTest("https://github.com/microsoft/aspire/issues/17995")]
    [CaptureWorkspaceOnFailure]
    public async Task PersistentContainersPreserveDataAcrossAppHostRuns()
    {
        var repoRoot = CliE2ETestHelpers.GetRepoRoot();
        var strategy = CliInstallStrategy.Detect(output.WriteLine);
        using var workspace = TemporaryWorkspace.Create(output);
 
        // The AppHost runs inside the E2E container while DCP starts backing containers through the host Docker socket.
        // Host networking lets project resources connect to the Docker-published ports that Aspire puts in connection strings.
        using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace, network: "host");
        var counter = new SequenceCounter();
        var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
        await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken);
 
        await auto.PrepareDockerEnvironmentAsync(counter, workspace);
        await auto.InstallAspireCliAsync(strategy, counter);
 
        var appHostCode = $$"""
            #pragma warning disable ASPIREPERSISTENCE001
 
            var builder = DistributedApplication.CreateBuilder(args);
 
            var redis = builder.AddRedis("redis")
                .WithPersistentLifetime();
 
            var postgres = builder.AddPostgres("postgres")
                .WithPersistentLifetime();
            var postgresDatabase = postgres.AddDatabase("pgdb");
 
            var storage = builder.AddAzureStorage("storage")
                .RunAsEmulator(container => container.WithPersistentLifetime());
            var blobs = storage.AddBlobs("blobs");
 
            builder.AddProject<Projects.{{ProjectName}}_ApiService>("server")
                .WithReference(redis)
                .WithReference(postgresDatabase)
                .WithReference(blobs)
                .WaitFor(redis)
                .WaitFor(postgresDatabase)
                .WaitFor(blobs)
                .WithExternalHttpEndpoints();
 
            builder.Build().Run();
            """;
 
        var apiProgramCode = """
            using Azure.Storage.Blobs;
            using Npgsql;
            using StackExchange.Redis;
 
            const string Marker = "persistent-container-value";
            const string RedisKey = "persistent-container-key";
 
            var builder = WebApplication.CreateBuilder(args);
            builder.AddServiceDefaults();
            builder.AddRedisClient("redis");
            builder.AddNpgsqlDataSource("pgdb");
            builder.AddAzureBlobServiceClient("blobs");
 
            var app = builder.Build();
            app.MapDefaultEndpoints();
 
            app.MapGet("/write", async (IConnectionMultiplexer redis, NpgsqlDataSource postgres, BlobServiceClient blobService) =>
            {
                await redis.GetDatabase().StringSetAsync(RedisKey, Marker);
 
                await using (var connection = await postgres.OpenConnectionAsync())
                await using (var command = connection.CreateCommand())
                {
                    command.CommandText = "CREATE TABLE IF NOT EXISTS persistence_check (id integer PRIMARY KEY, value text NOT NULL);" +
                        "INSERT INTO persistence_check (id, value) VALUES (1, 'persistent-container-value') " +
                        "ON CONFLICT (id) DO UPDATE SET value = EXCLUDED.value;";
                    await command.ExecuteNonQueryAsync();
                }
 
                var container = blobService.GetBlobContainerClient("persistence-check");
                await container.CreateIfNotExistsAsync();
                await container.GetBlobClient("marker").UploadAsync(BinaryData.FromString(Marker), overwrite: true);
 
                return Results.Ok("PERSISTENCE_WRITE_OK");
            });
 
            app.MapGet("/verify", async (IConnectionMultiplexer redis, NpgsqlDataSource postgres, BlobServiceClient blobService) =>
            {
                var redisValue = await redis.GetDatabase().StringGetAsync(RedisKey);
                if (redisValue != Marker)
                {
                    return Results.Problem($"Redis value was '{redisValue}'.");
                }
 
                await using (var connection = await postgres.OpenConnectionAsync())
                await using (var command = connection.CreateCommand())
                {
                    command.CommandText = "SELECT value FROM persistence_check WHERE id = 1";
                    var postgresValue = (await command.ExecuteScalarAsync())?.ToString();
                    if (!StringComparer.Ordinal.Equals(postgresValue, Marker))
                    {
                        return Results.Problem($"PostgreSQL value was '{postgresValue}'.");
                    }
                }
 
                var blob = blobService.GetBlobContainerClient("persistence-check").GetBlobClient("marker");
                var blobContent = (await blob.DownloadContentAsync()).Value.Content.ToString();
                if (blobContent != Marker)
                {
                    return Results.Problem($"Azure Storage blob value was '{blobContent}'.");
                }
 
                return Results.Ok("PERSISTENCE_VERIFY_OK");
            });
 
            app.Run();
            """;
 
        await auto.ScaffoldK8sDeployProjectAsync(
            counter,
            ProjectName,
            Path.Combine(workspace.WorkspaceRoot.FullName, ProjectName),
            appHostHostingPackages: ["Aspire.Hosting.Redis", "Aspire.Hosting.PostgreSQL", "Aspire.Hosting.Azure.Storage"],
            apiClientPackages: ["Aspire.StackExchange.Redis", "Aspire.Npgsql", "Aspire.Azure.Storage.Blobs"],
            appHostCode: appHostCode,
            apiProgramCode: apiProgramCode,
            output: output);
 
        await auto.AspireStartAsync(counter, TimeSpan.FromMinutes(5));
        await VerifyEndpointAsync(auto, counter, "/write", "PERSISTENCE_WRITE_OK");
        await auto.AspireStopAsync(counter);
 
        await auto.AspireStartAsync(counter, TimeSpan.FromMinutes(5));
        await VerifyEndpointAsync(auto, counter, "/verify", "PERSISTENCE_VERIFY_OK");
        await auto.AspireStopAsync(counter);
    }
 
    /// <summary>
    /// Waits for the server resource and verifies that the endpoint returns the expected marker.
    /// </summary>
    /// <param name="auto">The terminal automator used to run CLI and shell commands.</param>
    /// <param name="counter">The prompt sequence counter used to synchronize command completion.</param>
    /// <param name="path">The server endpoint path to call.</param>
    /// <param name="marker">The marker text expected in the endpoint response.</param>
    private static async Task VerifyEndpointAsync(Hex1bTerminalAutomator auto, SequenceCounter counter, string path, string marker)
    {
        await auto.TypeAsync("aspire wait server --status up --timeout 300");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(5));
 
        await auto.TypeAsync("aspire describe server --format json > server.json");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30));
 
        await auto.TypeAsync($"SERVER_URL=$(jq -er '.resources[0].urls[0].url' server.json) && for i in $(seq 1 30); do result=$(curl -ksS \"$SERVER_URL{path}\" 2>/dev/null || true); echo \"$result\"; echo \"$result\" | grep -q '{marker}' && break; sleep 2; done && echo \"$result\" | grep -q '{marker}'");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));
    }
}