File: Projects\IntegrationRestorePlan.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.csproj (aspire)
// 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;
using Aspire.Cli.NuGet;
using Aspire.Cli.Packaging;
using Aspire.Cli.Utils;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Cli.Projects;
 
/// <summary>
/// Resolves channel and ambient NuGet state once for one AppHost preparation.
/// </summary>
internal sealed class IntegrationRestorePlanResolver(
    IPackagingService packagingService,
    BundleNuGetService nugetService,
    CliExecutionContext executionContext,
    ILogger logger)
{
    public async Task<IntegrationRestorePlan> ResolveAsync(
        string appDirectoryPath,
        string workloadId,
        string sdkVersion,
        string? requestedChannel,
        string? packageSourceOverride,
        string? packageSourceOverridePattern,
        CancellationToken cancellationToken)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(appDirectoryPath);
        ArgumentException.ThrowIfNullOrWhiteSpace(workloadId);
        ArgumentException.ThrowIfNullOrWhiteSpace(sdkVersion);
 
        ThrowIfStagingUnavailable(requestedChannel);
 
        var effectivePackageSourceOverride = packageSourceOverride;
        var explicitPackageSourceOverridePattern = string.IsNullOrWhiteSpace(packageSourceOverride)
            ? null
            : PackageSourceOverrideMappings.GetEffectivePackagePattern(packageSourceOverridePattern);
        var localSourceDiscoveryChannel = requestedChannel;
        var canAppendIdentitySource =
            explicitPackageSourceOverridePattern is not null &&
            PackageSourceOverrideMappings.IsExactPackagePattern(explicitPackageSourceOverridePattern);
        if ((string.IsNullOrWhiteSpace(effectivePackageSourceOverride) || canAppendIdentitySource) &&
            localSourceDiscoveryChannel is null &&
            string.Equals(sdkVersion, executionContext.IdentitySdkVersion, StringComparison.OrdinalIgnoreCase))
        {
            // The identity channel is used only to discover the co-installed local source. It must
            // not become the project's channel policy when the project did not request a channel.
            localSourceDiscoveryChannel = executionContext.IdentityChannel;
        }
 
        var channelLookupName = requestedChannel ?? localSourceDiscoveryChannel;
        PackageChannel? requestedPolicyChannel = null;
        PackageChannel? sourceDiscoveryChannel = null;
        var channelLookupSucceeded = false;
 
        if (!string.IsNullOrEmpty(channelLookupName))
        {
            try
            {
                var channels = (await packagingService.GetChannelsAsync(
                    cancellationToken,
                    channelLookupName).ConfigureAwait(false)).ToArray();
                channelLookupSucceeded = true;
                requestedPolicyChannel = FindChannel(channels, requestedChannel);
                sourceDiscoveryChannel = FindChannel(channels, localSourceDiscoveryChannel);
            }
            catch (OperationCanceledException)
            {
                throw;
            }
            catch (Exception ex) when (
                !string.IsNullOrWhiteSpace(packageSourceOverride) ||
                string.IsNullOrEmpty(requestedChannel))
            {
                logger.LogWarning(
                    ex,
                    "Failed to resolve integration restore package channels, relying on configured NuGet sources.");
            }
        }
 
        if (channelLookupSucceeded &&
            !string.IsNullOrEmpty(requestedChannel) &&
            requestedPolicyChannel is null)
        {
            throw new InvalidOperationException($"Package channel '{requestedChannel}' was not found.");
        }
 
        if (string.IsNullOrWhiteSpace(effectivePackageSourceOverride))
        {
            effectivePackageSourceOverride = sourceDiscoveryChannel?.GetExistingLocalAspirePackageSource();
            if (!string.IsNullOrWhiteSpace(effectivePackageSourceOverride))
            {
                logger.LogDebug(
                    "Using local package source '{Source}' for channel '{Channel}'.",
                    effectivePackageSourceOverride,
                    localSourceDiscoveryChannel);
            }
        }
 
        var effectivePackageSourceOverridePattern = string.IsNullOrWhiteSpace(effectivePackageSourceOverride)
            ? null
            : explicitPackageSourceOverridePattern ?? PackageSourceOverrideMappings.DefaultPackagePattern;
        var restoreSources = ResolveSources(
            effectivePackageSourceOverride,
            effectivePackageSourceOverridePattern,
            requestedPolicyChannel,
            sourceDiscoveryChannel,
            executionContext.NuGetServiceIndexOverride);
        restoreSources = NormalizeSources(restoreSources, new DirectoryInfo(appDirectoryPath));
 
        var configuration = nugetService.BuildConfiguration(
            new DirectoryInfo(appDirectoryPath),
            workloadId,
            restoreSources.PackageSourceMappings,
            restrictToSelectedSources: false,
            restoreSources.PackageScopedAppendSource,
            restoreSources.HasAuthoritativeAspirePolicy,
            cancellationToken);
 
        return new IntegrationRestorePlan(
            effectivePackageSourceOverride,
            effectivePackageSourceOverridePattern,
            restoreSources,
            configuration,
            nugetService,
            appDirectoryPath,
            executionContext.AspireHomeDirectory);
    }
 
    private void ThrowIfStagingUnavailable(string? requestedChannel)
    {
        if (!string.Equals(requestedChannel, PackageChannelNames.Staging, StringComparisons.ChannelName))
        {
            return;
        }
 
        var reason = packagingService.GetStagingChannelUnavailableReason();
        if (reason is not null)
        {
            throw new InvalidOperationException(reason);
        }
    }
 
    private static PackageChannel? FindChannel(
        IReadOnlyList<PackageChannel> channels,
        string? channelName)
        => string.IsNullOrEmpty(channelName)
            ? null
            : channels.FirstOrDefault(channel =>
                string.Equals(channel.Name, channelName, StringComparisons.ChannelName));
 
    private static IntegrationRestoreSources ResolveSources(
        string? packageSourceOverride,
        string? packageSourceOverridePattern,
        PackageChannel? requestedPolicyChannel,
        PackageChannel? sourceDiscoveryChannel,
        string? nugetServiceIndexOverride)
    {
        var additionalSources = new List<string>();
        var hasOverride = !string.IsNullOrWhiteSpace(packageSourceOverride);
        if (hasOverride)
        {
            additionalSources.Add(packageSourceOverride!);
        }
 
        var selectedSourcePolicyChannel = requestedPolicyChannel;
        if (selectedSourcePolicyChannel is null &&
            hasOverride &&
            packageSourceOverridePattern is not null &&
            PackageSourceOverrideMappings.IsExactPackagePattern(packageSourceOverridePattern))
        {
            selectedSourcePolicyChannel = sourceDiscoveryChannel;
        }
 
        var sourcePolicyChannel = selectedSourcePolicyChannel is not null && !UsesAmbientSourcePolicy(selectedSourcePolicyChannel)
            ? selectedSourcePolicyChannel
            : null;
        var packageScopedAppendSource =
            hasOverride &&
            packageSourceOverridePattern is not null &&
            PackageSourceOverrideMappings.IsExactPackagePattern(packageSourceOverridePattern)
                ? packageSourceOverride
                : null;
        var hasAuthoritativeAspirePolicy = sourcePolicyChannel?.Mappings?.Any(mapping =>
            (!hasOverride ||
             packageSourceOverridePattern is null ||
             !PackageSourceOverrideMappings.CompetesWithAuthoritativePattern(
                 mapping.PackageFilter,
                 packageSourceOverridePattern)) &&
            IsAspireSpecificMapping(mapping)) == true;
        if (sourcePolicyChannel?.Mappings is { } channelMappings)
        {
            foreach (var mapping in channelMappings)
            {
                if (mapping.PackageFilter == PackageMapping.AllPackages)
                {
                    // Channel catch-all mappings supported the former standalone configuration.
                    // The integration restore now preserves ambient sources and mappings instead.
                    continue;
                }
 
                if (hasOverride &&
                    packageSourceOverridePattern is not null &&
                    PackageSourceOverrideMappings.CompetesWithAuthoritativePattern(
                        mapping.PackageFilter,
                        packageSourceOverridePattern))
                {
                    continue;
                }
 
                if (!additionalSources.Contains(mapping.Source, PackageSourceIdentity.Comparer))
                {
                    additionalSources.Add(mapping.Source);
                }
            }
        }
 
        PackageMapping[]? packageSourceMappings = null;
        var configureGlobalPackagesFolder = false;
        if (hasOverride)
        {
            packageSourceMappings = PackageSourceOverrideMappings.Create(
                packageSourceOverride!,
                sourcePolicyChannel,
                nugetServiceIndexOverride,
                packageSourceOverridePattern)
                .Where(mapping =>
                    mapping.PackageFilter != PackageMapping.AllPackages ||
                    PackageSourceIdentity.Comparer.Equals(mapping.Source, packageSourceOverride))
                .ToArray();
            configureGlobalPackagesFolder = sourcePolicyChannel?.ConfigureGlobalPackagesFolder == true;
 
            foreach (var mapping in packageSourceMappings.Where(
                static mapping => mapping.PackageFilter == PackageMapping.AllPackages))
            {
                if (!additionalSources.Contains(mapping.Source, PackageSourceIdentity.Comparer))
                {
                    additionalSources.Add(mapping.Source);
                }
            }
        }
        else if (sourcePolicyChannel?.Mappings is { Length: > 0 } &&
            !string.Equals(sourcePolicyChannel.Name, PackageChannelNames.Local, StringComparisons.ChannelName))
        {
            packageSourceMappings =
            [
                .. sourcePolicyChannel.Mappings.Where(
                    static mapping => mapping.PackageFilter != PackageMapping.AllPackages)
            ];
            configureGlobalPackagesFolder = sourcePolicyChannel.ConfigureGlobalPackagesFolder;
        }
        return new IntegrationRestoreSources(
            [.. additionalSources],
            packageSourceMappings,
            packageScopedAppendSource,
            hasAuthoritativeAspirePolicy,
            configureGlobalPackagesFolder,
            configureGlobalPackagesFolder
                ? CreateGlobalPackagesFolderIdentity(additionalSources, packageSourceMappings)
                : null);
    }
 
    private static IntegrationRestoreSources NormalizeSources(
        IntegrationRestoreSources restoreSources,
        DirectoryInfo appDirectory)
    {
        var normalizedAdditionalSources = restoreSources.AdditionalSources
            .Select(source => PackageSourceOverrideMappings.ResolveForWorkingDirectory(source, appDirectory))
            .ToArray();
        var normalizedMappings = restoreSources.PackageSourceMappings?
            .Select(mapping => new PackageMapping(
                mapping.PackageFilter,
                PackageSourceOverrideMappings.ResolveForWorkingDirectory(mapping.Source, appDirectory)))
            .ToArray();
 
        return restoreSources with
        {
            AdditionalSources = normalizedAdditionalSources,
            PackageSourceMappings = normalizedMappings,
            PackageScopedAppendSource = restoreSources.PackageScopedAppendSource is null
                ? null
                : PackageSourceOverrideMappings.ResolveForWorkingDirectory(
                    restoreSources.PackageScopedAppendSource,
                    appDirectory),
            GlobalPackagesFolderIdentity = restoreSources.ConfigureGlobalPackagesFolder
                ? CreateGlobalPackagesFolderIdentity(normalizedAdditionalSources, normalizedMappings)
                : null
        };
    }
 
    internal static string CreateGlobalPackagesFolderIdentity(
        IReadOnlyList<string> additionalSources,
        IReadOnlyList<PackageMapping>? mappings)
    {
        var builder = new StringBuilder();
        IEnumerable<PackageMapping> orderedMappings = mappings is null
            ? []
            : mappings
                .OrderBy(static mapping => mapping.PackageFilter, StringComparer.OrdinalIgnoreCase)
                .ThenBy(static mapping => PackageSourceIdentity.Normalize(mapping.Source), StringComparer.Ordinal);
        foreach (var mapping in orderedMappings)
        {
            AppendIdentityPart(builder, mapping.PackageFilter.ToUpperInvariant());
            AppendIdentityPart(builder, PackageSourceIdentity.Normalize(mapping.Source));
        }
 
        foreach (var source in additionalSources
            .Distinct(PackageSourceIdentity.Comparer)
            .OrderBy(PackageSourceIdentity.Normalize, StringComparer.Ordinal))
        {
            AppendIdentityPart(builder, PackageSourceIdentity.Normalize(source));
        }
 
        return builder.ToString();
    }
 
    private static void AppendIdentityPart(StringBuilder builder, string value)
    {
        builder.Append(value.Length);
        builder.Append(':');
        builder.Append(value);
    }
 
    private static bool IsAspireSpecificMapping(PackageMapping mapping) =>
        mapping.PackageFilter != PackageMapping.AllPackages &&
        mapping.PackageFilter.StartsWith("Aspire", StringComparison.OrdinalIgnoreCase);
 
    private static bool UsesAmbientSourcePolicy(PackageChannel channel) =>
        string.Equals(channel.Name, PackageChannelNames.Stable, StringComparisons.ChannelName);
}
 
