File: DockerComposeDeployWithVolumeTests.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.Text.Json;
using Aspire.Cli.EndToEnd.Tests.Helpers;
using Hex1b.Automation;
using Xunit;
 
namespace Aspire.Cli.EndToEnd.Tests;
 
/// <summary>
/// E2E test for <c>aspire deploy</c> to Docker Compose that proves the
/// <c>WithVolume(name, target, env)</c> overload projects correctly for project resources.
///
/// Scenario: a project mounts a named volume and only ever learns the mount path from
/// <c>DATA_PATH</c>. The test asserts the generated compose file and then the *running*
/// container, because generation alone cannot show that the environment variable and the
/// volume actually reach the deployed workload.
///
/// The obvious next assertion — write a file, force-recreate the container, read it back —
/// is deliberately absent. It cannot pass today: .NET images run as a non-root user while a
/// fresh Docker named volume is created root-owned, so the app gets "Permission denied" on
/// its own volume. Kubernetes avoids this by setting fsGroup (see KubernetesResource); Compose
/// has no equivalent. Tracked by https://github.com/microsoft/aspire/issues/19422 — add the
/// durability round-trip here once that is fixed.
///
/// This is the Compose counterpart to <see cref="KubernetesDeployWithProjectPersistentVolumeTests"/>.
/// Both run on every PR because neither needs a cloud subscription.
/// </summary>
public sealed class DockerComposeDeployWithVolumeTests(ITestOutputHelper output)
{
    private const string ProjectName = "ComposeDeployVolumeTest";
    private const string VolumeName = "serverdata";
    private const string MountPath = "/data";
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    [CaptureWorkspaceOnFailure]
    public async Task DeployComposeWithProjectVolumeMountsNamedVolumeAtEnvPath(bool useProjectV2)
    {
        var repoRoot = CliE2ETestHelpers.GetRepoRoot();
        var strategy = CliInstallStrategy.Detect(output.WriteLine);
        var workspace = TemporaryWorkspace.Create(output);
        var deploymentId = $"compose-{Guid.NewGuid():N}";
        var serverName = $"server-{deploymentId}";
        var webName = $"web-{deploymentId}";
        var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, ProjectName);
        var response = $"PASSED: {deploymentId} at {MountPath}";
 
        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);
 
        // The project never names the mount path itself — it only knows DATA_PATH. That is the
        // whole point of the overload: the same source works in run mode (where the path is an
        // Aspire store directory on the host) and after publish (where it is MountPath).
        var appHostCode = $$"""
            #pragma warning disable ASPIREDOTNETPROJECT001
            #pragma warning disable ASPIREPIPELINES003
            using Aspire.Hosting;
 
            var builder = DistributedApplication.CreateBuilder(args);
 
            builder.AddDockerComposeEnvironment("compose")
                .ConfigureComposeFile(compose =>
                {
                    foreach (var service in compose.Services.Values)
                    {
                        service.Labels["{{LocalDeploymentTestHelpers.DeploymentLabel}}"] = "{{deploymentId}}";
                    }
                    foreach (var volume in compose.Volumes.Values)
                    {
                        volume.Labels["{{LocalDeploymentTestHelpers.DeploymentLabel}}"] = "{{deploymentId}}";
                    }
                    foreach (var network in compose.Networks.Values)
                    {
                        network.Labels["{{LocalDeploymentTestHelpers.DeploymentLabel}}"] = "{{deploymentId}}";
                    }
                });
 
            var api = {{(useProjectV2
                ? $"builder.AddDotnetProject(\"{serverName}\", \"../{ProjectName}.ApiService/{ProjectName}.ApiService.csproj\")"
                : $"builder.AddProject<Projects.{ProjectName}_ApiService>(\"{serverName}\")")}}
                .WithRemoteImageName("{{deploymentId}}/server")
                .WithVolume("{{VolumeName}}", "{{MountPath}}", env: "DATA_PATH")
                .WithExternalHttpEndpoints();
 
            {{(useProjectV2
                ? $"builder.AddDotnetProject(\"{webName}\", \"../{ProjectName}.Web/{ProjectName}.Web.csproj\")"
                : $"builder.AddProject<Projects.{ProjectName}_Web>(\"{webName}\")")}}
                .WithRemoteImageName("{{deploymentId}}/web")
                .WithReference(api)
                .WaitFor(api)
                .WithExternalHttpEndpoints();
 
            builder.Build().Run();
            """;
 
        // Throwing at startup turns a missing projection into a container that never reaches a
        // running state, so the container lookup below fails loudly instead of the test quietly
        // asserting against a workload that ignored DATA_PATH.
        var apiProgramCode = $$"""
            var builder = WebApplication.CreateBuilder(args);
            builder.AddServiceDefaults();
 
            var app = builder.Build();
            app.MapDefaultEndpoints();
 
            var dataPath = Environment.GetEnvironmentVariable("DATA_PATH")
                ?? throw new InvalidOperationException("DATA_PATH is not configured.");
 
            app.MapGet("/data-path", () => dataPath);
            app.MapGet("/test-deployment", () => Results.Text("PASSED: {{deploymentId}} at " + dataPath));
 
            app.Run();
            """;
 
        await auto.ScaffoldK8sDeployProjectAsync(
            counter,
            ProjectName,
            projectDir,
            appHostHostingPackages: ["Aspire.Hosting.Docker", "Aspire.Hosting.Dotnet"],
            apiClientPackages: [],
            appHostCode: appHostCode,
            apiProgramCode: apiProgramCode,
            output: output);
 
        LocalDeploymentTestHelpers.WriteReferencingWebProgram(projectDir, ProjectName, serverName);
 
        try
        {
            // ASPIRE_PLAYGROUND forces interactive displays even with --non-interactive.
            await auto.RunCommandAsync("unset ASPIRE_PLAYGROUND; mkdir -p deploy-output", counter);
            await auto.RunCommandAsync(
                "aspire deploy -o deploy-output --non-interactive", counter, TimeSpan.FromMinutes(10));
 
            // Read Compose's normalized model so the dependency assertion pins the consumer,
            // not just the presence of a depends_on string somewhere in the generated YAML.
            await auto.RunCommandAsync(
                "env_file=$(find deploy-output -maxdepth 1 -name '.env.*' -print); " +
                "test -f \"$env_file\" && docker compose --env-file \"$env_file\" " +
                "-f deploy-output/docker-compose.yaml config --format json > deploy-output/compose.json",
                counter);
            using var compose = JsonDocument.Parse(File.ReadAllText(Path.Combine(projectDir, "deploy-output", "compose.json")));
            var services = compose.RootElement.GetProperty("services");
            Assert.Equal("service_started", services.GetProperty(webName).GetProperty("depends_on").GetProperty(serverName).GetProperty("condition").GetString());
            var server = services.GetProperty(serverName);
            Assert.Equal(MountPath, server.GetProperty("environment").GetProperty("DATA_PATH").GetString());
            var volume = Assert.Single(server.GetProperty("volumes").EnumerateArray());
            Assert.Equal("volume", volume.GetProperty("type").GetString());
            Assert.Equal(VolumeName, volume.GetProperty("source").GetString());
            Assert.Equal(MountPath, volume.GetProperty("target").GetString());
 
            // Unique resource names isolate the default local build tags; labels scope runtime
            // discovery and cleanup. Also prove the containers run our newly built images.
            foreach (var service in new[] { serverName, webName })
            {
                await auto.RunCommandAsync(
                    $"id=$(docker ps -q --filter 'label={LocalDeploymentTestHelpers.DeploymentLabel}={deploymentId}' " +
                    $"--filter 'label=com.docker.compose.service={service}'); test -n \"$id\" && " +
                    "test \"$(docker inspect -f '{{.Image}}' \"$id\")\" = " +
                    $"\"$(docker image inspect -f '{{{{.Id}}}}' '{service}:latest')\"",
                    counter);
            }
 
            await auto.RunCommandAsync(
                $"id=$(docker ps -q --filter 'label={LocalDeploymentTestHelpers.DeploymentLabel}={deploymentId}' " +
                $"--filter 'label=com.docker.compose.service={serverName}'); " +
                $"test \"$(docker exec \"$id\" printenv DATA_PATH)\" = '{MountPath}' && " +
                "docker inspect -f '{{range .Mounts}}{{.Type}}|{{.Name}}|{{.Destination}}{{println}}{{end}}' \"$id\" " +
                $"| grep -E '^volume\\|.*_{VolumeName}\\|{MountPath}$'",
                counter);
 
            await VerifyResponseAsync(auto, counter, deploymentId, serverName, "/data-path", MountPath);
            await VerifyResponseAsync(auto, counter, deploymentId, webName, "/test-deployment", $"WEB->{response}");
        }
        finally
        {
            try
            {
                await auto.AspireDestroyAsync(counter);
            }
            catch (Exception ex)
            {
                output.WriteLine($"Destroy failed; removing this deployment's labeled resources: {ex.Message}");
            }
 
            await LocalDeploymentTestHelpers.CleanupLabeledDockerResourcesAsync(
                $"{LocalDeploymentTestHelpers.DeploymentLabel}={deploymentId}", output);
            await LocalDeploymentTestHelpers.CleanupImageAsync($"{deploymentId}/server:latest", output);
            await LocalDeploymentTestHelpers.CleanupImageAsync($"{deploymentId}/web:latest", output);
            await LocalDeploymentTestHelpers.CleanupImageAsync($"{serverName}:latest", output);
            await LocalDeploymentTestHelpers.CleanupImageAsync($"{webName}:latest", output);
        }
    }
 
    private static async Task VerifyResponseAsync(
        Hex1bTerminalAutomator auto, SequenceCounter counter, string deploymentId,
        string service, string path, string expectedResponse)
    {
        // The host Docker daemon owns the workload network. Probe inside that namespace
        // rather than assuming the published host port is reachable from the helper container.
        await auto.RunCommandAsync(
            $"id=$(docker ps -q --filter 'label={LocalDeploymentTestHelpers.DeploymentLabel}={deploymentId}' " +
            $"--filter 'label=com.docker.compose.service={service}'); test -n \"$id\" && " +
            "{ found=0; for i in $(seq 1 20); do " +
            $"if result=$(docker run --rm --label '{LocalDeploymentTestHelpers.DeploymentLabel}={deploymentId}' " +
            "--network container:$id curlimages/curl:8.12.1 --connect-timeout 2 --max-time 5 -fsS " +
            $"http://localhost:8080{path}) && [ \"$result\" = '{expectedResponse}' ]; " +
            "then found=1; printf '%s\\n' \"$result\"; break; fi; sleep 3; done; test \"$found\" = 1; }",
            counter, TimeSpan.FromMinutes(4));
    }
}