File: EntityFrameworkCoreDeploymentTests.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 System.Xml.Linq;
using Aspire.Cli.EndToEnd.Tests.Helpers;
using Hex1b.Automation;
using Xunit;
 
namespace Aspire.Cli.EndToEnd.Tests;
 
/// <summary>
/// End-to-end coverage for publishing and deploying an EF Core migration bundle with Docker Compose.
/// </summary>
public sealed class EntityFrameworkCoreDeploymentTests(ITestOutputHelper output)
{
    private const string ProjectName = "EfCoreDeployTest";
 
    [Fact]
    [CaptureWorkspaceOnFailure]
    public async Task DeployMigrationBundleToDockerCompose()
    {
        var repoRoot = CliE2ETestHelpers.GetRepoRoot();
        var strategy = CliInstallStrategy.Detect(output.WriteLine);
        using var workspace = TemporaryWorkspace.Create(output);
 
        using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(
            repoRoot,
            strategy,
            output,
            mountDockerSocket: true,
            workspace: workspace);
        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);
        await auto.VerifyPullRequestCliVersionAsync(counter);
 
        await ScaffoldApplicationAsync(auto, counter, workspace);
 
        var appHostSha = Guid.NewGuid().ToString("N");
        var composeProject = $"aspire-compose-{appHostSha[..8]}";
 
        try
        {
            await auto.TypeAsync($"unset ASPIRE_PLAYGROUND; export AppHostSha={appHostSha}");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter);
 