/// <summary>
/// Materializes restore-specific configuration from one resolved channel and NuGet settings snapshot.
/// </summary>
internal sealed class IntegrationRestorePlan
{
    private readonly string? _effectivePackageSourceOverridePattern;
    private readonly IntegrationRestoreSources _restoreSources;
    private readonly NuGetConfiguration _configuration;
    private readonly BundleNuGetService _nugetService;
    private readonly string _appDirectoryPath;
    private readonly DirectoryInfo _aspireHomeDirectory;
 
    public IntegrationRestorePlan(
        string? effectivePackageSourceOverride,
        string? effectivePackageSourceOverridePattern,
        IntegrationRestoreSources restoreSources,
        NuGetConfiguration configuration,
        BundleNuGetService nugetService,
        string appDirectoryPath,
        DirectoryInfo aspireHomeDirectory)
    {
        EffectivePackageSourceOverride = effectivePackageSourceOverride;
        _effectivePackageSourceOverridePattern = effectivePackageSourceOverridePattern;
        _restoreSources = restoreSources with
        {
            AdditionalSources = [.. restoreSources.AdditionalSources],
            PackageSourceMappings = restoreSources.PackageSourceMappings is null
                ? null
                : [.. restoreSources.PackageSourceMappings]
        };
        _configuration = configuration;
        _nugetService = nugetService;
        _appDirectoryPath = appDirectoryPath;
        _aspireHomeDirectory = aspireHomeDirectory;
    }
 
