File: ApplicationModel\VolumeMountBindingAnnotation.cs
Web Access
Project: src\src\Aspire.Hosting\Aspire.Hosting.csproj (Aspire.Hosting)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using Microsoft.Extensions.DependencyInjection;
 
namespace Aspire.Hosting.ApplicationModel;
 
/// <summary>
/// Describes how a named volume binds to a resource across the inner and outer loop.
/// </summary>
/// <remarks>
/// <para>
/// This annotation is the extensibility point compute environments use to participate in the portable
/// volume path convention. It carries two independent facets, either of which may be absent:
/// </para>
/// <list type="bullet">
/// <item><description>
/// <see cref="EnvironmentVariableName"/> records that the workload reads the effective storage path from
/// an environment variable. The variable itself is written by an <see cref="EnvironmentCallbackAnnotation"/>
/// whose closure captures the name, which makes the intent invisible to anything inspecting the model.
/// Restating it here lets a compute environment tell whether a host process materializes a local backing
/// store for the volume, without having to observe the callback running.
/// </description></item>
/// <item><description>
/// <see cref="RunModeHostPathResolver"/> lets a compute environment supply the local directory that backs
/// the volume in run mode. Without it, host processes fall back to a workload-scoped directory under
/// <see cref="IAspireStore"/>.
/// </description></item>
/// </list>
/// <para>
/// The two facets are produced by different parties at different times — the AppHost author opts into the
/// environment variable, while the compute environment supplies the local path — so a resource can carry
/// several of these annotations for the same <see cref="VolumeName"/>. A binding resolves through its own
/// <see cref="RunModeHostPathResolver"/> when it has one, and otherwise takes the last sibling that does.
/// <see cref="VolumeName"/> alone is not a unique key, because separate compute environments can each
/// declare a volume under the same name.
/// </para>
/// </remarks>
public sealed class VolumeMountBindingAnnotation(string volumeName) : IResourceAnnotation
{
    /// <summary>
    /// Gets the name of the volume this binding applies to.
    /// </summary>
    public string VolumeName { get; } = ThrowIfNullOrEmpty(volumeName);
 
    /// <summary>
    /// Gets the environment variable that receives the effective storage path, or <see langword="null"/>
    /// when this binding only supplies a run-mode path.
    /// </summary>
    public string? EnvironmentVariableName { get; init; }
 
    /// <summary>
    /// Gets the path the volume is mounted at once deployed, or <see langword="null"/> when this binding
    /// only supplies a run-mode path for a mount declared elsewhere.
    /// </summary>
    public string? MountPath { get; init; }
 
    /// <summary>
    /// Gets a callback that returns the local host directory backing the volume in run mode, or
    /// <see langword="null"/> to use the default workload-scoped directory under <see cref="IAspireStore"/>.
    /// </summary>
    public Func<EnvironmentCallbackContext, string>? RunModeHostPathResolver { get; init; }
 
    /// <summary>
    /// Resolves the storage path the workload should use for the current execution mode.
    /// </summary>
    /// <param name="context">The environment callback context being evaluated.</param>
    /// <returns>
    /// <see cref="MountPath"/> when publishing or when the workload runs as a container, and otherwise a
    /// local host directory.
    /// </returns>
    /// <exception cref="InvalidOperationException">
    /// Thrown when the deployed mount path is required but this binding does not declare one.
    /// </exception>
    public string ResolvePath(EnvironmentCallbackContext context)
    {
        ArgumentNullException.ThrowIfNull(context);
 
        if (context.ExecutionContext.IsPublishMode || context.Resource is ContainerResource)
        {
            var mountPath = MountPath ?? throw new InvalidOperationException(
                $"Volume '{VolumeName}' on resource '{context.Resource.Name}' does not declare a mount path.");
 
            if (context.ExecutionContext.IsPublishMode)
            {
                ThrowIfEnvironmentCannotMount(context);
            }
 
            return mountPath;
        }
 
        // Prefer this binding's own resolver. The sibling scan below only exists for the name-match
        // composition, where the mount and the compute environment binding are spelled as two separate
        // calls and the mount-declaring annotation therefore carries no resolver of its own.
        //
        // Scanning unconditionally would alias distinct volumes, because VolumeName is not a unique key —
        // two compute environments can each declare a volume under the same name, and every binding would
        // then select the last resolver and point at one environment's store. Aspire.Hosting.Kubernetes
        // rejects that shape up front, but this annotation is public and shared across compute
        // environments, so it resolves correctly on its own rather than relying on any one of them.
        var resolver = RunModeHostPathResolver ?? context.Resource.Annotations
            .OfType<VolumeMountBindingAnnotation>()
            .LastOrDefault(annotation =>
                annotation.RunModeHostPathResolver is not null &&
                string.Equals(annotation.VolumeName, VolumeName, StringComparison.Ordinal))
            ?.RunModeHostPathResolver;
 
        if (resolver is not null)
        {
            return resolver(context);
        }
 
        // Containers already returned above, so everything remaining runs as a host process and needs
        // a local directory. Projects and executables are the in-box cases, but the public overload
        // accepts any IComputeResource, so custom compute resources resolve here too. Throwing instead
        // would let a call that compiles cleanly fail much later during environment evaluation.
        var store = context.ExecutionContext.Services.GetRequiredService<IAspireStore>();
        return VolumeMountPathResolver.GetOrCreateLocalPath(store, context.Resource, VolumeName);
    }
 
    /// <summary>
    /// Throws when the resource is published to a compute environment that cannot back the volume with
    /// real storage.
    /// </summary>
    private void ThrowIfEnvironmentCannotMount(EnvironmentCallbackContext context)
    {
        // Only an environment that consumes ContainerMountAnnotation can back the path handed to the
        // workload. When the environment is known and does not, the variable resolves to ordinary
        // container storage: writes succeed and are then lost on restart. Fail at publish time instead,
        // because nothing downstream surfaces the problem.
        //
        // A null environment means the model has no compute environment, or several with no explicit
        // binding. Those are ambiguous rather than known-unsupported, so stay quiet rather than block a
        // publish that may well be fine.
        if (context.Resource.GetComputeEnvironment() is not { } environment ||
            environment is IComputeEnvironmentWithVolumeMounts)
        {
            return;
        }
 
        throw new InvalidOperationException(
            $"Resource '{context.Resource.Name}' binds volume '{VolumeName}' to environment variable " +
            $"'{EnvironmentVariableName}', but compute environment '{environment.Name}' does not support volume " +
            $"mounts. The variable would point at a path that is not backed by storage, so anything written there " +
            $"is lost when the workload restarts. Remove the environment variable binding, or target a compute " +
            $"environment that supports volume mounts.");
    }
 
    private static string ThrowIfNullOrEmpty([NotNull] string? argument, [CallerArgumentExpression(nameof(argument))] string? paramName = null)
    {
        ArgumentException.ThrowIfNullOrEmpty(argument, paramName);
        return argument;
    }
}