diff --git a/src/Aspire.Cli/Packaging/PackageSourceOverrideMappings.cs b/src/Aspire.Cli/Packaging/PackageSourceOverrideMappings.cs index 6858745d8d5..d490c02a9dd 100644 --- a/src/Aspire.Cli/Packaging/PackageSourceOverrideMappings.cs +++ b/src/Aspire.Cli/Packaging/PackageSourceOverrideMappings.cs @@ -5,8 +5,6 @@ namespace Aspire.Cli.Packaging; internal static class PackageSourceOverrideMappings { - internal const string NuGetOrgSource = "https://api.nuget.org/v3/index.json"; - public static PackageMapping[] Create(string packageSourceOverride, PackageChannel? requestedChannel) { ArgumentException.ThrowIfNullOrWhiteSpace(packageSourceOverride); @@ -35,7 +33,7 @@ public static PackageMapping[] Create(string packageSourceOverride, PackageChann if (!mappings.Any(static mapping => mapping.PackageFilter == PackageMapping.AllPackages)) { - mappings.Add(new PackageMapping(PackageMapping.AllPackages, NuGetOrgSource)); + mappings.Add(new PackageMapping(PackageMapping.AllPackages, PackageSources.NuGetOrg)); } return [.. mappings.DistinctBy(static mapping => $"{mapping.PackageFilter}\0{mapping.Source}")]; diff --git a/src/Aspire.Cli/Packaging/PackageSources.cs b/src/Aspire.Cli/Packaging/PackageSources.cs new file mode 100644 index 00000000000..528a8619cb6 --- /dev/null +++ b/src/Aspire.Cli/Packaging/PackageSources.cs @@ -0,0 +1,9 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Cli.Packaging; + +internal static class PackageSources +{ + internal const string NuGetOrg = "https://api.nuget.org/v3/index.json"; +} diff --git a/src/Aspire.Cli/Packaging/PackagingService.cs b/src/Aspire.Cli/Packaging/PackagingService.cs index 93b2b633ce4..712bd412332 100644 --- a/src/Aspire.Cli/Packaging/PackagingService.cs +++ b/src/Aspire.Cli/Packaging/PackagingService.cs @@ -10,6 +10,7 @@ using Semver; using System.Globalization; using System.Reflection; +using System.Security; namespace Aspire.Cli.Packaging; @@ -47,6 +48,7 @@ internal class PackagingService : IPackagingService private readonly IFeatures _features; private readonly IConfiguration _configuration; private readonly ILogger _logger; + private readonly Func _processPathProvider; // Cached result of the staging-channel availability check. The inputs (CLI identity, // overrideStagingFeed, StagingChannelEnabled feature) are effectively static for the @@ -55,13 +57,20 @@ internal class PackagingService : IPackagingService // UpdateCommand, IntegrationPackageSearchService, NuGetPackagePrefetcher, etc.). private readonly Lazy _stagingUnavailableReasonCache; - public PackagingService(CliExecutionContext executionContext, INuGetPackageCache nuGetPackageCache, IFeatures features, IConfiguration configuration, ILogger logger) + public PackagingService( + CliExecutionContext executionContext, + INuGetPackageCache nuGetPackageCache, + IFeatures features, + IConfiguration configuration, + ILogger logger, + Func? processPathProvider = null) { _executionContext = executionContext; _nuGetPackageCache = nuGetPackageCache; _features = features; _configuration = configuration; _logger = logger; + _processPathProvider = processPathProvider ?? (() => Environment.ProcessPath); _stagingUnavailableReasonCache = new Lazy(ComputeStagingChannelUnavailableReason); } @@ -79,13 +88,13 @@ public Task> GetChannelsAsync(CancellationToken canc var stableChannel = PackageChannel.CreateExplicitChannel(PackageChannelNames.Stable, PackageChannelQuality.Stable, new[] { - new PackageMapping(PackageMapping.AllPackages, "https://api.nuget.org/v3/index.json") + new PackageMapping(PackageMapping.AllPackages, PackageSources.NuGetOrg) }, _nuGetPackageCache, cliDownloadBaseUrl: "https://aka.ms/dotnet/9/aspire/ga/daily", logger: _logger); var dailyChannel = PackageChannel.CreateExplicitChannel(PackageChannelNames.Daily, PackageChannelQuality.Prerelease, new[] { new PackageMapping("Aspire*", "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet9/nuget/v3/index.json"), - new PackageMapping(PackageMapping.AllPackages, "https://api.nuget.org/v3/index.json") + new PackageMapping(PackageMapping.AllPackages, PackageSources.NuGetOrg) }, _nuGetPackageCache, cliDownloadBaseUrl: "https://aka.ms/dotnet/9/aspire/daily", logger: _logger); var prPackageChannels = new List(); @@ -99,22 +108,18 @@ public Task> GetChannelsAsync(CancellationToken canc var prHives = _executionContext.HivesDirectory.GetDirectories(); foreach (var prHive in prHives) { - // The packages subdirectory contains the actual .nupkg files - var packagesDirectory = new DirectoryInfo(Path.Combine(prHive.FullName, "packages")); - var pinnedVersion = GetLocalHivePinnedVersion(packagesDirectory); - - // Use forward slashes for cross-platform NuGet config compatibility - var packagesPath = packagesDirectory.FullName.Replace('\\', '/'); - var prChannel = PackageChannel.CreateExplicitChannel(prHive.Name, PackageChannelQuality.Both, new[] - { - new PackageMapping("Aspire*", packagesPath), - new PackageMapping(PackageMapping.AllPackages, "https://api.nuget.org/v3/index.json") - }, _nuGetPackageCache, pinnedVersion: pinnedVersion, logger: _logger); - - prPackageChannels.Add(prChannel); + prPackageChannels.Add(CreateLocalHiveChannel(prHive.Name, new DirectoryInfo(Path.Combine(prHive.FullName, "packages")))); } } + if (TryResolvePrInstallPackagesDirectory(TryGetProcessPathForPrInstallDiscovery(), _executionContext.IdentityChannel) is { } prInstallPackagesDirectory) + { + // The install-prefix hive belongs to the running PR CLI. Prefer it over a same-named + // default hive so a stale ~/.aspire/hives/pr- cannot mask the co-installed packages. + prPackageChannels.RemoveAll(c => string.Equals(c.Name, _executionContext.IdentityChannel, StringComparisons.ChannelName)); + prPackageChannels.Add(CreateLocalHiveChannel(_executionContext.IdentityChannel, prInstallPackagesDirectory)); + } + var channels = new List([defaultChannel, stableChannel]); // Add staging channel after stable and before daily. Staging CLI builds should @@ -142,6 +147,74 @@ public Task> GetChannelsAsync(CancellationToken canc return Task.FromResult>(channels); } + private string? TryGetProcessPathForPrInstallDiscovery() + { + try + { + return _processPathProvider(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to get process path while discovering PR package hive."); + return null; + } + } + + private PackageChannel CreateLocalHiveChannel(string name, DirectoryInfo packagesDirectory) + { + var pinnedVersion = GetLocalHivePinnedVersion(packagesDirectory); + + // Use forward slashes for cross-platform NuGet config compatibility + var packagesPath = packagesDirectory.FullName.Replace('\\', '/'); + return PackageChannel.CreateExplicitChannel(name, PackageChannelQuality.Both, new[] + { + new PackageMapping("Aspire*", packagesPath), + new PackageMapping(PackageMapping.AllPackages, PackageSources.NuGetOrg) + }, _nuGetPackageCache, pinnedVersion: pinnedVersion, logger: _logger); + } + + internal static DirectoryInfo? TryResolvePrInstallPackagesDirectory(string? processPath, string identityChannel) + { + if (!identityChannel.StartsWith("pr-", StringComparison.OrdinalIgnoreCase) || + string.IsNullOrEmpty(processPath)) + { + return null; + } + + DirectoryInfo binaryDirectory; + try + { + var binaryDirectoryPath = Path.GetDirectoryName(Path.GetFullPath(processPath)); + if (string.IsNullOrEmpty(binaryDirectoryPath)) + { + return null; + } + + binaryDirectory = new DirectoryInfo(binaryDirectoryPath); + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException or SecurityException) + { + return null; + } + + // Archive installs from get-aspire-cli-pr place the binary at: + // /dogfood/pr-/bin/aspire + // and the matching packages at: + // /hives/pr-/packages + if (!string.Equals(binaryDirectory.Name, "bin", StringComparison.OrdinalIgnoreCase) || + binaryDirectory.Parent is not { } prDirectory || + !string.Equals(prDirectory.Name, identityChannel, StringComparisons.ChannelName) || + prDirectory.Parent is not { } dogfoodDirectory || + !string.Equals(dogfoodDirectory.Name, "dogfood", StringComparison.OrdinalIgnoreCase) || + dogfoodDirectory.Parent is not { } installPrefix) + { + return null; + } + + var packagesDirectory = new DirectoryInfo(Path.Combine(installPrefix.FullName, "hives", identityChannel, "packages")); + return packagesDirectory.Exists ? packagesDirectory : null; + } + private PackageChannel? CreateStagingChannel(PackageChannelQuality defaultQuality) { // Refuse to synthesize a staging channel on CLI identities that cannot produce a real @@ -181,7 +254,7 @@ public Task> GetChannelsAsync(CancellationToken canc var stagingChannel = PackageChannel.CreateExplicitChannel(PackageChannelNames.Staging, stagingQuality, new[] { new PackageMapping("Aspire*", stagingFeedUrl), - new PackageMapping(PackageMapping.AllPackages, "https://api.nuget.org/v3/index.json") + new PackageMapping(PackageMapping.AllPackages, PackageSources.NuGetOrg) }, _nuGetPackageCache, configureGlobalPackagesFolder: !useSharedFeed, cliDownloadBaseUrl: "https://aka.ms/dotnet/9/aspire/rc/daily", pinnedVersion: pinnedVersion, logger: _logger); // Surface the resolved staging routing so users can see what `--channel staging` actually diff --git a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs index 9f1cdc08ae1..085c6c4e5a2 100644 --- a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs +++ b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs @@ -632,9 +632,9 @@ private void ThrowIfStagingUnavailable(string? requestedChannel) // --source argument list must agree so non-Aspire transitives have the same // catch-all source in both views. if (hasOverride && !matchedChannelHasAllPackagesMapping && - !sources.Contains(PackageSourceOverrideMappings.NuGetOrgSource, StringComparer.OrdinalIgnoreCase)) + !sources.Contains(PackageSources.NuGetOrg, StringComparer.OrdinalIgnoreCase)) { - sources.Add(PackageSourceOverrideMappings.NuGetOrgSource); + sources.Add(PackageSources.NuGetOrg); } } catch (Exception ex) diff --git a/src/Aspire.Cli/Templating/TemplateNuGetConfigService.cs b/src/Aspire.Cli/Templating/TemplateNuGetConfigService.cs index 67181150b86..b7da85b4db8 100644 --- a/src/Aspire.Cli/Templating/TemplateNuGetConfigService.cs +++ b/src/Aspire.Cli/Templating/TemplateNuGetConfigService.cs @@ -208,7 +208,11 @@ public async Task ResolveTemplatePackageAsync(Template // Honor PR hives only when the caller opts in. Init suppresses this so a developer // with stale ~/.aspire/hives/* doesn't get a different template than on a clean machine. - var hasPrHives = query.IncludePrHives && executionContext.GetHiveCount() > 0; + // PR dogfood installs can discover a matching local-build channel outside the default + // hives directory, so also treat an installed local-build source as a hive signal. + var hasPrHives = query.IncludePrHives && + (executionContext.GetHiveCount() > 0 || + allChannels.Any(static c => c.Type is PackageChannelType.Explicit && HasInstalledLocalBuildPackageSource(c))); IEnumerable channels; if (!string.IsNullOrEmpty(query.RequestedChannel)) @@ -292,6 +296,16 @@ await Parallel.ForEachAsync(channels, cancellationToken, async (channel, ct) => return new TemplatePackageSelection(prompted.Package, prompted.Channel); } + private static bool HasInstalledLocalBuildPackageSource(PackageChannel channel) + { + return VersionHelper.IsLocalBuildChannel(channel.Name) && + channel.Mappings?.Any(static mapping => + mapping.PackageFilter.StartsWith("Aspire", StringComparison.OrdinalIgnoreCase) && + mapping.PackageFilter != PackageMapping.AllPackages && + !UrlHelper.IsHttpUrl(mapping.Source) && + Directory.Exists(mapping.Source)) == true; + } + /// /// Installs the resolved Aspire project templates package, generating a temporary NuGet.config from the channel mappings when the channel is explicit. /// diff --git a/tests/Aspire.Cli.Tests/Commands/NewCommandTemplateConfigPersistenceTests.cs b/tests/Aspire.Cli.Tests/Commands/NewCommandTemplateConfigPersistenceTests.cs index c154267bdfe..e1137c9babe 100644 --- a/tests/Aspire.Cli.Tests/Commands/NewCommandTemplateConfigPersistenceTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/NewCommandTemplateConfigPersistenceTests.cs @@ -3,13 +3,17 @@ using Aspire.Cli.Commands; using Aspire.Cli.Configuration; +using Aspire.Cli.NuGet; using Aspire.Cli.Packaging; using Aspire.Cli.Projects; using Aspire.Cli.Templating; using Aspire.Cli.Tests.TestServices; using Aspire.Cli.Tests.Utils; +using Aspire.Cli.Utils; using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; using NuGetPackage = Aspire.Shared.NuGetPackageCli; namespace Aspire.Cli.Tests.Commands; @@ -59,6 +63,48 @@ namespace Aspire.Cli.Tests.Commands; /// public class NewCommandTemplateConfigPersistenceTests(ITestOutputHelper outputHelper) { + private const string PrChannelName = "pr-17225"; + private static readonly string s_prVersion = VersionHelper.GetDefaultSdkVersion(); + + private static readonly PrDogfoodNewTemplateCase[] s_prDogfoodNewTemplateCases = + [ + PrDogfoodNewTemplateCase.CliConfig(KnownTemplateId.TypeScriptStarter, ["--localhost-tld", "false"]), + PrDogfoodNewTemplateCase.SelectableEmpty(KnownLanguageId.CSharp, PrDogfoodNewTemplateContract.CSharpEmptyAppHost, ["--localhost-tld", "false"]), + PrDogfoodNewTemplateCase.SelectableEmpty(KnownLanguageId.TypeScript, PrDogfoodNewTemplateContract.AspireConfig, ["--localhost-tld", "false"]), + PrDogfoodNewTemplateCase.SelectableEmpty(KnownLanguageId.Python, PrDogfoodNewTemplateContract.AspireConfig, ["--localhost-tld", "false"]), + PrDogfoodNewTemplateCase.SelectableEmpty(KnownLanguageId.Go, PrDogfoodNewTemplateContract.AspireConfig, ["--localhost-tld", "false"]), + PrDogfoodNewTemplateCase.SelectableEmpty(KnownLanguageId.Java, PrDogfoodNewTemplateContract.AspireConfig, ["--localhost-tld", "false"]), + PrDogfoodNewTemplateCase.SelectableEmpty(KnownLanguageId.Rust, PrDogfoodNewTemplateContract.AspireConfig, ["--localhost-tld", "false"]), + PrDogfoodNewTemplateCase.CliConfig(KnownTemplateId.TypeScriptEmptyAppHost, ["--localhost-tld", "false"]), + PrDogfoodNewTemplateCase.CliConfig(KnownTemplateId.PythonEmptyAppHost, ["--localhost-tld", "false"]), + PrDogfoodNewTemplateCase.CliConfig(KnownTemplateId.JavaEmptyAppHost, ["--localhost-tld", "false"]), + PrDogfoodNewTemplateCase.CliConfig(KnownTemplateId.GoEmptyAppHost, ["--localhost-tld", "false"]), + PrDogfoodNewTemplateCase.CliConfig(KnownTemplateId.RustEmptyAppHost, ["--localhost-tld", "false"]), + PrDogfoodNewTemplateCase.CliConfig(KnownTemplateId.PythonStarter, ["--localhost-tld", "false", "--use-redis-cache", "false"]), + PrDogfoodNewTemplateCase.CliConfig(KnownTemplateId.GoStarter, ["--localhost-tld", "false"]), + PrDogfoodNewTemplateCase.DotNet("aspire-starter", ["--localhost-tld", "false", "--use-redis-cache", "false"]), + PrDogfoodNewTemplateCase.DotNet("aspire-ts-cs-starter", ["--localhost-tld", "false", "--use-redis-cache", "false"]), + PrDogfoodNewTemplateCase.DotNet(KnownTemplateId.DotNetEmptyAppHost, ["--localhost-tld", "false"]), + PrDogfoodNewTemplateCase.DotNet("aspire-apphost", ["--localhost-tld", "false"]), + PrDogfoodNewTemplateCase.DotNet("aspire-servicedefaults"), + ]; + + private static readonly PrDogfoodNewTemplateExclusion[] s_prDogfoodNewTemplateExclusions = + [ + new("aspire-test", "This wrapper requires an interactive framework sub-template selection before it reaches package resolution.") + ]; + + public static TheoryData PrDogfoodNewTemplateCases() + { + var data = new TheoryData(); + foreach (var testCase in s_prDogfoodNewTemplateCases) + { + data.Add(testCase); + } + + return data; + } + /// /// Templates that pin aspire.config.json#channel when /// resolves an Explicit channel. Covers both writer code paths: @@ -161,6 +207,132 @@ public async Task ChannelPinningTemplate_StagingIdentityWithRegisteredChannel_Pi Assert.Equal(PackageChannelNames.Staging, persisted); } + [Fact] + public void PrDogfoodNewTemplateContract_AccountsForEveryRegisteredNewTemplate() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var services = CreatePrDogfoodNewTemplateServices(workspace, processPath: null, dotNetRunner: new TestDotNetCliRunner()); + + using var serviceProvider = services.BuildServiceProvider(); + var templateProvider = serviceProvider.GetRequiredService(); + var registeredTemplateKeys = templateProvider.GetTemplates() + .SelectMany(GetPrDogfoodTemplateCoverageKeys) + .OrderBy(static key => key, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + var accountedForTemplateKeys = s_prDogfoodNewTemplateCases.Select(static testCase => testCase.CoverageKey) + .Concat(s_prDogfoodNewTemplateExclusions.Select(static exclusion => exclusion.CoverageKey)) + .OrderBy(static key => key, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + Assert.Equal(accountedForTemplateKeys, registeredTemplateKeys); + Assert.All(s_prDogfoodNewTemplateExclusions, static exclusion => Assert.False(string.IsNullOrWhiteSpace(exclusion.Reason))); + } + + /// + /// Issue #17225 regression guard: when PackagingService discovers the running + /// pr-<N> CLI's matching dogfood install hive, every registered aspire new + /// template that consumes Aspire packages must scaffold from that channel/source. + /// + [Theory] + [MemberData(nameof(PrDogfoodNewTemplateCases))] + public async Task NewTemplate_PrDogfoodInstallHiveDiscovered_UsesPrChannel(PrDogfoodNewTemplateCase testCase) + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var (processPath, packagesDirectory) = CreatePrDogfoodInstallLayout(workspace); + var dotNetTemplateInstalls = new List(); + var dotNetRunner = new TestDotNetCliRunner + { + InstallTemplateAsyncCallback = (packageName, version, nugetConfigFile, nugetSource, _, _, _) => + { + dotNetTemplateInstalls.Add(new DotNetTemplateInstall(packageName, version, nugetConfigFile?.FullName, nugetSource)); + return (0, version); + }, + NewProjectAsyncCallback = (templateName, name, outputPath, _, _) => + { + Directory.CreateDirectory(outputPath); + File.WriteAllText(Path.Combine(outputPath, $"{name}.generated"), templateName); + return 0; + } + }; + + var services = CreatePrDogfoodNewTemplateServices(workspace, processPath, dotNetRunner); + + services.AddSingleton(_ => new TestAppHostServerProjectFactory + { + CreateAsyncCallback = (path, _) => + Task.FromResult(new FakeFailingAppHostServerProject(path)) + }); + + using var serviceProvider = services.BuildServiceProvider(); + var newCommand = serviceProvider.GetRequiredService(); + + const string outputDirectoryName = "TemplateOut"; + var commandArguments = new List + { + "new", + testCase.TemplateId, + "--name", + "TemplateOut", + "--output", + $"./{outputDirectoryName}", + }; + if (testCase.LanguageId is not null) + { + commandArguments.Add("--language"); + commandArguments.Add(testCase.LanguageId); + } + commandArguments.AddRange(testCase.ExtraArguments); + + var parseResult = newCommand.Parse(string.Join(" ", commandArguments)); + var exitCode = await parseResult.InvokeAsync().DefaultTimeout(); + + var outputDirectory = Path.Combine(workspace.WorkspaceRoot.FullName, outputDirectoryName); + switch (testCase.Contract) + { + case PrDogfoodNewTemplateContract.CSharpEmptyAppHost: + Assert.Empty(dotNetTemplateInstalls); + var appHostFile = Path.Combine(outputDirectory, "apphost.cs"); + Assert.True(File.Exists(appHostFile)); + Assert.Contains(s_prVersion, await File.ReadAllTextAsync(appHostFile)); + + var csharpNuGetConfig = await File.ReadAllTextAsync(Path.Combine(outputDirectory, "nuget.config")); + Assert.Contains(packagesDirectory.FullName.Replace('\\', '/'), csharpNuGetConfig); + break; + + case PrDogfoodNewTemplateContract.AspireConfig: + Assert.Empty(dotNetTemplateInstalls); + var config = AspireConfigFile.Load(outputDirectory); + Assert.NotNull(config); + Assert.Equal(PrChannelName, config.Channel); + if (config.SdkVersion is not null) + { + Assert.Equal(s_prVersion, config.SdkVersion); + } + break; + + case PrDogfoodNewTemplateContract.DotNetTemplate: + Assert.Equal((int)CliExitCodes.Success, exitCode); + var install = Assert.Single(dotNetTemplateInstalls); + Assert.Equal(TemplateNuGetConfigService.TemplatesPackageName, install.PackageName); + Assert.Equal(s_prVersion, install.Version); + Assert.Equal(packagesDirectory.FullName.Replace('\\', '/'), install.NuGetSource); + + var dotNetConfig = AspireConfigFile.Load(outputDirectory); + Assert.NotNull(dotNetConfig); + Assert.Equal(PrChannelName, dotNetConfig.Channel); + + var dotNetNuGetConfig = await File.ReadAllTextAsync(Path.Combine(outputDirectory, "nuget.config")); + Assert.Contains(packagesDirectory.FullName.Replace('\\', '/'), dotNetNuGetConfig); + break; + + default: + throw new InvalidOperationException($"Unknown template contract: {testCase.Contract}"); + } + } + /// /// Drives aspire new <templateId> against the real , /// real CliTemplateFactory, and real @@ -251,7 +423,7 @@ private static IPackagingService BuildPackagingService(string identityChannel, b var stableChannel = PackageChannel.CreateExplicitChannel( PackageChannelNames.Stable, PackageChannelQuality.Stable, - [new PackageMapping(PackageMapping.AllPackages, "https://api.nuget.org/v3/index.json")], + [new PackageMapping(PackageMapping.AllPackages, PackageSources.NuGetOrg)], stableCache); var channels = new List { implicitChannel, stableChannel }; @@ -277,7 +449,7 @@ [new PackageMapping(PackageMapping.AllPackages, "https://api.nuget.org/v3/index. quality, [ new PackageMapping("Aspire*", feed), - new PackageMapping(PackageMapping.AllPackages, "https://api.nuget.org/v3/index.json"), + new PackageMapping(PackageMapping.AllPackages, PackageSources.NuGetOrg), ], cache)); } @@ -292,4 +464,104 @@ private static CliExecutionContext BuildExecutionContextWithIdentity(TemporaryWo { return workspace.CreateExecutionContext(identityChannel: identityChannel); } + + private IServiceCollection CreatePrDogfoodNewTemplateServices(TemporaryWorkspace workspace, string? processPath, TestDotNetCliRunner dotNetRunner) + { + return CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.CliExecutionContextFactory = _ => TestExecutionContextHelper.CreateExecutionContext( + workspace.WorkspaceRoot, + hivesDirectory: new DirectoryInfo(Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "hives")), + identityChannel: PrChannelName); + options.PackagingServiceFactory = sp => new PackagingService( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + NullLogger.Instance, + processPathProvider: () => processPath); + options.NuGetPackageCacheFactory = _ => new FakeNuGetPackageCache + { + GetTemplatePackagesAsyncCallback = (_, prerelease, _, _) => + Task.FromResult>( + [ + new NuGetPackage + { + Id = TemplateNuGetConfigService.TemplatesPackageName, + Source = prerelease ? "preview-feed" : "stable-feed", + Version = prerelease ? "0.0.1-preview.1" : "0.0.1" + } + ]) + }; + options.DotNetCliRunnerFactory = _ => dotNetRunner; + options.EnabledFeatures = + [ + KnownFeatures.ExperimentalPolyglotPython, + KnownFeatures.ExperimentalPolyglotGo, + KnownFeatures.ExperimentalPolyglotJava, + KnownFeatures.ExperimentalPolyglotRust, + KnownFeatures.ShowAllTemplates, + ]; + }); + } + + private static (string ProcessPath, DirectoryInfo PackagesDirectory) CreatePrDogfoodInstallLayout(TemporaryWorkspace workspace) + { + var installPrefix = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, "custom-aspire-prefix")); + var processPath = Path.Combine(installPrefix.FullName, "dogfood", PrChannelName, "bin", "aspire"); + Directory.CreateDirectory(Path.GetDirectoryName(processPath)!); + File.WriteAllText(processPath, string.Empty); + + var packagesDirectory = Directory.CreateDirectory(Path.Combine(installPrefix.FullName, "hives", PrChannelName, "packages")); + File.WriteAllText(Path.Combine(packagesDirectory.FullName, $"Aspire.ProjectTemplates.{s_prVersion}.nupkg"), string.Empty); + + return (processPath, packagesDirectory); + } + + private static IEnumerable GetPrDogfoodTemplateCoverageKeys(ITemplate template) + { + return template.SelectableAppHostLanguages.Count == 0 + ? [template.Name] + : template.SelectableAppHostLanguages.Select(languageId => $"{template.Name}:{languageId}"); + } + + public sealed record PrDogfoodNewTemplateCase( + string TemplateId, + string? LanguageId, + PrDogfoodNewTemplateContract Contract, + string[] ExtraArguments) + { + public string CoverageKey => LanguageId is null ? TemplateId : $"{TemplateId}:{LanguageId}"; + + public static PrDogfoodNewTemplateCase SelectableEmpty(string languageId, PrDogfoodNewTemplateContract contract, string[] extraArguments) + { + return new(KnownTemplateId.CSharpEmptyAppHost, languageId, contract, extraArguments); + } + + public static PrDogfoodNewTemplateCase CliConfig(string templateId, string[]? extraArguments = null) + { + return new(templateId, LanguageId: null, PrDogfoodNewTemplateContract.AspireConfig, extraArguments ?? []); + } + + public static PrDogfoodNewTemplateCase DotNet(string templateId, string[]? extraArguments = null) + { + return new(templateId, LanguageId: null, PrDogfoodNewTemplateContract.DotNetTemplate, extraArguments ?? []); + } + + public override string ToString() + { + return CoverageKey; + } + } + + private sealed record PrDogfoodNewTemplateExclusion(string CoverageKey, string Reason); + + private sealed record DotNetTemplateInstall(string PackageName, string Version, string? NuGetConfigFile, string? NuGetSource); + + public enum PrDogfoodNewTemplateContract + { + CSharpEmptyAppHost, + AspireConfig, + DotNetTemplate + } } diff --git a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs index 01918d1f74d..a22140dfc81 100644 --- a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs @@ -718,9 +718,9 @@ private static void AssertSourceOverrideNuGetConfig(string outputPath, string so Assert.Contains(packageSources.Elements("clear"), _ => true); Assert.Contains(packageSources.Elements("add"), e => (string?)e.Attribute("value") == sourceOverride); - Assert.Contains(packageSources.Elements("add"), e => (string?)e.Attribute("value") == PackageSourceOverrideMappings.NuGetOrgSource); + Assert.Contains(packageSources.Elements("add"), e => (string?)e.Attribute("value") == PackageSources.NuGetOrg); Assert.Equal(["Aspire*"], GetPackagePatternsForSource(doc, sourceOverride)); - Assert.Equal([PackageMapping.AllPackages], GetPackagePatternsForSource(doc, PackageSourceOverrideMappings.NuGetOrgSource)); + Assert.Equal([PackageMapping.AllPackages], GetPackagePatternsForSource(doc, PackageSources.NuGetOrg)); } private static string[] GetPackagePatternsForSource(XDocument doc, string source) diff --git a/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs b/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs index 86ea16ff0c2..5f5f373254e 100644 --- a/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs @@ -313,7 +313,7 @@ public async Task GetChannelsAsync_StableChannel_IsExplicitWithAllPackagesMappin Assert.NotEmpty(stableChannel.Mappings!); Assert.Contains(stableChannel.Mappings!, m => m.PackageFilter == PackageMapping.AllPackages && - m.Source == "https://api.nuget.org/v3/index.json"); + m.Source == PackageSources.NuGetOrg); } [Fact] @@ -356,7 +356,7 @@ public async Task GetChannelsAsync_WhenStagingChannelEnabled_IncludesStagingChan var nugetMapping = stagingChannel.Mappings!.FirstOrDefault(m => m.PackageFilter == "*"); Assert.NotNull(nugetMapping); - Assert.Equal("https://api.nuget.org/v3/index.json", nugetMapping.Source); + Assert.Equal(PackageSources.NuGetOrg, nugetMapping.Source); } [Fact] @@ -988,13 +988,271 @@ public async Task GetChannelsAsync_WhenLocalHiveContainsProjectTemplatesPackage_ Assert.Equal(localVersion, localChannel.PinnedVersion); } + [Fact] + public async Task GetChannelsAsync_WhenPrIdentityRunsFromDogfoodInstallPrefix_AddsMatchingPrHiveChannel() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var tempDir = workspace.WorkspaceRoot; + + const string prChannelName = "pr-17225"; + const string prVersion = "13.4.0-pr.17225.g1234567"; + var installPrefix = Directory.CreateDirectory(Path.Combine(tempDir.FullName, "custom-aspire-prefix")); + var processPath = Path.Combine(installPrefix.FullName, "dogfood", prChannelName, "bin", "aspire"); + Directory.CreateDirectory(Path.GetDirectoryName(processPath)!); + File.WriteAllText(processPath, string.Empty); + + var packagesDirectory = Directory.CreateDirectory(Path.Combine(installPrefix.FullName, "hives", prChannelName, "packages")); + File.WriteAllText(Path.Combine(packagesDirectory.FullName, $"Aspire.ProjectTemplates.{prVersion}.nupkg"), string.Empty); + + // Deliberately point the execution context at the default Aspire home, not the custom + // PR install prefix. This is the dogfood acquisition shape that previously made a + // PR-acquired CLI fall back to normal channels unless the user passed --source. + var defaultHivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")); + var executionContext = TestExecutionContextHelper.CreateExecutionContext( + tempDir, + hivesDirectory: defaultHivesDir, + identityChannel: prChannelName); + var packagingService = new PackagingService( + executionContext, + new FakeNuGetPackageCache(), + new TestFeatures(), + new ConfigurationBuilder().Build(), + NullLogger.Instance, + processPathProvider: () => processPath); + + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + + var prChannel = Assert.Single(channels, c => string.Equals(c.Name, prChannelName, StringComparison.OrdinalIgnoreCase)); + Assert.Equal(prVersion, prChannel.PinnedVersion); + Assert.Contains(prChannel.Mappings!, mapping => + mapping.PackageFilter == "Aspire*" && + mapping.Source == packagesDirectory.FullName.Replace('\\', '/')); + } + + [Fact] + public async Task GetChannelsAsync_WhenPrIdentityExistsInDefaultHiveAndDogfoodInstallPrefix_UsesDogfoodHiveChannel() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var tempDir = workspace.WorkspaceRoot; + + const string prChannelName = "pr-17225"; + const string defaultHiveVersion = "13.4.0-pr.17225.g1111111"; + const string dogfoodHiveVersion = "13.4.0-pr.17225.g2222222"; + + var defaultHivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")); + var defaultPackagesDirectory = Directory.CreateDirectory(Path.Combine(defaultHivesDir.FullName, prChannelName, "packages")); + File.WriteAllText(Path.Combine(defaultPackagesDirectory.FullName, $"Aspire.ProjectTemplates.{defaultHiveVersion}.nupkg"), string.Empty); + + var installPrefix = Directory.CreateDirectory(Path.Combine(tempDir.FullName, "custom-aspire-prefix")); + var processPath = Path.Combine(installPrefix.FullName, "dogfood", prChannelName, "bin", "aspire"); + Directory.CreateDirectory(Path.GetDirectoryName(processPath)!); + File.WriteAllText(processPath, string.Empty); + + var dogfoodPackagesDirectory = Directory.CreateDirectory(Path.Combine(installPrefix.FullName, "hives", prChannelName, "packages")); + File.WriteAllText(Path.Combine(dogfoodPackagesDirectory.FullName, $"Aspire.ProjectTemplates.{dogfoodHiveVersion}.nupkg"), string.Empty); + + var executionContext = TestExecutionContextHelper.CreateExecutionContext( + tempDir, + hivesDirectory: defaultHivesDir, + identityChannel: prChannelName); + var packagingService = new PackagingService( + executionContext, + new FakeNuGetPackageCache(), + new TestFeatures(), + new ConfigurationBuilder().Build(), + NullLogger.Instance, + processPathProvider: () => processPath); + + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + + var prChannel = Assert.Single(channels, c => string.Equals(c.Name, prChannelName, StringComparison.OrdinalIgnoreCase)); + Assert.Equal(dogfoodHiveVersion, prChannel.PinnedVersion); + Assert.Contains(prChannel.Mappings!, mapping => + mapping.PackageFilter == "Aspire*" && + mapping.Source == dogfoodPackagesDirectory.FullName.Replace('\\', '/')); + } + + [Fact] + public async Task GetChannelsAsync_WhenPrDogfoodHiveHasOnlyMalformedPackageNames_AddsChannelWithoutPinnedVersion() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var tempDir = workspace.WorkspaceRoot; + + const string prChannelName = "pr-17225"; + var installPrefix = Directory.CreateDirectory(Path.Combine(tempDir.FullName, "custom-aspire-prefix")); + var processPath = Path.Combine(installPrefix.FullName, "dogfood", prChannelName, "bin", "aspire"); + Directory.CreateDirectory(Path.GetDirectoryName(processPath)!); + File.WriteAllText(processPath, string.Empty); + + var packagesDirectory = Directory.CreateDirectory(Path.Combine(installPrefix.FullName, "hives", prChannelName, "packages")); + File.WriteAllText(Path.Combine(packagesDirectory.FullName, "Aspire.ProjectTemplates.not-a-semver.nupkg"), string.Empty); + + var executionContext = TestExecutionContextHelper.CreateExecutionContext( + tempDir, + hivesDirectory: new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")), + identityChannel: prChannelName); + var packagingService = new PackagingService( + executionContext, + new FakeNuGetPackageCache(), + new TestFeatures(), + new ConfigurationBuilder().Build(), + NullLogger.Instance, + processPathProvider: () => processPath); + + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + + var prChannel = Assert.Single(channels, c => string.Equals(c.Name, prChannelName, StringComparison.OrdinalIgnoreCase)); + Assert.Null(prChannel.PinnedVersion); + Assert.Contains(prChannel.Mappings!, mapping => + mapping.PackageFilter == "Aspire*" && + mapping.Source == packagesDirectory.FullName.Replace('\\', '/')); + } + + [Fact] + public async Task GetChannelsAsync_WhenPrIdentityDogfoodPackagesDirectoryIsMissing_DoesNotAddPrHiveChannel() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var tempDir = workspace.WorkspaceRoot; + + const string prChannelName = "pr-17225"; + var installPrefix = Directory.CreateDirectory(Path.Combine(tempDir.FullName, "custom-aspire-prefix")); + var processPath = Path.Combine(installPrefix.FullName, "dogfood", prChannelName, "bin", "aspire"); + Directory.CreateDirectory(Path.GetDirectoryName(processPath)!); + File.WriteAllText(processPath, string.Empty); + + var executionContext = TestExecutionContextHelper.CreateExecutionContext( + tempDir, + hivesDirectory: new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")), + identityChannel: prChannelName); + var packagingService = new PackagingService( + executionContext, + new FakeNuGetPackageCache(), + new TestFeatures(), + new ConfigurationBuilder().Build(), + NullLogger.Instance, + processPathProvider: () => processPath); + + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + + Assert.DoesNotContain(channels, c => string.Equals(c.Name, prChannelName, StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task GetChannelsAsync_WhenPrIdentityDoesNotMatchDogfoodDirectory_DoesNotAddPrHiveChannel() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var tempDir = workspace.WorkspaceRoot; + + const string installedPrChannelName = "pr-11111"; + const string identityPrChannelName = "pr-22222"; + var installPrefix = Directory.CreateDirectory(Path.Combine(tempDir.FullName, "custom-aspire-prefix")); + var processPath = Path.Combine(installPrefix.FullName, "dogfood", installedPrChannelName, "bin", "aspire"); + Directory.CreateDirectory(Path.GetDirectoryName(processPath)!); + File.WriteAllText(processPath, string.Empty); + Directory.CreateDirectory(Path.Combine(installPrefix.FullName, "hives", identityPrChannelName, "packages")); + + var executionContext = TestExecutionContextHelper.CreateExecutionContext( + tempDir, + hivesDirectory: new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")), + identityChannel: identityPrChannelName); + var packagingService = new PackagingService( + executionContext, + new FakeNuGetPackageCache(), + new TestFeatures(), + new ConfigurationBuilder().Build(), + NullLogger.Instance, + processPathProvider: () => processPath); + + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + + Assert.DoesNotContain(channels, c => string.Equals(c.Name, identityPrChannelName, StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task GetChannelsAsync_WhenProcessPathProviderThrows_DoesNotAddPrHiveChannel() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var tempDir = workspace.WorkspaceRoot; + + const string prChannelName = "pr-17225"; + var executionContext = TestExecutionContextHelper.CreateExecutionContext( + tempDir, + hivesDirectory: new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")), + identityChannel: prChannelName); + var packagingService = new PackagingService( + executionContext, + new FakeNuGetPackageCache(), + new TestFeatures(), + new ConfigurationBuilder().Build(), + NullLogger.Instance, + processPathProvider: () => throw new IOException("Process path unavailable.")); + + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + + Assert.DoesNotContain(channels, c => string.Equals(c.Name, prChannelName, StringComparison.OrdinalIgnoreCase)); + Assert.Contains(channels, c => c.Name == PackageChannelNames.Stable); + Assert.Contains(channels, c => c.Name == PackageChannelNames.Daily); + } + + [Fact] + public async Task GetChannelsAsync_WhenNonPrIdentityRunsFromDogfoodInstallPrefix_DoesNotAddPrHiveChannel() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var tempDir = workspace.WorkspaceRoot; + + const string prChannelName = "pr-17225"; + var installPrefix = Directory.CreateDirectory(Path.Combine(tempDir.FullName, "custom-aspire-prefix")); + var processPath = Path.Combine(installPrefix.FullName, "dogfood", prChannelName, "bin", "aspire"); + Directory.CreateDirectory(Path.GetDirectoryName(processPath)!); + File.WriteAllText(processPath, string.Empty); + Directory.CreateDirectory(Path.Combine(installPrefix.FullName, "hives", prChannelName, "packages")); + + var executionContext = TestExecutionContextHelper.CreateExecutionContext( + tempDir, + hivesDirectory: new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")), + identityChannel: PackageChannelNames.Daily); + var packagingService = new PackagingService( + executionContext, + new FakeNuGetPackageCache(), + new TestFeatures(), + new ConfigurationBuilder().Build(), + NullLogger.Instance, + processPathProvider: () => processPath); + + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + + Assert.DoesNotContain(channels, c => string.Equals(c.Name, prChannelName, StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void TryResolvePrInstallPackagesDirectory_WithMalformedProcessPath_ReturnsNull() + { + Assert.Null(PackagingService.TryResolvePrInstallPackagesDirectory("bad\0path", "pr-17225")); + } + + [Fact] + public void TryResolvePrInstallPackagesDirectory_WithWrongInstallLayout_ReturnsNull() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var tempDir = workspace.WorkspaceRoot; + + const string prChannelName = "pr-17225"; + var installPrefix = Directory.CreateDirectory(Path.Combine(tempDir.FullName, "custom-aspire-prefix")); + var processPath = Path.Combine(installPrefix.FullName, "bin", "aspire"); + Directory.CreateDirectory(Path.GetDirectoryName(processPath)!); + File.WriteAllText(processPath, string.Empty); + Directory.CreateDirectory(Path.Combine(installPrefix.FullName, "hives", prChannelName, "packages")); + + Assert.Null(PackagingService.TryResolvePrInstallPackagesDirectory(processPath, prChannelName)); + } + [Fact] public async Task LocalHiveChannel_WithPinnedVersion_ReturnsSyntheticTemplatePackage() { // Arrange - simulate package search returning a mismatched stable version var fakeCache = new FakeNuGetPackageCacheWithPackages( [ - new() { Id = "Aspire.ProjectTemplates", Version = "13.2.2", Source = "https://api.nuget.org/v3/index.json" }, + new() { Id = "Aspire.ProjectTemplates", Version = "13.2.2", Source = PackageSources.NuGetOrg }, ]); using var workspace = TemporaryWorkspace.Create(outputHelper); @@ -1317,7 +1575,7 @@ public async Task GetChannelsAsync_LocalHive_AspireMappingPointsAtLocalDirectory var fallbackMapping = localChannel.Mappings!.FirstOrDefault(m => m.PackageFilter == PackageMapping.AllPackages); Assert.NotNull(fallbackMapping); - Assert.Equal("https://api.nuget.org/v3/index.json", fallbackMapping.Source); + Assert.Equal(PackageSources.NuGetOrg, fallbackMapping.Source); } [Fact] diff --git a/tests/Aspire.Cli.Tests/Templating/TemplateNuGetConfigServiceTests.cs b/tests/Aspire.Cli.Tests/Templating/TemplateNuGetConfigServiceTests.cs index 53a33b520a4..87994463017 100644 --- a/tests/Aspire.Cli.Tests/Templating/TemplateNuGetConfigServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Templating/TemplateNuGetConfigServiceTests.cs @@ -60,7 +60,7 @@ await File.WriteAllTextAsync( var doc = XDocument.Load(Path.Combine(outputDirectory.FullName, "nuget.config")); Assert.Contains(doc.Root!.Element("packageSources")!.Elements("clear"), _ => true); Assert.Contains(doc.Root!.Element("packageSources")!.Elements("add"), e => (string?)e.Attribute("value") == sourceOverride); - Assert.Contains(doc.Root!.Element("packageSources")!.Elements("add"), e => (string?)e.Attribute("value") == PackageSourceOverrideMappings.NuGetOrgSource); + Assert.Contains(doc.Root!.Element("packageSources")!.Elements("add"), e => (string?)e.Attribute("value") == PackageSources.NuGetOrg); Assert.DoesNotContain(doc.Descendants("add"), e => (string?)e.Attribute("value") == "https://private.example/v3/index.json"); Assert.Null(doc.Root!.Element("disabledPackageSources")); Assert.Null(doc.Root!.Element("packageSourceCredentials")); @@ -102,7 +102,7 @@ public async Task CreateOrUpdateNuGetConfigForSourceOverrideAsync_PreservesReque Assert.Equal(["CommunityToolkit*"], GetPackagePatternsForSource(doc, communitySource)); Assert.Equal([PackageMapping.AllPackages], GetPackagePatternsForSource(doc, fallbackSource)); Assert.Empty(GetPackagePatternsForSource(doc, channelAspireSource)); - Assert.Empty(GetPackagePatternsForSource(doc, PackageSourceOverrideMappings.NuGetOrgSource)); + Assert.Empty(GetPackagePatternsForSource(doc, PackageSources.NuGetOrg)); } [Fact]