    public string? EffectivePackageSourceOverride { get; }
 
    internal IReadOnlyList<string> AdditionalSources => _restoreSources.AdditionalSources;
 
    public string GetRestoreVersion(string packageName, string version)
    {
        var useExactPackageVersion =
            _effectivePackageSourceOverridePattern is not null &&
            PackageSourceOverrideMappings.MatchesPackage(
                _effectivePackageSourceOverridePattern,
                packageName);
        if (!useExactPackageVersion || version.Length == 0 || version[0] is '[' or '(')
        {
            return version;
        }
 
        return $"[{version}]";
    }
 
    public async Task<IntegrationPackageRestoreConfiguration> CreatePackageRestoreConfigurationAsync(
        CancellationToken cancellationToken)
    {
        var overlay = await CreateRestoreOverlayAsync(cancellationToken).ConfigureAwait(false);
        var sources = GetNuGetSources()?.ToArray();
        string[] configPaths = overlay is null
            ? [.. _configuration.Settings.ConfigPaths]
            : [overlay.ConfigFile.FullName, .. _configuration.Settings.ConfigPaths];
        var sensitiveSources = _configuration.Settings.SensitiveSourceValues
            .Concat(
                _restoreSources.PackageSourceMappings?
                    .Select(static mapping => mapping.Source)
                    .Where(PackageSourceOverrideMappings.HasCredentialMaterial) ?? [])
            .Distinct(StringComparer.Ordinal)
            .ToArray();
 
        return new IntegrationPackageRestoreConfiguration(
            overlay,
            sources,
            configPaths,
            _configuration.Settings.CacheIdentity,
            sensitiveSources,
            GetGlobalPackagesFolder(overlay));
    }
 
