File: KubernetesPersistentVolumeRunModeTests.cs
Web Access
Project: src\tests\Aspire.Hosting.Kubernetes.Tests\Aspire.Hosting.Kubernetes.Tests.csproj (Aspire.Hosting.Kubernetes.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 ASPIRECOMPUTE002
 
using System.Runtime.CompilerServices;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Tests.Utils;
using Aspire.Hosting.Utils;
using Microsoft.Extensions.DependencyInjection;
 
namespace Aspire.Hosting.Kubernetes.Tests;
 
public class KubernetesPersistentVolumeRunModeTests
{
    [Fact]
    public async Task ProjectAndExecutableUseSharedAspireStoreDirectory()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
        var kubernetes = builder.AddKubernetesEnvironment("env");
        var volume = kubernetes.AddPersistentVolume("data");
 
        var project = builder.AddProject<Projects.ServiceA>("project", launchProfileName: null)
            .WithPersistentVolume(volume, "/srv/data", env: "DATA_PATH");
        var executable = builder.AddExecutable("executable", "test-command", ".")
            .WithPersistentVolume(volume, "/srv/data", env: "DATA_PATH");
 
        using var app = builder.Build();
        var store = app.Services.GetRequiredService<IAspireStore>();
        var expectedPath = KubernetesPersistentVolumeLocalStorage.GetPath(store, volume.Resource);
 
        Assert.False(Directory.Exists(expectedPath));
 
        var projectEnvironment = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(
            project.Resource,
            serviceProvider: app.Services);
 
        Assert.Equal(expectedPath, projectEnvironment["DATA_PATH"]);
        Assert.True(Directory.Exists(expectedPath));
 
        var executableEnvironment = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(
            executable.Resource,
            serviceProvider: app.Services);
 
        Assert.Equal(expectedPath, executableEnvironment["DATA_PATH"]);
    }
 
    [Fact]
    public async Task DifferentVolumesUseDifferentAspireStoreDirectories()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
        var kubernetes = builder.AddKubernetesEnvironment("env");
        var firstVolume = kubernetes.AddPersistentVolume("first");
        var secondVolume = kubernetes.AddPersistentVolume("second");
 
        var firstProject = builder.AddProject<Projects.ServiceA>("first-project", launchProfileName: null)
            .WithPersistentVolume(firstVolume, "/srv/data", env: "DATA_PATH");
        var secondProject = builder.AddProject<Projects.ServiceA>("second-project", launchProfileName: null)
            .WithPersistentVolume(secondVolume, "/srv/data", env: "DATA_PATH");
 
        using var app = builder.Build();
        var firstEnvironment = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(
            firstProject.Resource,
            serviceProvider: app.Services);
        var secondEnvironment = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(
            secondProject.Resource,
            serviceProvider: app.Services);
 
        Assert.NotEqual(firstEnvironment["DATA_PATH"], secondEnvironment["DATA_PATH"]);
    }
 
    [Fact]
    public async Task SameNamedVolumesInDifferentEnvironmentsUseDifferentAspireStoreDirectories()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
        var firstEnvironment = builder.AddKubernetesEnvironment("first-env");
        var secondEnvironment = builder.AddKubernetesEnvironment("second-env");
        var firstVolume = firstEnvironment.AddPersistentVolume("data");
        var secondVolume = secondEnvironment.AddPersistentVolume("data");
 
        var firstProject = builder.AddProject<Projects.ServiceA>("first-project", launchProfileName: null)
            .WithPersistentVolume(firstVolume, "/srv/data", env: "DATA_PATH");
        var secondProject = builder.AddProject<Projects.ServiceA>("second-project", launchProfileName: null)
            .WithPersistentVolume(secondVolume, "/srv/data", env: "DATA_PATH");
 
        using var app = builder.Build();
        var firstProjectEnvironment = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(
            firstProject.Resource,
            serviceProvider: app.Services);
        var secondProjectEnvironment = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(
            secondProject.Resource,
            serviceProvider: app.Services);
 
        Assert.NotEqual(firstProjectEnvironment["DATA_PATH"], secondProjectEnvironment["DATA_PATH"]);
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task OneResourceBoundToSameNamedVolumesInDifferentEnvironmentsIsRejected(bool nameMatchSpelling)
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
        var firstEnvironment = builder.AddKubernetesEnvironment("first-env");
        var secondEnvironment = builder.AddKubernetesEnvironment("second-env");
        var firstVolume = firstEnvironment.AddPersistentVolume("data");
        var secondVolume = secondEnvironment.AddPersistentVolume("data");
 
        // Both volumes are named "data", so neither the local path lookup nor the container mount
        // rewrite can tell the two bindings apart. Only run mode can express this: AddPersistentVolume
        // registers the volume in publish mode and the duplicate name is rejected while the model is
        // built, while run mode hands back a CreateResourceBuilder that never registers it.
        var project = builder.AddProject<Projects.ServiceA>("project", launchProfileName: null);
 
        if (nameMatchSpelling)
        {
            project.WithVolume("data", "/srv/first", env: "FIRST_PATH")
                .WithPersistentVolume(firstVolume)
                .WithVolume("data", "/srv/second", env: "SECOND_PATH")
                .WithPersistentVolume(secondVolume);
        }
        else
        {
            project.WithPersistentVolume(firstVolume, "/srv/first", env: "FIRST_PATH")
                .WithPersistentVolume(secondVolume, "/srv/second", env: "SECOND_PATH");
        }
 
        using var app = builder.Build();
        var exception = await Assert.ThrowsAsync<DistributedApplicationException>(
            () => ExecuteBeforeStartHooksAsync(app, CancellationToken.None));
 
        Assert.Contains("binds 2 different Kubernetes persistent volumes named 'data'", exception.Message);
        Assert.Contains("'first-env'", exception.Message);
        Assert.Contains("'second-env'", exception.Message);
        Assert.Contains("project", exception.Message);
    }
 
    [Fact]
    public async Task OneContainerBoundToSameNamedVolumesInDifferentEnvironmentsIsRejected()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
        var firstEnvironment = builder.AddKubernetesEnvironment("first-env");
        var secondEnvironment = builder.AddKubernetesEnvironment("second-env");
        var firstVolume = firstEnvironment.AddPersistentVolume("data");
        var secondVolume = secondEnvironment.AddPersistentVolume("data");
 
        builder.AddContainer("container", "image")
            .WithPersistentVolume(firstVolume, "/srv/first", env: "FIRST_PATH")
            .WithPersistentVolume(secondVolume, "/srv/second", env: "SECOND_PATH");
 
        using var app = builder.Build();
        var exception = await Assert.ThrowsAsync<DistributedApplicationException>(
            () => ExecuteBeforeStartHooksAsync(app, CancellationToken.None));
 
        Assert.Contains("binds 2 different Kubernetes persistent volumes named 'data'", exception.Message);
        Assert.Contains("container", exception.Message);
    }
 
    [Fact]
    public async Task ContainerUsesScopedVolumeAndContainerMountPath()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
        var kubernetes = builder.AddKubernetesEnvironment("env");
        var volume = kubernetes.AddPersistentVolume("data");
 
        var firstContainer = builder.AddContainer("first", "image")
            .WithPersistentVolume(volume, "/srv/data", env: "DATA_PATH");
        var secondContainer = builder.AddContainer("second", "image")
            .WithPersistentVolume(volume, "/srv/data", env: "DATA_PATH");
 
        using var app = builder.Build();
        await ExecuteBeforeStartHooksAsync(app, CancellationToken.None);
 
        var expectedVolumeName = VolumeNameGenerator.Generate(volume, "kubernetes-env");
        var firstMount = Assert.Single(firstContainer.Resource.Annotations.OfType<ContainerMountAnnotation>());
        var secondMount = Assert.Single(secondContainer.Resource.Annotations.OfType<ContainerMountAnnotation>());
 
        Assert.Equal(expectedVolumeName, firstMount.Source);
        Assert.Equal(expectedVolumeName, secondMount.Source);
 
        var environment = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(
            firstContainer.Resource,
            serviceProvider: app.Services);
 
        Assert.Equal("/srv/data", environment["DATA_PATH"]);
    }
 
    [Fact]
    public async Task NameMatchBindingPreservesExistingContainerVolume()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
        var kubernetes = builder.AddKubernetesEnvironment("env");
        var volume = kubernetes.AddPersistentVolume("data");
 
        var container = builder.AddContainer("container", "image")
            .WithVolume("data", "/srv/data")
            .WithPersistentVolume(volume);
 
        using var app = builder.Build();
        await ExecuteBeforeStartHooksAsync(app, CancellationToken.None);
 
        // The name-match overload predates the env convention, so it must keep mounting the volume
        // the container already declared. Scoping it would strand data written by earlier runs.
        var mount = Assert.Single(container.Resource.Annotations.OfType<ContainerMountAnnotation>());
        Assert.Equal("data", mount.Source);
    }
 
    [Fact]
    public async Task NameMatchBindingIsIndependentOfBuilderOrder()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
        var kubernetes = builder.AddKubernetesEnvironment("env");
        var volume = kubernetes.AddPersistentVolume("data");
 
        var container = builder.AddContainer("container", "image")
            .WithPersistentVolume(volume)
            .WithVolume("data", "/srv/data");
 
        using var app = builder.Build();
        await ExecuteBeforeStartHooksAsync(app, CancellationToken.None);
 
        var mount = Assert.Single(container.Resource.Annotations.OfType<ContainerMountAnnotation>());
        Assert.Equal("data", mount.Source);
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task NameMatchBindingWithEnvScopesContainerVolume(bool bindBeforeVolume)
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
        var kubernetes = builder.AddKubernetesEnvironment("env");
        var volume = kubernetes.AddPersistentVolume("data");
        var container = builder.AddContainer("container", "image");
 
        if (bindBeforeVolume)
        {
            container.WithPersistentVolume(volume)
                .WithVolume("data", "/srv/data", env: "DATA_PATH");
        }
        else
        {
            container.WithVolume("data", "/srv/data", env: "DATA_PATH")
                .WithPersistentVolume(volume);
        }
 
        using var app = builder.Build();
        await ExecuteBeforeStartHooksAsync(app, CancellationToken.None);
 
        // Spelling env on the mount is the same opt-in as spelling it on the binding, so it has to
        // produce the same worktree-scoped volume. The scoping decision runs at finalization, which
        // is what keeps this independent of the order the two calls were made in.
        var expectedVolumeName = VolumeNameGenerator.Generate(volume, "kubernetes-env");
        var mount = Assert.Single(container.Resource.Annotations.OfType<ContainerMountAnnotation>());
        Assert.Equal(expectedVolumeName, mount.Source);
 
        var environment = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(
            container.Resource,
            serviceProvider: app.Services);
 
        Assert.Equal("/srv/data", environment["DATA_PATH"]);
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task NameMatchBindingUsesSharedPersistentVolumePathForHostProcesses(bool bindBeforeVolume)
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
        var kubernetes = builder.AddKubernetesEnvironment("env");
        var volume = kubernetes.AddPersistentVolume("data");
        var project = builder.AddProject<Projects.ServiceA>("project", launchProfileName: null);
 
        if (bindBeforeVolume)
        {
            project.WithPersistentVolume(volume)
                .WithVolume("data", "/srv/data", env: "DATA_PATH");
        }
        else
        {
            project.WithVolume("data", "/srv/data", env: "DATA_PATH")
                .WithPersistentVolume(volume);
        }
 
        using var app = builder.Build();
        var store = app.Services.GetRequiredService<IAspireStore>();
        var environment = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(
            project.Resource,
            serviceProvider: app.Services);
 
        Assert.Equal(
            KubernetesPersistentVolumeLocalStorage.GetPath(store, volume.Resource),
            environment["DATA_PATH"]);
    }
 
    [Fact]
    public async Task SameNamedVolumesInDifferentEnvironmentsUseDifferentContainerVolumes()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
        var firstEnvironment = builder.AddKubernetesEnvironment("first-env");
        var secondEnvironment = builder.AddKubernetesEnvironment("second-env");
        var firstVolume = firstEnvironment.AddPersistentVolume("data");
        var secondVolume = secondEnvironment.AddPersistentVolume("data");
 
        var firstContainer = builder.AddContainer("first", "image")
            .WithPersistentVolume(firstVolume, "/srv/data", env: "DATA_PATH");
        var secondContainer = builder.AddContainer("second", "image")
            .WithPersistentVolume(secondVolume, "/srv/data", env: "DATA_PATH");
 
        using var app = builder.Build();
        await ExecuteBeforeStartHooksAsync(app, CancellationToken.None);
 
        var firstMount = Assert.Single(firstContainer.Resource.Annotations.OfType<ContainerMountAnnotation>());
        var secondMount = Assert.Single(secondContainer.Resource.Annotations.OfType<ContainerMountAnnotation>());
 
        Assert.NotEqual(firstMount.Source, secondMount.Source);
    }
 
    [Fact]
    public async Task MountPathBindingWithoutEnvPreservesPersistentVolumeName()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
        var kubernetes = builder.AddKubernetesEnvironment("env");
        var volume = kubernetes.AddPersistentVolume("data");
 
        var container = builder.AddContainer("container", "image")
            .WithPersistentVolume(volume, "/srv/data");
 
        using var app = builder.Build();
        await ExecuteBeforeStartHooksAsync(app, CancellationToken.None);
 
        // Scoping is opt-in via env. Without it this overload has to mount the same local volume it
        // mounted in 13.5.0, otherwise upgrading silently repoints the container at empty storage.
        var mount = Assert.Single(container.Resource.Annotations.OfType<ContainerMountAnnotation>());
        Assert.Equal("data", mount.Source);
    }
 
    [Fact]
    public void ExistingPersistentVolumeOverloadStillAcceptsPositionalDefault()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
        var kubernetes = builder.AddKubernetesEnvironment("env");
        var volume = kubernetes.AddPersistentVolume("data");
 
        var container = builder.AddContainer("container", "image")
            .WithPersistentVolume(volume, "/srv/data", default);
 
        var mount = Assert.Single(container.Resource.Annotations.OfType<ContainerMountAnnotation>());
        Assert.False(mount.IsReadOnly);
    }
 
    [Fact]
    public async Task MixedContainerAndExecutableConsumersAreRejected()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
        var kubernetes = builder.AddKubernetesEnvironment("env");
        var volume = kubernetes.AddPersistentVolume("data");
 
        builder.AddExecutable("executable", "test-command", ".")
            .WithPersistentVolume(volume, "/srv/data", env: "DATA_PATH");
        builder.AddContainer("container", "image")
            .WithPersistentVolume(volume, "/srv/data", env: "DATA_PATH");
 
        using var app = builder.Build();
        var exception = await Assert.ThrowsAsync<DistributedApplicationException>(
            () => ExecuteBeforeStartHooksAsync(app, CancellationToken.None));
 
        Assert.Contains("both local container and host-process resources", exception.Message);
        Assert.Contains("data", exception.Message);
        Assert.Contains("executable", exception.Message);
        Assert.Contains("container", exception.Message);
    }
 
    [Fact]
    public async Task PublishOnlyBindingsAllowMixedContainerAndProjectConsumers()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
        var kubernetes = builder.AddKubernetesEnvironment("env");
        var volume = kubernetes.AddPersistentVolume("data");
 
        // Neither binding asks for the run-mode environment path, so the project materializes no
        // local backing store and cannot conflict with the container's named volume. This shape
        // predates the env overload and must keep working.
        builder.AddProject<Projects.ServiceA>("project", launchProfileName: null)
            .WithPersistentVolume(volume, "/srv/data");
        builder.AddContainer("container", "image")
            .WithPersistentVolume(volume, "/srv/data");
 
        using var app = builder.Build();
        await ExecuteBeforeStartHooksAsync(app, CancellationToken.None);
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task NameMatchBindingWithEnvVolumeConflictsWithContainerConsumer(bool bindBeforeVolume)
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
        var kubernetes = builder.AddKubernetesEnvironment("env");
        var volume = kubernetes.AddPersistentVolume("data");
 
        // The env lives on the mount rather than the binding here, so the conflict is only visible by
        // inspecting the resource. Both orders must detect it.
        var project = builder.AddProject<Projects.ServiceA>("project", launchProfileName: null);
        if (bindBeforeVolume)
        {
            project.WithPersistentVolume(volume)
                .WithVolume("data", "/srv/data", env: "DATA_PATH");
        }
        else
        {
            project.WithVolume("data", "/srv/data", env: "DATA_PATH")
                .WithPersistentVolume(volume);
        }
 
        builder.AddContainer("container", "image")
            .WithVolume("data", "/srv/data")
            .WithPersistentVolume(volume);
 
        using var app = builder.Build();
        var exception = await Assert.ThrowsAsync<DistributedApplicationException>(
            () => ExecuteBeforeStartHooksAsync(app, CancellationToken.None));
 
        Assert.Contains("both local container and host-process resources", exception.Message);
        Assert.Contains("project", exception.Message);
        Assert.Contains("container", exception.Message);
    }
 
    [Fact]
    public async Task EnvVolumeForDifferentNameDoesNotConflictWithContainerConsumer()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
        var kubernetes = builder.AddKubernetesEnvironment("env");
        var volume = kubernetes.AddPersistentVolume("data");
 
        // The env mount names a different volume, so it resolves an unrelated local directory and
        // never touches this persistent volume's store path.
        builder.AddProject<Projects.ServiceA>("project", launchProfileName: null)
            .WithVolume("scratch", "/srv/scratch", env: "SCRATCH_PATH")
            .WithPersistentVolume(volume);
        builder.AddContainer("container", "image")
            .WithVolume("data", "/srv/data")
            .WithPersistentVolume(volume);
 
        using var app = builder.Build();
        await ExecuteBeforeStartHooksAsync(app, CancellationToken.None);
    }
 
    [Theory]
    [InlineData(true)]
    [InlineData(false)]
    public async Task NameMatchBindingRejectsUnsupportedResourceResolvingLocalPath(bool bindBeforeVolume)
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
        var kubernetes = builder.AddKubernetesEnvironment("env");
        var volume = kubernetes.AddPersistentVolume("data");
 
        // A custom compute resource still resolves the persistent volume's local store path, because
        // only containers short-circuit to the declared mount path. The env is spelled on the mount
        // here, so the binding itself carries none and both builder orders must still be rejected.
        var custom = builder.AddResource(new TestComputeResource("custom"));
        if (bindBeforeVolume)
        {
            custom.WithPersistentVolume(volume)
                .WithVolume("data", "/srv/data", env: "DATA_PATH");
        }
        else
        {
            custom.WithVolume("data", "/srv/data", env: "DATA_PATH")
                .WithPersistentVolume(volume);
        }
 
        using var app = builder.Build();
        var exception = await Assert.ThrowsAsync<DistributedApplicationException>(
            () => ExecuteBeforeStartHooksAsync(app, CancellationToken.None));
 
        Assert.Contains("custom", exception.Message);
        Assert.Contains("DATA_PATH", exception.Message);
        Assert.Contains("Only project, executable, and container resources are supported", exception.Message);
    }
 
    [Fact]
    public async Task NameMatchBindingAllowsUnsupportedResourceWithoutLocalPath()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
        var kubernetes = builder.AddKubernetesEnvironment("env");
        var volume = kubernetes.AddPersistentVolume("data");
 
        // The env mount names an unrelated volume, so this binding never resolves the persistent
        // volume's store path and stays publish-only.
        builder.AddResource(new TestComputeResource("custom"))
            .WithVolume("scratch", "/srv/scratch", env: "SCRATCH_PATH")
            .WithPersistentVolume(volume);
 
        using var app = builder.Build();
        await ExecuteBeforeStartHooksAsync(app, CancellationToken.None);
    }
 
    [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "ExecuteBeforeStartHooksAsync")]
    private static extern Task ExecuteBeforeStartHooksAsync(
        DistributedApplication app,
        CancellationToken cancellationToken);
 
    private sealed class TestComputeResource(string name) : Resource(name), IComputeResource, IResourceWithEnvironment;
}