diff --git a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs index 3541614949f..c5f0e8d5d4a 100644 --- a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs +++ b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs @@ -422,7 +422,8 @@ private XDocument CreateProjectFile(IEnumerable integratio public async Task PrepareAsync( string sdkVersion, IEnumerable integrations, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + string? packageSourceOverride = null) { var (_, channelName) = await CreateProjectFilesAsync(integrations, cancellationToken); var (buildSuccess, buildOutput) = await BuildAsync(cancellationToken); diff --git a/src/Aspire.Cli/Projects/IAppHostServerProject.cs b/src/Aspire.Cli/Projects/IAppHostServerProject.cs index a5fc0730826..e3cba326b6f 100644 --- a/src/Aspire.Cli/Projects/IAppHostServerProject.cs +++ b/src/Aspire.Cli/Projects/IAppHostServerProject.cs @@ -41,11 +41,13 @@ internal interface IAppHostServerProject /// The Aspire SDK version to use. /// The integration references (NuGet packages and/or project references) required by the app host. /// Cancellation token. + /// Optional package source to prefer for Aspire package restore. /// The preparation result indicating success/failure and any output. Task PrepareAsync( string sdkVersion, IEnumerable integrations, - CancellationToken cancellationToken = default); + CancellationToken cancellationToken = default, + string? packageSourceOverride = null); /// /// Runs the AppHost server process. diff --git a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs index a00a7aa8605..3b9c6917e8b 100644 --- a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs +++ b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs @@ -26,6 +26,10 @@ namespace Aspire.Cli.Projects; /// internal sealed class PrebuiltAppHostServer : IAppHostServerProject { + // An explicit PR/local source only contains Aspire packages. Keep NuGet.org available + // for non-Aspire packages and transitive dependencies that are outside the Aspire* mapping. + private const string NuGetOrgSource = "https://api.nuget.org/v3/index.json"; + internal const string ClosureMetadataFileName = "closure-metadata.txt"; internal const string ClosureSourcesFileName = "closure-sources.txt"; internal const string ClosureTargetsFileName = "closure-targets.txt"; @@ -123,7 +127,8 @@ public string GetServerPath() public async Task PrepareAsync( string sdkVersion, IEnumerable integrations, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + string? packageSourceOverride = null) { var integrationList = integrations.ToList(); var packageRefs = integrationList.Where(r => r.IsPackageReference).ToList(); @@ -141,6 +146,11 @@ public async Task PrepareAsync( // with a legacy .aspire/settings.json#channel fallback). This is independent of the // running CLI's identity hive (CliExecutionContext.IdentityChannel). requestedChannel = ResolveRequestedChannel(); + var effectivePackageSourceOverride = packageSourceOverride; + if (string.IsNullOrWhiteSpace(effectivePackageSourceOverride)) + { + effectivePackageSourceOverride = await ResolveLocalPackageSourceOverrideAsync(requestedChannel, cancellationToken).ConfigureAwait(false); + } if (projectRefs.Count > 0) { @@ -160,6 +170,7 @@ public async Task PrepareAsync( packageRefs, projectRefs, requestedChannel, + effectivePackageSourceOverride, cancellationToken).ConfigureAwait(false); if (closureManifest.Entries.Any(static entry => entry.IsPackageBacked)) @@ -185,7 +196,7 @@ await IntegrationPackageProbeManifest.WriteAsync( { // NuGet-only — use the bundled NuGet service (no SDK required) _integrationProbeManifestPath = await RestoreNuGetPackagesAsync( - packageRefs, requestedChannel, cancellationToken); + packageRefs, requestedChannel, effectivePackageSourceOverride, cancellationToken); } var appSettingsContent = CreateAppSettingsContent(packageRefs, []); @@ -226,13 +237,17 @@ await IntegrationPackageProbeManifest.WriteAsync( private async Task RestoreNuGetPackagesAsync( List packageRefs, string? requestedChannel, + string? packageSourceOverride, CancellationToken cancellationToken) { _logger.LogDebug("Restoring {Count} integration packages via bundled NuGet", packageRefs.Count); - var packages = packageRefs.Select(r => (r.Name, r.Version!)).ToList(); - using var temporaryNuGetConfig = await TryCreateTemporaryNuGetConfigAsync(requestedChannel, cancellationToken); - var sources = await GetNuGetSourcesAsync(requestedChannel, cancellationToken); + var useExactPackageVersions = !string.IsNullOrWhiteSpace(packageSourceOverride); + var packages = packageRefs + .Select(r => (r.Name, Version: GetRestoreVersion(r.Name, r.Version!, useExactPackageVersions))) + .ToList(); + using var temporaryNuGetConfig = await TryCreateTemporaryNuGetConfigAsync(requestedChannel, packageSourceOverride, cancellationToken); + var sources = await GetNuGetSourcesAsync(requestedChannel, packageSourceOverride, cancellationToken); return await _nugetService.RestorePackagesAsync( packages, @@ -253,13 +268,31 @@ private async Task BuildIntegrationClosureManifest List packageRefs, List projectRefs, string? requestedChannel, + string? packageSourceOverride, CancellationToken cancellationToken) { var restoreDir = Path.Combine(_workingDirectory, "integration-restore"); Directory.CreateDirectory(restoreDir); - var channelSources = await GetNuGetSourcesAsync(requestedChannel, cancellationToken); - var projectContent = GenerateIntegrationProjectFile(packageRefs, projectRefs, restoreDir, channelSources); + // Only emit a synthesized when the caller is overriding the Aspire + // package source. The temporary nuget.config writes , so using it on a plain + // explicit-channel restore (staging, daily) would silently bypass any private feeds in + // the user's ambient nuget.config that the project's transitive non-Aspire dependencies + // rely on. For non-override channels, keep composing channel sources on top of the + // ambient config via RestoreAdditionalProjectSources. + using var temporaryNuGetConfig = !string.IsNullOrWhiteSpace(packageSourceOverride) + ? await TryCreateTemporaryNuGetConfigAsync(requestedChannel, packageSourceOverride, cancellationToken) + : null; + var channelSources = temporaryNuGetConfig is null + ? await GetNuGetSourcesAsync(requestedChannel, packageSourceOverride, cancellationToken) + : null; + var projectContent = GenerateIntegrationProjectFile( + packageRefs, + projectRefs, + restoreDir, + channelSources, + useExactPackageVersions: !string.IsNullOrWhiteSpace(packageSourceOverride), + restoreConfigFile: temporaryNuGetConfig?.ConfigFile.FullName); var projectFilePath = Path.Combine(restoreDir, IntegrationProjectFileName); await File.WriteAllTextAsync(projectFilePath, projectContent, cancellationToken); @@ -354,7 +387,9 @@ internal static string GenerateIntegrationProjectFile( List packageRefs, List projectRefs, string restoreDir, - IEnumerable? additionalSources = null) + IEnumerable? additionalSources = null, + bool useExactPackageVersions = false, + string? restoreConfigFile = null) { var propertyGroup = new XElement("PropertyGroup", new XElement("TargetFramework", DotNetBasedAppHostServerProject.TargetFramework), @@ -368,8 +403,12 @@ internal static string GenerateIntegrationProjectFile( new XElement("AspireClosureTargetsFile", Path.Combine(restoreDir, ClosureTargetsFileName)), new XElement("AspireProjectRefAssemblyNamesFile", Path.Combine(restoreDir, ProjectRefAssemblyNamesFileName))); - // Add channel sources without replacing the user's nuget.config - if (additionalSources is not null) + if (!string.IsNullOrWhiteSpace(restoreConfigFile)) + { + propertyGroup.Add(new XElement("RestoreConfigFile", restoreConfigFile)); + } + // Add channel sources without replacing the user's nuget.config. + else if (additionalSources is not null) { var sourceList = string.Join(";", additionalSources); if (sourceList.Length > 0) @@ -394,7 +433,7 @@ internal static string GenerateIntegrationProjectFile( } return new XElement("PackageReference", new XAttribute("Include", p.Name), - new XAttribute("Version", p.Version)); + new XAttribute("Version", GetRestoreVersion(p.Name, p.Version, useExactPackageVersions))); }))); } @@ -461,26 +500,18 @@ internal static string GenerateIntegrationProjectFile( /// /// Gets NuGet sources from the resolved channel for bundled restore. /// - private async Task?> GetNuGetSourcesAsync(string? requestedChannel, CancellationToken cancellationToken) + private async Task?> GetNuGetSourcesAsync(string? requestedChannel, string? packageSourceOverride, CancellationToken cancellationToken) { var sources = new List(); - try + if (!string.IsNullOrWhiteSpace(packageSourceOverride)) { - var channels = await _packagingService.GetChannelsAsync(cancellationToken, requestedChannel); - - IEnumerable explicitChannels; - if (!string.IsNullOrEmpty(requestedChannel)) - { - var matchingChannel = channels.FirstOrDefault(c => string.Equals(c.Name, requestedChannel, StringComparison.OrdinalIgnoreCase)); - explicitChannels = matchingChannel is not null ? [matchingChannel] : channels.Where(c => c.Type == PackageChannelType.Explicit); - } - else - { - explicitChannels = channels.Where(c => c.Type == PackageChannelType.Explicit); - } + sources.Add(packageSourceOverride); + } - foreach (var channel in explicitChannels) + try + { + foreach (var channel in await GetExplicitRestoreChannelsAsync(requestedChannel, cancellationToken)) { if (channel.Mappings is null) { @@ -489,6 +520,11 @@ internal static string GenerateIntegrationProjectFile( foreach (var mapping in channel.Mappings) { + if (!string.IsNullOrWhiteSpace(packageSourceOverride) && IsAspireSpecificMapping(mapping)) + { + continue; + } + if (!sources.Contains(mapping.Source, StringComparer.OrdinalIgnoreCase)) { sources.Add(mapping.Source); @@ -501,17 +537,78 @@ internal static string GenerateIntegrationProjectFile( _logger.LogWarning(ex, "Failed to get package channels, relying on nuget.config and nuget.org fallback"); } + if (!string.IsNullOrWhiteSpace(packageSourceOverride) && sources.Count == 1) + { + sources.Add(NuGetOrgSource); + } + return sources.Count > 0 ? sources : null; } - private async Task TryCreateTemporaryNuGetConfigAsync(string? requestedChannel, CancellationToken cancellationToken) + private async Task TryCreateTemporaryNuGetConfigAsync(string? requestedChannel, string? packageSourceOverride, CancellationToken cancellationToken) { + if (!string.IsNullOrWhiteSpace(packageSourceOverride)) + { + var mappings = new List + { + new("Aspire*", packageSourceOverride) + }; + var configureGlobalPackagesFolder = false; + + try + { + foreach (var restoreChannel in await GetExplicitRestoreChannelsAsync(requestedChannel, cancellationToken)) + { + if (restoreChannel.Mappings is null) + { + continue; + } + + mappings.AddRange(restoreChannel.Mappings.Where(static mapping => !IsAspireSpecificMapping(mapping))); + configureGlobalPackagesFolder |= restoreChannel.ConfigureGlobalPackagesFolder; + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to get package channels while creating source override NuGet.config"); + } + + if (!mappings.Any(static mapping => mapping.PackageFilter == PackageMapping.AllPackages)) + { + mappings.Add(new PackageMapping(PackageMapping.AllPackages, NuGetOrgSource)); + } + + return await TemporaryNuGetConfig.CreateAsync( + [.. mappings.DistinctBy(static mapping => $"{mapping.PackageFilter}\0{mapping.Source}")], + configureGlobalPackagesFolder); + } + if (string.IsNullOrEmpty(requestedChannel)) { return null; } - var channels = await _packagingService.GetChannelsAsync(cancellationToken, requestedChannel); + IEnumerable channels; + try + { + channels = await _packagingService.GetChannelsAsync(cancellationToken, requestedChannel); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + // Mirror the defensive catch in GetNuGetSourcesAsync: a transient packaging-service + // failure must degrade to the ambient nuget.config + nuget.org fallback rather than + // failing the whole PrepareAsync. Returning null skips the PSM-bearing temp config; + // the caller still gets channel-source composition via GetNuGetSourcesAsync (which + // also catches), or, in the package-ref path, the bundled NuGet helper's working-dir + // nuget.config discovery. + _logger.LogWarning(ex, "Failed to get package channels while creating channel NuGet.config for '{Channel}'.", requestedChannel); + return null; + } + var channel = channels.FirstOrDefault(c => c.Type == PackageChannelType.Explicit && c.Mappings is { Length: > 0 } && @@ -541,6 +638,95 @@ internal static string GenerateIntegrationProjectFile( return await TemporaryNuGetConfig.CreateAsync(channel.Mappings, channel.ConfigureGlobalPackagesFolder); } + private async Task ResolveLocalPackageSourceOverrideAsync(string? requestedChannel, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(requestedChannel)) + { + return null; + } + + PackageChannel? channel; + try + { + var channels = await _packagingService.GetChannelsAsync(cancellationToken, requestedChannel); + channel = channels.FirstOrDefault(c => + c.Type == PackageChannelType.Explicit && + c.Mappings is { Length: > 0 } && + string.Equals(c.Name, requestedChannel, StringComparison.OrdinalIgnoreCase)); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + // A transient packaging-service failure during auto-discovery must not turn + // `aspire new` into a hard failure. Returning null falls through to the existing + // ambient + channel-sources path, matching the defensive catches in + // TryCreateTemporaryNuGetConfigAsync and GetNuGetSourcesAsync. + _logger.LogWarning(ex, "Failed to resolve local Aspire package source for channel '{Channel}'.", requestedChannel); + return null; + } + + var source = channel is null ? null : GetExistingLocalAspirePackageSource(channel); + + if (!string.IsNullOrWhiteSpace(source)) + { + _logger.LogDebug("Using local package source '{Source}' for channel '{Channel}'.", source, requestedChannel); + } + + return source; + } + + private static string? GetExistingLocalAspirePackageSource(PackageChannel channel) + { + if (channel.Mappings is null) + { + return null; + } + + foreach (var mapping in channel.Mappings) + { + if (!IsAspireSpecificMapping(mapping) || + UrlHelper.IsHttpUrl(mapping.Source) || + !Directory.Exists(mapping.Source)) + { + continue; + } + + return mapping.Source; + } + + return null; + } + + private static bool IsAspireSpecificMapping(PackageMapping mapping) => + mapping.PackageFilter != PackageMapping.AllPackages && + mapping.PackageFilter.StartsWith("Aspire", StringComparison.OrdinalIgnoreCase); + + private async Task> GetExplicitRestoreChannelsAsync(string? requestedChannel, CancellationToken cancellationToken) + { + var channels = await _packagingService.GetChannelsAsync(cancellationToken, requestedChannel); + if (!string.IsNullOrEmpty(requestedChannel)) + { + var matchingChannel = channels.FirstOrDefault(c => string.Equals(c.Name, requestedChannel, StringComparison.OrdinalIgnoreCase)); + return matchingChannel is not null ? [matchingChannel] : channels.Where(c => c.Type == PackageChannelType.Explicit).ToArray(); + } + + return channels.Where(c => c.Type == PackageChannelType.Explicit).ToArray(); + } + + private static string GetRestoreVersion(string packageName, string version, bool useExactPackageVersions) + { + var useExactAspirePackageVersion = useExactPackageVersions && packageName.StartsWith("Aspire", StringComparison.OrdinalIgnoreCase); + if (!useExactAspirePackageVersion || version.Length == 0 || version[0] is '[' or '(') + { + return version; + } + + return $"[{version}]"; + } + /// public (string SocketPath, Process Process, OutputCollector OutputCollector) Run( int hostPid, diff --git a/src/Aspire.Cli/Scaffolding/IScaffoldingService.cs b/src/Aspire.Cli/Scaffolding/IScaffoldingService.cs index 2ad51d9943d..dbd5ed0bb90 100644 --- a/src/Aspire.Cli/Scaffolding/IScaffoldingService.cs +++ b/src/Aspire.Cli/Scaffolding/IScaffoldingService.cs @@ -13,12 +13,14 @@ namespace Aspire.Cli.Scaffolding; /// Optional project name. /// Optional Aspire SDK version to use for scaffolding. /// Optional Aspire channel to use for scaffolding. +/// Optional package source to prefer when restoring scaffold/code-generation packages. internal record ScaffoldContext( LanguageInfo Language, DirectoryInfo TargetDirectory, string? ProjectName = null, string? SdkVersion = null, - string? Channel = null); + string? Channel = null, + string? PackageSourceOverride = null); /// /// Service for scaffolding new AppHost projects. diff --git a/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs b/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs index bf5f4a17b1f..af34060a682 100644 --- a/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs +++ b/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs @@ -97,7 +97,7 @@ private async Task ScaffoldGuestLanguageAsync(ScaffoldContext context, Can var prepareResult = await _interactionService.ShowStatusAsync( "Preparing Aspire server...", - () => appHostServerProject.PrepareAsync(prepareSdkVersion, integrations, cancellationToken), + () => appHostServerProject.PrepareAsync(prepareSdkVersion, integrations, cancellationToken, packageSourceOverride: context.PackageSourceOverride), emoji: KnownEmojis.Gear); if (!prepareResult.Success) { diff --git a/src/Aspire.Cli/Templating/CliTemplateFactory.EmptyTemplate.cs b/src/Aspire.Cli/Templating/CliTemplateFactory.EmptyTemplate.cs index 37f715e8ca1..896e84f1dc3 100644 --- a/src/Aspire.Cli/Templating/CliTemplateFactory.EmptyTemplate.cs +++ b/src/Aspire.Cli/Templating/CliTemplateFactory.EmptyTemplate.cs @@ -77,7 +77,8 @@ private async Task ApplyEmptyAppHostTemplateAsync(CallbackTempla new DirectoryInfo(outputPath), projectName, SdkVersion: inputs.Version, - Channel: inputs.Channel); + Channel: inputs.Channel, + PackageSourceOverride: inputs.Source); if (!await _scaffoldingService.ScaffoldAsync(context, cancellationToken)) { return new TemplateResult((int)CliExitCodes.FailedToCreateNewProject); diff --git a/tests/Aspire.Cli.Tests/Projects/AppHostServerSessionTests.cs b/tests/Aspire.Cli.Tests/Projects/AppHostServerSessionTests.cs index 71bf1ee28e6..f468a832e0c 100644 --- a/tests/Aspire.Cli.Tests/Projects/AppHostServerSessionTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/AppHostServerSessionTests.cs @@ -133,7 +133,8 @@ private sealed class RecordingAppHostServerProject : IAppHostServerProject public Task PrepareAsync( string sdkVersion, IEnumerable integrations, - CancellationToken cancellationToken = default) => + CancellationToken cancellationToken = default, + string? packageSourceOverride = null) => throw new NotSupportedException(); public (string SocketPath, Process Process, OutputCollector OutputCollector) Run( diff --git a/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs b/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs index 406e708f0c6..16b3855e122 100644 --- a/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs @@ -19,6 +19,8 @@ namespace Aspire.Cli.Tests.Projects; public class PrebuiltAppHostServerTests(ITestOutputHelper outputHelper) { + private const string NuGetOrgSource = "https://api.nuget.org/v3/index.json"; + [Fact] public void GenerateIntegrationProjectFile_WithPackagesOnly_ProducesPackageReferences() { @@ -170,6 +172,26 @@ public void GenerateIntegrationProjectFile_WithAdditionalSources_SetsRestoreAddi Assert.Contains("https://my-feed/v3/index.json", restoreSources); } + [Fact] + public void GenerateIntegrationProjectFile_WithRestoreConfigFile_SetsRestoreConfigFile() + { + var sources = new[] { "/local/packages", "https://my-feed/v3/index.json" }; + + var xml = PrebuiltAppHostServer.GenerateIntegrationProjectFile( + [], + [], + "/tmp/libs", + sources, + restoreConfigFile: "/tmp/nuget.config"); + var doc = XDocument.Parse(xml); + + var ns = doc.Root!.GetDefaultNamespace(); + var restoreConfigFile = doc.Descendants(ns + "RestoreConfigFile").FirstOrDefault()?.Value; + var restoreSources = doc.Descendants(ns + "RestoreAdditionalProjectSources").FirstOrDefault(); + Assert.Equal("/tmp/nuget.config", restoreConfigFile); + Assert.Null(restoreSources); + } + [Fact] public void GenerateIntegrationProjectFile_WithEmptyAdditionalSources_DoesNotSetRestoreAdditionalProjectSources() { @@ -181,6 +203,27 @@ public void GenerateIntegrationProjectFile_WithEmptyAdditionalSources_DoesNotSet Assert.Null(restoreSources); } + [Fact] + public void GenerateIntegrationProjectFile_WithExactVersions_ExactPinsOnlyAspirePackages() + { + var packageRefs = new List + { + IntegrationReference.FromPackage("Aspire.Hosting.Redis", "13.4.0-pr.17166.ga49d604d"), + IntegrationReference.FromPackage("CommunityToolkit.Aspire.Hosting.Redis", "1.0.0") + }; + + var xml = PrebuiltAppHostServer.GenerateIntegrationProjectFile( + packageRefs, + [], + "/tmp/libs", + useExactPackageVersions: true); + var doc = XDocument.Parse(xml); + + var packageElements = doc.Descendants("PackageReference").ToList(); + Assert.Contains(packageElements, e => e.Attribute("Include")?.Value == "Aspire.Hosting.Redis" && e.Attribute("Version")?.Value == "[13.4.0-pr.17166.ga49d604d]"); + Assert.Contains(packageElements, e => e.Attribute("Include")?.Value == "CommunityToolkit.Aspire.Hosting.Redis" && e.Attribute("Version")?.Value == "1.0.0"); + } + [Fact] public void Constructor_UsesWorkspaceAspireDirectoryForWorkingDirectory() { @@ -485,7 +528,7 @@ private static PrebuiltAppHostServer CreateServerWithChannel( System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); Assert.NotNull(method); - var task = (Task)method.Invoke(server, [requestedChannel, CancellationToken.None])!; + var task = (Task)method.Invoke(server, [requestedChannel, null, CancellationToken.None])!; return await task; } @@ -590,6 +633,384 @@ public async Task PrepareAsync_WithPackageReferences_SetsOnlyPackageProbeManifes } } + [Fact] + public async Task PrepareAsync_WithPackageReferences_UsesPackageSourceOverride() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + const string packageSourceOverride = "/tmp/aspire-pr-hive/packages"; + List? restoreArgs = null; + + var (server, executionFactory) = CreatePackageReferenceServer(workspace); + executionFactory.AssertionCallback = (args, _, _, _) => + { + if (args is ["nuget", "restore", ..]) + { + restoreArgs = [.. args]; + } + }; + + var workingDirectory = GetWorkingDirectory(server); + + try + { + var result = await server.PrepareAsync( + "13.4.0-pr.17141.gf142085f", + [ + IntegrationReference.FromPackage("Aspire.Hosting.CodeGeneration.TypeScript", "13.4.0-pr.17141.gf142085f"), + IntegrationReference.FromPackage("CommunityToolkit.Aspire.Hosting.Redis", "1.0.0") + ], + packageSourceOverride: packageSourceOverride); + + Assert.True(result.Success); + Assert.NotNull(restoreArgs); + Assert.Equal([packageSourceOverride, NuGetOrgSource], GetSourceArguments(restoreArgs!)); + Assert.Contains("Aspire.Hosting.CodeGeneration.TypeScript,[13.4.0-pr.17141.gf142085f]", restoreArgs!); + Assert.Contains("CommunityToolkit.Aspire.Hosting.Redis,1.0.0", restoreArgs!); + Assert.Contains("--nuget-config", restoreArgs!); + } + finally + { + DeleteWorkingDirectory(workingDirectory); + } + } + + [Theory] + [InlineData("pr-12345")] + [InlineData("local")] + [InlineData("worktree-feature")] + public async Task PrepareAsync_WithHiveBackedChannel_UsesLocalAspireSourceAsOverride(string channelName) + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + const string packageVersion = "13.4.0-pr.17141.gf142085f"; + var packageSource = workspace.CreateDirectory($"{channelName}-packages").FullName; + List? restoreArgs = null; + + await WriteAspireConfigChannelAsync(workspace, channelName); + var packagingService = CreatePackagingService(channelName, packageSource, pinnedVersion: packageVersion); + var (server, executionFactory) = CreatePackageReferenceServer(workspace, packagingService); + executionFactory.AssertionCallback = (args, _, _, _) => + { + if (args is ["nuget", "restore", ..]) + { + restoreArgs = [.. args]; + } + }; + + var workingDirectory = GetWorkingDirectory(server); + + try + { + var result = await server.PrepareAsync( + packageVersion, + [ + IntegrationReference.FromPackage("Aspire.Hosting.CodeGeneration.TypeScript", packageVersion), + IntegrationReference.FromPackage("CommunityToolkit.Aspire.Hosting.Redis", "1.0.0") + ]); + + Assert.True(result.Success); + Assert.NotNull(restoreArgs); + Assert.Equal([packageSource, NuGetOrgSource], GetSourceArguments(restoreArgs!)); + Assert.Contains($"Aspire.Hosting.CodeGeneration.TypeScript,[{packageVersion}]", restoreArgs!); + Assert.Contains("CommunityToolkit.Aspire.Hosting.Redis,1.0.0", restoreArgs!); + Assert.Contains("--nuget-config", restoreArgs!); + } + finally + { + DeleteWorkingDirectory(workingDirectory); + } + } + + [Fact] + public async Task PrepareAsync_WithExplicitPackageSourceOverride_IgnoresHiveBackedAspireSource() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + const string channelName = "pr-12345"; + const string packageVersion = "13.4.0-pr.17141.gf142085f"; + var explicitSource = workspace.CreateDirectory("explicit-source").FullName; + var hiveSource = workspace.CreateDirectory("hive-source").FullName; + List? restoreArgs = null; + + await WriteAspireConfigChannelAsync(workspace, channelName); + var packagingService = CreatePackagingService(channelName, hiveSource, pinnedVersion: packageVersion); + var (server, executionFactory) = CreatePackageReferenceServer(workspace, packagingService); + executionFactory.AssertionCallback = (args, _, _, _) => + { + if (args is ["nuget", "restore", ..]) + { + restoreArgs = [.. args]; + } + }; + + var workingDirectory = GetWorkingDirectory(server); + + try + { + var result = await server.PrepareAsync( + packageVersion, + [IntegrationReference.FromPackage("Aspire.Hosting.CodeGeneration.TypeScript", packageVersion)], + packageSourceOverride: explicitSource); + + Assert.True(result.Success); + Assert.NotNull(restoreArgs); + Assert.Equal([explicitSource, NuGetOrgSource], GetSourceArguments(restoreArgs!)); + Assert.DoesNotContain(hiveSource, restoreArgs!); + Assert.Contains($"Aspire.Hosting.CodeGeneration.TypeScript,[{packageVersion}]", restoreArgs!); + } + finally + { + DeleteWorkingDirectory(workingDirectory); + } + } + + [Fact] + public async Task PrepareAsync_WithHttpBackedChannel_DoesNotUseExactPackageVersions() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + const string channelName = "daily"; + const string packageVersion = "13.4.0-preview.1.12345.1"; + const string channelSource = "https://pkgs.dev.azure.com/fake/v3/index.json"; + List? restoreArgs = null; + + await WriteAspireConfigChannelAsync(workspace, channelName); + var packagingService = CreatePackagingService(channelName, channelSource, pinnedVersion: packageVersion); + var (server, executionFactory) = CreatePackageReferenceServer(workspace, packagingService); + executionFactory.AssertionCallback = (args, _, _, _) => + { + if (args is ["nuget", "restore", ..]) + { + restoreArgs = [.. args]; + } + }; + + var workingDirectory = GetWorkingDirectory(server); + + try + { + var result = await server.PrepareAsync( + packageVersion, + [IntegrationReference.FromPackage("Aspire.Hosting.CodeGeneration.TypeScript", packageVersion)]); + + Assert.True(result.Success); + Assert.NotNull(restoreArgs); + Assert.Equal([channelSource, NuGetOrgSource], GetSourceArguments(restoreArgs!)); + Assert.Contains($"Aspire.Hosting.CodeGeneration.TypeScript,{packageVersion}", restoreArgs!); + Assert.DoesNotContain($"Aspire.Hosting.CodeGeneration.TypeScript,[{packageVersion}]", restoreArgs!); + } + finally + { + DeleteWorkingDirectory(workingDirectory); + } + } + + [Fact] + public async Task PrepareAsync_WithProjectReferencesAndPackageSourceOverride_UsesNuGetConfig() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + const string packageSourceOverride = "/tmp/aspire-pr-hive/packages"; + XDocument? generatedProject = null; + bool restoreConfigFileExistedDuringBuild = false; + + var closureFiles = new Dictionary(StringComparer.Ordinal) + { + ["MyIntegration.dll"] = "integration-v1" + }; + var dotNetCliRunner = new TestDotNetCliRunner + { + BuildAsyncCallback = (projectFilePath, _, _, _) => + { + generatedProject = XDocument.Load(projectFilePath.FullName); + var ns = generatedProject.Root!.GetDefaultNamespace(); + var restoreConfigFile = generatedProject.Descendants(ns + "RestoreConfigFile").FirstOrDefault()?.Value; + restoreConfigFileExistedDuringBuild = restoreConfigFile is not null && File.Exists(restoreConfigFile); + WriteClosureInputs(projectFilePath.Directory!, closureFiles, ["MyIntegration"]); + return 0; + } + }; + var nugetService = new BundleNuGetService( + new NullLayoutDiscovery(), + new LayoutProcessRunner(new TestProcessExecutionFactory()), + new TestFeatures(), + TestExecutionContextFactory.CreateTestContext(), + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + var server = new PrebuiltAppHostServer( + workspace.WorkspaceRoot.FullName, + "test.sock", + new LayoutConfiguration(), + nugetService, + dotNetCliRunner, + new TestDotNetSdkInstaller(), + MockPackagingServiceFactory.Create(), + TestExecutionContextFactory.CreateTestContext(), + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + var workingDirectory = GetWorkingDirectory(server); + + try + { + var result = await server.PrepareAsync( + "13.4.0-pr.17166.ga49d604d", + [ + IntegrationReference.FromPackage("Aspire.Hosting.Redis", "13.4.0-pr.17166.ga49d604d"), + IntegrationReference.FromPackage("CommunityToolkit.Aspire.Hosting.Redis", "1.0.0"), + IntegrationReference.FromProject("MyIntegration", "/path/to/MyIntegration.csproj") + ], + packageSourceOverride: packageSourceOverride); + + Assert.True(result.Success); + Assert.NotNull(generatedProject); + + var ns = generatedProject.Root!.GetDefaultNamespace(); + var restoreConfigFile = generatedProject.Descendants(ns + "RestoreConfigFile").FirstOrDefault()?.Value; + Assert.NotNull(restoreConfigFile); + Assert.True(restoreConfigFileExistedDuringBuild); + Assert.Null(generatedProject.Descendants(ns + "RestoreAdditionalProjectSources").FirstOrDefault()); + + var packageElements = generatedProject.Descendants("PackageReference").ToList(); + Assert.Contains(packageElements, e => e.Attribute("Include")?.Value == "Aspire.Hosting.Redis" && e.Attribute("Version")?.Value == "[13.4.0-pr.17166.ga49d604d]"); + Assert.Contains(packageElements, e => e.Attribute("Include")?.Value == "CommunityToolkit.Aspire.Hosting.Redis" && e.Attribute("Version")?.Value == "1.0.0"); + } + finally + { + DeleteWorkingDirectory(workingDirectory); + } + } + + [Fact] + public async Task PrepareAsync_WithProjectReferencesAndExplicitChannelButNoOverride_PreservesAmbientNuGetConfig() + { + // Regression guard: project-reference restores must NOT emit when + // no --source override is set. TemporaryNuGetConfig writes , which would silently + // strip any private feeds the user has in their ambient nuget.config that the project's + // transitive non-Aspire dependencies depend on. + using var workspace = TemporaryWorkspace.Create(outputHelper); + const string channelName = "staging"; + const string stagingFeed = "https://example.com/staging/v3/index.json"; + XDocument? generatedProject = null; + + await WriteAspireConfigChannelAsync(workspace, channelName); + + var stagingChannel = PackageChannel.CreateExplicitChannel( + channelName, + PackageChannelQuality.Both, + [ + new PackageMapping("Aspire*", stagingFeed), + new PackageMapping(PackageMapping.AllPackages, NuGetOrgSource) + ], + new FakeNuGetPackageCache()); + var packagingService = new TestPackagingService + { + GetChannelsWithRequestedChannelAsyncCallback = (_, requestedChannelName) => Task.FromResult>( + string.Equals(requestedChannelName, channelName, StringComparison.OrdinalIgnoreCase) + ? [stagingChannel] + : []) + }; + + var closureFiles = new Dictionary(StringComparer.Ordinal) + { + ["MyIntegration.dll"] = "integration-v1" + }; + var dotNetCliRunner = new TestDotNetCliRunner + { + BuildAsyncCallback = (projectFilePath, _, _, _) => + { + generatedProject = XDocument.Load(projectFilePath.FullName); + WriteClosureInputs(projectFilePath.Directory!, closureFiles, ["MyIntegration"]); + return 0; + } + }; + var nugetService = new BundleNuGetService( + new NullLayoutDiscovery(), + new LayoutProcessRunner(new TestProcessExecutionFactory()), + new TestFeatures(), + TestExecutionContextFactory.CreateTestContext(), + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + var server = new PrebuiltAppHostServer( + workspace.WorkspaceRoot.FullName, + "test.sock", + new LayoutConfiguration(), + nugetService, + dotNetCliRunner, + new TestDotNetSdkInstaller(), + packagingService, + TestExecutionContextFactory.CreateTestContext(), + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + var workingDirectory = GetWorkingDirectory(server); + + try + { + var result = await server.PrepareAsync( + "13.2.0", + [ + IntegrationReference.FromPackage("Aspire.Hosting.Redis", "13.2.0"), + IntegrationReference.FromProject("MyIntegration", "/path/to/MyIntegration.csproj") + ]); + + Assert.True(result.Success); + Assert.NotNull(generatedProject); + + var ns = generatedProject.Root!.GetDefaultNamespace(); + Assert.Null(generatedProject.Descendants(ns + "RestoreConfigFile").FirstOrDefault()); + var restoreSources = generatedProject.Descendants(ns + "RestoreAdditionalProjectSources").FirstOrDefault()?.Value; + Assert.NotNull(restoreSources); + Assert.Contains(stagingFeed, restoreSources!); + + // Versions remain unpinned (no exact-version brackets) without an override. + var packageElements = generatedProject.Descendants("PackageReference").ToList(); + Assert.Contains(packageElements, e => e.Attribute("Include")?.Value == "Aspire.Hosting.Redis" && e.Attribute("Version")?.Value == "13.2.0"); + } + finally + { + DeleteWorkingDirectory(workingDirectory); + } + } + + [Fact] + public async Task PrepareAsync_WhenPackagingServiceThrowsDuringAutoDiscovery_DegradesGracefully() + { + // Regression guard: an unexpected failure from IPackagingService.GetChannelsAsync during + // hive-source auto-discovery must NOT turn `aspire new` into a hard failure. The auto- + // discovery path should fall through to "no override discovered" and let restore proceed + // on the ambient + channel-sources path, matching the defensive catches in the sibling + // channel-lookup helpers. + using var workspace = TemporaryWorkspace.Create(outputHelper); + const string channelName = "pr-12345"; + List? restoreArgs = null; + + await WriteAspireConfigChannelAsync(workspace, channelName); + + var packagingService = new TestPackagingService + { + GetChannelsAsyncCallback = _ => Task.FromException>( + new InvalidOperationException("simulated packaging service failure")) + }; + var (server, executionFactory) = CreatePackageReferenceServer(workspace, packagingService); + executionFactory.AssertionCallback = (args, _, _, _) => + { + if (args is ["nuget", "restore", ..]) + { + restoreArgs = [.. args]; + } + }; + + var workingDirectory = GetWorkingDirectory(server); + + try + { + var result = await server.PrepareAsync( + "13.4.0-pr.17141.gf142085f", + [IntegrationReference.FromPackage("Aspire.Hosting.CodeGeneration.TypeScript", "13.4.0-pr.17141.gf142085f")]); + + Assert.True(result.Success); + Assert.NotNull(restoreArgs); + // No override resolved → no exact version pinning, no synthesized [override, nuget.org] source set. + Assert.Contains("Aspire.Hosting.CodeGeneration.TypeScript,13.4.0-pr.17141.gf142085f", restoreArgs!); + Assert.DoesNotContain("Aspire.Hosting.CodeGeneration.TypeScript,[13.4.0-pr.17141.gf142085f]", restoreArgs!); + } + finally + { + DeleteWorkingDirectory(workingDirectory); + } + } + [Fact] public async Task PrepareAsync_WithStagingPinnedProjectOutsideLaunchDirectory_UsesStagingSourcesAndNuGetConfig() { @@ -639,7 +1060,10 @@ public async Task PrepareAsync_WithStagingPinnedProjectOutsideLaunchDirectory_Us new FakeNuGetPackageCache()); var packagingService = new TestPackagingService { - GetChannelsAsyncCallback = _ => Task.FromResult>([stagingChannel]) + GetChannelsWithRequestedChannelAsyncCallback = (_, requestedChannelName) => Task.FromResult>( + string.Equals(requestedChannelName, PackageChannelNames.Staging, StringComparison.OrdinalIgnoreCase) + ? [stagingChannel] + : []) }; var server = new PrebuiltAppHostServer( @@ -1137,6 +1561,11 @@ private static PrebuiltAppHostServer CreateProjectReferenceServer( } private static (PrebuiltAppHostServer Server, TestProcessExecutionFactory ExecutionFactory) CreatePackageReferenceServer(TemporaryWorkspace workspace) + { + return CreatePackageReferenceServer(workspace, MockPackagingServiceFactory.Create()); + } + + private static (PrebuiltAppHostServer Server, TestProcessExecutionFactory ExecutionFactory) CreatePackageReferenceServer(TemporaryWorkspace workspace, IPackagingService packagingService) { var layout = CreateBundleLayout(workspace); var executionFactory = new TestProcessExecutionFactory(); @@ -1154,13 +1583,42 @@ private static (PrebuiltAppHostServer Server, TestProcessExecutionFactory Execut nugetService, new TestDotNetCliRunner(), new TestDotNetSdkInstaller(), - MockPackagingServiceFactory.Create(), + packagingService, TestExecutionContextFactory.CreateTestContext(), Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); return (server, executionFactory); } + private static TestPackagingService CreatePackagingService(string channelName, string aspirePackageSource, string? pinnedVersion = null) + { + var channel = PackageChannel.CreateExplicitChannel( + channelName, + PackageChannelQuality.Both, + [ + new PackageMapping("Aspire*", aspirePackageSource), + new PackageMapping(PackageMapping.AllPackages, NuGetOrgSource) + ], + new FakeNuGetPackageCache(), + pinnedVersion: pinnedVersion); + + return new TestPackagingService + { + GetChannelsAsyncCallback = _ => Task.FromResult>([channel]) + }; + } + + private static Task WriteAspireConfigChannelAsync(TemporaryWorkspace workspace, string channelName) + { + return File.WriteAllTextAsync( + Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName), + $$""" + { + "channel": "{{channelName}}" + } + """); + } + private static LayoutConfiguration CreateBundleLayout(TemporaryWorkspace workspace) { var layoutRoot = workspace.CreateDirectory("layout"); @@ -1309,6 +1767,20 @@ public void CreateStartInfo_SetsCliLogFilePathEnvironmentVariable() Assert.Equal(executionContext.LogFilePath, startInfo.Environment[KnownConfigNames.CliLogFilePath]); } + private static string[] GetSourceArguments(IReadOnlyList args) + { + var sources = new List(); + for (var i = 0; i < args.Count - 1; i++) + { + if (args[i] == "--source") + { + sources.Add(args[i + 1]); + } + } + + return [.. sources]; + } + private static void DeleteWorkingDirectory(string workingDirectory) { if (Directory.Exists(workingDirectory)) diff --git a/tests/Aspire.Cli.Tests/Scaffolding/ChannelReseedTests.cs b/tests/Aspire.Cli.Tests/Scaffolding/ChannelReseedTests.cs index 2aef7b05024..a3e12f9dabf 100644 --- a/tests/Aspire.Cli.Tests/Scaffolding/ChannelReseedTests.cs +++ b/tests/Aspire.Cli.Tests/Scaffolding/ChannelReseedTests.cs @@ -6,7 +6,9 @@ using Aspire.Cli.Scaffolding; using Aspire.Cli.Tests.TestServices; using Aspire.Cli.Tests.Utils; +using Aspire.Cli.Utils; using Microsoft.Extensions.Logging.Abstractions; +using System.Diagnostics; namespace Aspire.Cli.Tests.Scaffolding; @@ -70,6 +72,36 @@ await Assert.ThrowsAnyAsync( Assert.Equal(expectedPersistedChannel, reloaded.Channel); } + [Fact] + public async Task ScaffoldAsync_PassesPackageSourceOverrideToPrepareAsync() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + const string packageSourceOverride = "/tmp/aspire-pr-hive/packages"; + var language = s_testLanguage with { PackageName = "Aspire.Hosting.CodeGeneration.TypeScript" }; + var appHostServerProject = new CapturingAppHostServerProject(workspace.WorkspaceRoot.FullName); + + var scaffoldingService = new ScaffoldingService( + appHostServerProjectFactory: new TestAppHostServerProjectFactory + { + CreateAsyncCallback = (_, _) => Task.FromResult(appHostServerProject) + }, + languageDiscovery: new TestLanguageDiscovery(language), + interactionService: new TestInteractionService(), + logger: NullLogger.Instance); + + var context = new ScaffoldContext( + Language: language, + TargetDirectory: workspace.WorkspaceRoot, + ProjectName: "test", + SdkVersion: "13.4.0-pr.17141.gf142085f", + PackageSourceOverride: packageSourceOverride); + + var result = await scaffoldingService.ScaffoldAsync(context, CancellationToken.None); + + Assert.False(result); + Assert.Equal(packageSourceOverride, appHostServerProject.PackageSourceOverride); + } + private static readonly LanguageInfo s_testLanguage = new( LanguageId: new LanguageId(KnownLanguageId.TypeScript), DisplayName: "TypeScript", @@ -86,5 +118,30 @@ private static ScaffoldingService CreateScaffoldingService() interactionService: new TestInteractionService(), logger: NullLogger.Instance); } -} + private sealed class CapturingAppHostServerProject(string appDirectoryPath) : IAppHostServerProject + { + public string AppDirectoryPath { get; } = appDirectoryPath; + + public string? PackageSourceOverride { get; private set; } + + public string GetInstanceIdentifier() => AppDirectoryPath; + + public Task PrepareAsync( + string sdkVersion, + IEnumerable integrations, + CancellationToken cancellationToken = default, + string? packageSourceOverride = null) + { + PackageSourceOverride = packageSourceOverride; + return Task.FromResult(new AppHostServerPrepareResult(Success: false, Output: null)); + } + + public (string SocketPath, Process Process, OutputCollector OutputCollector) Run( + int hostPid, + IReadOnlyDictionary? environmentVariables = null, + string[]? additionalArgs = null, + bool debug = false) => + throw new NotSupportedException("Run should not be invoked when PrepareAsync fails."); + } +} diff --git a/tests/Aspire.Cli.Tests/TestServices/FakeFailingAppHostServerProject.cs b/tests/Aspire.Cli.Tests/TestServices/FakeFailingAppHostServerProject.cs index b03c0c8db06..36799b62b9e 100644 --- a/tests/Aspire.Cli.Tests/TestServices/FakeFailingAppHostServerProject.cs +++ b/tests/Aspire.Cli.Tests/TestServices/FakeFailingAppHostServerProject.cs @@ -24,7 +24,8 @@ internal sealed class FakeFailingAppHostServerProject(string appDirectoryPath) : public Task PrepareAsync( string sdkVersion, IEnumerable integrations, - CancellationToken cancellationToken = default) => + CancellationToken cancellationToken = default, + string? packageSourceOverride = null) => Task.FromResult(new AppHostServerPrepareResult(Success: false, Output: null)); public (string SocketPath, Process Process, OutputCollector OutputCollector) Run( diff --git a/tests/Aspire.Cli.Tests/TestServices/TestPackagingService.cs b/tests/Aspire.Cli.Tests/TestServices/TestPackagingService.cs index c7d9ee625fc..94820becd2f 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestPackagingService.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestPackagingService.cs @@ -7,10 +7,17 @@ namespace Aspire.Cli.Tests.TestServices; internal sealed class TestPackagingService : IPackagingService { + public Func>>? GetChannelsWithRequestedChannelAsyncCallback { get; set; } + public Func>>? GetChannelsAsyncCallback { get; set; } public Task> GetChannelsAsync(CancellationToken cancellationToken = default, string? requestedChannelName = null) { + if (GetChannelsWithRequestedChannelAsyncCallback is not null) + { + return GetChannelsWithRequestedChannelAsyncCallback(cancellationToken, requestedChannelName); + } + if (GetChannelsAsyncCallback is not null) { return GetChannelsAsyncCallback(cancellationToken);