    public IntegrationProjectRestoreConfiguration ApplyProjectRestoreConfiguration(
        DirectoryInfo policyDirectory,
        CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(policyDirectory);
 
        var selectedSensitiveSources = _restoreSources.AdditionalSources
            .Concat(_restoreSources.PackageSourceMappings?.Select(static mapping => mapping.Source) ?? [])
            .Where(PackageSourceOverrideMappings.HasCredentialMaterial)
            .Distinct(StringComparer.Ordinal)
            .ToArray();
        var globalPackagesFolder = GetGlobalPackagesFolder(restoreOverlay: null);
        FileInfo? restoreOverlayFile = new(Path.Combine(policyDirectory.FullName, "NuGet.Config"));
        if (restoreOverlayFile.Exists)
        {
            restoreOverlayFile.Delete();
        }
 
        if (_restoreSources.PackageSourceMappings is null)
        {
            restoreOverlayFile = null;
        }
        else
        {
            _nugetService.WriteNuGetConfig(
                _configuration,
                restoreOverlayFile.FullName,
                globalPackagesFolder,
                cancellationToken);
        }
 
        var rootAdditionalSources = _restoreSources.PackageSourceMappings is null
            ? GetNuGetSources()?.ToArray() ?? []
            : [];
        var sensitiveSources = _configuration.Settings.SensitiveSourceValues
            .Concat(selectedSensitiveSources)
            .Distinct(StringComparer.Ordinal)
            .ToArray();
        var packageSourceHints = GetIntegrationPackageSourceHints();
 
        return new IntegrationProjectRestoreConfiguration(
            restoreOverlayFile?.DirectoryName ?? _appDirectoryPath,
            rootAdditionalSources,
            packageSourceHints,
            GetIntegrationPackageSourceAlias(packageSourceHints),
            sensitiveSources,
            globalPackagesFolder);
    }
 