            // This is the boundary that regressed in #17905: the EF tool runs while the database
            // connection string is still a manifest placeholder, then the generated bundle image
            // receives the real connection string when Compose starts it.
            await auto.TypeAsync("aspire deploy -o deploy-output --non-interactive");
            await auto.EnterAsync();
            await auto.WaitForPipelineSuccessAsync(timeout: TimeSpan.FromMinutes(10));
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(1));
 
            await VerifyComposeArtifactsAsync(auto, counter);
            await VerifyDeploymentAsync(auto, counter, composeProject);
 
            await auto.AspireDestroyAsync(counter);
        }
        finally
        {
            // Aspire destroy is the primary cleanup path. This fallback also handles failures after
            // Compose has started but before deployment state has been persisted.
            await auto.TypeAsync(
                "if [ -f deploy-output/.env.Production ]; then " +
                $"docker compose --project-name {composeProject} --env-file deploy-output/.env.Production " +
                "-f deploy-output/docker-compose.yaml down --volumes --remove-orphans " +
                "|| echo 'WARNING: fallback compose cleanup failed'; fi");
            await auto.EnterAsync();
            await auto.WaitForAnyPromptAsync(counter, TimeSpan.FromMinutes(2));
        }
    }
 
    private static async Task ScaffoldApplicationAsync(
        Hex1bTerminalAutomator auto,
        SequenceCounter counter,
        TemporaryWorkspace workspace)
    {
        await auto.AspireNewAsync(ProjectName, counter, useRedisCache: false);
 
        await auto.TypeAsync($"cd {ProjectName}");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter);
 
        foreach (var package in new[]
        {
            "Aspire.Hosting.Docker",
            "Aspire.Hosting.EntityFrameworkCore",
            "Aspire.Hosting.PostgreSQL",
        })
        {
            await auto.TypeAsync($"aspire add {package}");
            await auto.EnterAsync();
            await auto.WaitForAspireAddCompletionAsync(counter, TimeSpan.FromMinutes(3));
        }
 
        await auto.TypeAsync(AspireCliShellCommandHelpers.GetDotnetAddPackageCommand(
            $"{ProjectName}.ApiService",
            "Aspire.Npgsql.EntityFrameworkCore.PostgreSQL"));
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(3));
 
        // Microsoft.EntityFrameworkCore.Design must match the EF Core version brought in by the
        // Aspire integration. Selecting the current prerelease independently can cross major versions.
        await auto.TypeAsync(
            $"EF_VERSION=$(dotnet list {ProjectName}.ApiService package --include-transitive " +
            "--format json | jq -r '.projects[].frameworks[].transitivePackages[] " +
            "| select(.id == \"Microsoft.EntityFrameworkCore\") | .resolvedVersion' | head -1) && " +
            $"test -n \"$EF_VERSION\" && dotnet add {ProjectName}.ApiService package " +
            "Microsoft.EntityFrameworkCore.Design --version \"$EF_VERSION\"");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(3));
 
        await auto.TypeAsync(
            "case \"$(uname -m)\" in " +
            "x86_64) echo linux-x64 ;; " +
            "aarch64|arm64) echo linux-arm64 ;; " +
            "*) echo \"Unsupported architecture: $(uname -m)\" >&2; exit 1 ;; " +
            "esac > .aspire-e2e-rid");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter);
 
        var projectDirectory = Path.Combine(workspace.WorkspaceRoot.FullName, ProjectName);
        var targetRuntimePath = Path.Combine(projectDirectory, ".aspire-e2e-rid");
        var targetRuntime = File.ReadAllText(targetRuntimePath).Trim();
        File.Delete(targetRuntimePath);
 
        WriteApplicationFiles(workspace, targetRuntime);
 
        // A valid design-time connection string lets this setup command create the initial migration
        // without starting PostgreSQL. The publish pipeline deliberately receives no such override.
        await auto.TypeAsync(
            $"ConnectionStrings__db='Host=localhost;Database=design;Username=postgres;Password=postgres' " +
            $"dotnet tool exec dotnet-ef --yes -- migrations add Initial " +
            $"--project {ProjectName}.ApiService --startup-project {ProjectName}.ApiService " +
            "--context AppDbContext --output-dir Migrations");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(5));
    }
 
    private static void WriteApplicationFiles(TemporaryWorkspace workspace, string targetRuntime)
    {
        var projectDirectory = Path.Combine(workspace.WorkspaceRoot.FullName, ProjectName);
        var appHostDirectory = Path.Combine(projectDirectory, $"{ProjectName}.AppHost");
        var apiDirectory = Path.Combine(projectDirectory, $"{ProjectName}.ApiService");
 
        File.WriteAllText(
            Path.Combine(appHostDirectory, "AppHost.cs"),
            $$"""
            using Aspire.Hosting.Docker.Resources.ComposeNodes;
            using Aspire.Hosting.Docker.Resources.ServiceNodes;
 
            var builder = DistributedApplication.CreateBuilder(args);
 
            var postgres = builder.AddPostgres("postgres")
                .PublishAsDockerComposeService((_, service) =>
                {
                    service.Healthcheck = new Healthcheck
                    {
                        Test = ["CMD-SHELL", "pg_isready -U postgres"],
                        Interval = "2s",
                        Timeout = "5s",
                        Retries = 30,
                        StartPeriod = "5s"
                    };
                });
            var database = postgres.AddDatabase("db");
 
            var api = builder.AddProject<Projects.{{ProjectName}}_ApiService>("server")
                .WithReference(database)
                .WithExternalHttpEndpoints();
 
            var migrations = api.AddEFMigrations("migrations", "AppDbContext")
                .WithReference(database)
                .WaitFor(database)
                // The startup project uses Microsoft.NET.Sdk.Web, so its framework-dependent
                // bundle requires Microsoft.AspNetCore.App rather than the runtime-only image.
                .PublishAsMigrationBundle(
                    targetRuntime: "{{targetRuntime}}",
                    publishContainer: true,
                    baseImage: "mcr.microsoft.com/dotnet/aspnet:10.0")
                .PublishAsDockerComposeService((_, service) =>
                {
                    service.DependsOn["postgres"] = new ServiceDependency
                    {
                        Condition = "service_healthy"
                    };
                    service.Restart = "no";
                });
 
            api.WaitForCompletion(migrations);
 
            builder.AddDockerComposeEnvironment("compose");
 
            builder.Build().Run();
            """);
 
        File.WriteAllText(
            Path.Combine(apiDirectory, "Program.cs"),
            """
            using Microsoft.EntityFrameworkCore;
 
            var builder = WebApplication.CreateBuilder(args);
            builder.AddServiceDefaults();
            builder.AddNpgsqlDbContext<AppDbContext>("db");
 
            var app = builder.Build();
            app.MapDefaultEndpoints();
            app.MapGet("/test-deployment", async (AppDbContext dbContext) =>
            {
                var marker = await dbContext.Markers.SingleAsync();
                return marker.Value == "migration-applied"
                    ? Results.Ok("PASSED: EF Core migration bundle applied")
                    : Results.Problem($"Unexpected marker '{marker.Value}'");
            });
            app.Run();
 
            public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
            {
                public DbSet<MigrationMarker> Markers => Set<MigrationMarker>();
 
                protected override void OnModelCreating(ModelBuilder modelBuilder)
                {
                    modelBuilder.Entity<MigrationMarker>().HasData(
                        new MigrationMarker { Id = 1, Value = "migration-applied" });
                }
            }
 
            public sealed class MigrationMarker
            {
                public int Id { get; set; }
 
                public required string Value { get; set; }
            }
            """);
 
        var apiProjectPath = Path.Combine(apiDirectory, $"{ProjectName}.ApiService.csproj");
        var apiProject = XDocument.Load(apiProjectPath, LoadOptions.PreserveWhitespace);
        var targetFrameworkProperty = apiProject
            .Descendants()
            .SingleOrDefault(element => element.Name.LocalName is "TargetFramework" or "TargetFrameworks");
        Assert.NotNull(targetFrameworkProperty);
 
        var propertyGroup = targetFrameworkProperty.Parent;
        Assert.NotNull(propertyGroup);
 
        var runtimeIdentifiersProperty = propertyGroup
            .Elements()
            .SingleOrDefault(element => element.Name.LocalName == "RuntimeIdentifiers");
        if (runtimeIdentifiersProperty is null)
        {
            propertyGroup.Add(new XElement(propertyGroup.Name.Namespace + "RuntimeIdentifiers", targetRuntime));
        }
        else
        {
            runtimeIdentifiersProperty.Value = targetRuntime;
        }
 
        apiProject.Save(apiProjectPath, SaveOptions.DisableFormatting);
    }
 
    private static async Task VerifyComposeArtifactsAsync(
        Hex1bTerminalAutomator auto,
        SequenceCounter counter)
    {
        // Check the exact deployment relationships rather than only proving that a Compose file exists.
        await auto.TypeAsync(
            "test -f deploy-output/docker-compose.yaml && " +
            "docker compose --env-file deploy-output/.env.Production " +
            "-f deploy-output/docker-compose.yaml config --format json | " +
            "jq -e '((.services.migrations.environment | type) == \"object\" and " +
            "(.services.migrations.environment | has(\"ConnectionStrings__db\")) or " +
            "((.services.migrations.environment | type) == \"array\" and " +
            "any(.services.migrations.environment[]; startswith(\"ConnectionStrings__db=\")))) and " +
            "(.services.migrations.depends_on.postgres.condition == \"service_healthy\") and " +
            "(.services.server.depends_on.migrations.condition == \"service_completed_successfully\")'");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30));
    }
 
    private static async Task VerifyDeploymentAsync(
        Hex1bTerminalAutomator auto,
        SequenceCounter counter,
        string composeProject)
    {
        await auto.TypeAsync(
            "migration_id=$(docker ps -aq " +
            $"--filter \"label=com.docker.compose.project={composeProject}\" " +
            "--filter \"label=com.docker.compose.service=migrations\" | head -1) && " +
            "test -n \"$migration_id\" && " +
            "test \"$(docker inspect -f '{{.State.ExitCode}}' \"$migration_id\")\" = 0");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30));
 
        // Compose does not publish the API port to the outer test container. Join the server's
        // network namespace and retry until the application is accepting requests.
        await auto.TypeAsync(
            "server_id=$(docker ps -q " +
            $"--filter \"label=com.docker.compose.project={composeProject}\" " +
            "--filter \"label=com.docker.compose.service=server\" | head -1) && " +
            "test -n \"$server_id\" && " +
            "succeeded=false; " +
            "for i in $(seq 1 30); do " +
            "result=$(docker run --rm --network container:$server_id curlimages/curl:8.12.1 " +
            "-fsS http://localhost:8080/test-deployment 2>/dev/null || true); " +
            "echo \"$result\"; " +
            "if echo \"$result\" | grep -q 'PASSED: EF Core migration bundle applied'; then succeeded=true; break; fi; " +
            "sleep 5; done; test \"$succeeded\" = true");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(3));
    }
}