From b7ad6a2000274585c761e8fd18df76dd59d86f1b Mon Sep 17 00:00:00 2001 From: Jose Perez Rodriguez Date: Wed, 11 Feb 2026 17:29:54 -0800 Subject: [PATCH 1/5] Add override options for staging feed and quality in configuration schema and implement related tests (#14455) * Add override options for staging feed and quality in configuration schema and implement related tests * Add stagingVersionPrefix property to configuration schemas and implement related logic in PackagingService --- .../aspire-global-settings.schema.json | 17 ++ extension/schemas/aspire-settings.schema.json | 17 ++ src/Aspire.Cli/Packaging/PackageChannel.cs | 25 +- src/Aspire.Cli/Packaging/PackagingService.cs | 42 ++- .../Packaging/PackagingServiceTests.cs | 247 ++++++++++++++++++ 5 files changed, 337 insertions(+), 11 deletions(-) diff --git a/extension/schemas/aspire-global-settings.schema.json b/extension/schemas/aspire-global-settings.schema.json index bf16ec26e56..8f7a8db0414 100644 --- a/extension/schemas/aspire-global-settings.schema.json +++ b/extension/schemas/aspire-global-settings.schema.json @@ -294,6 +294,23 @@ "sdkVersion": { "description": "The Aspire SDK version used for this polyglot AppHost project. Determines the version of Aspire.Hosting packages to use.", "type": "string" + }, + "overrideStagingFeed": { + "description": "Override the NuGet feed URL used by the staging channel. When set, this URL is used instead of the default SHA-based build-specific feed.", + "type": "string" + }, + "overrideStagingQuality": { + "description": "Override the package quality filter for the staging channel. Set to \"Prerelease\" when staging builds are not yet marked as stable to use the shared daily feed instead of the SHA-based feed. Valid values: \"Stable\", \"Prerelease\", \"Both\".", + "type": "string", + "enum": [ + "Stable", + "Prerelease", + "Both" + ] + }, + "stagingVersionPrefix": { + "description": "Filter staging channel packages to a specific Major.Minor version (e.g., \"13.2\"). When set, only packages matching this version prefix are shown, preventing newer daily versions from being selected.", + "type": "string" } }, "additionalProperties": false diff --git a/extension/schemas/aspire-settings.schema.json b/extension/schemas/aspire-settings.schema.json index 391cf7adb02..c2da807d981 100644 --- a/extension/schemas/aspire-settings.schema.json +++ b/extension/schemas/aspire-settings.schema.json @@ -298,6 +298,23 @@ "sdkVersion": { "description": "The Aspire SDK version used for this polyglot AppHost project. Determines the version of Aspire.Hosting packages to use.", "type": "string" + }, + "overrideStagingFeed": { + "description": "Override the NuGet feed URL used by the staging channel. When set, this URL is used instead of the default SHA-based build-specific feed.", + "type": "string" + }, + "overrideStagingQuality": { + "description": "Override the package quality filter for the staging channel. Set to \"Prerelease\" when staging builds are not yet marked as stable to use the shared daily feed instead of the SHA-based feed. Valid values: \"Stable\", \"Prerelease\", \"Both\".", + "type": "string", + "enum": [ + "Stable", + "Prerelease", + "Both" + ] + }, + "stagingVersionPrefix": { + "description": "Filter staging channel packages to a specific Major.Minor version (e.g., \"13.2\"). When set, only packages matching this version prefix are shown, preventing newer daily versions from being selected.", + "type": "string" } }, "additionalProperties": false diff --git a/src/Aspire.Cli/Packaging/PackageChannel.cs b/src/Aspire.Cli/Packaging/PackageChannel.cs index e9bc936d6e9..8152e83ca9c 100644 --- a/src/Aspire.Cli/Packaging/PackageChannel.cs +++ b/src/Aspire.Cli/Packaging/PackageChannel.cs @@ -8,7 +8,7 @@ namespace Aspire.Cli.Packaging; -internal class PackageChannel(string name, PackageChannelQuality quality, PackageMapping[]? mappings, INuGetPackageCache nuGetPackageCache, bool configureGlobalPackagesFolder = false, string? cliDownloadBaseUrl = null) +internal class PackageChannel(string name, PackageChannelQuality quality, PackageMapping[]? mappings, INuGetPackageCache nuGetPackageCache, bool configureGlobalPackagesFolder = false, string? cliDownloadBaseUrl = null, SemVersion? versionPrefix = null) { public string Name { get; } = name; public PackageChannelQuality Quality { get; } = quality; @@ -16,9 +16,20 @@ internal class PackageChannel(string name, PackageChannelQuality quality, Packag public PackageChannelType Type { get; } = mappings is null ? PackageChannelType.Implicit : PackageChannelType.Explicit; public bool ConfigureGlobalPackagesFolder { get; } = configureGlobalPackagesFolder; public string? CliDownloadBaseUrl { get; } = cliDownloadBaseUrl; + public SemVersion? VersionPrefix { get; } = versionPrefix; public string SourceDetails { get; } = ComputeSourceDetails(mappings); + private bool MatchesVersionPrefix(SemVersion semVer) + { + if (VersionPrefix is null) + { + return true; + } + + return semVer.Major == VersionPrefix.Major && semVer.Minor == VersionPrefix.Minor; + } + private static string ComputeSourceDetails(PackageMapping[]? mappings) { if (mappings is null) @@ -69,7 +80,7 @@ public async Task> GetTemplatePackagesAsync(DirectoryI { Quality: PackageChannelQuality.Stable, SemVer: { IsPrerelease: false } } => true, { Quality: PackageChannelQuality.Prerelease, SemVer: { IsPrerelease: true } } => true, _ => false - }); + }).Where(p => MatchesVersionPrefix(SemVersion.Parse(p.Version))); return filteredPackages; } @@ -104,7 +115,7 @@ public async Task> GetIntegrationPackagesAsync(Directo { Quality: PackageChannelQuality.Stable, SemVer: { IsPrerelease: false } } => true, { Quality: PackageChannelQuality.Prerelease, SemVer: { IsPrerelease: true } } => true, _ => false - }); + }).Where(p => MatchesVersionPrefix(SemVersion.Parse(p.Version))); return filteredPackages; } @@ -159,7 +170,7 @@ public async Task> GetPackagesAsync(string packageId, useCache: true, // Enable caching for package channel resolution cancellationToken: cancellationToken); - return packages; + return packages.Where(p => MatchesVersionPrefix(SemVersion.Parse(p.Version))); } // When doing a `dotnet package search` the the results may include stable packages even when searching for @@ -170,14 +181,14 @@ public async Task> GetPackagesAsync(string packageId, { Quality: PackageChannelQuality.Stable, SemVer: { IsPrerelease: false } } => true, { Quality: PackageChannelQuality.Prerelease, SemVer: { IsPrerelease: true } } => true, _ => false - }); + }).Where(p => MatchesVersionPrefix(SemVersion.Parse(p.Version))); return filteredPackages; } - public static PackageChannel CreateExplicitChannel(string name, PackageChannelQuality quality, PackageMapping[]? mappings, INuGetPackageCache nuGetPackageCache, bool configureGlobalPackagesFolder = false, string? cliDownloadBaseUrl = null) + public static PackageChannel CreateExplicitChannel(string name, PackageChannelQuality quality, PackageMapping[]? mappings, INuGetPackageCache nuGetPackageCache, bool configureGlobalPackagesFolder = false, string? cliDownloadBaseUrl = null, SemVersion? versionPrefix = null) { - return new PackageChannel(name, quality, mappings, nuGetPackageCache, configureGlobalPackagesFolder, cliDownloadBaseUrl); + return new PackageChannel(name, quality, mappings, nuGetPackageCache, configureGlobalPackagesFolder, cliDownloadBaseUrl, versionPrefix); } public static PackageChannel CreateImplicitChannel(INuGetPackageCache nuGetPackageCache) diff --git a/src/Aspire.Cli/Packaging/PackagingService.cs b/src/Aspire.Cli/Packaging/PackagingService.cs index 0456a85bf62..7bde39025b0 100644 --- a/src/Aspire.Cli/Packaging/PackagingService.cs +++ b/src/Aspire.Cli/Packaging/PackagingService.cs @@ -4,6 +4,7 @@ using Aspire.Cli.Configuration; using Aspire.Cli.NuGet; using Microsoft.Extensions.Configuration; +using Semver; using System.Reflection; namespace Aspire.Cli.Packaging; @@ -75,24 +76,34 @@ public Task> GetChannelsAsync(CancellationToken canc private PackageChannel? CreateStagingChannel() { - var stagingFeedUrl = GetStagingFeedUrl(); + var stagingQuality = GetStagingQuality(); + var hasExplicitFeedOverride = !string.IsNullOrEmpty(configuration["overrideStagingFeed"]); + + // When quality is Prerelease or Both and no explicit feed override is set, + // use the shared daily feed instead of the SHA-specific feed. SHA-specific + // darc-pub-* feeds are only created for stable-quality builds, so a non-Stable + // quality without an explicit feed override can only work with the shared feed. + var useSharedFeed = !hasExplicitFeedOverride && + stagingQuality is not PackageChannelQuality.Stable; + + var stagingFeedUrl = GetStagingFeedUrl(useSharedFeed); if (stagingFeedUrl is null) { return null; } - var stagingQuality = GetStagingQuality(); + var versionPrefix = GetStagingVersionPrefix(); var stagingChannel = PackageChannel.CreateExplicitChannel(PackageChannelNames.Staging, stagingQuality, new[] { new PackageMapping("Aspire*", stagingFeedUrl), new PackageMapping(PackageMapping.AllPackages, "https://api.nuget.org/v3/index.json") - }, nuGetPackageCache, configureGlobalPackagesFolder: true, cliDownloadBaseUrl: "https://aka.ms/dotnet/9/aspire/rc/daily"); + }, nuGetPackageCache, configureGlobalPackagesFolder: !useSharedFeed, cliDownloadBaseUrl: "https://aka.ms/dotnet/9/aspire/rc/daily", versionPrefix: versionPrefix); return stagingChannel; } - private string? GetStagingFeedUrl() + private string? GetStagingFeedUrl(bool useSharedFeed) { // Check for configuration override first var overrideFeed = configuration["overrideStagingFeed"]; @@ -107,6 +118,12 @@ public Task> GetChannelsAsync(CancellationToken canc // Invalid URL, fall through to default behavior } + // Use the shared daily feed when builds aren't marked stable + if (useSharedFeed) + { + return "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet9/nuget/v3/index.json"; + } + // Extract commit hash from assembly version to build staging feed URL // Staging feed URL template: https://pkgs.dev.azure.com/dnceng/public/_packaging/darc-pub-dotnet-aspire-{commitHash}/nuget/v3/index.json var assembly = Assembly.GetExecutingAssembly(); @@ -148,4 +165,21 @@ private PackageChannelQuality GetStagingQuality() // Default to Stable if not specified or invalid return PackageChannelQuality.Stable; } + + private SemVersion? GetStagingVersionPrefix() + { + var versionPrefixValue = configuration["stagingVersionPrefix"]; + if (string.IsNullOrEmpty(versionPrefixValue)) + { + return null; + } + + // Parse "Major.Minor" format (e.g., "13.2") as a SemVersion for comparison + if (SemVersion.TryParse($"{versionPrefixValue}.0", SemVersionStyles.Strict, out var semVersion)) + { + return semVersion; + } + + return null; + } } diff --git a/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs b/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs index 5928d77e2d5..74b71823ea7 100644 --- a/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs @@ -482,4 +482,251 @@ public async Task GetChannelsAsync_WhenStagingChannelDisabled_OrderIsDefaultStab Assert.True(stableIndex < dailyIndex, "stable should come before daily"); Assert.True(dailyIndex < pr12345Index, "daily should come before pr-12345"); } + + [Fact] + public async Task GetChannelsAsync_WhenStagingQualityPrerelease_AndNoFeedOverride_UsesSharedFeed() + { + // Arrange + using var workspace = TemporaryWorkspace.Create(outputHelper); + var tempDir = workspace.WorkspaceRoot; + var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")); + var cacheDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "cache")); + var executionContext = new CliExecutionContext(tempDir, hivesDir, cacheDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); + + var features = new TestFeatures(); + features.SetFeature(KnownFeatures.StagingChannelEnabled, true); + + // Set quality to Prerelease but do NOT set overrideStagingFeed + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["overrideStagingQuality"] = "Prerelease" + }) + .Build(); + + var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration); + + // Act + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + + // Assert + var stagingChannel = channels.First(c => c.Name == "staging"); + Assert.Equal(PackageChannelQuality.Prerelease, stagingChannel.Quality); + Assert.False(stagingChannel.ConfigureGlobalPackagesFolder); + + var aspireMapping = stagingChannel.Mappings!.FirstOrDefault(m => m.PackageFilter == "Aspire*"); + Assert.NotNull(aspireMapping); + Assert.Equal("https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet9/nuget/v3/index.json", aspireMapping.Source); + } + + [Fact] + public async Task GetChannelsAsync_WhenStagingQualityBoth_AndNoFeedOverride_UsesSharedFeed() + { + // Arrange + using var workspace = TemporaryWorkspace.Create(outputHelper); + var tempDir = workspace.WorkspaceRoot; + var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")); + var cacheDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "cache")); + var executionContext = new CliExecutionContext(tempDir, hivesDir, cacheDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); + + var features = new TestFeatures(); + features.SetFeature(KnownFeatures.StagingChannelEnabled, true); + + // Set quality to Both but do NOT set overrideStagingFeed + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["overrideStagingQuality"] = "Both" + }) + .Build(); + + var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration); + + // Act + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + + // Assert + var stagingChannel = channels.First(c => c.Name == "staging"); + Assert.Equal(PackageChannelQuality.Both, stagingChannel.Quality); + Assert.False(stagingChannel.ConfigureGlobalPackagesFolder); + + var aspireMapping = stagingChannel.Mappings!.FirstOrDefault(m => m.PackageFilter == "Aspire*"); + Assert.NotNull(aspireMapping); + Assert.Equal("https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet9/nuget/v3/index.json", aspireMapping.Source); + } + + [Fact] + public async Task GetChannelsAsync_WhenStagingQualityPrerelease_WithFeedOverride_UsesFeedOverride() + { + // Arrange + using var workspace = TemporaryWorkspace.Create(outputHelper); + var tempDir = workspace.WorkspaceRoot; + var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")); + var cacheDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "cache")); + var executionContext = new CliExecutionContext(tempDir, hivesDir, cacheDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); + + var features = new TestFeatures(); + features.SetFeature(KnownFeatures.StagingChannelEnabled, true); + + // Set both quality override AND feed override — feed override should win + var customFeed = "https://custom-feed.example.com/v3/index.json"; + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["overrideStagingQuality"] = "Prerelease", + ["overrideStagingFeed"] = customFeed + }) + .Build(); + + var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration); + + // Act + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + + // Assert + var stagingChannel = channels.First(c => c.Name == "staging"); + Assert.Equal(PackageChannelQuality.Prerelease, stagingChannel.Quality); + // When an explicit feed override is provided, globalPackagesFolder stays enabled + Assert.True(stagingChannel.ConfigureGlobalPackagesFolder); + + var aspireMapping = stagingChannel.Mappings!.FirstOrDefault(m => m.PackageFilter == "Aspire*"); + Assert.NotNull(aspireMapping); + Assert.Equal(customFeed, aspireMapping.Source); + } + + [Fact] + public async Task NuGetConfigMerger_WhenStagingUsesSharedFeed_DoesNotAddGlobalPackagesFolder() + { + // Arrange + using var workspace = TemporaryWorkspace.Create(outputHelper); + var tempDir = workspace.WorkspaceRoot; + + var features = new TestFeatures(); + features.SetFeature(KnownFeatures.StagingChannelEnabled, true); + + // Quality=Prerelease with no feed override → shared feed mode + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["overrideStagingQuality"] = "Prerelease" + }) + .Build(); + + var packagingService = new PackagingService( + new CliExecutionContext(tempDir, tempDir, tempDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"), + new FakeNuGetPackageCache(), + features, + configuration); + + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + var stagingChannel = channels.First(c => c.Name == "staging"); + + // Act + await NuGetConfigMerger.CreateOrUpdateAsync(tempDir, stagingChannel).DefaultTimeout(); + + // Assert + var nugetConfigPath = Path.Combine(tempDir.FullName, "nuget.config"); + Assert.True(File.Exists(nugetConfigPath)); + + var configContent = await File.ReadAllTextAsync(nugetConfigPath); + Assert.DoesNotContain("globalPackagesFolder", configContent); + Assert.DoesNotContain(".nugetpackages", configContent); + + // Verify it still has the shared feed URL + Assert.Contains("dotnet9", configContent); + } + + [Fact] + public async Task GetChannelsAsync_WhenStagingVersionPrefixSet_ChannelHasVersionPrefix() + { + // Arrange + using var workspace = TemporaryWorkspace.Create(outputHelper); + var tempDir = workspace.WorkspaceRoot; + var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")); + var cacheDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "cache")); + var executionContext = new CliExecutionContext(tempDir, hivesDir, cacheDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); + + var features = new TestFeatures(); + features.SetFeature(KnownFeatures.StagingChannelEnabled, true); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["overrideStagingFeed"] = "https://example.com/nuget/v3/index.json", + ["stagingVersionPrefix"] = "13.2" + }) + .Build(); + + var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration); + + // Act + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + + // Assert + var stagingChannel = channels.First(c => c.Name == "staging"); + Assert.NotNull(stagingChannel.VersionPrefix); + Assert.Equal(13, stagingChannel.VersionPrefix!.Major); + Assert.Equal(2, stagingChannel.VersionPrefix.Minor); + } + + [Fact] + public async Task GetChannelsAsync_WhenStagingVersionPrefixNotSet_ChannelHasNoVersionPrefix() + { + // Arrange + using var workspace = TemporaryWorkspace.Create(outputHelper); + var tempDir = workspace.WorkspaceRoot; + var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")); + var cacheDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "cache")); + var executionContext = new CliExecutionContext(tempDir, hivesDir, cacheDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); + + var features = new TestFeatures(); + features.SetFeature(KnownFeatures.StagingChannelEnabled, true); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["overrideStagingFeed"] = "https://example.com/nuget/v3/index.json" + }) + .Build(); + + var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration); + + // Act + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + + // Assert + var stagingChannel = channels.First(c => c.Name == "staging"); + Assert.Null(stagingChannel.VersionPrefix); + } + + [Fact] + public async Task GetChannelsAsync_WhenStagingVersionPrefixInvalid_ChannelHasNoVersionPrefix() + { + // Arrange + using var workspace = TemporaryWorkspace.Create(outputHelper); + var tempDir = workspace.WorkspaceRoot; + var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")); + var cacheDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "cache")); + var executionContext = new CliExecutionContext(tempDir, hivesDir, cacheDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); + + var features = new TestFeatures(); + features.SetFeature(KnownFeatures.StagingChannelEnabled, true); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["overrideStagingFeed"] = "https://example.com/nuget/v3/index.json", + ["stagingVersionPrefix"] = "not-a-version" + }) + .Build(); + + var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration); + + // Act + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + + // Assert + var stagingChannel = channels.First(c => c.Name == "staging"); + Assert.Null(stagingChannel.VersionPrefix); + } } From ec55d98ba2d434815355df838ac927918622d82f Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 08:09:50 -0800 Subject: [PATCH 2/5] Hide the aspire setup command if the bundle isn't available (#14464) * Initial plan * Hide aspire setup command when bundle is not available Conditionally add SetupCommand to the CLI command tree only when IBundleService.IsBundle is true, following the same pattern used for ExecCommand and SdkCommand feature-flag gating. Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> * Add tests for setup command visibility based on bundle availability Add two tests to RootCommandTests: - SetupCommand_NotAvailable_WhenBundleIsNotAvailable: verifies setup command is hidden when the CLI has no embedded bundle - SetupCommand_Available_WhenBundleIsAvailable: verifies setup command is visible when the CLI has an embedded bundle Also add BundleServiceFactory to CliServiceCollectionTestOptions and TestBundleService to support overriding bundle behavior in tests. Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> --- src/Aspire.Cli/Commands/RootCommand.cs | 8 ++++- .../Commands/RootCommandTests.cs | 29 +++++++++++++++++++ tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs | 21 +++++++++++++- 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Cli/Commands/RootCommand.cs b/src/Aspire.Cli/Commands/RootCommand.cs index db956046a9a..6126c8d8761 100644 --- a/src/Aspire.Cli/Commands/RootCommand.cs +++ b/src/Aspire.Cli/Commands/RootCommand.cs @@ -10,6 +10,7 @@ using System.Diagnostics; #endif +using Aspire.Cli.Bundles; using Aspire.Cli.Commands.Sdk; using Aspire.Cli.Configuration; using Aspire.Cli.Interaction; @@ -131,6 +132,7 @@ public RootCommand( SdkCommand sdkCommand, SetupCommand setupCommand, ExtensionInternalCommand extensionInternalCommand, + IBundleService bundleService, IFeatures featureFlags, IInteractionService interactionService) : base(RootCommandStrings.Description) @@ -208,7 +210,11 @@ public RootCommand( Subcommands.Add(agentCommand); Subcommands.Add(telemetryCommand); Subcommands.Add(docsCommand); - Subcommands.Add(setupCommand); + + if (bundleService.IsBundle) + { + Subcommands.Add(setupCommand); + } if (featureFlags.IsFeatureEnabled(KnownFeatures.ExecCommandEnabled, false)) { diff --git a/tests/Aspire.Cli.Tests/Commands/RootCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/RootCommandTests.cs index c58ec07c433..5a11b6da189 100644 --- a/tests/Aspire.Cli.Tests/Commands/RootCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/RootCommandTests.cs @@ -344,4 +344,33 @@ public async Task InformationalFlag_DoesNotCreateSentinel_OnSubsequentFirstRun() Assert.True(sentinel.WasCreated); } + [Fact] + public void SetupCommand_NotAvailable_WhenBundleIsNotAvailable() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var hasSetupCommand = command.Subcommands.Any(cmd => cmd.Name == "setup"); + + Assert.False(hasSetupCommand); + } + + [Fact] + public void SetupCommand_Available_WhenBundleIsAvailable() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.BundleServiceFactory = _ => new TestBundleService(isBundle: true); + }); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var hasSetupCommand = command.Subcommands.Any(cmd => cmd.Name == "setup"); + + Assert.True(hasSetupCommand); + } + } diff --git a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs index 5ef18fe61ad..7b746e42e66 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs @@ -130,7 +130,7 @@ public static IServiceCollection CreateServiceCollection(TemporaryWorkspace work // Bundle layout services - return null/no-op implementations to trigger SDK mode fallback // This ensures backward compatibility: no layout found = use legacy SDK mode services.AddSingleton(options.LayoutDiscoveryFactory); - services.AddSingleton(); + services.AddSingleton(options.BundleServiceFactory); services.AddSingleton(); // AppHost project handlers - must match Program.cs registration pattern @@ -501,6 +501,9 @@ public ISolutionLocator CreateDefaultSolutionLocatorFactory(IServiceProvider ser // Layout discovery - returns null by default (no bundle layout), causing SDK mode fallback public Func LayoutDiscoveryFactory { get; set; } = _ => new NullLayoutDiscovery(); + // Bundle service - returns no-op implementation by default (no embedded bundle) + public Func BundleServiceFactory { get; set; } = _ => new NullBundleService(); + public Func McpServerTransportFactory { get; set; } = (IServiceProvider serviceProvider) => { var loggerFactory = serviceProvider.GetService(); @@ -553,6 +556,22 @@ public Task ExtractAsync(string destinationPath, bool force => Task.FromResult(null); } +/// +/// A configurable bundle service for testing bundle-dependent behavior. +/// +internal sealed class TestBundleService(bool isBundle) : IBundleService +{ + public bool IsBundle => isBundle; + + public Task EnsureExtractedAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + + public Task ExtractAsync(string destinationPath, bool force = false, CancellationToken cancellationToken = default) + => Task.FromResult(isBundle ? BundleExtractResult.AlreadyUpToDate : BundleExtractResult.NoPayload); + + public Task EnsureExtractedAndGetLayoutAsync(CancellationToken cancellationToken = default) + => Task.FromResult(null); +} + internal sealed class TestOutputTextWriter : TextWriter { private readonly ITestOutputHelper _outputHelper; From da9cf85c99584c2311bb1794757ad76b7ac2a2da Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 17:17:53 -0600 Subject: [PATCH 3/5] Fix transitive Azure role assignments through WaitFor dependencies (#14473) * Initial plan * Fix transitive Azure role assignments through WaitFor dependencies Remove CollectAnnotationDependencies calls from CollectDependenciesFromValue to prevent WaitFor/parent/connection-string-redirect annotations from referenced resources being included as direct dependencies of the caller. Add tests verifying: - DirectOnly mode excludes WaitFor deps from referenced resources - WaitFor doesn't create transitive role assignments in Azure publish Co-authored-by: eerhardt <8291187+eerhardt@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: eerhardt <8291187+eerhardt@users.noreply.github.com> --- .../ApplicationModel/ResourceExtensions.cs | 2 -- .../RoleAssignmentTests.cs | 30 +++++++++++++++++++ .../ResourceDependencyTests.cs | 25 ++++++++++++++++ 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs b/src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs index 4836d473b9a..fec5e55d6c7 100644 --- a/src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs +++ b/src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs @@ -1416,7 +1416,6 @@ private static void CollectDependenciesFromValue(object? value, HashSet("server", launchProfileName: null) + .WithHttpEndpoint() + .WithExternalHttpEndpoints() + .WithReference(cache) + .WaitFor(cache); + + builder.AddProject("webfrontend", launchProfileName: null) + .WithReference(server) + .WaitFor(server); + + var app = builder.Build(); + var model = app.Services.GetRequiredService(); + + await ExecuteBeforeStartHooksAsync(app, default); + + // The server should have a role assignment to the cache since it directly references it + Assert.Single(model.Resources.OfType(), r => r.Name == "server-roles-cache"); + + // The webfrontend should NOT have a role assignment to the cache since it only references the server + Assert.DoesNotContain(model.Resources, r => r.Name == "webfrontend-roles-cache"); + } + private static async Task RoleAssignmentTest( string azureResourceName, Action configureBuilder, diff --git a/tests/Aspire.Hosting.Tests/ResourceDependencyTests.cs b/tests/Aspire.Hosting.Tests/ResourceDependencyTests.cs index 65ae7925b24..0688a07e352 100644 --- a/tests/Aspire.Hosting.Tests/ResourceDependencyTests.cs +++ b/tests/Aspire.Hosting.Tests/ResourceDependencyTests.cs @@ -545,6 +545,31 @@ public async Task DirectOnlyIncludesReferencedResourcesFromConnectionString() Assert.Contains(postgres.Resource, dependencies); } + [Fact] + public async Task DirectOnlyDoesNotIncludeWaitForDependenciesFromReferencedResources() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + // Chain: A -> (ref) B -> (waitfor) C + // A has WithReference(B) and WaitFor(B) + // B has WaitFor(C) but A does NOT reference C directly + var c = builder.AddRedis("c"); + var b = builder.AddContainer("b", "alpine") + .WithHttpEndpoint(5000, 5000, "http") + .WaitFor(c); + var a = builder.AddContainer("a", "alpine") + .WithReference(b.GetEndpoint("http")) + .WaitFor(b); + + var executionContext = new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run); + var dependencies = await a.Resource.GetResourceDependenciesAsync(executionContext, ResourceDependencyDiscoveryMode.DirectOnly); + + // A depends on B (via WithReference and WaitFor) + Assert.Contains(b.Resource, dependencies); + // A should NOT depend on C because C is only a WaitFor dependency of B, not of A + Assert.DoesNotContain(c.Resource, dependencies); + } + [Fact] public async Task DefaultOverloadUsesTransitiveClosure() { From 715a77f0d7d7a72056278f16d8b9805b7e5bf212 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Thu, 12 Feb 2026 17:22:33 -0600 Subject: [PATCH 4/5] Stop ViteApps (build only containers) from getting Azure managed identities and roles (#14474) * Stop ViteApps (build only containers) from getting Azure managed identities and roles These resources don't get deployed, so they should be filtered from getting role assignments and managed identities added for them. * Update src/Aspire.Hosting.Azure/AzureResourcePreparer.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../AzureResourcePreparer.cs | 23 +++-------- .../Aspire.Hosting.Azure.Tests.csproj | 1 + .../AzureResourcePreparerTests.cs | 39 +++++++++++++++++++ 3 files changed, 45 insertions(+), 18 deletions(-) diff --git a/src/Aspire.Hosting.Azure/AzureResourcePreparer.cs b/src/Aspire.Hosting.Azure/AzureResourcePreparer.cs index 2f068b6404d..4006f488429 100644 --- a/src/Aspire.Hosting.Azure/AzureResourcePreparer.cs +++ b/src/Aspire.Hosting.Azure/AzureResourcePreparer.cs @@ -125,19 +125,13 @@ private async Task BuildRoleAssignmentAnnotations(DistributedApplicationModel ap // - if in PublishMode // - if a compute resource has RoleAssignmentAnnotations, use them // - if the resource doesn't, copy the DefaultRoleAssignments to RoleAssignmentAnnotations to apply the defaults - var resourceSnapshot = appModel.Resources.ToArray(); // avoid modifying the collection while iterating + var resourceSnapshot = appModel.GetComputeResources() + .Concat(appModel.Resources + .OfType() + .Where(r => !r.IsExcludedFromPublish())) + .ToArray(); // avoid modifying the collection while iterating foreach (var resource in resourceSnapshot) { - if (resource.IsExcludedFromPublish()) - { - continue; - } - - if (!IsResourceValidForRoleAssignments(resource)) - { - continue; - } - var azureReferences = await GetAzureReferences(resource, cancellationToken).ConfigureAwait(false); var azureReferencesWithRoleAssignments = @@ -231,13 +225,6 @@ private async Task BuildRoleAssignmentAnnotations(DistributedApplicationModel ap { CreateGlobalRoleAssignments(appModel, globalRoleAssignments); } - - // We can derive role assignments for compute resources and declared - // AzureUserAssignedIdentityResources - static bool IsResourceValidForRoleAssignments(IResource resource) - { - return resource.IsContainer() || resource is ProjectResource || resource is AzureUserAssignedIdentityResource; - } } private static Dictionary> GetAllRoleAssignments(IResource resource) diff --git a/tests/Aspire.Hosting.Azure.Tests/Aspire.Hosting.Azure.Tests.csproj b/tests/Aspire.Hosting.Azure.Tests/Aspire.Hosting.Azure.Tests.csproj index 8d5c2093659..cbfc36135a2 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Aspire.Hosting.Azure.Tests.csproj +++ b/tests/Aspire.Hosting.Azure.Tests/Aspire.Hosting.Azure.Tests.csproj @@ -9,6 +9,7 @@ + diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureResourcePreparerTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureResourcePreparerTests.cs index a7d3d4a3282..6ec0f7accaf 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureResourcePreparerTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureResourcePreparerTests.cs @@ -297,6 +297,45 @@ public async Task AppliesRoleAssignmentsOnlyToDirectReferences() n => Assert.Equal("api-roles-storage", n)); } + [Fact] + public async Task ViteAppDoesNotGetManagedIdentity() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + builder.AddAzureContainerAppEnvironment("env"); + + var storage = builder.AddAzureStorage("storage"); + var blobs = storage.AddBlobs("blobs"); + + var api = builder.AddProject("api", launchProfileName: null) + .WithHttpEndpoint() + .WithReference(blobs) + .WaitFor(blobs); + + var frontend = builder.AddViteApp("frontend", "./frontend") + .WithReference(api) + .WithReference(blobs) + .WaitFor(blobs); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + await ExecuteBeforeStartHooksAsync(app, default); + + Assert.Collection(model.Resources.Select(r => r.Name), + n => Assert.StartsWith("azure", n), + n => Assert.Equal("env-acr", n), + n => Assert.Equal("env", n), + n => Assert.Equal("storage", n), + n => Assert.Equal("blobs", n), + n => Assert.Equal("api", n), + n => Assert.Equal("frontend", n), + n => Assert.Equal("api-identity", n), + n => Assert.Equal("api-roles-storage", n)); + + // The ViteApp should NOT get a managed identity since it is a BuildOnlyContainer resource, + // even though it references the storage account. Only the API should get a managed identity. + Assert.DoesNotContain(model.Resources, r => r.Name == "frontend-identity"); + } + private sealed class Project : IProjectMetadata { public string ProjectPath => "project"; From 79d58386799cd13f8ad450c13e972811c237bd46 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 12 Feb 2026 17:28:04 -0600 Subject: [PATCH 5/5] [release/13.2] Update Azure.Core to latest version - lift all runtime dependencies to latest (#14467) * Update to Azure.Core 1.51.1 Use latest versions for all dotnet/runtime nuget packages. This simplifies our dependency management. Remove ForceLatestDotnetVersions property from multiple project files * Update AzureDeployerTests to use WaitForShutdown instead of StopAsync There is a timing issue when using Start/Stop since the background pipeline might still be running and it cancels the pipeline before it can complete. * Fix AuxiliaryBackchannelTests by adding a Task that completes when the AuxiliaryBackchannelService is listening and ready for connections. * Remove double registration of AuxiliaryBackchannelService as an IHostedService. * Fix ResourceLoggerForwarderServiceTests to ensure the ResourceLoggerForwarderService has started before signalling the stopping token. --------- Co-authored-by: Eric Erhardt --- Directory.Packages.props | 70 ++++++----------- .../AzureAIFoundryEndToEnd.WebStory.csproj | 3 - .../AzureOpenAIEndToEnd.WebStory.csproj | 3 - .../GitHubModelsEndToEnd.WebStory.csproj | 3 - .../OpenAIEndToEnd.WebStory.csproj | 3 - .../AuxiliaryBackchannelService.cs | 10 +++ .../Aspire.Azure.AI.Inference.csproj | 2 - .../Aspire.Azure.AI.OpenAI.csproj | 2 - .../Aspire.OpenAI/Aspire.OpenAI.csproj | 2 - .../Aspire.Azure.AI.Inference.Tests.csproj | 3 - .../Aspire.Azure.AI.OpenAI.Tests.csproj | 3 - .../AzureDeployerTests.cs | 4 +- .../ResourceLoggerForwarderServiceTests.cs | 18 +++++ .../Backchannel/AuxiliaryBackchannelTests.cs | 75 +++++-------------- .../Aspire.OpenAI.Tests.csproj | 3 - 15 files changed, 71 insertions(+), 133 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 8e4674e5743..097be0ed6d4 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -175,17 +175,18 @@ - + - + + - + @@ -195,6 +196,23 @@ + + + + + + + + + + + + + + + + @@ -218,18 +236,6 @@ - - - - - - - - - - - - @@ -254,26 +260,8 @@ - - - - - - - - - - - - - - - - - - - + @@ -295,17 +283,5 @@ - - - - - - - - - - - - diff --git a/playground/AzureAIFoundryEndToEnd/AzureAIFoundryEndToEnd.WebStory/AzureAIFoundryEndToEnd.WebStory.csproj b/playground/AzureAIFoundryEndToEnd/AzureAIFoundryEndToEnd.WebStory/AzureAIFoundryEndToEnd.WebStory.csproj index cd25e05445f..aa19fad6805 100644 --- a/playground/AzureAIFoundryEndToEnd/AzureAIFoundryEndToEnd.WebStory/AzureAIFoundryEndToEnd.WebStory.csproj +++ b/playground/AzureAIFoundryEndToEnd/AzureAIFoundryEndToEnd.WebStory/AzureAIFoundryEndToEnd.WebStory.csproj @@ -4,9 +4,6 @@ $(DefaultTargetFramework) enable enable - - - true diff --git a/playground/AzureOpenAIEndToEnd/AzureOpenAIEndToEnd.WebStory/AzureOpenAIEndToEnd.WebStory.csproj b/playground/AzureOpenAIEndToEnd/AzureOpenAIEndToEnd.WebStory/AzureOpenAIEndToEnd.WebStory.csproj index cefd34325a6..ede52a85213 100644 --- a/playground/AzureOpenAIEndToEnd/AzureOpenAIEndToEnd.WebStory/AzureOpenAIEndToEnd.WebStory.csproj +++ b/playground/AzureOpenAIEndToEnd/AzureOpenAIEndToEnd.WebStory/AzureOpenAIEndToEnd.WebStory.csproj @@ -4,9 +4,6 @@ $(DefaultTargetFramework) enable enable - - - true diff --git a/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/GitHubModelsEndToEnd.WebStory.csproj b/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/GitHubModelsEndToEnd.WebStory.csproj index cd25e05445f..aa19fad6805 100644 --- a/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/GitHubModelsEndToEnd.WebStory.csproj +++ b/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/GitHubModelsEndToEnd.WebStory.csproj @@ -4,9 +4,6 @@ $(DefaultTargetFramework) enable enable - - - true diff --git a/playground/OpenAIEndToEnd/OpenAIEndToEnd.WebStory/OpenAIEndToEnd.WebStory.csproj b/playground/OpenAIEndToEnd/OpenAIEndToEnd.WebStory/OpenAIEndToEnd.WebStory.csproj index 2df50b9502b..60f4356d1a3 100644 --- a/playground/OpenAIEndToEnd/OpenAIEndToEnd.WebStory/OpenAIEndToEnd.WebStory.csproj +++ b/playground/OpenAIEndToEnd/OpenAIEndToEnd.WebStory/OpenAIEndToEnd.WebStory.csproj @@ -4,9 +4,6 @@ $(DefaultTargetFramework) enable enable - - - true diff --git a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelService.cs b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelService.cs index 6bf750cff6a..2ef434dd1af 100644 --- a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelService.cs +++ b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelService.cs @@ -22,12 +22,21 @@ internal sealed class AuxiliaryBackchannelService( : BackgroundService { private Socket? _serverSocket; + private readonly TaskCompletionSource _listeningTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); /// /// Gets the Unix socket path where the auxiliary backchannel is listening. /// public string? SocketPath { get; private set; } + /// + /// Gets a task that completes when the server socket is bound and listening for connections. + /// + /// + /// Used by tests to wait until the backchannel is ready before attempting to connect. + /// + internal Task ListeningTask => _listeningTcs.Task; + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { try @@ -72,6 +81,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) _serverSocket.Listen(backlog: 10); // Allow multiple pending connections logger.LogDebug("Auxiliary backchannel listening on {SocketPath}", SocketPath); + _listeningTcs.TrySetResult(); // Accept connections in a loop (supporting multiple concurrent connections) while (!stoppingToken.IsCancellationRequested) diff --git a/src/Components/Aspire.Azure.AI.Inference/Aspire.Azure.AI.Inference.csproj b/src/Components/Aspire.Azure.AI.Inference/Aspire.Azure.AI.Inference.csproj index 7370e834905..7926621c583 100644 --- a/src/Components/Aspire.Azure.AI.Inference/Aspire.Azure.AI.Inference.csproj +++ b/src/Components/Aspire.Azure.AI.Inference/Aspire.Azure.AI.Inference.csproj @@ -9,8 +9,6 @@ $(NoWarn);SYSLIB1100;SYSLIB1101 true - - true diff --git a/src/Components/Aspire.Azure.AI.OpenAI/Aspire.Azure.AI.OpenAI.csproj b/src/Components/Aspire.Azure.AI.OpenAI/Aspire.Azure.AI.OpenAI.csproj index 99d48574b2e..db2deffe6bc 100644 --- a/src/Components/Aspire.Azure.AI.OpenAI/Aspire.Azure.AI.OpenAI.csproj +++ b/src/Components/Aspire.Azure.AI.OpenAI/Aspire.Azure.AI.OpenAI.csproj @@ -10,8 +10,6 @@ $(NoWarn);SYSLIB1100;SYSLIB1101;AOAI001 true - - true diff --git a/src/Components/Aspire.OpenAI/Aspire.OpenAI.csproj b/src/Components/Aspire.OpenAI/Aspire.OpenAI.csproj index 836c1d9d11c..6b76d148c20 100644 --- a/src/Components/Aspire.OpenAI/Aspire.OpenAI.csproj +++ b/src/Components/Aspire.OpenAI/Aspire.OpenAI.csproj @@ -8,8 +8,6 @@ $(NoWarn);SYSLIB1100;SYSLIB1101 true - - true diff --git a/tests/Aspire.Azure.AI.Inference.Tests/Aspire.Azure.AI.Inference.Tests.csproj b/tests/Aspire.Azure.AI.Inference.Tests/Aspire.Azure.AI.Inference.Tests.csproj index cca72f73ac9..948e92b0096 100644 --- a/tests/Aspire.Azure.AI.Inference.Tests/Aspire.Azure.AI.Inference.Tests.csproj +++ b/tests/Aspire.Azure.AI.Inference.Tests/Aspire.Azure.AI.Inference.Tests.csproj @@ -2,9 +2,6 @@ $(AllTargetFrameworks) - - - true diff --git a/tests/Aspire.Azure.AI.OpenAI.Tests/Aspire.Azure.AI.OpenAI.Tests.csproj b/tests/Aspire.Azure.AI.OpenAI.Tests/Aspire.Azure.AI.OpenAI.Tests.csproj index a27e07a3a63..2f45183b667 100644 --- a/tests/Aspire.Azure.AI.OpenAI.Tests/Aspire.Azure.AI.OpenAI.Tests.csproj +++ b/tests/Aspire.Azure.AI.OpenAI.Tests/Aspire.Azure.AI.OpenAI.Tests.csproj @@ -2,9 +2,6 @@ $(AllTargetFrameworks) - - - true diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs index 0c9ff5c0492..09460f25c8d 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs @@ -1072,7 +1072,7 @@ public async Task DeployAsync_WithAzureResourceDependencies_DoesNotHang(string s // Act using var app = builder.Build(); await app.StartAsync(); - await app.StopAsync(); + await app.WaitForShutdownAsync(); if (step == "diagnostics") { @@ -1159,7 +1159,7 @@ public async Task DeployAsync_WithRedisAccessKeyAuthentication_CreatesCorrectDep // Act using var app = builder.Build(); await app.StartAsync(); - await app.StopAsync(); + await app.WaitForShutdownAsync(); // In diagnostics mode, verify the deployment graph shows correct dependencies var logs = mockActivityReporter.LoggedMessages diff --git a/tests/Aspire.Hosting.Testing.Tests/ResourceLoggerForwarderServiceTests.cs b/tests/Aspire.Hosting.Testing.Tests/ResourceLoggerForwarderServiceTests.cs index 2e1084139c8..d10d3e2775f 100644 --- a/tests/Aspire.Hosting.Testing.Tests/ResourceLoggerForwarderServiceTests.cs +++ b/tests/Aspire.Hosting.Testing.Tests/ResourceLoggerForwarderServiceTests.cs @@ -35,11 +35,29 @@ public async Task ExecuteDoesNotThrowOperationCanceledWhenAppStoppingTokenSignal var loggerFactory = new NullLoggerFactory(); var resourceLogForwarder = new ResourceLoggerForwarderService(resourceNotificationService, resourceLoggerService, hostEnvironment, loggerFactory); + // use a task to signal when the resourceLogForwarder has started executing + var subscribedTcs = new TaskCompletionSource(); + var subscriberLoop = Task.Run(async () => + { + await foreach (var sub in resourceLoggerService.WatchAnySubscribersAsync(hostApplicationLifetime.ApplicationStopping)) + { + subscribedTcs.TrySetResult(); + return; + } + }); + await resourceLogForwarder.StartAsync(hostApplicationLifetime.ApplicationStopping); Assert.NotNull(resourceLogForwarder.ExecuteTask); Assert.Equal(TaskStatus.WaitingForActivation, resourceLogForwarder.ExecuteTask.Status); + // Publish an update to the resource to kickstart the notification service loop + var myresource = new CustomResource("myresource"); + await resourceNotificationService.PublishUpdateAsync(myresource, snapshot => snapshot with { State = "Running" }); + + // Wait for the log stream to begin + await subscribedTcs.Task.WaitAsync(TimeSpan.FromSeconds(15)); + // Signal the stopping token hostApplicationLifetime.StopApplication(); diff --git a/tests/Aspire.Hosting.Tests/Backchannel/AuxiliaryBackchannelTests.cs b/tests/Aspire.Hosting.Tests/Backchannel/AuxiliaryBackchannelTests.cs index 86f7622be67..1c06ea8f482 100644 --- a/tests/Aspire.Hosting.Tests/Backchannel/AuxiliaryBackchannelTests.cs +++ b/tests/Aspire.Hosting.Tests/Backchannel/AuxiliaryBackchannelTests.cs @@ -28,16 +28,13 @@ public async Task CanStartAuxiliaryBackchannelService() return Task.CompletedTask; }); - // Register the auxiliary backchannel service - builder.Services.AddSingleton(); - builder.Services.AddSingleton(sp => sp.GetRequiredService()); - using var app = builder.Build(); await app.StartAsync().WaitAsync(TimeSpan.FromSeconds(60)); // Get the service and verify it started var service = app.Services.GetRequiredService(); + await service.ListeningTask.WaitAsync(TimeSpan.FromSeconds(60)); Assert.NotNull(service.SocketPath); Assert.True(File.Exists(service.SocketPath)); @@ -71,25 +68,22 @@ public async Task CanConnectMultipleClientsToAuxiliaryBackchannel() return Task.CompletedTask; }); - // Register the auxiliary backchannel service - builder.Services.AddSingleton(); - builder.Services.AddSingleton(sp => sp.GetRequiredService()); - using var app = builder.Build(); await app.StartAsync().WaitAsync(TimeSpan.FromSeconds(60)); // Get the service var service = app.Services.GetRequiredService(); + await service.ListeningTask.WaitAsync(TimeSpan.FromSeconds(60)); Assert.NotNull(service.SocketPath); // Connect multiple clients concurrently var client1Socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); var client2Socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); var client3Socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); - + var endpoint = new UnixDomainSocketEndPoint(service.SocketPath); - + await client1Socket.ConnectAsync(endpoint).WaitAsync(TimeSpan.FromSeconds(60)); await client2Socket.ConnectAsync(endpoint).WaitAsync(TimeSpan.FromSeconds(60)); await client3Socket.ConnectAsync(endpoint).WaitAsync(TimeSpan.FromSeconds(60)); @@ -116,16 +110,13 @@ public async Task CanInvokeRpcMethodOnAuxiliaryBackchannel() // When the Dashboard is not part of the app model, null should be returned using var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(outputHelper); - // Register the auxiliary backchannel service - builder.Services.AddSingleton(); - builder.Services.AddSingleton(sp => sp.GetRequiredService()); - using var app = builder.Build(); await app.StartAsync().WaitAsync(TimeSpan.FromSeconds(60)); // Get the service var service = app.Services.GetRequiredService(); + await service.ListeningTask.WaitAsync(TimeSpan.FromSeconds(60)); Assert.NotNull(service.SocketPath); // Connect a client @@ -154,16 +145,13 @@ public async Task GetAppHostInformationAsyncReturnsAppHostPath() // This test verifies that GetAppHostInformationAsync returns the AppHost path using var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(outputHelper); - // Register the auxiliary backchannel service - builder.Services.AddSingleton(); - builder.Services.AddSingleton(sp => sp.GetRequiredService()); - using var app = builder.Build(); await app.StartAsync().WaitAsync(TimeSpan.FromSeconds(60)); // Get the service var service = app.Services.GetRequiredService(); + await service.ListeningTask.WaitAsync(TimeSpan.FromSeconds(60)); Assert.NotNull(service.SocketPath); // Connect a client @@ -198,16 +186,13 @@ public async Task MultipleClientsCanInvokeRpcMethodsConcurrently() // When the Dashboard is not part of the app model, null should be returned using var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(outputHelper); - // Register the auxiliary backchannel service - builder.Services.AddSingleton(); - builder.Services.AddSingleton(sp => sp.GetRequiredService()); - using var app = builder.Build(); await app.StartAsync().WaitAsync(TimeSpan.FromSeconds(60)); // Get the service var service = app.Services.GetRequiredService(); + await service.ListeningTask.WaitAsync(TimeSpan.FromSeconds(60)); Assert.NotNull(service.SocketPath); // Create multiple clients and invoke RPC methods concurrently @@ -245,16 +230,13 @@ public async Task GetAppHostInformationAsyncReturnsFilePathWithExtension() // For .csproj-based AppHosts, it should include the .csproj extension using var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(outputHelper); - // Register the auxiliary backchannel service - builder.Services.AddSingleton(); - builder.Services.AddSingleton(sp => sp.GetRequiredService()); - using var app = builder.Build(); await app.StartAsync().WaitAsync(TimeSpan.FromSeconds(60)); // Get the service var service = app.Services.GetRequiredService(); + await service.ListeningTask.WaitAsync(TimeSpan.FromSeconds(60)); Assert.NotNull(service.SocketPath); // Connect a client @@ -275,10 +257,10 @@ public async Task GetAppHostInformationAsyncReturnsFilePathWithExtension() Assert.NotNull(appHostInfo); Assert.NotNull(appHostInfo.AppHostPath); Assert.NotEmpty(appHostInfo.AppHostPath); - + // The path should be an absolute path Assert.True(Path.IsPathRooted(appHostInfo.AppHostPath), $"Expected absolute path but got: {appHostInfo.AppHostPath}"); - + // In test scenarios where assembly metadata is not available, we may get a path without extension // (falling back to AppHost:Path). In real scenarios with proper metadata, we should get .csproj or .cs // So we just verify the path is non-empty and rooted @@ -294,22 +276,19 @@ public async Task SocketPathUsesAuxiPrefix() // to avoid Windows reserved device name issues (AUX is reserved on Windows < 11) using var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(outputHelper); - // Register the auxiliary backchannel service - builder.Services.AddSingleton(); - builder.Services.AddSingleton(sp => sp.GetRequiredService()); - using var app = builder.Build(); await app.StartAsync().WaitAsync(TestConstants.DefaultTimeoutTimeSpan); // Get the service var service = app.Services.GetRequiredService(); + await service.ListeningTask.WaitAsync(TimeSpan.FromSeconds(60)); Assert.NotNull(service.SocketPath); // Verify that the socket path uses "auxi.sock." prefix var fileName = Path.GetFileName(service.SocketPath); Assert.StartsWith("auxi.sock.", fileName); - + // Verify that the socket file can be created (not blocked by Windows reserved names) Assert.True(File.Exists(service.SocketPath), $"Socket file should exist at: {service.SocketPath}"); @@ -328,16 +307,13 @@ public async Task CallResourceMcpToolAsyncThrowsWhenResourceNotFound() // Add a simple container resource (without MCP) builder.AddContainer("mycontainer", "nginx"); - // Register the auxiliary backchannel service - builder.Services.AddSingleton(); - builder.Services.AddSingleton(sp => sp.GetRequiredService()); - using var app = builder.Build(); await app.StartAsync().WaitAsync(TestConstants.DefaultTimeoutTimeSpan); // Get the service var service = app.Services.GetRequiredService(); + await service.ListeningTask.WaitAsync(TimeSpan.FromSeconds(60)); Assert.NotNull(service.SocketPath); // Connect a client @@ -372,16 +348,13 @@ public async Task CallResourceMcpToolAsyncThrowsWhenResourceHasNoMcpAnnotation() // Add a simple container resource (without MCP) builder.AddContainer("mycontainer", "nginx"); - // Register the auxiliary backchannel service - builder.Services.AddSingleton(); - builder.Services.AddSingleton(sp => sp.GetRequiredService()); - using var app = builder.Build(); await app.StartAsync().WaitAsync(TestConstants.DefaultTimeoutTimeSpan); // Get the service var service = app.Services.GetRequiredService(); + await service.ListeningTask.WaitAsync(TimeSpan.FromSeconds(60)); Assert.NotNull(service.SocketPath); // Connect a client @@ -412,16 +385,13 @@ public async Task StopAppHostAsyncInitiatesShutdown() // This test verifies that StopAppHostAsync initiates AppHost shutdown using var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(outputHelper); - // Register the auxiliary backchannel service - builder.Services.AddSingleton(); - builder.Services.AddSingleton(sp => sp.GetRequiredService()); - using var app = builder.Build(); await app.StartAsync().WaitAsync(TestConstants.DefaultTimeoutTimeSpan); // Get the service var service = app.Services.GetRequiredService(); + await service.ListeningTask.WaitAsync(TimeSpan.FromSeconds(60)); Assert.NotNull(service.SocketPath); // Connect a client @@ -464,16 +434,13 @@ public async Task GetCapabilitiesAsyncReturnsV1AndV2() // This test verifies that GetCapabilitiesAsync returns both v1 and v2 capabilities using var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(outputHelper); - // Register the auxiliary backchannel service - builder.Services.AddSingleton(); - builder.Services.AddSingleton(sp => sp.GetRequiredService()); - using var app = builder.Build(); await app.StartAsync().WaitAsync(TestConstants.DefaultTimeoutTimeSpan); // Get the service var service = app.Services.GetRequiredService(); + await service.ListeningTask.WaitAsync(TimeSpan.FromSeconds(60)); Assert.NotNull(service.SocketPath); // Connect a client @@ -505,16 +472,13 @@ public async Task GetAppHostInfoAsyncV2ReturnsAppHostInfo() // This test verifies that the v2 GetAppHostInfoAsync returns AppHost info using var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(outputHelper); - // Register the auxiliary backchannel service - builder.Services.AddSingleton(); - builder.Services.AddSingleton(sp => sp.GetRequiredService()); - using var app = builder.Build(); await app.StartAsync().WaitAsync(TestConstants.DefaultTimeoutTimeSpan); // Get the service var service = app.Services.GetRequiredService(); + await service.ListeningTask.WaitAsync(TimeSpan.FromSeconds(60)); Assert.NotNull(service.SocketPath); // Connect a client @@ -551,16 +515,13 @@ public async Task GetResourcesAsyncV2ReturnsResources() // Add a simple parameter resource builder.AddParameter("myparam"); - // Register the auxiliary backchannel service - builder.Services.AddSingleton(); - builder.Services.AddSingleton(sp => sp.GetRequiredService()); - using var app = builder.Build(); await app.StartAsync().WaitAsync(TestConstants.DefaultTimeoutTimeSpan); // Get the service var service = app.Services.GetRequiredService(); + await service.ListeningTask.WaitAsync(TimeSpan.FromSeconds(60)); Assert.NotNull(service.SocketPath); // Connect a client diff --git a/tests/Aspire.OpenAI.Tests/Aspire.OpenAI.Tests.csproj b/tests/Aspire.OpenAI.Tests/Aspire.OpenAI.Tests.csproj index 62892924e61..dec5f627481 100644 --- a/tests/Aspire.OpenAI.Tests/Aspire.OpenAI.Tests.csproj +++ b/tests/Aspire.OpenAI.Tests/Aspire.OpenAI.Tests.csproj @@ -2,9 +2,6 @@ $(AllTargetFrameworks) - - - true