    private IEnumerable<string>? GetNuGetSources()
        => _restoreSources.PackageSourceMappings is null && _restoreSources.AdditionalSources.Count > 0
            ? _restoreSources.AdditionalSources
            : null;
 
    private string[] GetIntegrationPackageSourceHints()
    {
        IEnumerable<string> candidateSources;
        if (_restoreSources.PackageSourceMappings is { Length: > 0 } mappings)
        {
            var integrationSpecificSources = mappings
                .Where(static mapping => mapping.PackageFilter != PackageMapping.AllPackages)
                .Select(static mapping => mapping.Source)
                .ToArray();
            candidateSources = integrationSpecificSources.Length > 0
                ? integrationSpecificSources
                : mappings
                    .Where(static mapping => mapping.PackageFilter == PackageMapping.AllPackages)
                    .Select(static mapping => mapping.Source);
        }
        else
        {
            candidateSources = _restoreSources.AdditionalSources;
        }
 
        // Source hints flow through MSBuild and can be persisted in restore artifacts. Credential
        // bearing URLs must use NuGet-owned authentication instead of being copied into MSBuild.
        return candidateSources
            .Where(static source => !PackageSourceOverrideMappings.HasCredentialMaterial(source))
            .Distinct(PackageSourceIdentity.Comparer)
            .ToArray();
    }
 
