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 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";
 
    [Fact]
    [CaptureWorkspaceOnFailure]
    public async Task DeployComposeWithProjectVolumeMountsNamedVolumeAtEnvPath()
    {
        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);
 
        // 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 = $$"""
            using Aspire.Hosting;
 
            var builder = DistributedApplication.CreateBuilder(args);
 
            builder.AddDockerComposeEnvironment("compose");
 
            builder.AddProject<Projects.{{ProjectName}}_ApiService>("server")
                .WithVolume("{{VolumeName}}", "{{MountPath}}", env: "DATA_PATH")
                .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.Run();
            """;
 
        await auto.ScaffoldK8sDeployProjectAsync(
            counter,
            ProjectName,
            Path.Combine(workspace.WorkspaceRoot.FullName, ProjectName),
            appHostHostingPackages: ["Aspire.Hosting.Docker"],
            apiClientPackages: [],
            appHostCode: appHostCode,
            apiProgramCode: apiProgramCode,
            output: output);
 
        await auto.TypeAsync("mkdir -p deploy-output");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter);
 
        // ASPIRE_PLAYGROUND=true takes precedence over --non-interactive in CliHostEnvironment,
        // which causes Spectre.Console to try to show interactive spinners and prompts concurrently,
        // resulting in "Operations with dynamic displays cannot run at the same time" errors.
        await auto.TypeAsync("unset ASPIRE_PLAYGROUND");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter);
 
        await auto.TypeAsync("aspire deploy -o deploy-output --non-interactive");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(10));
 
        // === Verify the published projection ===
        // DATA_PATH must carry the published target (not a host path), and the mount must be
        // backed by a named volume rather than a bind mount, which is what makes the storage
        // outlive the container.
        output.WriteLine("Verify: compose file projects DATA_PATH and a named volume");
        // The grep chain alone sets the exit status. An `|| echo ...BAD` fallback would swallow a
        // failed grep behind a zero exit, so WaitForSuccessPromptAsync (which reads the prompt's
        // OK/ERR marker) would pass on a broken compose file.
        //
        // The sentinel is split so only the shell can assemble it: the typed line reads
        // COMPOSE_SHAPE$(echo _OK) while the executed echo emits COMPOSE_SHAPE_OK. Otherwise
        // WaitUntilTextAsync matches the echoed command still on screen and passes regardless of
        // the greps. Same idiom as KubernetesDeployWithPersistentVolumeTests.
        await auto.TypeAsync(
            $"grep -q 'DATA_PATH: \"{MountPath}\"' deploy-output/docker-compose.yaml && " +
            $"grep -q 'source: \"{VolumeName}\"' deploy-output/docker-compose.yaml && " +
            $"grep -q 'type: \"volume\"' deploy-output/docker-compose.yaml && " +
            "echo \"COMPOSE_SHAPE$(echo _OK)\"");
        await auto.EnterAsync();
        await auto.WaitUntilTextAsync("COMPOSE_SHAPE_OK", timeout: TimeSpan.FromSeconds(30));
        await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30));
 
        // === Verify the running container ===
        // Compose expands the volume name with a project prefix (aspire-compose-<hash>_serverdata),
        // so match on a substring rather than the literal declared name. Retried because the
        // service can still be starting immediately after deploy reports success.
        output.WriteLine("Verify: running container has DATA_PATH and the named volume mounted there");
        // `found` is what makes the exit status meaningful: an exhausted loop would otherwise end
        // on `sleep 3` and report success. The trailing `test` turns "never matched" into a
        // non-zero exit, and the split sentinel keeps WaitUntilTextAsync from matching the typed
        // command line rather than real output.
        await auto.TypeAsync(
            "found=0; for i in $(seq 1 20); do " +
            "id=$(docker ps --filter 'name=server' --format '{{.ID}}' | head -1); " +
            "if [ -n \"$id\" ]; then " +
            "envval=$(docker exec $id printenv DATA_PATH 2>/dev/null); " +
            "mounts=$(docker inspect -f '{{range .Mounts}}{{.Type}}|{{.Name}}|{{.Destination}} {{end}}' $id 2>/dev/null); " +
            "echo \"DATA_PATH=[$envval] MOUNTS=[$mounts]\"; " +
            $"if [ \"$envval\" = \"{MountPath}\" ] && echo \"$mounts\" | grep -q 'volume|.*{VolumeName}|{MountPath}'; " +
            "then found=1; echo \"RUNTIME$(echo _OK)\"; break; fi; " +
            "fi; " +
            "echo \"Attempt $i: waiting for server container...\"; sleep 3; done; " +
            "test \"$found\" = 1");
        await auto.EnterAsync();
        await auto.WaitUntilTextAsync("RUNTIME_OK", timeout: TimeSpan.FromMinutes(3));
        await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(3));
 
        await auto.AspireDestroyAsync(counter);
    }
}