// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.IO.Compression;
using System.Xml.Linq;
using Aspire.Cli.Configuration;
using Aspire.Cli.NuGet;
using Aspire.Cli.Resources;
using Aspire.Cli.Utils;
using Microsoft.Extensions.Logging;
using Semver;
using NuGetPackage = Aspire.Shared.NuGetPackageCli;
namespace Aspire.Cli.Packaging;
internal class PackageChannel(string name, PackageChannelQuality quality, PackageMapping[]? mappings, INuGetPackageCache nuGetPackageCache, IFeatures features, ILogger logger, bool configureGlobalPackagesFolder = false, string? cliDownloadBaseUrl = null, string? pinnedVersion = null, string? currentCliVersion = null, Action? validateTemplatePackageMetadataPrefetching = null)
{
// Threaded so the local-folder integration listing can honor the same
// ShowDeprecatedPackages flag that NuGetPackageCache honors on the feed-based path.
// Without this, flipping the flag silently has no effect on local hive / PR hive listings
// (https://github.com/microsoft/aspire/issues — divergence between two paths through the same intent).
private readonly IFeatures _features = features;
// The running CLI's identity SDK version (CliExecutionContext.IdentitySdkVersion), threaded in
// by PackagingService so the template-package filter below honors ASPIRE_CLI_VERSION / sidecar
// overrides. Null when a channel is constructed directly (e.g. in tests) without identity, in
// which case the filter falls back to the assembly version.
private readonly string? _currentCliVersion = currentCliVersion;
private const string GuestAppHostSdkPackageId = "Aspire.Hosting";
private const string TemplatePackageId = "Aspire.ProjectTemplates";
// The NuGet 'polyglot' tag added by default to Aspire.Hosting integrations that run the export
// analyzer; authors opt out with <IsAspirePolyglotCompatible>false</IsAspirePolyglotCompatible> (see
// src/Directory.Build.targets and Aspire.Hosting/buildTransitive/Aspire.Hosting.targets). Its presence
// identifies an integration whose API surface is projected to non-C# AppHosts via [AspireExport] (ATS)
// coverage, so `aspire add` can hide integrations that a polyglot AppHost cannot consume.
private const string PolyglotTag = "polyglot";
// NuGet search query scoping that restricts results to packages carrying the polyglot tag, e.g.
// dotnet package search "tags:polyglot" --source https://api.nuget.org/v3/index.json
//
// Support for this scoping is inconsistent across feed implementations, and no remote feed answers it
// usefully today, which is why the polyglot filter is gated behind the off-by-default
// KnownFeatures.PolyglotIntegrationFilterEnabled flag:
// - nuget.org honours the scoping, but `dotnet package search` broadens the query before it gets there
// (68 results via the CLI versus 29 from the raw search service), so the response is a relevance
// ranking rather than an exact tag set. Either way the result contains no first-party integration:
// only Aspire.TypeSystem and Aspire.Hosting.Integration.Analyzers carry the tag on nuget.org, and
// PackageIdFilters.IsIntegrationPackageId excludes both.
// - Azure DevOps Artifacts feeds (the daily, staging, and PR channels) silently ignore it and return
// zero results, even for packages whose search payload contains the tag. There is no error to
// detect, so the caller cannot distinguish "no compatible packages" from "this feed cannot answer
// the question". See https://github.com/microsoft/aspire/issues/19161.
// - Local folder feeds do not support tag search at all, which is why the local-source path reads the
// nuspec <tags> directly instead. That path is the only one that resolves a correct allow-list.
private const string PolyglotTagSearchTerm = "tags:polyglot";
public string Name { get; } = name;
public PackageChannelQuality Quality { get; } = quality;
public PackageMapping[]? Mappings { get; } = mappings;
public PackageChannelType Type { get; } = mappings is null ? PackageChannelType.Implicit : PackageChannelType.Explicit;
public bool ConfigureGlobalPackagesFolder { get; } = configureGlobalPackagesFolder;
public string? CliDownloadBaseUrl { get; } = cliDownloadBaseUrl;
public string? PinnedVersion { get; } = pinnedVersion;
public string SourceDetails { get; } = ComputeSourceDetails(mappings);
public bool ShouldPersistChannelName() =>
Type is PackageChannelType.Explicit && !string.Equals(Name, PackageChannelNames.Stable, StringComparisons.ChannelName);
/// <summary>
/// Whether scaffolding (<c>aspire new</c>/<c>aspire init</c>) should drop a per-project
/// <c>NuGet.config</c> that pins Aspire packages to this channel's feed(s).
/// </summary>
/// <remarks>
/// Only channels that route Aspire packages to a <em>custom</em> feed need a per-project
/// <c>NuGet.config</c>. The <c>stable</c> channel maps everything to nuget.org — the ambient
/// default source — so emitting a <c><clear/></c>-based config would be redundant and,
/// worse, would wipe any additional feeds the user already relies on. Daily
/// (<c>dnceng/dotnet9</c>), staging (<c>darc-pub-microsoft-aspire-<sha></c>), and
/// <c>pr-<N></c>/local-hive channels all point at custom feeds, so their mappings must be
/// persisted for restore to succeed. This deliberately mirrors <see cref="ShouldPersistChannelName"/>:
/// the set of channels whose name we pin is exactly the set we drop a config for (those that
/// additionally carry feed mappings).
/// </remarks>
public bool ShouldCreateNuGetConfig() =>
ShouldPersistChannelName() && Mappings is { Length: > 0 };
/// <summary>
/// Whether this channel resolves Aspire.* packages from a local directory of <c>.nupkg</c>
/// files (a <c>~/.aspire/hives/<name></c> hive, a PR-install hive, or an
/// <c>ASPIRE_CLI_PACKAGES</c> / sidecar <c>packages</c> override) rather than a remote feed.
/// </summary>
/// <remarks>
/// Unlike <see cref="VersionHelper.IsLocalBuildChannel(string)"/> — which keys off the channel
/// <em>name</em> (<c>local</c>/<c>pr-<N></c>/<c>run-<N></c>) — this inspects the actual
/// mappings via <see cref="PackageMapping.IsAspireDirectoryMapping"/>. That matters when a locally
/// built CLI emulates a released build: the synthesized channel is named after the emulated
/// identity (<c>stable</c>/<c>daily</c>/<c>staging</c>) yet points Aspire.* at a local directory.
/// The name check would misclassify it as remote and silently fall back to nuget.org; this
/// property recognizes it as locally backed so <c>aspire new</c>/<c>aspire add</c> resolve those
/// local packages. See <c>docs/specs/cli-identity-sidecar.md</c>.
/// </remarks>
public bool IsBackedByLocalPackageDirectory =>
Type is PackageChannelType.Explicit &&
Mappings?.Any(static mapping => mapping.IsAspireDirectoryMapping) == true;
private static string ComputeSourceDetails(PackageMapping[]? mappings)
{
if (mappings is null)
{
return PackagingStrings.BasedOnNuGetConfig;
}
var aspireMapping = mappings.FirstOrDefault(m => m.PackageFilter.StartsWith("Aspire", StringComparison.OrdinalIgnoreCase));
var allPackagesMapping = mappings.FirstOrDefault(m => m.PackageFilter == PackageMapping.AllPackages);
if (aspireMapping is not null)
{
return aspireMapping.Source;
}
else
{
return allPackagesMapping?.Source ?? PackagingStrings.BasedOnNuGetConfig;
}
}
public Task<IEnumerable<NuGetPackage>> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, CancellationToken cancellationToken)
{
return GetTemplatePackagesAsync(workingDirectory, Mappings, filterLocalPackagesToPinnedVersion: true, cancellationToken);
}
/// <summary>
/// Gets template packages using the specified mappings without changing this channel's identity.
/// </summary>
public Task<IEnumerable<NuGetPackage>> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, PackageMapping[]? mappings, CancellationToken cancellationToken)
{
return GetTemplatePackagesAsync(workingDirectory, mappings, filterLocalPackagesToPinnedVersion: false, cancellationToken);
}
/// <summary>
/// Gets template packages using the specified mappings, optionally retaining every version
/// found in a local package directory instead of applying this channel's pinned version.
/// </summary>
public async Task<IEnumerable<NuGetPackage>> GetTemplatePackagesAsync(
DirectoryInfo workingDirectory,
PackageMapping[]? mappings,
bool filterLocalPackagesToPinnedVersion,
CancellationToken cancellationToken)
{
validateTemplatePackageMetadataPrefetching?.Invoke();
// Local directory discovery must follow the caller-supplied mappings (e.g. `--source <dir>`),
// not this channel's own mappings, otherwise the override is silently ignored.
if (GetLocalAspirePackageSource(mappings) is { } localPackageSource)
{
var localPackages = GetTemplatePackagesFromLocalPackageSource(localPackageSource.Source, localPackageSource.PackageSource, cancellationToken);
return PinnedVersion is null || !filterLocalPackagesToPinnedVersion
? localPackages
: localPackages.Where(package => string.Equals(package.Version, PinnedVersion, StringComparison.OrdinalIgnoreCase));
}
if (PinnedVersion is not null)
{
return [new NuGetPackage { Id = TemplatePackageId, Version = PinnedVersion, Source = ComputeSourceDetails(mappings) }];
}
var tasks = new List<Task<IEnumerable<NuGetPackage>>>();
using var tempNuGetConfig = mappings is not null ? await TemporaryNuGetConfig.CreateAsync(mappings) : null;
if (Quality is PackageChannelQuality.Stable || Quality is PackageChannelQuality.Both)
{
tasks.Add(nuGetPackageCache.GetTemplatePackagesAsync(workingDirectory, false, tempNuGetConfig?.ConfigFile, cancellationToken));
}
if (Quality is PackageChannelQuality.Prerelease || Quality is PackageChannelQuality.Both)
{
tasks.Add(nuGetPackageCache.GetTemplatePackagesAsync(workingDirectory, true, tempNuGetConfig?.ConfigFile, cancellationToken));
}
var packageResults = await Task.WhenAll(tasks);
var packages = packageResults
.SelectMany(p => p)
.DistinctBy(p => $"{p.Id}-{p.Version}");
// When doing a `dotnet package search` the results may include stable packages even when searching for
// prerelease packages. Keep the current CLI/SDK version so shipped CLIs can resolve their
// matching template package from daily/staging feeds, then filter out the remaining noise.
// Falls back to the assembly version only when no identity was threaded in (direct construction);
// production channels created by PackagingService always supply CliExecutionContext.IdentitySdkVersion.
var currentCliVersion = _currentCliVersion ?? VersionHelper.GetDefaultSdkVersion();
var filteredPackages = packages.Where(p => new { SemVer = SemVersion.Parse(p.Version), Quality = Quality } switch
{
{ Quality: PackageChannelQuality.Both } => true,
{ Quality: PackageChannelQuality.Stable, SemVer: { IsPrerelease: false } } => true,
{ Quality: PackageChannelQuality.Prerelease, SemVer: { IsPrerelease: true } } => true,
{ Quality: PackageChannelQuality.Prerelease, SemVer: { IsPrerelease: false } } when string.Equals(p.Version, currentCliVersion, StringComparison.OrdinalIgnoreCase) => true,
_ => false
});
return filteredPackages;
}
public async Task<IEnumerable<NuGetPackage>> GetIntegrationPackagesAsync(DirectoryInfo workingDirectory, CancellationToken cancellationToken)
{
if (GetLocalAspirePackageSource() is { } localPackageSource)
{
return GetIntegrationPackagesFromLocalPackageSource(
localPackageSource.Source,
localPackageSource.PackageSource,
GetLocalPackageVersion(workingDirectory),
cancellationToken);
}
var tasks = new List<Task<IEnumerable<NuGetPackage>>>();
using var tempNuGetConfig = Type is PackageChannelType.Explicit ? await TemporaryNuGetConfig.CreateAsync(Mappings!) : null;
if (Quality is PackageChannelQuality.Stable || Quality is PackageChannelQuality.Both)
{
tasks.Add(nuGetPackageCache.GetIntegrationPackagesAsync(workingDirectory, false, tempNuGetConfig?.ConfigFile, cancellationToken));
}
if (Quality is PackageChannelQuality.Prerelease || Quality is PackageChannelQuality.Both)
{
tasks.Add(nuGetPackageCache.GetIntegrationPackagesAsync(workingDirectory, true, tempNuGetConfig?.ConfigFile, cancellationToken));
}
var packageResults = await Task.WhenAll(tasks);
var packages = packageResults
.SelectMany(p => p)
.DistinctBy(p => $"{p.Id}-{p.Version}");
// When doing a `dotnet package search` the results may include stable packages even when searching for
// prerelease packages. This filters out this noise.
var filteredPackages = packages.Where(p => new { SemVer = SemVersion.Parse(p.Version), Quality = Quality } switch
{
{ Quality: PackageChannelQuality.Both } => true,
{ Quality: PackageChannelQuality.Stable, SemVer: { IsPrerelease: false } } => true,
{ Quality: PackageChannelQuality.Prerelease, SemVer: { IsPrerelease: true } } => true,
_ => false
});
// When pinned to a specific version, override the version on each discovered package
// so the correct version gets installed regardless of what the feed reports as latest.
if (PinnedVersion is not null)
{
return filteredPackages.Select(p => new NuGetPackage { Id = p.Id, Version = PinnedVersion, Source = p.Source });
}
return filteredPackages;
}
private (string Source, DirectoryInfo PackageSource)? GetLocalAspirePackageSource() => GetLocalAspirePackageSource(Mappings);
private static (string Source, DirectoryInfo PackageSource)? GetLocalAspirePackageSource(PackageMapping[]? mappings)
{
if (mappings is null)
{
return null;
}
foreach (var mapping in mappings)
{
if (!mapping.IsAspireDirectoryMapping)
{
continue;
}
return (mapping.Source, new DirectoryInfo(PackageSourceOverrideMappings.GetNormalizedLocalDirectory(mapping.Source)!));
}
return null;
}
private IEnumerable<NuGetPackage> GetTemplatePackagesFromLocalPackageSource(string source, DirectoryInfo packageSource, CancellationToken cancellationToken)
{
var currentCliVersion = _currentCliVersion ?? VersionHelper.GetDefaultSdkVersion();
return EnumerateLocalPackageFiles(packageSource, cancellationToken)
.Select(file => GetPackageFileMetadata(file.FullName))
.OfType<PackageFileMetadata>()
.Where(metadata => string.Equals(metadata.PackageId, TemplatePackageId, StringComparisons.NuGetPackageId))
.Where(metadata => new { metadata.Version, Quality } switch
{
{ Quality: PackageChannelQuality.Both } => true,
{ Quality: PackageChannelQuality.Stable, Version: { IsPrerelease: false } } => true,
{ Quality: PackageChannelQuality.Prerelease, Version: { IsPrerelease: true } } => true,
{ Quality: PackageChannelQuality.Prerelease, Version: { IsPrerelease: false } } when string.Equals(metadata.Version.ToString(), currentCliVersion, StringComparison.OrdinalIgnoreCase) => true,
_ => false
})
.DistinctBy(metadata => metadata.Version)
.Select(metadata => new NuGetPackage { Id = metadata.PackageId, Version = metadata.Version.ToString(), Source = source })
.ToArray();
}
private string? GetLocalPackageVersion(DirectoryInfo workingDirectory)
{
// `aspire new` records an exact local selection as:
// { "channel": "local", "sdk": { "version": "13.6.0-dev" } }
// A local hive can also contain stale higher-version packages, so use the project-scoped
// version when the persisted channel matches this channel. Projects created before that
// version was persisted continue to use the channel's discovered pin.
var configPath = ConfigurationHelper.FindNearestConfigFilePath(workingDirectory);
if (configPath is null ||
!ConfigurationHelper.TryLoadSettingsFile(configPath, out var configuration) ||
!string.Equals(configuration["channel"], Name, StringComparisons.ChannelName))
{
return PinnedVersion;
}
var configuredVersion = configuration["sdk:version"];
if (string.IsNullOrWhiteSpace(configuredVersion))
{
configuredVersion = configuration["sdkVersion"];
}
return string.IsNullOrWhiteSpace(configuredVersion) ? PinnedVersion : configuredVersion;
}
private IEnumerable<NuGetPackage> GetIntegrationPackagesFromLocalPackageSource(string source, DirectoryInfo packageSource, string? packageVersion, CancellationToken cancellationToken)
{
// Mirror NuGetPackageCache.GetIntegrationPackagesAsync: a user who flipped
// ShowDeprecatedPackages to see deprecated packages on stable/staging/daily
// must also see them on local-hive / PR-hive listings. Previously the deprecation
// check was hardcoded into IsIntegrationPackageId and silently dropped them here.
var showDeprecatedPackages = _features.IsFeatureEnabled(KnownFeatures.ShowDeprecatedPackages, defaultValue: false);
var packageMetadata = EnumerateLocalPackageFiles(packageSource, cancellationToken)
.Select(file => GetPackageFileMetadata(file.FullName))
.OfType<PackageFileMetadata>()
.Where(metadata => PackageIdFilters.IsIntegrationPackageId(metadata.PackageId))
.Where(metadata => showDeprecatedPackages || !DeprecatedPackages.IsDeprecated(metadata.PackageId))
.Where(IsAllowedByQuality);
if (packageVersion is not null)
{
packageMetadata = packageMetadata
.Where(metadata => string.Equals(metadata.Version.ToString(), packageVersion, StringComparison.OrdinalIgnoreCase));
}
return packageMetadata
.GroupBy(metadata => metadata.PackageId, StringComparers.NuGetPackageId)
.Select(group => group.OrderByDescending(metadata => metadata.Version, SemVersion.PrecedenceComparer).First())
.OrderBy(metadata => metadata.PackageId, StringComparers.NuGetPackageId)
.Select(metadata => new NuGetPackage { Id = metadata.PackageId, Version = packageVersion ?? metadata.Version.ToString(), Source = source })
.ToArray();
bool IsAllowedByQuality(PackageFileMetadata metadata) => new { metadata.Version, Quality } switch
{
{ Quality: PackageChannelQuality.Both } => true,
{ Quality: PackageChannelQuality.Stable, Version: { IsPrerelease: false } } => true,
{ Quality: PackageChannelQuality.Prerelease, Version: { IsPrerelease: true } } => true,
_ => false
};
}
/// <summary>
/// Returns the set of integration package IDs in this channel that are marked polyglot-compatible,
/// i.e. carry the <c>polyglot</c> NuGet tag. That tag is added by default to Aspire.Hosting
/// integrations that run the export analyzer; authors opt out with
/// <c><IsAspirePolyglotCompatible>false</IsAspirePolyglotCompatible></c>.
/// </summary>
/// <remarks>
/// Used by <c>aspire add</c> to hide integrations that have no ATS export coverage from non-C#
/// AppHosts. The tag is read differently per channel kind because <c>dotnet package search --format json</c>
/// does not return tags and <c>tags:</c> query scoping is not honored by local folder feeds:
/// <list type="bullet">
/// <item>Local package source / hive: the <c><tags></c> element is read from the nuspec inside each <c>.nupkg</c>.</item>
/// <item>Remote feeds: a secondary <c>tags:polyglot</c> search is issued.</item>
/// </list>
/// </remarks>
public async Task<IReadOnlySet<string>> GetPolyglotCompatiblePackageIdsAsync(DirectoryInfo workingDirectory, CancellationToken cancellationToken)
{
if (GetLocalAspirePackageSource() is { } localPackageSource)
{
return GetPolyglotCompatiblePackageIdsFromLocalPackageSource(
localPackageSource.PackageSource,
GetLocalPackageVersion(workingDirectory),
cancellationToken);
}
using var tempNuGetConfig = Type is PackageChannelType.Explicit ? await TemporaryNuGetConfig.CreateAsync(Mappings!) : null;
var tasks = new List<Task<IEnumerable<NuGetPackage>>>();
if (Quality is PackageChannelQuality.Stable or PackageChannelQuality.Both)
{
tasks.Add(nuGetPackageCache.GetPackagesAsync(workingDirectory, PolyglotTagSearchTerm, filter: null, prerelease: false, tempNuGetConfig?.ConfigFile, useCache: true, cancellationToken));
}
if (Quality is PackageChannelQuality.Prerelease or PackageChannelQuality.Both)
{
tasks.Add(nuGetPackageCache.GetPackagesAsync(workingDirectory, PolyglotTagSearchTerm, filter: null, prerelease: true, tempNuGetConfig?.ConfigFile, useCache: true, cancellationToken));
}
var results = await Task.WhenAll(tasks);
return results
.SelectMany(r => r)
.Select(p => p.Id)
.ToHashSet(StringComparers.NuGetPackageId);
}
private IReadOnlySet<string> GetPolyglotCompatiblePackageIdsFromLocalPackageSource(
DirectoryInfo packageSource,
string? packageVersion,
CancellationToken cancellationToken)
{
var ids = new HashSet<string>(StringComparers.NuGetPackageId);
foreach (var file in EnumerateLocalPackageFiles(packageSource, cancellationToken))
{
if (GetPackageFileMetadata(file.FullName) is not { } metadata ||
!PackageIdFilters.IsIntegrationPackageId(metadata.PackageId) ||
packageVersion is not null &&
!string.Equals(metadata.Version.ToString(), packageVersion, StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (PackageHasPolyglotTag(file.FullName, logger))
{
ids.Add(metadata.PackageId);
}
}
return ids;
}
private static IEnumerable<FileInfo> EnumerateLocalPackageFiles(DirectoryInfo packageSource, CancellationToken cancellationToken)
{
foreach (var file in packageSource.EnumerateFiles("*.nupkg", SearchOption.AllDirectories))
{
cancellationToken.ThrowIfCancellationRequested();
yield return file;
}
}
private static bool PackageHasPolyglotTag(string packageFile, ILogger logger)
{
try
{
using var archive = ZipFile.OpenRead(packageFile);
var nuspecEntry = archive.Entries.FirstOrDefault(entry => entry.FullName.EndsWith(".nuspec", StringComparison.OrdinalIgnoreCase));
if (nuspecEntry is null)
{
return false;
}
using var stream = nuspecEntry.Open();
var document = XDocument.Load(stream);
var tags = document
.Descendants()
.FirstOrDefault(element => element.Name.LocalName == "tags")?.Value;
return HasPolyglotTag(tags);
}
catch (IOException ex)
{
logger.LogDebug(ex, "Failed to read package file '{PackageFile}' while resolving polyglot tag.", packageFile);
return false;
}
catch (InvalidDataException ex)
{
logger.LogDebug(ex, "Package file '{PackageFile}' contains invalid data.", packageFile);
return false;
}
catch (System.Xml.XmlException ex)
{
logger.LogDebug(ex, "Failed to parse nuspec in package file '{PackageFile}'.", packageFile);
return false;
}
}
private static bool HasPolyglotTag(string? tags)
{
// nuspec <tags> are a single space-delimited string, e.g.
// <tags>aspire integration hosting redis cache caching polyglot</tags>
// Match whole tokens so a substring like "polyglotted" wouldn't be treated as the marker.
return tags is not null && tags
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Any(tag => string.Equals(tag, PolyglotTag, StringComparison.OrdinalIgnoreCase));
}
public async Task<IEnumerable<NuGetPackage>> GetPackagesAsync(string packageId, DirectoryInfo workingDirectory, CancellationToken cancellationToken)
{
if (PinnedVersion is not null)
{
return [new NuGetPackage { Id = packageId, Version = PinnedVersion, Source = SourceDetails }];
}
var tasks = new List<Task<IEnumerable<NuGetPackage>>>();
using var tempNuGetConfig = Type is PackageChannelType.Explicit ? await TemporaryNuGetConfig.CreateAsync(Mappings!) : null;
if (Quality is PackageChannelQuality.Stable || Quality is PackageChannelQuality.Both)
{
tasks.Add(nuGetPackageCache.GetPackagesAsync(
workingDirectory: workingDirectory,
packageId: packageId,
filter: id => id.Equals(packageId, StringComparisons.NuGetPackageId),
prerelease: false,
nugetConfigFile: tempNuGetConfig?.ConfigFile,
useCache: true, // Enable caching for package channel resolution
cancellationToken: cancellationToken));
}
if (Quality is PackageChannelQuality.Prerelease || Quality is PackageChannelQuality.Both)
{
tasks.Add(nuGetPackageCache.GetPackagesAsync(
workingDirectory: workingDirectory,
packageId: packageId,
filter: id => id.Equals(packageId, StringComparisons.NuGetPackageId),
prerelease: true,
nugetConfigFile: tempNuGetConfig?.ConfigFile,
useCache: true, // Enable caching for package channel resolution
cancellationToken: cancellationToken));
}
var packageResults = await Task.WhenAll(tasks);
var packages = packageResults
.SelectMany(p => p)
.DistinctBy(p => $"{p.Id}-{p.Version}");
// In the event that we have no stable packages we fallback to
// returning prerelease packages. Example a package that is currently
// in preview (Aspire.Hosting.Docker circa 9.4).
if (Quality is PackageChannelQuality.Stable && !packages.Any())
{
packages = await nuGetPackageCache.GetPackagesAsync(
workingDirectory: workingDirectory,
packageId: packageId,
filter: id => id.Equals(packageId, StringComparisons.NuGetPackageId),
prerelease: true,
nugetConfigFile: tempNuGetConfig?.ConfigFile,
useCache: true, // Enable caching for package channel resolution
cancellationToken: cancellationToken);
return packages;
}
// When doing a `dotnet package search` the results may include stable packages even when searching for
// prerelease packages. This filters out this noise.
var filteredPackages = packages.Where(p => new { SemVer = SemVersion.Parse(p.Version), Quality = Quality } switch
{
{ Quality: PackageChannelQuality.Both } => true,
{ Quality: PackageChannelQuality.Stable, SemVer: { IsPrerelease: false } } => true,
{ Quality: PackageChannelQuality.Prerelease, SemVer: { IsPrerelease: true } } => true,
_ => false
});
return filteredPackages;
}
public async Task<NuGetPackage?> GetLatestGuestAppHostSdkPackageAsync(DirectoryInfo workingDirectory, CancellationToken cancellationToken)
{
// Guest AppHost sdk.version resolves to the base Aspire.Hosting package because
// the managed server restores that package to evaluate and generate the AppHost.
var packages = await GetPackagesAsync(GuestAppHostSdkPackageId, workingDirectory, cancellationToken);
NuGetPackage? latestPackage = null;
SemVersion? latestVersion = null;
foreach (var package in packages)
{
if (!SemVersion.TryParse(package.Version, SemVersionStyles.Strict, out var version))
{
continue;
}
if (latestVersion is null || SemVersion.PrecedenceComparer.Compare(version, latestVersion) > 0)
{
latestPackage = package;
latestVersion = version;
}
}
return latestPackage;
}
public async Task<IEnumerable<NuGetPackage>> GetPackageVersionsAsync(string packageId, DirectoryInfo workingDirectory, CancellationToken cancellationToken)
{
var tasks = new List<Task<IEnumerable<NuGetPackage>>>();
using var tempNuGetConfig = Type is PackageChannelType.Explicit ? await TemporaryNuGetConfig.CreateAsync(Mappings!) : null;
if (Quality is PackageChannelQuality.Stable || Quality is PackageChannelQuality.Both)
{
tasks.Add(nuGetPackageCache.GetPackageVersionsAsync(
workingDirectory: workingDirectory,
exactPackageId: packageId,
prerelease: false,
nugetConfigFile: tempNuGetConfig?.ConfigFile,
useCache: true, // Enable caching for package channel resolution
cancellationToken: cancellationToken));
}
if (Quality is PackageChannelQuality.Prerelease || Quality is PackageChannelQuality.Both)
{
tasks.Add(nuGetPackageCache.GetPackageVersionsAsync(
workingDirectory: workingDirectory,
exactPackageId: packageId,
prerelease: true,
nugetConfigFile: tempNuGetConfig?.ConfigFile,
useCache: true, // Enable caching for package channel resolution
cancellationToken: cancellationToken));
}
var packageResults = await Task.WhenAll(tasks);
var packages = packageResults
.SelectMany(p => p)
.DistinctBy(p => $"{p.Id}-{p.Version}");
// In the event that we have no stable packages we fallback to
// returning prerelease packages. Example a package that is currently
// in preview (Aspire.Hosting.Docker circa 9.4).
if (Quality is PackageChannelQuality.Stable && !packages.Any())
{
packages = await nuGetPackageCache.GetPackageVersionsAsync(
workingDirectory: workingDirectory,
exactPackageId: packageId,
prerelease: true,
nugetConfigFile: tempNuGetConfig?.ConfigFile,
useCache: true, // Enable caching for package channel resolution
cancellationToken: cancellationToken);
return packages;
}
// When doing a `dotnet package search` the results may include stable packages even when searching for
// prerelease packages. This filters out this noise.
var filteredPackages = packages.Where(p => new { SemVer = SemVersion.Parse(p.Version), Quality = Quality } switch
{
{ Quality: PackageChannelQuality.Both } => true,
{ Quality: PackageChannelQuality.Stable, SemVer: { IsPrerelease: false } } => true,
{ Quality: PackageChannelQuality.Prerelease, SemVer: { IsPrerelease: true } } => true,
_ => false
});
return filteredPackages;
}
public PackageChannel CreateScopedChannelForPackage(string packageId)
{
return CreateScopedChannelForPackages([packageId]);
}
public PackageChannel CreateScopedChannelForPackages(IEnumerable<string> packageIds)
{
ArgumentNullException.ThrowIfNull(packageIds);
var requestedPackageIds = packageIds
.Where(packageId => !string.IsNullOrWhiteSpace(packageId))
.Distinct(StringComparers.NuGetPackageId)
.ToArray();
if (requestedPackageIds.Length == 0)
{
throw new ArgumentException("At least one package ID must be provided.", nameof(packageIds));
}
var mappings = Mappings;
if (!VersionHelper.IsLocalBuildChannel(Name) || Type is not PackageChannelType.Explicit || mappings is not { Length: > 0 })
{
return this;
}
var scopedMappings = mappings
.SelectMany(mapping => CreateScopedMappings(mapping, requestedPackageIds, logger))
.ToArray();
return new PackageChannel(Name, Quality, scopedMappings, nuGetPackageCache, _features, logger, ConfigureGlobalPackagesFolder, CliDownloadBaseUrl, PinnedVersion, _currentCliVersion, validateTemplatePackageMetadataPrefetching);
}
private static IEnumerable<PackageMapping> CreateScopedMappings(PackageMapping mapping, IReadOnlyCollection<string> packageIds, ILogger logger)
{
if (!IsScopedAspireMapping(mapping))
{
yield return mapping;
yield break;
}
var scopedPackageIds = GetScopedPackageIds(mapping.Source, packageIds, logger);
foreach (var scopedPackageId in scopedPackageIds)
{
yield return new PackageMapping(scopedPackageId, mapping.Source);
}
}
private static HashSet<string> GetScopedPackageIds(string source, IEnumerable<string> packageIds, ILogger logger)
{
var resolvedPackageIds = new HashSet<string>(packageIds, StringComparers.NuGetPackageId);
var normalizedSource = PackageSourceOverrideMappings.GetNormalizedLocalDirectory(source);
if (normalizedSource is null || !Directory.Exists(normalizedSource))
{
return resolvedPackageIds;
}
var packageFiles = Directory.EnumerateFiles(normalizedSource, "*.nupkg", SearchOption.AllDirectories)
.Select(GetPackageFileMetadata)
.OfType<PackageFileMetadata>()
.GroupBy(metadata => metadata.PackageId, StringComparers.NuGetPackageId)
.ToDictionary(
group => group.Key,
group => group.OrderByDescending(metadata => metadata.Version, SemVersion.PrecedenceComparer).First(),
StringComparers.NuGetPackageId);
var packagesToProcess = new Queue<string>(resolvedPackageIds);
while (packagesToProcess.Count > 0)
{
var currentPackageId = packagesToProcess.Dequeue();
if (!packageFiles.TryGetValue(currentPackageId, out var metadata))
{
continue;
}
foreach (var dependencyPackageId in GetDependencyPackageIds(metadata.PackageFilePath, logger))
{
if (packageFiles.ContainsKey(dependencyPackageId) && resolvedPackageIds.Add(dependencyPackageId))
{
packagesToProcess.Enqueue(dependencyPackageId);
}
}
}
return resolvedPackageIds;
}
private static PackageFileMetadata? GetPackageFileMetadata(string packageFile)
{
var packageIdentity = TryGetPackageIdentityFromPackageFileName(packageFile);
if (packageIdentity is null)
{
return null;
}
return new PackageFileMetadata(packageIdentity.Value.PackageId, packageIdentity.Value.Version, packageFile);
}
private static IEnumerable<string> GetDependencyPackageIds(string packageFile, ILogger logger)
{
try
{
using var archive = ZipFile.OpenRead(packageFile);
var nuspecEntry = archive.Entries.FirstOrDefault(entry => entry.FullName.EndsWith(".nuspec", StringComparison.OrdinalIgnoreCase));
if (nuspecEntry is null)
{
return [];
}
using var stream = nuspecEntry.Open();
var document = XDocument.Load(stream);
return document
.Descendants()
.Where(element => element.Name.LocalName == "dependency")
.Select(element => element.Attribute("id")?.Value)
.Where(id => !string.IsNullOrWhiteSpace(id))
.Distinct(StringComparers.NuGetPackageId)
.Cast<string>()
.ToArray();
}
catch (IOException ex)
{
logger.LogDebug(ex, "Failed to read package file '{PackageFile}' while resolving dependencies.", packageFile);
return [];
}
catch (InvalidDataException ex)
{
logger.LogDebug(ex, "Package file '{PackageFile}' contains invalid data.", packageFile);
return [];
}
catch (System.Xml.XmlException ex)
{
logger.LogDebug(ex, "Failed to parse nuspec in package file '{PackageFile}'.", packageFile);
return [];
}
}
private static (string PackageId, SemVersion Version)? TryGetPackageIdentityFromPackageFileName(string packageFile)
{
var packageFileName = Path.GetFileNameWithoutExtension(packageFile);
if (string.IsNullOrWhiteSpace(packageFileName))
{
return null;
}
var separatorIndex = packageFileName.IndexOf('.');
while (separatorIndex >= 0 && separatorIndex < packageFileName.Length - 1)
{
var versionCandidate = packageFileName[(separatorIndex + 1)..];
if (SemVersion.TryParse(versionCandidate, SemVersionStyles.Strict, out var version))
{
return (packageFileName[..separatorIndex], version);
}
separatorIndex = packageFileName.IndexOf('.', separatorIndex + 1);
}
return null;
}
private readonly record struct PackageFileMetadata(string PackageId, SemVersion Version, string PackageFilePath);
private static bool IsScopedAspireMapping(PackageMapping mapping)
{
return mapping.PackageFilter.StartsWith("Aspire", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(mapping.PackageFilter, PackageMapping.AllPackages, StringComparison.Ordinal);
}
public static PackageChannel CreateExplicitChannel(string name, PackageChannelQuality quality, PackageMapping[]? mappings, INuGetPackageCache nuGetPackageCache, IFeatures features, ILogger logger, bool configureGlobalPackagesFolder = false, string? cliDownloadBaseUrl = null, string? pinnedVersion = null, string? currentCliVersion = null, Action? validateTemplatePackageMetadataPrefetching = null)
{
return new PackageChannel(name, quality, mappings, nuGetPackageCache, features, logger, configureGlobalPackagesFolder, cliDownloadBaseUrl, pinnedVersion, currentCliVersion, validateTemplatePackageMetadataPrefetching);
}
public static PackageChannel CreateImplicitChannel(INuGetPackageCache nuGetPackageCache, IFeatures features, ILogger logger, string? currentCliVersion = null, Action? validateTemplatePackageMetadataPrefetching = null)
{
// The reason that PackageChannelQuality.Both is because there are situations like
// in community toolkit where there is a newer beta version available for a package
// in the case of implicit feeds we want to be able to show that, along side the stable
// version. Not really an issue for template selection though (unless we start allowing)
// for broader templating options.
return new PackageChannel("default", PackageChannelQuality.Both, null, nuGetPackageCache, features, logger, currentCliVersion: currentCliVersion, validateTemplatePackageMetadataPrefetching: validateTemplatePackageMetadataPrefetching);
}
}