    private string? GetIntegrationPackageSourceAlias(IReadOnlyList<string> packageSourceHints)
    {
        if (packageSourceHints.Count == 0)
        {
            return null;
        }
 
        var primarySource = packageSourceHints[0];
        return _configuration.ConfigSources
            .FirstOrDefault(source => PackageSourceIdentity.Comparer.Equals(source.Source, primarySource))
            ?.Key ?? primarySource;
    }
 
    private string? GetGlobalPackagesFolder(TemporaryNuGetConfigFile? restoreOverlay)
    {
        if (!_restoreSources.ConfigureGlobalPackagesFolder)
        {
            return null;
        }
 
        var identity = _restoreSources.GlobalPackagesFolderIdentity
            ?? throw new InvalidOperationException("A global packages folder identity is required for an isolated restore cache.");
        if (restoreOverlay is not null)
        {
            // Reused ambient aliases are not re-emitted with their source URLs, so the overlay
            // identity must be paired with the selected source policy to distinguish those feeds.
            identity = BundleNuGetService.CombineCacheIdentities(
                identity,
                restoreOverlay.CacheIdentity);
        }
 
        return CliPathHelper.GetStagingNuGetPackagesIdentityDirectory(
            _aspireHomeDirectory,
            identity);
    }
 
    private async Task<TemporaryNuGetConfigFile?> CreateRestoreOverlayAsync(
        CancellationToken cancellationToken)
    {
        if (_restoreSources.PackageSourceMappings is null)
        {
            return null;
        }
 
        var config = await _nugetService.WriteTemporaryOverlayAsync(
            _configuration,
            parentDirectory: null,
            globalPackagesFolder: null,
            cancellationToken).ConfigureAwait(false)
            ?? throw new InvalidOperationException("Integration restore mappings did not produce a NuGet policy overlay.");
        return await ConfigureGlobalPackagesFolderAsync(
            config,
            cancellationToken).ConfigureAwait(false);
    }
 
    private async Task<TemporaryNuGetConfigFile> ConfigureGlobalPackagesFolderAsync(
        TemporaryNuGetConfigFile config,
        CancellationToken cancellationToken)
    {
        var globalPackagesFolder = GetGlobalPackagesFolder(config);
        if (globalPackagesFolder is null)
        {
            return config;
        }
 
        try
        {
            await config.RegenerateAsync(
                path => _nugetService.WriteNuGetConfig(
                    _configuration,
                    path,
                    globalPackagesFolder,
                    cancellationToken)).ConfigureAwait(false);
            return config;
        }
        catch
        {
            config.Dispose();
            throw;
        }
    }
 
}
 
internal sealed record IntegrationRestoreSources(
    IReadOnlyList<string> AdditionalSources,
    PackageMapping[]? PackageSourceMappings,
    string? PackageScopedAppendSource,
    bool HasAuthoritativeAspirePolicy,
    bool ConfigureGlobalPackagesFolder,
    string? GlobalPackagesFolderIdentity);
 
internal sealed class IntegrationPackageRestoreConfiguration(
    TemporaryNuGetConfigFile? policyOverlay,
    string[]? sources,
    string[] configPaths,
    string settingsCacheIdentity,
    string[] sensitiveSources,
    string? globalPackagesFolder) : IDisposable
{
    public string[]? Sources { get; } = sources;
 
    public string[] ConfigPaths { get; } = configPaths;
 
    public string SettingsCacheIdentity { get; } = settingsCacheIdentity;
 
    public string? OverlayCacheIdentity => policyOverlay?.CacheIdentity;
 
    public string[] SensitiveSources { get; } = sensitiveSources;
 
    public string? GlobalPackagesFolder { get; } = globalPackagesFolder;
 
    internal FileInfo? PolicyOverlayFile => policyOverlay?.ConfigFile;
 
    public void Dispose() => policyOverlay?.Dispose();
}
 
internal sealed record IntegrationProjectRestoreConfiguration(
    string RestoreRootConfigDirectory,
    string[] RootAdditionalSources,
    string[] PackageSourceHints,
    string? PackageSourceAlias,
    string[] SensitiveSources,
    string? GlobalPackagesFolder);