File: WithVolumeTests.cs
Web Access
Project: src\tests\Aspire.Hosting.Tests\Aspire.Hosting.Tests.csproj (Aspire.Hosting.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
#pragma warning disable ASPIREPERSISTENCE001
 
using Aspire.Hosting.Ats;
using Aspire.Hosting.Dcp.Model;
using Aspire.Hosting.Tests.Utils;
using Aspire.Hosting.Utils;
using Microsoft.Extensions.DependencyInjection;
 
namespace Aspire.Hosting.Tests;
 
public class WithVolumeTests(ITestOutputHelper outputHelper)
{
    [Theory]
    [InlineData(DistributedApplicationOperation.Run)]
    [InlineData(DistributedApplicationOperation.Publish)]
    public async Task WithVolumeEnvironmentUsesContainerMountPath(DistributedApplicationOperation operation)
    {
        using var builder = TestDistributedApplicationBuilder.Create(operation);
        var container = builder.AddContainer("container", "image")
            .WithVolume("data", "/srv/data", env: "DATA_PATH", isReadOnly: true);
 
        using var app = builder.Build();
        var environment = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(
            container.Resource,
            operation,
            app.Services);
 
        Assert.Equal("/srv/data", environment["DATA_PATH"]);
 
        var mount = Assert.Single(container.Resource.Annotations.OfType<ContainerMountAnnotation>());
        Assert.Equal("data", mount.Source);
        Assert.Equal("/srv/data", mount.Target);
        Assert.True(mount.IsReadOnly);
    }
 
    [Fact]
    public async Task WithVolumeEnvironmentUsesWorkloadScopedPathsForProjectAndExecutable()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
        var project = builder.AddProject<Projects.ServiceA>("project", launchProfileName: null)
            .WithVolume("data", "/srv/data", env: "DATA_PATH");
        var executable = builder.AddExecutable("executable", "test-command", ".")
            .WithVolume("data", "/srv/data", env: "DATA_PATH");
 
        using var app = builder.Build();
        var store = app.Services.GetRequiredService<IAspireStore>();
        var expectedProjectPath = VolumeMountPathResolver.GetLocalPath(store, project.Resource, "data");
        var expectedExecutablePath = VolumeMountPathResolver.GetLocalPath(store, executable.Resource, "data");
 
        Assert.False(Directory.Exists(expectedProjectPath));
        Assert.False(Directory.Exists(expectedExecutablePath));
 
        var projectEnvironment = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(
            project.Resource,
            serviceProvider: app.Services);
        var executableEnvironment = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(
            executable.Resource,
            serviceProvider: app.Services);
 
        Assert.Equal(expectedProjectPath, projectEnvironment["DATA_PATH"]);
        Assert.Equal(expectedExecutablePath, executableEnvironment["DATA_PATH"]);
        Assert.True(Directory.Exists(expectedProjectPath));
        Assert.True(Directory.Exists(expectedExecutablePath));
    }
 
    [Fact]
    public async Task WithVolumeEnvironmentUsesMountPathForPublishedProjectAndExecutable()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var project = builder.AddProject<Projects.ServiceA>("project", launchProfileName: null)
            .WithVolume("data", "/srv/project", env: "DATA_PATH");
        var executable = builder.AddExecutable("executable", "test-command", ".")
            .WithVolume("data", "/srv/executable", env: "DATA_PATH");
 
        using var app = builder.Build();
        var projectEnvironment = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(
            project.Resource,
            DistributedApplicationOperation.Publish,
            app.Services);
        var executableEnvironment = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(
            executable.Resource,
            DistributedApplicationOperation.Publish,
            app.Services);
 
        Assert.Equal("/srv/project", projectEnvironment["DATA_PATH"]);
        Assert.Equal("/srv/executable", executableEnvironment["DATA_PATH"]);
    }
 
    [Theory]
    [InlineData(null)]
    [InlineData("")]
    public void WithVolumeEnvironmentValidatesName(string? env)
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var container = builder.AddContainer("container", "image");
 
        var exception = Assert.ThrowsAny<ArgumentException>(() =>
            container.WithVolume("data", "/srv/data", env!));
 
        Assert.Equal(nameof(env), exception.ParamName);
    }
 
    [Fact]
    public void WithVolumeEnvironmentRequiresNameForProject()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var project = builder.AddProject<Projects.ServiceA>("project", launchProfileName: null);
        string name = null!;
 
        var exception = Assert.ThrowsAny<ArgumentException>(() =>
            VolumeResourceBuilderExtensions.WithVolume(project, name, "/srv/data", env: "DATA_PATH"));
 
        Assert.Equal(nameof(name), exception.ParamName);
    }
 
    [Fact]
    public void ExistingContainerOverloadStillAcceptsPositionalDefault()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        var container = builder.AddContainer("container", "image")
            .WithVolume("data", "/srv/data", default);
 
        var mount = Assert.Single(container.Resource.Annotations.OfType<ContainerMountAnnotation>());
        Assert.False(mount.IsReadOnly);
    }
 
    [Fact]
    public async Task WithVolumeEnvironmentKeepsDistinctFilesystemSafeIdentities()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
        var executable = builder.AddExecutable("executable", "test-command", ".")
            .WithVolume("Data", "/srv/upper", env: "UPPER_PATH")
            .WithVolume("data", "/srv/lower", env: "LOWER_PATH")
            .WithVolume("../escape", "/srv/escape", env: "ESCAPE_PATH");
 
        using var app = builder.Build();
        var environment = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(
            executable.Resource,
            serviceProvider: app.Services);
 
        Assert.NotEqual(environment["UPPER_PATH"], environment["LOWER_PATH"]);
 
        var storePrefix = Path.GetFullPath(app.Services.GetRequiredService<IAspireStore>().BasePath) + Path.DirectorySeparatorChar;
        var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
        Assert.All(
            ["UPPER_PATH", "LOWER_PATH", "ESCAPE_PATH"],
            name => Assert.StartsWith(storePrefix, environment[name], comparison));
    }
 
    [Fact]
    public async Task WithVolumeEnvironmentReusesLocalPathAcrossAppHostRunsAndLifetimes()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
 
        var sessionPath = await GetVolumePathAsync(usePersistentLifetime: false);
        var markerPath = Path.Combine(sessionPath, "marker.txt");
        await File.WriteAllTextAsync(markerPath, "persisted");
 
        var persistentPath = await GetVolumePathAsync(usePersistentLifetime: true);
 
        Assert.Equal(sessionPath, persistentPath);
        Assert.Equal("persisted", await File.ReadAllTextAsync(Path.Combine(persistentPath, "marker.txt")));
 
        async Task<string> GetVolumePathAsync(bool usePersistentLifetime)
        {
            using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
            builder.Configuration[AspireStore.AspireStorePathKeyName] = workspace.Path;
 
            var executable = builder.AddExecutable("worker", "test-command", ".")
                .WithVolume("data", "/srv/data", env: "DATA_PATH");
            if (usePersistentLifetime)
            {
                executable.WithPersistentLifetime();
            }
            else
            {
                executable.WithSessionLifetime();
            }
 
            using var app = builder.Build();
            var environment = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(
                executable.Resource,
                serviceProvider: app.Services);
            return environment["DATA_PATH"];
        }
    }
 
    [Fact]
    public void NamedContainerVolumeIdentityAndPersistenceAreIndependentOfContainerLifetime()
    {
        using var sessionBuilder = TestDistributedApplicationBuilder.Create();
        var sessionContainer = sessionBuilder.AddContainer("session", "image")
            .WithSessionLifetime()
            .WithVolume("shared-data", "/srv/data");
 
        using var persistentBuilder = TestDistributedApplicationBuilder.Create();
        var persistentContainer = persistentBuilder.AddContainer("persistent", "image")
            .WithPersistentLifetime()
            .WithVolume("shared-data", "/srv/data");
 
        var sessionMount = Assert.Single(sessionContainer.Resource.Annotations.OfType<ContainerMountAnnotation>());
        var persistentMount = Assert.Single(persistentContainer.Resource.Annotations.OfType<ContainerMountAnnotation>());
        Assert.Equal("shared-data", sessionMount.Source);
        Assert.Equal(sessionMount.Source, persistentMount.Source);
 
        var dcpVolume = ContainerVolume.Create("shared-data-resource", sessionMount.Source!);
        Assert.True(dcpVolume.Spec.Persistent);
    }
 
    [Fact]
    public async Task WithVolumeEnvironmentUsesStorePathForCustomComputeResources()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
        var custom = builder.AddResource(new TestComputeResource("custom"))
            .WithVolume("data", "/srv/data", env: "DATA_PATH");
 
        using var app = builder.Build();
        var store = app.Services.GetRequiredService<IAspireStore>();
        var expectedPath = VolumeMountPathResolver.GetLocalPath(store, custom.Resource, "data");
 
        var environment = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(
            custom.Resource,
            serviceProvider: app.Services);
 
        Assert.Equal(expectedPath, environment["DATA_PATH"]);
    }
 
    [Theory]
    [InlineData("", "data", "DATA_PATH")]
    [InlineData("/data", "", "DATA_PATH")]
    [InlineData("/data", "data", "")]
    public void PolyglotVolumeExportsRejectEmptyArguments(string target, string name, string env)
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var project = builder.AddProject<Projects.ServiceA>("project", launchProfileName: null);
        var executable = builder.AddExecutable("exe", "cmd", ".");
 
        Assert.Throws<ArgumentException>(() => CoreExports.WithProjectVolumeForPolyglot(project, target, name, env));
        Assert.Throws<ArgumentException>(() => CoreExports.WithExecutableVolumeForPolyglot(executable, target, name, env));
    }
 
    [Fact]
    public async Task WithVolumeEnvironmentThrowsWhenComputeEnvironmentCannotMountVolumes()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var environment = builder.AddResource(new MountIncapableComputeEnvironmentResource("env"));
        var project = builder.AddProject<Projects.ServiceA>("project", launchProfileName: null)
            .WithVolume("data", "/srv/data", env: "DATA_PATH")
            .WithComputeEnvironment(environment);
 
        using var app = builder.Build();
 
        var exception = await Assert.ThrowsAsync<InvalidOperationException>(
            async () => await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(
                project.Resource,
                DistributedApplicationOperation.Publish,
                app.Services));
 
        Assert.Contains("'project'", exception.Message);
        Assert.Contains("volume 'data'", exception.Message);
        Assert.Contains("'DATA_PATH'", exception.Message);
        Assert.Contains("'env'", exception.Message);
    }
 
    [Fact]
    public async Task WithVolumeEnvironmentResolvesWhenComputeEnvironmentSupportsVolumeMounts()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var computeEnvironment = builder.AddResource(new MountCapableComputeEnvironmentResource("env"));
        var project = builder.AddProject<Projects.ServiceA>("project", launchProfileName: null)
            .WithVolume("data", "/srv/data", env: "DATA_PATH")
            .WithComputeEnvironment(computeEnvironment);
 
        using var app = builder.Build();
        var environment = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(
            project.Resource,
            DistributedApplicationOperation.Publish,
            app.Services);
 
        Assert.Equal("/srv/data", environment["DATA_PATH"]);
    }
 
    [Fact]
    public async Task WithVolumeEnvironmentResolvesWhenComputeEnvironmentIsUnknown()
    {
        // No compute environment is bound, so the target is ambiguous rather than known-unsupported.
        // Publishing must not be blocked on a guess.
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var project = builder.AddProject<Projects.ServiceA>("project", launchProfileName: null)
            .WithVolume("data", "/srv/data", env: "DATA_PATH");
 
        using var app = builder.Build();
        var environment = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(
            project.Resource,
            DistributedApplicationOperation.Publish,
            app.Services);
 
        Assert.Equal("/srv/data", environment["DATA_PATH"]);
    }
 
    private sealed class TestComputeResource(string name) : Resource(name), IComputeResource, IResourceWithEnvironment
    {
    }
 
    private sealed class MountCapableComputeEnvironmentResource(string name)
        : Resource(name), IComputeEnvironmentResource, IComputeEnvironmentWithVolumeMounts
    {
    }
 
    private sealed class MountIncapableComputeEnvironmentResource(string name)
        : Resource(name), IComputeEnvironmentResource
    {
    }
}