From 889cab29b70068e41b74a522efd9da097bf9d8e4 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Thu, 28 May 2026 04:19:52 +1000 Subject: [PATCH 01/15] Fix 13.4 staging CLI dropping nuget.config without Aspire package source mapping (#17528) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Bake AspireCliChannel=staging for release-branch builds even when stabilizing The channel-compute step in build_sign_native.yml was checking $versionKind -eq 'release' BEFORE the release-branch regex check. A 13.4 staging build runs from a release/* branch with StabilizePackageVersion=true, which sets DotNetFinalVersionKind=release, so the wrong arm fired and baked AspireCliChannel=stable into the binary. Downstream, aspire init reads CliExecutionContext.IdentityChannel to pick the channel mappings it writes into the workspace nuget.config. With identity=stable there's no Aspire.* → staging-feed mapping, so aspire add tries to resolve packages from nuget.org and either gets 13.3.5 or fails outright (the apphost.cs template pins #:sdk Aspire.AppHost.Sdk@13.4.0+, which isn't on nuget.org). Swap the conditions so the release-branch check runs first. Release- branch builds are always staging artifacts; only release-shaped non-release-branch builds (effectively none in practice) get stable. Fixes #17527 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add aspireCliChannelOverride pipeline parameter for GA ship builds With release-branch builds now defaulting to 'staging' (so stabilizing dogfood builds aren't mis-baked as 'stable'), there was no remaining path to produce a real 'stable' GA ship binary — the same release/* branch that produces the staging dogfood drops also produces the final ship build, and the pipeline has no other signal to tell them apart. Add a runtime pipeline parameter 'aspireCliChannelOverride' (auto | stable | staging | daily, default auto) to azure-pipelines.yml and thread it through every build_sign_native.yml invocation. When the release manager kicks off the official GA ship build, they set this to 'stable' so the distributed binary bakes AspireCliChannel=stable and aspire init writes the nuget.org-only nuget.config that matches the promoted package set. Routine stabilizing builds leave it on 'auto' and continue to bake 'staging'. The override is validated against the same accepted-channel set that IdentityChannelReader.IsValidChannel enforces at CLI startup so a typo fails the pipeline step rather than producing a binary that refuses to boot. pr- is intentionally excluded from the override set since PR builds always come from the PullRequest reason arm. The unofficial pipeline doesn't get the parameter — its test builds should always derive the channel from branch+reason, and the template's default 'auto' achieves that. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Route stabilizing staging CLI to SHA-derived darc feed The staging-channel synthesis in PackagingService defaulted to PackageChannelQuality.Both for staging-identity CLIs. With Both, useSharedFeed=true and Aspire.* gets routed to the shared dnceng/dotnet9 daily feed -- which only contains prerelease-tagged 13.4.0-preview.* packages, not the stable-shaped 13.4.0 packages produced during release stabilization (StabilizePackageVersion=true). Net effect on the just-shipped staging build of 13.4: `aspire init` drops a NuGet.config pointing Aspire.* at dotnet9, then `aspire add yarp` fails to resolve Aspire.Hosting.Yarp 13.4.0 because dotnet9 doesn't carry it. The packages actually live in the SHA-derived darc-pub-microsoft-aspire- feed. Fix: when the CLI's identity is staging, derive the synthesized channel's default quality from the CLI build's version shape: - Stable-shaped (no semver prerelease tag) -> Stable, which makes useSharedFeed=false and routes Aspire.* to the SHA-derived darc feed where stabilizing packages actually live. - Prerelease-shaped -> Both (the historical default), since SHA- specific darc feeds are only created for stable release-branch builds and prerelease staging CLIs must use the shared feed. The identity-staging branch runs before the requested/configured branches in the if/else because `init` (and many other commands) calls GetChannelsAsync(requestedChannelName: "staging") when the running CLI's identity is staging -- short-circuiting on the requested branch would re-introduce the bug. The version-shape predicate is injected via constructor so unit tests can deterministically exercise both paths regardless of the test-host assembly's baked InformationalVersion. Validated end-to-end with a locally-built NAOT `aspire` binary (`/p:AspireCliChannel=staging`) + overrideStagingFeed pointing at darc-pub-microsoft-aspire-0f514452: `aspire init` -> `aspire add yarp` now resolves Aspire.Hosting.Yarp 13.4.0 instead of falling back to 13.3.5. Refs https://github.com/microsoft/aspire/issues/17527 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Pin PackagingService stable-shape predicate to false in CliTestHelper The stabilization-check CI job builds the test host with StabilizePackageVersion=true, which bakes a stable-shaped (no '-') informational version into Aspire.Cli. PackagingService's new identity-staging branch then defaults quality to Stable and requires a SHA suffix in the assembly's InformationalVersion to compute the darc-pub feed URL. Stabilized test-host assemblies don't carry a +sha suffix, so CreateStagingChannel returned null and the UpdateCommand_WhenStagingIdentityRegistersChannel_UsesStagingForUnpinnedProject test fell back to the default channel instead of staging. Default CliTestHelper.PackagingServiceFactory to inject isStableShapedCliVersion: () => false so command-level tests get deterministic prerelease-shaped behavior (quality=Both → shared dotnet9 feed) regardless of how the test host was built. Tests that specifically exercise the stable-shape branch (in PackagingServiceTests) construct PackagingService directly and already pass an explicit predicate. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Update eng/pipelines/templates/build_sign_native.yml --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Jose Perez Rodriguez (cherry picked from commit 6a8235582edcc92b676480612f82284843cb96e9) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- eng/pipelines/azure-pipelines.yml | 21 ++++++ eng/pipelines/templates/build_sign_native.yml | 47 ++++++++++++- src/Aspire.Cli/Packaging/PackagingService.cs | 67 ++++++++++++++++++- .../Packaging/PackagingServiceTests.cs | 31 ++++++++- tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs | 9 ++- 5 files changed, 168 insertions(+), 7 deletions(-) diff --git a/eng/pipelines/azure-pipelines.yml b/eng/pipelines/azure-pipelines.yml index 23f22623e87..5ef192d329a 100644 --- a/eng/pipelines/azure-pipelines.yml +++ b/eng/pipelines/azure-pipelines.yml @@ -3,6 +3,24 @@ parameters: displayName: 'Package VS Code Extension as Pre-Release' type: boolean default: false + # Operator-controlled override for the AspireCliChannel baked into native CLI + # binaries by build_sign_native.yml. The default 'auto' lets the pipeline pick + # stable / staging / daily / pr- from Build.Reason and Build.SourceBranch + # (where release/* and internal/release/* branches always resolve to 'staging' + # so stabilizing dogfood builds aren't mis-baked as 'stable' — see + # https://github.com/microsoft/aspire/issues/17527). Set this to 'stable' when + # kicking off the official GA ship build from a release/* branch so the + # distributed binary identifies as stable and `aspire init` writes a + # nuget.org-only nuget.config matching the promoted package set. + - name: aspireCliChannelOverride + displayName: 'Aspire CLI channel override (auto = derive from branch; set to stable for the GA ship build)' + type: string + default: 'auto' + values: + - auto + - stable + - staging + - daily trigger: batch: true @@ -158,6 +176,7 @@ extends: - osx-x64 codeSign: true teamName: $(_TeamName) + aspireCliChannelOverride: ${{ parameters.aspireCliChannelOverride }} extraBuildArgs: >- /p:Configuration=$(_BuildConfig) $(_SignArgs) @@ -173,6 +192,7 @@ extends: # no need to sign ELF binaries on linux codeSign: false teamName: $(_TeamName) + aspireCliChannelOverride: ${{ parameters.aspireCliChannelOverride }} extraBuildArgs: >- /p:Configuration=$(_BuildConfig) $(_OfficialBuildIdArgs) @@ -185,6 +205,7 @@ extends: - win-arm64 codeSign: true teamName: $(_TeamName) + aspireCliChannelOverride: ${{ parameters.aspireCliChannelOverride }} extraBuildArgs: >- /p:Configuration=$(_BuildConfig) $(_SignArgs) diff --git a/eng/pipelines/templates/build_sign_native.yml b/eng/pipelines/templates/build_sign_native.yml index e06831ab7d6..eb2af1e8e34 100644 --- a/eng/pipelines/templates/build_sign_native.yml +++ b/eng/pipelines/templates/build_sign_native.yml @@ -6,6 +6,19 @@ parameters: extraBuildArgs: '' codeSign: false teamName: '' + # Optional override for AspireCliChannel. Accepted values: 'auto' (default; + # let computeCliChannel below pick stable/staging/daily/pr- from Build.Reason + # and Build.SourceBranch), 'stable', 'staging', 'daily'. Set this to 'stable' + # when running the official ship pipeline so the GA CLI binary is baked with + # AspireCliChannel=stable (and aspire init then writes a nuget.org-only + # nuget.config, which is correct because the packages have been promoted to + # nuget.org). For routine stabilizing builds from a release/* branch — which + # also set DotNetFinalVersionKind=release — leave this on 'auto' so the + # channel falls through to 'staging' and aspire init writes a nuget.config + # that maps Aspire.* to the staging feed. See + # https://github.com/microsoft/aspire/issues/17527 for the bug that made this + # override necessary. + aspireCliChannelOverride: 'auto' jobs: @@ -84,15 +97,32 @@ jobs: $reason = '$(Build.Reason)' $sourceBranch = '$(Build.SourceBranch)' $prNumber = '$(System.PullRequest.PullRequestNumber)' + # Template-time substitution: the value is the resolved + # aspireCliChannelOverride parameter literal, never a runtime + # variable. Quoting protects an empty/default value. + $override = '${{ parameters.aspireCliChannelOverride }}' Write-Host "Build.Reason: '$reason'" Write-Host "Build.SourceBranch: '$sourceBranch'" Write-Host "System.PullRequest.PullRequestNumber: '$prNumber'" + Write-Host "aspireCliChannelOverride: '$override'" $versionKind = & "$(Build.SourcesDirectory)/$(dotnetScript)" msbuild "$(Build.SourcesDirectory)/eng/Versions.props" -getProperty:DotNetFinalVersionKind $versionKind = $versionKind.Trim() Write-Host "DotNetFinalVersionKind: '$versionKind'" - if ($reason -eq 'PullRequest') { + if ($override -and $override -ne 'auto') { + # Operator override path. Validate against the same accepted set + # that IdentityChannelReader.IsValidChannel enforces at CLI startup + # so a typo here fails the pipeline step rather than producing a + # binary that refuses to boot. pr- is intentionally excluded + # from the override set — PR builds always come from the + # PullRequest reason arm below. + if ($override -notin @('stable', 'staging', 'daily')) { + throw "aspireCliChannelOverride='$override' is not one of: auto, stable, staging, daily." + } + $channel = $override.ToLowerInvariant() + } + elseif ($reason -eq 'PullRequest') { # Defense in depth: validate digit-only PR number rather than just # non-emptiness. If the agent ever returns the literal macro string # (e.g. '$(System.PullRequest.PullRequestNumber)' unresolved) this @@ -105,10 +135,21 @@ jobs: # Bake the resolved hive label directly into AspireCliChannel. The CLI # consumes this verbatim and avoids the legacy "pr" + parsed-PrNumber join. $channel = "pr-$prNumber" - } elseif ($versionKind -eq 'release') { - $channel = 'stable' } elseif ($sourceBranch -match '^refs/heads/(release|internal/release)/') { + # Release/internal-release branches always produce staging artifacts — + # they are published to the staging feed for dogfooding and only later + # promoted to nuget.org. This must be checked BEFORE the + # `versionKind == release` arm, because a release-branch build also sets + # StabilizePackageVersion=true (→ DotNetFinalVersionKind=release) once + # we are stabilizing for ship. Without this ordering, the stabilized + # staging build would bake AspireCliChannel=stable and `aspire init` + # would drop a nuget.config with no staging feed mapping, causing + # `aspire add` to resolve Aspire.* packages from nuget.org (older + # versions) or fail to resolve the +sha-pinned Aspire.AppHost.Sdk. + # See https://github.com/microsoft/aspire/issues/17527. $channel = 'staging' + } elseif ($versionKind -eq 'release') { + $channel = 'stable' } else { # main and any other branch fall through to daily $channel = 'daily' diff --git a/src/Aspire.Cli/Packaging/PackagingService.cs b/src/Aspire.Cli/Packaging/PackagingService.cs index 38d148d9c78..dc229e4cef1 100644 --- a/src/Aspire.Cli/Packaging/PackagingService.cs +++ b/src/Aspire.Cli/Packaging/PackagingService.cs @@ -50,6 +50,11 @@ internal class PackagingService : IPackagingService private readonly IConfiguration _configuration; private readonly ILogger _logger; private readonly Func _processPathProvider; + // Predicate used by staging-channel synthesis to decide whether the running CLI is built + // from a stable-shaped version (no semver prerelease tag). Defaults to inspecting the + // current Aspire.Cli assembly's InformationalVersion; tests inject a deterministic value + // because the version baked into the test-host assembly varies by build configuration. + private readonly Func _isStableShapedCliVersion; // Cached result of the staging-channel availability check. The inputs (CLI identity, // overrideStagingFeed, StagingChannelEnabled feature) are effectively static for the @@ -64,7 +69,8 @@ public PackagingService( IFeatures features, IConfiguration configuration, ILogger logger, - Func? processPathProvider = null) + Func? processPathProvider = null, + Func? isStableShapedCliVersion = null) { _executionContext = executionContext; _nuGetPackageCache = nuGetPackageCache; @@ -72,6 +78,7 @@ public PackagingService( _configuration = configuration; _logger = logger; _processPathProvider = processPathProvider ?? (() => Environment.ProcessPath); + _isStableShapedCliVersion = isStableShapedCliVersion ?? IsStableShapedCliVersionFromAssembly; _stagingUnavailableReasonCache = new Lazy(ComputeStagingChannelUnavailableReason); } @@ -133,7 +140,46 @@ public Task> GetChannelsAsync(CancellationToken canc var stagingFeatureEnabled = _features.IsFeatureEnabled(KnownFeatures.StagingChannelEnabled, false); if (stagingFeatureEnabled || stagingChannelConfigured || stagingChannelRequested || stagingIdentityChannel) { - var defaultQuality = stagingChannelConfigured || stagingChannelRequested || stagingIdentityChannel ? PackageChannelQuality.Both : PackageChannelQuality.Stable; + // Default quality selection rules (per staging entry point): + // - Explicit user opt-in (`stagingChannelConfigured`, `stagingChannelRequested`): Both. + // The user picked staging deliberately; they get the broadest matching window. + // - `stagingFeatureEnabled` only (no other staging signal): Stable. Preserves the + // pre-existing behavior of the staging feature flag. + // - `stagingIdentityChannel` (the running CLI itself self-identifies as staging): + // depends on the CLI build's version shape. + // * Stable-shaped (e.g. "13.4.0", produced during release stabilization when + // StabilizePackageVersion=true) → Stable. The shared dotnet9 daily feed only + // carries prerelease-tagged 13.4.0-preview.* builds, so defaulting to Both + // would route Aspire.* to dotnet9 and fail to resolve the just-shipped + // stable-shaped packages — the bug from + // https://github.com/microsoft/aspire/issues/17527. Routing to Stable selects + // the SHA-derived darc-pub-microsoft-aspire- feed, where the + // stabilizing build's packages actually live. + // * Prerelease-shaped (e.g. "13.4.0-preview.1.123") → Both. SHA-specific darc + // feeds are only created for stable release-branch builds, so prerelease CLIs + // must use the shared daily feed; the historical Both default is correct. + PackageChannelQuality defaultQuality; + if (stagingIdentityChannel) + { + // When the running CLI's identity itself is staging, the synthesized channel's + // quality MUST follow the CLI build's version shape regardless of how synthesis + // was triggered. `init` and many other commands pass requestedChannelName=staging + // when identity is staging, so checking `stagingChannelRequested` first would + // short-circuit this path and re-introduce the #17527 misroute on stabilizing + // builds. + defaultQuality = _isStableShapedCliVersion() + ? PackageChannelQuality.Stable + : PackageChannelQuality.Both; + } + else if (stagingChannelConfigured || stagingChannelRequested) + { + defaultQuality = PackageChannelQuality.Both; + } + else + { + defaultQuality = PackageChannelQuality.Stable; + } + var stagingChannel = CreateStagingChannel(defaultQuality); if (stagingChannel is not null) { @@ -216,6 +262,23 @@ prDirectory.Parent is not { } dogfoodDirectory || return packagesDirectory.Exists ? packagesDirectory : null; } + // Returns true when the running CLI's version is stable-shaped (no semver prerelease tag). + // Used by the staging-channel synthesis to route stabilizing builds to the SHA-derived darc + // feed instead of the shared dotnet9 daily feed. Falls back to false on any error so we + // preserve the historical Both/shared-feed behavior rather than silently misrouting. + private static bool IsStableShapedCliVersionFromAssembly() + { + try + { + var version = VersionHelper.GetDefaultSdkVersion(); + return !string.IsNullOrEmpty(version) && !version.Contains('-'); + } + catch + { + return false; + } + } + private PackageChannel? CreateStagingChannel(PackageChannelQuality defaultQuality) { // Refuse to synthesize a staging channel on CLI identities that cannot produce a real diff --git a/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs b/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs index 5b25a742709..1780f14cf3e 100644 --- a/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs @@ -65,7 +65,7 @@ public async Task GetChannelsAsync_WhenIdentityChannelIsStaging_IncludesStagingC [PackagingService.OverrideStagingFeedConfigKey] = "https://example.com/nuget/v3/index.json" }) .Build(); - var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), new TestFeatures(), configuration, NullLogger.Instance); + var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), new TestFeatures(), configuration, NullLogger.Instance, isStableShapedCliVersion: () => false); var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); @@ -82,6 +82,35 @@ public async Task GetChannelsAsync_WhenIdentityChannelIsStaging_IncludesStagingC Assert.Equal(PackageChannelQuality.Both, stagingChannel.Quality); } + [Fact] + public async Task GetChannelsAsync_WhenIdentityChannelIsStagingOnStableShapedCli_DefaultsToStableQuality() + { + // Regression test for https://github.com/microsoft/aspire/issues/17527: during release + // stabilization the staging CLI ships with a stable-shaped version (e.g. "13.4.0"). The + // shared dotnet9 daily feed only carries prerelease-tagged 13.4.0-preview.* packages, + // so a stabilizing staging CLI must route Aspire.* to the SHA-derived darc-pub-aspire- + // feed instead — which requires defaulting the synthesized staging channel quality to + // Stable (so useSharedFeed in CreateStagingChannel resolves false). + 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", identityChannel: PackageChannelNames.Staging); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [PackagingService.OverrideStagingFeedConfigKey] = "https://example.com/nuget/v3/index.json" + }) + .Build(); + var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), new TestFeatures(), configuration, NullLogger.Instance, isStableShapedCliVersion: () => true); + + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + + var stagingChannel = channels.First(c => c.Name == PackageChannelNames.Staging); + Assert.Equal(PackageChannelQuality.Stable, stagingChannel.Quality); + } + [Fact] public async Task GetChannelsAsync_WhenRequestedChannelIsStaging_IncludesStagingChannel() { diff --git a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs index e1081821c39..a9a4b318fe4 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs @@ -576,7 +576,14 @@ public ISolutionLocator CreateDefaultSolutionLocatorFactory(IServiceProvider ser var nuGetPackageCache = serviceProvider.GetRequiredService(); var features = serviceProvider.GetRequiredService(); var configuration = serviceProvider.GetRequiredService(); - return new PackagingService(executionContext, nuGetPackageCache, features, configuration, NullLogger.Instance); + // Force prerelease-shaped CLI version semantics in tests so PackagingService's + // identity-staging quality default does not depend on whether the test-host assembly + // was produced under StabilizePackageVersion=true. Without this, tests that rely on + // the shared-daily routing for `staging` identity (quality=Both → useSharedFeed=true) + // would fail under the stabilization-check job which builds with a stable-shaped + // version (no '-' suffix) baked in. Tests that specifically exercise the stable-shape + // branch construct PackagingService directly with isStableShapedCliVersion: () => true. + return new PackagingService(executionContext, nuGetPackageCache, features, configuration, NullLogger.Instance, isStableShapedCliVersion: () => false); }; public Func DiskCacheFactory { get; set; } = (IServiceProvider serviceProvider) => new NullDiskCache(); From 44fc7a37993aff06024242a52d0af8c76df68a23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Ros?= Date: Wed, 27 May 2026 16:30:33 -0700 Subject: [PATCH 02/15] Prefer current CLI template version for aspire new (#17564) * Prefer current CLI template version Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Pin aspire new template version to CLI Ensure CLI-runtime templates selected from explicit package channels use the current bundled CLI/SDK version instead of floating to newer channel packages that can mismatch the bundled AppHost server. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> (cherry picked from commit 3f0998a95833d5fef96b3159d56b667889b71166) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/NewCommand.cs | 37 ++++++++-- src/Aspire.Cli/Packaging/PackageChannel.cs | 5 +- src/Aspire.Cli/Utils/VersionHelper.cs | 4 +- .../NewCommandChannelResolutionTests.cs | 73 ++++++++++++++----- .../Commands/NewCommandTests.cs | 4 +- .../Utils/VersionHelperTests.cs | 65 +++++++++++++++++ 6 files changed, 158 insertions(+), 30 deletions(-) diff --git a/src/Aspire.Cli/Commands/NewCommand.cs b/src/Aspire.Cli/Commands/NewCommand.cs index f7cf931cb75..3234e424748 100644 --- a/src/Aspire.Cli/Commands/NewCommand.cs +++ b/src/Aspire.Cli/Commands/NewCommand.cs @@ -177,6 +177,34 @@ private async Task PromptForAppHostLanguageAsync(IReadOnlyList s return selected.LanguageId; } + private static NuGetPackage? TryGetCurrentCliTemplateVersionPackage(PackageChannel selectedChannel, NuGetPackage[] packages, bool hasPrHives) + { + if (VersionHelper.TryGetCurrentCliVersionMatch( + packages, + p => p.Version, + out var cliVersionPackage, + channelName: selectedChannel.Name, + hasPrHives: hasPrHives)) + { + return cliVersionPackage; + } + + if (packages.Length > 0 && + selectedChannel.Type is PackageChannelType.Explicit && + !VersionHelper.IsLocalBuildChannel(selectedChannel.Name)) + { + // Prerelease channels can filter out the shipped stable package even when the feed can restore it. + return new NuGetPackage + { + Id = TemplateNuGetConfigService.TemplatesPackageName, + Version = VersionHelper.GetDefaultSdkVersion(), + Source = selectedChannel.SourceDetails + }; + } + + return null; + } + private async Task<(bool Success, string? LanguageId)> ResolveSelectedLanguageAsync(ITemplate template, ParseResult parseResult, CancellationToken cancellationToken) { var explicitLanguageId = ParseExplicitLanguageId(parseResult); @@ -379,14 +407,7 @@ private async Task ResolveCliTemplateVersionAsync( .ToArray(); var hasPrHives = ExecutionContext.GetHiveCount() > 0; - NuGetPackage? package = VersionHelper.TryGetCurrentCliVersionMatch( - packages, - p => p.Version, - out var cliVersionPackage, - channelName: selectedChannel.Name, - hasPrHives: hasPrHives) - ? cliVersionPackage - : null; + var package = TryGetCurrentCliTemplateVersionPackage(selectedChannel, packages, hasPrHives); package ??= packages .OrderByDescending(p => Semver.SemVersion.Parse(p.Version, Semver.SemVersionStyles.Strict), Semver.SemVersion.PrecedenceComparer) diff --git a/src/Aspire.Cli/Packaging/PackageChannel.cs b/src/Aspire.Cli/Packaging/PackageChannel.cs index 330821ff384..c619b2f7afb 100644 --- a/src/Aspire.Cli/Packaging/PackageChannel.cs +++ b/src/Aspire.Cli/Packaging/PackageChannel.cs @@ -85,12 +85,15 @@ public async Task> GetTemplatePackagesAsync(DirectoryI .DistinctBy(p => $"{p.Id}-{p.Version}"); // When doing a `dotnet package search` the results may include stable packages even when searching for - // prerelease packages. This filters out this noise. + // prerelease packages. Keep the current CLI/SDK version so shipped CLIs can resolve their + // matching template package from daily/staging feeds, then filter out the remaining noise. + var currentCliVersion = VersionHelper.GetDefaultSdkVersion(); var filteredPackages = packages.Where(p => new { SemVer = SemVersion.Parse(p.Version), Quality = Quality } switch { { Quality: PackageChannelQuality.Both } => true, { Quality: PackageChannelQuality.Stable, SemVer: { IsPrerelease: false } } => true, { Quality: PackageChannelQuality.Prerelease, SemVer: { IsPrerelease: true } } => true, + { Quality: PackageChannelQuality.Prerelease, SemVer: { IsPrerelease: false } } when string.Equals(p.Version, currentCliVersion, StringComparison.OrdinalIgnoreCase) => true, _ => false }); diff --git a/src/Aspire.Cli/Utils/VersionHelper.cs b/src/Aspire.Cli/Utils/VersionHelper.cs index dfb85cb73eb..02d3f78e6ea 100644 --- a/src/Aspire.Cli/Utils/VersionHelper.cs +++ b/src/Aspire.Cli/Utils/VersionHelper.cs @@ -24,7 +24,7 @@ public static bool IsLocalBuildChannel(string? channelName) } /// - /// Finds the candidate that exactly matches the current CLI/SDK version when running against local build channels or hives. + /// Finds the candidate that exactly matches the current CLI/SDK version when a channel has already been selected or local hives are present. /// public static bool TryGetCurrentCliVersionMatch( IEnumerable candidates, @@ -36,7 +36,7 @@ public static bool TryGetCurrentCliVersionMatch( ArgumentNullException.ThrowIfNull(candidates); ArgumentNullException.ThrowIfNull(versionSelector); - if (!hasPrHives && !IsLocalBuildChannel(channelName)) + if (!hasPrHives && string.IsNullOrWhiteSpace(channelName)) { match = default; return false; diff --git a/tests/Aspire.Cli.Tests/Commands/NewCommandChannelResolutionTests.cs b/tests/Aspire.Cli.Tests/Commands/NewCommandChannelResolutionTests.cs index 8288dc321c1..521b8815500 100644 --- a/tests/Aspire.Cli.Tests/Commands/NewCommandChannelResolutionTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/NewCommandChannelResolutionTests.cs @@ -8,6 +8,7 @@ using Aspire.Cli.Templating; using Aspire.Cli.Tests.TestServices; using Aspire.Cli.Tests.Utils; +using Aspire.Cli.Utils; using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.DependencyInjection; using NuGetPackage = Aspire.Shared.NuGetPackageCli; @@ -115,12 +116,9 @@ public async Task NewCommand_DoesNotConsultGlobalConfigurationServiceForChannelK /// /// Channel-resolution contract: when the running CLI's identity is a non-local channel /// (daily / staging / stable) and no --channel is passed, aspire new must - /// resolve the template version from the channel whose name matches the identity — not - /// from the Implicit (nuget.org) channel. Without this, a daily/staging CLI silently - /// resolves a stable nuget.org template while the per-project channel pin (written by - /// the template factories) still points at the channel-specific feed — yielding an - /// inconsistent project that aspire restore rejects with "Unable to find a stable - /// package". + /// resolve the channel whose name matches the identity — not the Implicit (nuget.org) + /// channel — while still pinning the template version to the current CLI/SDK version. + /// The bundled server and restored Aspire packages must stay on the same version. /// [Theory] [InlineData(PackageChannelNames.Daily, "13.4.0-preview.1.99999.1")] @@ -132,10 +130,22 @@ public async Task NewCommand_NoChannelArg_ResolvesTemplateFromIdentityChannel(st channelOptionArg: null, identityChannelVersion: identityChannelVersion); - Assert.Equal(identityChannelVersion, captured.Version); + Assert.Equal(VersionHelper.GetDefaultSdkVersion(), captured.Version); Assert.Equal(identityChannel, captured.Channel); } + [Fact] + public async Task NewCommand_NoChannelArg_DailyChannelWithoutExactCliVersion_PinsTemplateToCurrentCliVersion() + { + var captured = await CaptureTemplateInputsAsync( + identityChannel: PackageChannelNames.Daily, + channelOptionArg: null, + identityChannelVersion: "13.5.0-preview.1.99999.1"); + + Assert.Equal(VersionHelper.GetDefaultSdkVersion(), captured.Version); + Assert.Equal(PackageChannelNames.Daily, captured.Channel); + } + /// /// PR-channel CLI is already covered by the local-build channel branch retained in /// . Pinned here so a future refactor doesn't regress the @@ -179,8 +189,8 @@ public async Task NewCommand_NoChannelArg_IdentityChannelNotRegistered_FallsBack /// /// Issue #17121 regression guard: a staging-identity CLI should have a registered /// staging channel from PackagingService.GetChannelsAsync, so aspire new - /// resolves templates from staging instead of falling back to the Implicit NuGet.org - /// channel. + /// resolves the channel from staging instead of falling back to the Implicit NuGet.org + /// channel, while keeping the template version pinned to the current CLI. /// [Fact] public async Task NewCommand_NoChannelArg_StagingIdentityWithStagingChannelRegistered_ResolvesTemplateFromStaging() @@ -190,14 +200,15 @@ public async Task NewCommand_NoChannelArg_StagingIdentityWithStagingChannelRegis channelOptionArg: null, identityChannelVersion: "13.4.0-rc.1.99999.1"); - Assert.Equal("13.4.0-rc.1.99999.1", captured.Version); + Assert.Equal(VersionHelper.GetDefaultSdkVersion(), captured.Version); Assert.Equal(PackageChannelNames.Staging, captured.Channel); } /// /// Explicit --channel must always override the running CLI's identity channel — /// so a developer on a daily CLI can still scaffold a stable-channel project for - /// reproduction or migration testing. + /// reproduction or migration testing. The template version still stays pinned to the + /// current CLI so restored Aspire packages match the bundled server. /// [Fact] public async Task NewCommand_ExplicitChannelArg_OverridesIdentityChannel() @@ -207,10 +218,31 @@ public async Task NewCommand_ExplicitChannelArg_OverridesIdentityChannel() channelOptionArg: PackageChannelNames.Stable, identityChannelVersion: "13.4.0-preview.1.99999.1"); - Assert.Equal("13.5.0", captured.Version); // stable channel version + Assert.Equal(VersionHelper.GetDefaultSdkVersion(), captured.Version); Assert.Equal(PackageChannelNames.Stable, captured.Channel); } + /// + /// A shipped CLI must prefer its own SDK/template version from an explicitly selected + /// non-local channel instead of floating to a newer daily/staging package from the same feed. + /// + [Theory] + [InlineData(PackageChannelNames.Daily)] + [InlineData(PackageChannelNames.Staging)] + public async Task NewCommand_ExplicitPrereleaseChannel_PrefersCurrentCliVersionWhenAvailable(string channelName) + { + var cliVersion = VersionHelper.GetDefaultSdkVersion(); + + var captured = await CaptureTemplateInputsAsync( + identityChannel: channelName, + channelOptionArg: channelName, + identityChannelVersion: cliVersion, + identityChannelVersions: ["99.0.0-preview.1", cliVersion]); + + Assert.Equal(cliVersion, captured.Version); + Assert.Equal(channelName, captured.Channel); + } + /// /// Invokes with a fake CLI-runtime template that captures the /// handed to it. This is the contract surface the four @@ -228,7 +260,8 @@ public async Task NewCommand_ExplicitChannelArg_OverridesIdentityChannel() private async Task CaptureTemplateInputsAsync( string identityChannel, string? channelOptionArg, - string? identityChannelVersion) + string? identityChannelVersion, + IEnumerable? identityChannelVersions = null) { using var workspace = TemporaryWorkspace.Create(outputHelper); @@ -260,7 +293,7 @@ private async Task CaptureTemplateInputsAsync( options.TemplateProviderFactory = _ => new SingleTemplateProvider(fakeTemplate); - options.PackagingServiceFactory = _ => BuildPackagingService(identityChannel, identityChannelVersion); + options.PackagingServiceFactory = _ => BuildPackagingService(identityChannel, identityChannelVersion, identityChannelVersions); }); using var serviceProvider = services.BuildServiceProvider(); @@ -280,8 +313,14 @@ private async Task CaptureTemplateInputsAsync( /// pr-* explicit channels), but with deterministic per-channel template versions so /// tests can identify which channel won resolution. /// - private static IPackagingService BuildPackagingService(string identityChannel, string? identityChannelVersion) + private static IPackagingService BuildPackagingService( + string identityChannel, + string? identityChannelVersion, + IEnumerable? identityChannelVersions) { + var identityVersions = identityChannelVersions?.ToArray() + ?? (identityChannelVersion is null ? [] : [identityChannelVersion]); + // Implicit channel always returns the stable token so a "fell-through to Implicit" // outcome is distinguishable from an identity-channel pickup. var implicitCache = new FakeNuGetPackageCache @@ -313,7 +352,7 @@ [new PackageMapping(PackageMapping.AllPackages, "https://api.nuget.org/v3/index. // Register a non-stable explicit channel matching the identity, when the test // scenario calls for it. Deliberately omitted in the "identity not registered" // case so fallback to Implicit can be observed. - var isDailyOrStaging = identityChannelVersion is not null && + var isDailyOrStaging = identityVersions.Length > 0 && !string.Equals(identityChannel, PackageChannelNames.Stable, StringComparison.OrdinalIgnoreCase) && !identityChannel.StartsWith("pr-", StringComparison.OrdinalIgnoreCase); if (isDailyOrStaging) @@ -322,7 +361,7 @@ [new PackageMapping(PackageMapping.AllPackages, "https://api.nuget.org/v3/index. { GetTemplatePackagesAsyncCallback = (_, _, _, _) => Task.FromResult>( - [new NuGetPackage { Id = "Aspire.ProjectTemplates", Source = "nuget", Version = identityChannelVersion! }]) + identityVersions.Select(version => new NuGetPackage { Id = "Aspire.ProjectTemplates", Source = "nuget", Version = version })) }; channels.Add(PackageChannel.CreateExplicitChannel( identityChannel, diff --git a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs index b2398d5d226..44de0fd9aad 100644 --- a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs @@ -1610,7 +1610,7 @@ public async Task NewCommandWithTypeScriptEmptyTemplatePassesResolvedVersionAndC var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(CliExitCodes.Success, exitCode); - Assert.Equal("9.2.0", scaffoldSdkVersion); + Assert.Equal(VersionHelper.GetDefaultSdkVersion(), scaffoldSdkVersion); Assert.Equal("stable", scaffoldChannel); } @@ -1811,7 +1811,7 @@ public async Task NewCommandWithTypeScriptStarterGeneratesSdkArtifacts() Assert.Equal(CliExitCodes.Success, exitCode); Assert.True(buildAndGenerateCalled); Assert.Equal("daily", channelSeenByProject); - Assert.Equal("9.2.0", sdkVersionSeenByProject); + Assert.Equal(VersionHelper.GetDefaultSdkVersion(), sdkVersionSeenByProject); Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "output", LanguageInfo.GeneratedFolderName, "aspire.mts"))); } diff --git a/tests/Aspire.Cli.Tests/Utils/VersionHelperTests.cs b/tests/Aspire.Cli.Tests/Utils/VersionHelperTests.cs index ef217314d55..534de25d5fd 100644 --- a/tests/Aspire.Cli.Tests/Utils/VersionHelperTests.cs +++ b/tests/Aspire.Cli.Tests/Utils/VersionHelperTests.cs @@ -28,6 +28,71 @@ public void TryGetCurrentCliVersionMatch_WithPrHivesAndNoChannel_ReturnsCurrentC Assert.Equal(cliVersion, match); } + [Theory] + [InlineData("daily")] + [InlineData("staging")] + [InlineData("stable")] + public void TryGetCurrentCliVersionMatch_WithNamedChannel_ReturnsCurrentCliVersion(string channelName) + { + var cliVersion = VersionHelper.GetDefaultSdkVersion(); + var candidates = new[] + { + "99.0.0", + cliVersion, + }; + + var result = VersionHelper.TryGetCurrentCliVersionMatch( + candidates, + version => version, + out var match, + channelName: channelName, + hasPrHives: false); + + Assert.True(result); + Assert.Equal(cliVersion, match); + } + + [Fact] + public void TryGetCurrentCliVersionMatch_WithNamedChannelAndNoExactMatch_ReturnsFalse() + { + var candidates = new[] + { + "99.0.0", + "98.0.0", + }; + + var result = VersionHelper.TryGetCurrentCliVersionMatch( + candidates, + version => version, + out var match, + channelName: "daily", + hasPrHives: false); + + Assert.False(result); + Assert.Null(match); + } + + [Fact] + public void TryGetCurrentCliVersionMatch_WithNoChannelAndNoPrHives_ReturnsFalse() + { + var cliVersion = VersionHelper.GetDefaultSdkVersion(); + var candidates = new[] + { + "99.0.0", + cliVersion, + }; + + var result = VersionHelper.TryGetCurrentCliVersionMatch( + candidates, + version => version, + out var match, + channelName: null, + hasPrHives: false); + + Assert.False(result); + Assert.Null(match); + } + [Theory] [InlineData("pr-16820", true)] [InlineData("run-25422767716", true)] From 020c8efa4643243f82412ac4358c432e42492bb5 Mon Sep 17 00:00:00 2001 From: David Negstad <50252651+danegsta@users.noreply.github.com> Date: Wed, 27 May 2026 20:45:45 -0700 Subject: [PATCH 03/15] [release/13.4] Stabilize PrebuiltAppHostServer staging globalPackagesFolder path (#17573) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Stabilize PrebuiltAppHostServer staging globalPackagesFolder path PackagingService creates the staging channel with ConfigureGlobalPackagesFolder=true so each darc/override feed restore lands in an isolated cache (two staging builds of the same release branch ship as 13.4.0 but from different feeds, and NuGet keys by (id,version) only). PrebuiltAppHostServer was wiring that flag into a TemporaryNuGetConfig whose default globalPackagesFolder value is the relative '.nugetpackages' -- NuGet resolved it under the temp config's own directory, BundleNuGetService baked those temp paths into integration-package-probe-manifest.json, and TemporaryNuGetConfig.Dispose then recursively deleted the cache out from under the manifest. On macOS osx-arm64 polyglot staging builds this surfaced as a hang during DI / assembly loading in aspire-managed. Preserve the per-feed cache isolation behavior and anchor the override at a stable absolute path instead: /.nugetpackages/ Keying by the truncated commit hash matches the existing darc-pub-microsoft-aspire- feed URL convention in PackagingService.GetStagingFeedUrl, so the cache key and feed key stay aligned at 8 hex chars. 8 chars is short enough to avoid Windows MAX_PATH blow-ups on deep integration cache trees while keeping SHA collisions negligible. The cache lives under ASPIRE_HOME (not the per-AppHost working directory) so multiple AppHosts on the same machine running against the same staging build can share a single restore -- the unit of isolation here is the staging build, not the individual restore command. Mechanics: - TemporaryNuGetConfig.CreateAsync now accepts an optional globalPackagesFolderValue and propagates it through AddGlobalPackagesFolderToConfigAsync into the merger. - NuGetConfigMerger.AddGlobalPackagesFolderConfiguration takes the optional override and falls back to the workspace-relative default ('.nugetpackages') for the non-temp workspace-merge path. - PrebuiltAppHostServer.ResolveStableGlobalPackagesFolder routes both temp config branches (channel and package-source-override) through the new helper. - VersionHelper.TryGetCurrentCommitHashShort surfaces the truncated SHA from the running CLI's AssemblyInformationalVersion (returns null on clean release builds with no '+sha' suffix; callers fall back to 'default'). - CliPathHelper centralizes the '/.nugetpackages' path so the producer (PrebuiltAppHostServer) and consumer (CacheCommand) can't drift. - CacheCommand.ClearCommand now wipes /.nugetpackages so a wedged staging restore is recoverable through the same UX as every other CLI cache. Tests: - Updated existing PrebuiltAppHostServerTests staging cases to assert the globalPackagesFolder value is absolute and lives outside the temp config directory. - New PrebuiltAppHostServerTests case wires a real PackagingService with overrideStagingFeed on a stable-shaped CLI and pins the same invariant end-to-end. - New TemporaryNuGetConfigTests for the override propagation and for the no-override-when-disabled invariant. - New CacheCommandTests covering the staging cache wipe and the missing-cache no-op path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Key staging globalPackagesFolder cache by feed URL hash The staging globalPackagesFolder fix in the previous commit keyed the \`.nugetpackages\` subdirectory off the CLI's own commit SHA (first 8 hex chars of \`AssemblyInformationalVersionAttribute\`). That handles two darc-shipped staging builds of the same release branch, but it breaks the local-dev case where one CLI is repeatedly retargeted at different \`overrideStagingFeed\` values: the URL changes but the SHA doesn't, so every override silently shares one cache bucket and the second restore reuses the first feed's now-stale \`13.4.0\` assemblies. Switch the cache key to the first 8 hex chars of \`XxHash3\` over the trimmed, lower-cased resolved feed URL: - Override branch passes the explicit \`--source\` URL. - Channel branch passes the channel's \`Aspire*\` mapping source (or the first mapping for forward compatibility). Trim + lower-case before hashing so a stray whitespace from a config file or a hostname-case change doesn't fragment the cache. Non-cryptographic hashing is fine here — the key is a directory name, not a security boundary — and 8 hex chars keep deep integration cache paths well under Windows MAX_PATH while giving ~4 billion buckets, so collisions are negligible across the handful of staging feeds any user ever sees. Removes the now-unused \`VersionHelper.TryGetCurrentCommitHashShort\` helper (added in the previous commit, no remaining callers). Tests: - New \`CliPathHelperTests.ComputeStagingFeedCacheKey_*\` cover determinism, normalization, length defaults, and null/empty input. - Existing \`PrebuiltAppHostServerTests\` strengthened to assert the emitted globalPackagesFolder equals \`/.nugetpackages/\`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Narrow #17564 CLI-pin fallback to prerelease channels PR #17564 introduced a new fallback in TryGetCurrentCliTemplateVersionPackage that pins the template package to the running CLI's SDK version whenever the selected channel is explicit and not local-build. That preserved the intended fix for prerelease channels (daily, staging) where the channel feed filters the running CLI's package out of search results even though the feed can still restore it. But it also fired for the stable channel, where the filter does not apply. For a non-stable-shape CLI (PR build like '13.4.0-pr.X.gY' or a daily-shape preview) invoked with '--channel stable', it forced an unpublishable version into the generated apphost.cs, breaking the SmokeTests LatestCliCanStartStableChannelAppHost and LatestCliCanStartStableChannelTypeScriptAppHost. Exclude the stable channel from the new fallback so that case falls through to the OrderByDescending picker and the user gets the highest shipped stable package they explicitly asked for. The original prerelease-channel motivation ('Aspire.TypeSystem version mismatch when 13.4 CLI floats templates to a 13.5 daily preview') is preserved unchanged. Test updates: - NewCommandChannelResolutionTests.NewCommand_NoChannelArg_ResolvesTemplateFromIdentityChannel gets an expectedVersion theory parameter so the daily case still pins to the CLI version while the stable case asserts the highest shipped stable. - NewCommandChannelResolutionTests.NewCommand_ExplicitChannelArg_OverridesIdentityChannel asserts highest shipped stable (matching the SmokeTest contract). - New NewCommand_ExplicitStableChannel_NonStableCliVersion_FallsBackToHighestShippedStable regression test covers both daily-shape and PR-shape CLI identities. - NewCommandTests.NewCommandWithTypeScriptEmptyTemplatePassesResolvedVersionAndChannelToScaffolding reverts to its pre-#17564 assertion of '9.2.0' from the stable feed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> (cherry picked from commit 5d0da8b9c7745d54103b9b38d087dbaa7f6c3487) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/CacheCommand.cs | 8 ++ src/Aspire.Cli/Commands/NewCommand.cs | 17 ++- src/Aspire.Cli/Packaging/NuGetConfigMerger.cs | 15 ++- .../Packaging/TemporaryNuGetConfig.cs | 8 +- .../Projects/PrebuiltAppHostServer.cs | 67 ++++++++++- src/Aspire.Cli/Utils/CliPathHelper.cs | 69 +++++++++++ .../Commands/CacheCommandTests.cs | 56 +++++++++ .../NewCommandChannelResolutionTests.cs | 50 ++++++-- .../Commands/NewCommandTests.cs | 2 +- .../Packaging/TemporaryNuGetConfigTests.cs | 39 ++++++ .../Projects/PrebuiltAppHostServerTests.cs | 112 +++++++++++++++++- .../Utils/CliPathHelperTests.cs | 88 ++++++++++++++ 12 files changed, 503 insertions(+), 28 deletions(-) diff --git a/src/Aspire.Cli/Commands/CacheCommand.cs b/src/Aspire.Cli/Commands/CacheCommand.cs index 22d01fe6d7e..4cb30de7a6b 100644 --- a/src/Aspire.Cli/Commands/CacheCommand.cs +++ b/src/Aspire.Cli/Commands/CacheCommand.cs @@ -40,6 +40,14 @@ protected override Task ExecuteAsync(ParseResult parseResult, Can filesDeleted += ClearDirectoryContents(ExecutionContext.CacheDirectory); filesDeleted += ClearDirectoryContents(ExecutionContext.SdksDirectory); filesDeleted += ClearDirectoryContents(ExecutionContext.PackagesDirectory); + // Wipe the staging NuGet package cache too. Producers (PrebuiltAppHostServer's + // temporary nuget.config for the staging channel) deposit SHA-keyed package + // caches under /.nugetpackages/; clearing them lets users + // recover wedged staging restores without filesystem surgery. We hand the parent + // directory to ClearDirectoryContents so each SHA subdirectory is wiped while + // the parent itself stays in place for the next staging restore. + filesDeleted += ClearDirectoryContents( + new DirectoryInfo(CliPathHelper.GetStagingNuGetPackagesDirectory(ExecutionContext.AspireHomeDirectory))); filesDeleted += ClearDirectoryContents( ExecutionContext.LogsDirectory, skipFile: f => f.FullName.Equals(currentLogFilePath, StringComparison.OrdinalIgnoreCase)); diff --git a/src/Aspire.Cli/Commands/NewCommand.cs b/src/Aspire.Cli/Commands/NewCommand.cs index 3234e424748..dfc62cc3179 100644 --- a/src/Aspire.Cli/Commands/NewCommand.cs +++ b/src/Aspire.Cli/Commands/NewCommand.cs @@ -191,9 +191,24 @@ private async Task PromptForAppHostLanguageAsync(IReadOnlyList s if (packages.Length > 0 && selectedChannel.Type is PackageChannelType.Explicit && + !string.Equals(selectedChannel.Name, PackageChannelNames.Stable, StringComparisons.ChannelName) && !VersionHelper.IsLocalBuildChannel(selectedChannel.Name)) { - // Prerelease channels can filter out the shipped stable package even when the feed can restore it. + // Prerelease channels (daily, staging) filter the shipped stable package out of channel + // search even when the channel's feed mappings can still restore it (they fall back to + // nuget.org). For those channels, pinning to the running CLI's SDK version keeps the + // bundled server and the restored Aspire packages in lock-step. Without this, `aspire new + // --channel daily` on a shipped 13.4 CLI floats templates to a 13.5 daily preview, which + // then breaks the bundled 13.4 AppHost server with `Aspire.TypeSystem, Version=13.5.0.0` + // assembly load errors followed by `No language support found for: typescript/nodejs`. + // + // The stable channel is excluded here on purpose: it does not apply that filter, so a + // "no exact match" outcome means the CLI version is genuinely not published on the stable + // feed (the CLI is daily-shape, staging-shape, or PR-shape `13.4.0-pr.X.gY`). Forcing + // it through would either contradict the user's explicit `--channel stable` request or + // write an unpublishable version into `apphost.cs` that NuGet restore cannot satisfy. + // Fall through to the OrderByDescending picker so the user gets the highest shipped + // stable package they actually asked for. return new NuGetPackage { Id = TemplateNuGetConfigService.TemplatesPackageName, diff --git a/src/Aspire.Cli/Packaging/NuGetConfigMerger.cs b/src/Aspire.Cli/Packaging/NuGetConfigMerger.cs index 2b3fd441efd..38d578b5aeb 100644 --- a/src/Aspire.Cli/Packaging/NuGetConfigMerger.cs +++ b/src/Aspire.Cli/Packaging/NuGetConfigMerger.cs @@ -1032,7 +1032,13 @@ private static void AddGlobalPackagesFolderConfiguration(NuGetConfigContext conf AddGlobalPackagesFolderConfiguration(configContext.Configuration); } - internal static void AddGlobalPackagesFolderConfiguration(XElement configuration) + // Default workspace-relative cache used when no explicit path is supplied. Matches the + // long-standing 'aspire init / aspire new' convention of putting a per-workspace + // .nugetpackages folder next to the merged nuget.config so staging-vs-stable cache + // poisoning doesn't bleed into the user's global ~/.nuget/packages folder. + internal const string DefaultGlobalPackagesFolderValue = ".nugetpackages"; + + internal static void AddGlobalPackagesFolderConfiguration(XElement configuration, string? globalPackagesFolderValue = null) { // Check if config section already exists var config = configuration.Element("config"); @@ -1048,10 +1054,13 @@ internal static void AddGlobalPackagesFolderConfiguration(XElement configuration if (existingGlobalPackagesFolder is null) { - // Add globalPackagesFolder configuration + // Add globalPackagesFolder configuration. Callers (e.g. PrebuiltAppHostServer's + // temporary nuget.config) supply an absolute path when the config file itself is + // ephemeral so the cached packages outlive the config — otherwise NuGet would + // resolve the relative ".nugetpackages" under the about-to-be-deleted temp dir. var globalPackagesFolderAdd = new XElement("add"); globalPackagesFolderAdd.SetAttributeValue("key", "globalPackagesFolder"); - globalPackagesFolderAdd.SetAttributeValue("value", ".nugetpackages"); + globalPackagesFolderAdd.SetAttributeValue("value", globalPackagesFolderValue ?? DefaultGlobalPackagesFolderValue); config.Add(globalPackagesFolderAdd); } } diff --git a/src/Aspire.Cli/Packaging/TemporaryNuGetConfig.cs b/src/Aspire.Cli/Packaging/TemporaryNuGetConfig.cs index ff73f8f9d02..66ccd658cb8 100644 --- a/src/Aspire.Cli/Packaging/TemporaryNuGetConfig.cs +++ b/src/Aspire.Cli/Packaging/TemporaryNuGetConfig.cs @@ -18,7 +18,7 @@ private TemporaryNuGetConfig(FileInfo configFile) public FileInfo ConfigFile => _configFile; - public static async Task CreateAsync(PackageMapping[] mappings, bool configureGlobalPackagesFolder = false) + public static async Task CreateAsync(PackageMapping[] mappings, bool configureGlobalPackagesFolder = false, string? globalPackagesFolderValue = null) { var tempDirectory = Directory.CreateTempSubdirectory("aspire-nuget-config").FullName; try @@ -28,7 +28,7 @@ public static async Task CreateAsync(PackageMapping[] mapp await GenerateNuGetConfigAsync(mappings, configFile); if (configureGlobalPackagesFolder) { - await AddGlobalPackagesFolderToConfigAsync(configFile); + await AddGlobalPackagesFolderToConfigAsync(configFile, globalPackagesFolderValue); } return new TemporaryNuGetConfig(configFile); } @@ -125,7 +125,7 @@ private static async Task GenerateNuGetConfigAsync(PackageMapping[] mappings, Fi await xmlWriter.WriteEndDocumentAsync(); } - private static async Task AddGlobalPackagesFolderToConfigAsync(FileInfo configFile) + private static async Task AddGlobalPackagesFolderToConfigAsync(FileInfo configFile, string? globalPackagesFolderValue) { XDocument document; await using (var stream = configFile.OpenRead()) @@ -139,7 +139,7 @@ private static async Task AddGlobalPackagesFolderToConfigAsync(FileInfo configFi document.Add(configuration); } - NuGetConfigMerger.AddGlobalPackagesFolderConfiguration(configuration); + NuGetConfigMerger.AddGlobalPackagesFolderConfiguration(configuration, globalPackagesFolderValue); var content = document.Declaration is null ? document.ToString() diff --git a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs index d87fc185867..41599bcc26e 100644 --- a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs +++ b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs @@ -689,7 +689,8 @@ private void ThrowIfStagingUnavailable(string? requestedChannel) return await TemporaryNuGetConfig.CreateAsync( PackageSourceOverrideMappings.Create(packageSourceOverride, matchedChannel), - configureGlobalPackagesFolder); + configureGlobalPackagesFolder, + configureGlobalPackagesFolder ? ResolveStableGlobalPackagesFolder(packageSourceOverride) : null); } if (string.IsNullOrEmpty(requestedChannel)) @@ -745,7 +746,69 @@ private void ThrowIfStagingUnavailable(string? requestedChannel) // restore honors the channel's package source mappings. Let IO/XML failures // surface instead of silently falling back to the caller's unmapped sources, // which could otherwise restore from an unintended feed. - return await TemporaryNuGetConfig.CreateAsync(channel.Mappings, channel.ConfigureGlobalPackagesFolder); + return await TemporaryNuGetConfig.CreateAsync( + channel.Mappings, + channel.ConfigureGlobalPackagesFolder, + channel.ConfigureGlobalPackagesFolder ? ResolveStableGlobalPackagesFolder(GetPrimaryFeedUrl(channel.Mappings)) : null); + } + + /// + /// Returns the absolute globalPackagesFolder path to write into a temporary NuGet.config + /// when the resolved channel asks for per-build cache isolation (today: staging). + /// + /// + /// The default is a relative + /// .nugetpackages path that NuGet resolves next to the nuget.config it came from. For + /// the workspace-merge flow that's fine — the merged config is + /// persistent. For 's + /// the config file lives in a Directory.CreateTempSubdirectory("aspire-nuget-config") folder + /// that recursively deletes after restore. NuGet + /// would have just populated <temp>/.nugetpackages/<id>/<version>/ + /// with the staging assemblies, would have baked those + /// paths into integration-package-probe-manifest.json, and aspire-managed would then + /// try to load assemblies the dispose just removed — observed as a hang during DI / assembly + /// loading on macOS osx-arm64 polyglot staging builds. Anchoring the override at a stable + /// per-build location keeps the cached packages alive for as long as any manifest references + /// them. + /// + /// The cache lives under (i.e. the + /// ASPIRE_HOME override when set, otherwise ~/.aspire) rather than under + /// so that two AppHosts running on the same machine against + /// the same staging build can share a single restore — the unit of cache isolation here is + /// the staging build, not the individual restore command. + /// + /// The cache subdirectory is keyed by a truncated hash of the resolved feed URL (first 8 + /// hex chars of over the trimmed/lower-cased URL). + /// Two staging builds of the same release branch — which share the same stable-shaped semver + /// (e.g. 13.4.0) but ship from different darc feeds — therefore each get their own + /// cache. A user pointing the same CLI at multiple overrideStagingFeed values during + /// dev/test also gets a distinct cache per feed, instead of one bucket silently shared across + /// feeds. NuGet identifies packages by (id, version) only, so without that per-feed + /// key the second feed's restore would silently reuse the first feed's now-stale + /// 13.4.0 assemblies. When is null or empty (defensive — + /// both call sites currently always pass a real URL) the key falls back to "default" + /// so the path is still well-formed. + /// + private string ResolveStableGlobalPackagesFolder(string? feedUrl) + { + var cacheKey = CliPathHelper.ComputeStagingFeedCacheKey(feedUrl) ?? "default"; + return Path.Combine( + CliPathHelper.GetStagingNuGetPackagesDirectory(_executionContext.AspireHomeDirectory), + cacheKey); + } + + /// + /// Returns the URL we use as the cache-key input when materializing a temp nuget.config from + /// a . Prefers the explicit Aspire* mapping (the staging + /// channel's primary feed and the one whose restored assemblies actually need cache + /// isolation), falling back to the first mapping for forward compatibility with channel + /// shapes we don't yet emit. + /// + private static string GetPrimaryFeedUrl(PackageMapping[] mappings) + { + var aspire = mappings.FirstOrDefault(m => + string.Equals(m.PackageFilter, "Aspire*", StringComparison.OrdinalIgnoreCase)); + return aspire?.Source ?? mappings[0].Source; } private async Task ResolveLocalPackageSourceOverrideAsync(string? requestedChannel, CancellationToken cancellationToken) diff --git a/src/Aspire.Cli/Utils/CliPathHelper.cs b/src/Aspire.Cli/Utils/CliPathHelper.cs index fdb2114f17a..431868a3661 100644 --- a/src/Aspire.Cli/Utils/CliPathHelper.cs +++ b/src/Aspire.Cli/Utils/CliPathHelper.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.IO.Hashing; +using System.Text; using Aspire.Cli.Acquisition; using Aspire.Hosting.Backchannel; using Microsoft.Extensions.Logging; @@ -11,6 +13,26 @@ internal static class CliPathHelper { internal const string AspireHomeEnvironmentVariable = "ASPIRE_HOME"; + /// + /// Name of the directory under ASPIRE_HOME that holds NuGet package caches keyed by + /// a stable hash of the resolved staging feed URL. Two staging builds of the same release + /// branch share the same stable-shaped semver (e.g. 13.4.0) but ship from different + /// darc feeds; an overrideStagingFeed setting can also point the same CLI at any + /// arbitrary feed. Each distinct feed URL therefore gets its own feed-hash subdirectory + /// here to avoid (id, version) cache collisions in NuGet. aspire cache clear + /// wipes the feed-hash subdirectories so users can recover wedged staging restores without + /// manual filesystem surgery. + /// + internal const string StagingNuGetPackagesFolderName = ".nugetpackages"; + + /// + /// Default number of hex characters used for staging feed cache keys. 8 keeps deep + /// integration cache paths well under Windows MAX_PATH while still giving roughly + /// 4 billion buckets — orders of magnitude more than the handful of staging feeds any one + /// user ever sees, so collisions are negligible in practice. + /// + internal const int DefaultStagingFeedCacheKeyLength = 8; + // The maximum age before a leftover CLI socket file in the runtime sockets directory is // pruned. 24 hours is comfortably longer than any legitimate Aspire CLI run and short enough // that stale entries don't pile up indefinitely after crashes (see issue #16709). @@ -38,6 +60,53 @@ internal static string GetDefaultAspireHomeDirectory(string? configuredAspireHom : configuredAspireHome; } + /// + /// Returns the absolute path to the staging NuGet package cache root + /// (<ASPIRE_HOME>/.nugetpackages). Producers (the + /// PrebuiltAppHostServer temp nuget.config) write feed-hash-keyed + /// subdirectories under this root; the aspire cache clear command wipes those + /// subdirectories. Centralized here so both call sites agree on the location. + /// + internal static string GetStagingNuGetPackagesDirectory(DirectoryInfo aspireHomeDirectory) + { + ArgumentNullException.ThrowIfNull(aspireHomeDirectory); + return Path.Combine(aspireHomeDirectory.FullName, StagingNuGetPackagesFolderName); + } + + /// + /// Returns a stable lowercase hex cache key derived from , + /// truncated to characters. Returns when + /// the URL is null, empty, or whitespace-only. + /// + /// + /// Used by PrebuiltAppHostServer to compute the per-feed + /// globalPackagesFolder subdirectory under + /// <ASPIRE_HOME>/.nugetpackages. Keying on the feed URL (rather than the CLI + /// commit SHA) means that the same CLI talking to two different override staging feeds + /// gets two distinct caches, which is important because staging packages from different + /// feeds share the same stable-shaped (id, version) tuple and would otherwise + /// collide in NuGet's cache. + /// + /// The URL is trimmed and lower-cased before hashing so harmless variations (trailing + /// whitespace from a config file, hostname casing) don't fragment the cache. Hashing the + /// URL with (non-cryptographic but very high quality) keeps any + /// embedded credentials out of the on-disk directory name even when the feed URL itself + /// contains them. + /// + internal static string? ComputeStagingFeedCacheKey(string? feedUrl, int length = DefaultStagingFeedCacheKeyLength) + { + if (string.IsNullOrWhiteSpace(feedUrl) || length <= 0) + { + return null; + } + + var normalized = feedUrl.Trim().ToLowerInvariant(); + var bytes = Encoding.UTF8.GetBytes(normalized); + // XxHash3 emits 8 bytes (64 bits) -> 16 hex chars; truncate to the requested length. + var hex = Convert.ToHexString(XxHash3.Hash(bytes)).ToLowerInvariant(); + return length >= hex.Length ? hex : hex[..length]; + } + internal static string? TryGetAspireHomeDirectoryFromInstallRoute(string? processPath, ILogger? logger = null) { if (string.IsNullOrEmpty(processPath)) diff --git a/tests/Aspire.Cli.Tests/Commands/CacheCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/CacheCommandTests.cs index e96264aadf4..e5f42f3c184 100644 --- a/tests/Aspire.Cli.Tests/Commands/CacheCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/CacheCommandTests.cs @@ -3,6 +3,7 @@ using Aspire.Cli.Commands; using Aspire.Cli.Tests.Utils; +using Aspire.Cli.Utils; using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.DependencyInjection; @@ -84,6 +85,61 @@ public async Task CacheClear_ClearsAppHostInfoDiskCache() Assert.False(appHostInfoCacheDir.Exists); } + [Fact] + public async Task CacheClear_ClearsStagingNuGetPackagesCache() + { + // Pins that `aspire cache clear` wipes the SHA-keyed staging NuGet package caches under + // /.nugetpackages — produced by PrebuiltAppHostServer's temporary + // nuget.config for the staging channel. Without this, a wedged staging restore can only + // be recovered by manual filesystem surgery. + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + using var provider = services.BuildServiceProvider(); + var executionContext = provider.GetRequiredService(); + + var stagingCacheRoot = new DirectoryInfo( + CliPathHelper.GetStagingNuGetPackagesDirectory(executionContext.AspireHomeDirectory)); + var firstBuildCache = stagingCacheRoot.CreateSubdirectory("deadbeef").CreateSubdirectory("aspire.hosting").CreateSubdirectory("13.4.0"); + var secondBuildCache = stagingCacheRoot.CreateSubdirectory("cafef00d").CreateSubdirectory("aspire.hosting").CreateSubdirectory("13.4.0"); + await File.WriteAllTextAsync(Path.Combine(firstBuildCache.FullName, "Aspire.Hosting.dll"), "fake").DefaultTimeout(); + await File.WriteAllTextAsync(Path.Combine(secondBuildCache.FullName, "Aspire.Hosting.dll"), "fake").DefaultTimeout(); + + var command = provider.GetRequiredService(); + var result = command.Parse("cache clear"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + // SHA-keyed subdirectories should be gone; the parent stays so the next staging restore + // can populate a fresh cache without recreating the .nugetpackages root. + Assert.False(Directory.Exists(Path.Combine(stagingCacheRoot.FullName, "deadbeef"))); + Assert.False(Directory.Exists(Path.Combine(stagingCacheRoot.FullName, "cafef00d"))); + } + + [Fact] + public async Task CacheClear_HandlesMissingStagingNuGetPackagesCache() + { + // Common case for fresh installs and non-staging users: the staging cache root simply + // doesn't exist yet. The command must still succeed. + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + using var provider = services.BuildServiceProvider(); + var executionContext = provider.GetRequiredService(); + + var stagingCacheRoot = new DirectoryInfo( + CliPathHelper.GetStagingNuGetPackagesDirectory(executionContext.AspireHomeDirectory)); + Assert.False(stagingCacheRoot.Exists); + + var command = provider.GetRequiredService(); + var result = command.Parse("cache clear"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + } + [Fact] public async Task CacheClear_HandlesNonExistentPackagesDirectory() { diff --git a/tests/Aspire.Cli.Tests/Commands/NewCommandChannelResolutionTests.cs b/tests/Aspire.Cli.Tests/Commands/NewCommandChannelResolutionTests.cs index 521b8815500..25243715c70 100644 --- a/tests/Aspire.Cli.Tests/Commands/NewCommandChannelResolutionTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/NewCommandChannelResolutionTests.cs @@ -117,20 +117,22 @@ public async Task NewCommand_DoesNotConsultGlobalConfigurationServiceForChannelK /// Channel-resolution contract: when the running CLI's identity is a non-local channel /// (daily / staging / stable) and no --channel is passed, aspire new must /// resolve the channel whose name matches the identity — not the Implicit (nuget.org) - /// channel — while still pinning the template version to the current CLI/SDK version. - /// The bundled server and restored Aspire packages must stay on the same version. + /// channel. Prerelease identities (daily/staging) further pin the template version to the + /// current CLI/SDK so the bundled server and restored Aspire packages stay on the same + /// version; the stable identity falls through to the highest shipped stable package because + /// the stable feed doesn't filter the running CLI's version out of search. /// [Theory] - [InlineData(PackageChannelNames.Daily, "13.4.0-preview.1.99999.1")] - [InlineData(PackageChannelNames.Stable, "13.5.0")] - public async Task NewCommand_NoChannelArg_ResolvesTemplateFromIdentityChannel(string identityChannel, string identityChannelVersion) + [InlineData(PackageChannelNames.Daily, "13.4.0-preview.1.99999.1", null)] + [InlineData(PackageChannelNames.Stable, "13.5.0", "13.5.0")] + public async Task NewCommand_NoChannelArg_ResolvesTemplateFromIdentityChannel(string identityChannel, string identityChannelVersion, string? expectedVersion) { var captured = await CaptureTemplateInputsAsync( identityChannel: identityChannel, channelOptionArg: null, identityChannelVersion: identityChannelVersion); - Assert.Equal(VersionHelper.GetDefaultSdkVersion(), captured.Version); + Assert.Equal(expectedVersion ?? VersionHelper.GetDefaultSdkVersion(), captured.Version); Assert.Equal(identityChannel, captured.Channel); } @@ -205,10 +207,13 @@ public async Task NewCommand_NoChannelArg_StagingIdentityWithStagingChannelRegis } /// - /// Explicit --channel must always override the running CLI's identity channel — - /// so a developer on a daily CLI can still scaffold a stable-channel project for - /// reproduction or migration testing. The template version still stays pinned to the - /// current CLI so restored Aspire packages match the bundled server. + /// Explicit --channel stable must always override the running CLI's identity channel — + /// so a developer on a daily CLI can still scaffold a stable-channel project for reproduction + /// or migration testing. When the CLI's own version is not published on the stable feed + /// (a daily-shape or PR-shape build), the resolver falls through to the highest shipped stable + /// package rather than forcing the unpublishable CLI version into the generated apphost. + /// Pinning to the current CLI version is reserved for prerelease channels — see + /// . /// [Fact] public async Task NewCommand_ExplicitChannelArg_OverridesIdentityChannel() @@ -218,7 +223,8 @@ public async Task NewCommand_ExplicitChannelArg_OverridesIdentityChannel() channelOptionArg: PackageChannelNames.Stable, identityChannelVersion: "13.4.0-preview.1.99999.1"); - Assert.Equal(VersionHelper.GetDefaultSdkVersion(), captured.Version); + // Highest shipped stable from the stable channel feed in the test fixture. + Assert.Equal("13.5.0", captured.Version); Assert.Equal(PackageChannelNames.Stable, captured.Channel); } @@ -243,6 +249,28 @@ public async Task NewCommand_ExplicitPrereleaseChannel_PrefersCurrentCliVersionW Assert.Equal(channelName, captured.Channel); } + /// + /// SmokeTest regression guard: when a non-stable-shape CLI (PR build, daily-shape preview, etc.) + /// is invoked with --channel stable, the resolver must NOT pin the template to the + /// running CLI's version. The CLI's own version is not published on the stable feed, so a + /// generated apphost.cs referencing it would fail NuGet restore. The fallback that pins + /// to the current CLI version is reserved for prerelease channels (daily, staging) where the + /// feed search filter is what hides the otherwise-restorable shipped stable package. + /// + [Theory] + [InlineData("13.4.0-preview.1.99999.1")] // daily-shape CLI + [InlineData("13.4.0-pr.17573.gabc1234")] // PR-shape CLI (e2e SmokeTests scenario) + public async Task NewCommand_ExplicitStableChannel_NonStableCliVersion_FallsBackToHighestShippedStable(string identityChannelVersion) + { + var captured = await CaptureTemplateInputsAsync( + identityChannel: PackageChannelNames.Daily, + channelOptionArg: PackageChannelNames.Stable, + identityChannelVersion: identityChannelVersion); + + Assert.Equal("13.5.0", captured.Version); + Assert.Equal(PackageChannelNames.Stable, captured.Channel); + } + /// /// Invokes with a fake CLI-runtime template that captures the /// handed to it. This is the contract surface the four diff --git a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs index 44de0fd9aad..633bdd9ee82 100644 --- a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs @@ -1610,7 +1610,7 @@ public async Task NewCommandWithTypeScriptEmptyTemplatePassesResolvedVersionAndC var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(CliExitCodes.Success, exitCode); - Assert.Equal(VersionHelper.GetDefaultSdkVersion(), scaffoldSdkVersion); + Assert.Equal("9.2.0", scaffoldSdkVersion); Assert.Equal("stable", scaffoldChannel); } diff --git a/tests/Aspire.Cli.Tests/Packaging/TemporaryNuGetConfigTests.cs b/tests/Aspire.Cli.Tests/Packaging/TemporaryNuGetConfigTests.cs index dcf8d9305fa..733da516be1 100644 --- a/tests/Aspire.Cli.Tests/Packaging/TemporaryNuGetConfigTests.cs +++ b/tests/Aspire.Cli.Tests/Packaging/TemporaryNuGetConfigTests.cs @@ -125,6 +125,45 @@ [new PackageMapping("Aspire.*", "https://example.com/feed")], Assert.Equal(".nugetpackages", globalPackagesFolder!.Attributes!["value"]!.Value); } + [Fact] + public async Task CreateAsync_WithExplicitGlobalPackagesFolderOverride_UsesOverrideValue() + { + // Callers that need the cache to outlive the temp config (e.g. PrebuiltAppHostServer's + // staging path) supply an absolute, persistent path so BundleNuGetService manifest paths + // remain valid after TemporaryNuGetConfig.Dispose deletes the temp directory. + var overrideValue = Path.Combine(Path.GetTempPath(), "aspire-tests", "stable-cache", "deadbeef"); + + using var tempConfig = await TemporaryNuGetConfig.CreateAsync( + [new PackageMapping("Aspire.*", "https://example.com/feed")], + configureGlobalPackagesFolder: true, + globalPackagesFolderValue: overrideValue); + + var configContent = await File.ReadAllTextAsync(tempConfig.ConfigFile.FullName); + var xmlDoc = new XmlDocument(); + xmlDoc.LoadXml(configContent); + + var globalPackagesFolder = xmlDoc.SelectSingleNode("//config/add[@key='globalPackagesFolder']"); + Assert.NotNull(globalPackagesFolder); + Assert.Equal(overrideValue, globalPackagesFolder!.Attributes!["value"]!.Value); + } + + [Fact] + public async Task CreateAsync_WithoutConfiguredGlobalPackagesFolder_IgnoresOverride() + { + // When configureGlobalPackagesFolder is false the override is irrelevant — no + // element should be emitted at all. + using var tempConfig = await TemporaryNuGetConfig.CreateAsync( + [new PackageMapping("Aspire.*", "https://example.com/feed")], + configureGlobalPackagesFolder: false, + globalPackagesFolderValue: "/should/not/appear"); + + var configContent = await File.ReadAllTextAsync(tempConfig.ConfigFile.FullName); + var xmlDoc = new XmlDocument(); + xmlDoc.LoadXml(configContent); + + Assert.Null(xmlDoc.SelectSingleNode("//config/add[@key='globalPackagesFolder']")); + } + [Theory] [InlineData("https://example.com/feed")] [InlineData("/var/folders/X/hives/pr-17105/packages")] diff --git a/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs b/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs index ded0ee4c0b1..34ad8ecbc9a 100644 --- a/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs @@ -11,8 +11,10 @@ using Aspire.Cli.Tests.Mcp; using Aspire.Cli.Tests.TestServices; using Aspire.Cli.Tests.Utils; +using Aspire.Cli.Utils; using Aspire.Hosting; using Aspire.Shared; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging.Abstractions; namespace Aspire.Cli.Tests.Projects; @@ -418,13 +420,18 @@ public async Task TryCreateTemporaryNuGetConfig_LocalIdentity_StagingRequested_E { // Pins the rubber-duck finding: dropping the temp config also drops the staging-specific // global packages folder. The emitted nuget.config must contain a element with a - // globalPackagesFolder setting when the channel was built with configureGlobalPackagesFolder. + // globalPackagesFolder setting when the channel was built with configureGlobalPackagesFolder, + // AND the value must be an absolute path that lives outside the temp config's own directory + // so the cached packages survive the temp config's recursive cleanup (otherwise restore + // hands BundleNuGetService manifest paths that the temp dispose just deleted, hanging + // aspire-managed during DI / assembly loading on macOS osx-arm64 polyglot staging builds). using var workspace = TemporaryWorkspace.Create(outputHelper); var executionContext = CreateContextWithIdentityChannel("local"); + const string channelSource = "https://pkgs.dev.azure.com/fake/v3/index.json"; var mappings = new[] { - new PackageMapping(PackageMapping.AllPackages, "https://pkgs.dev.azure.com/fake/v3/index.json") + new PackageMapping(PackageMapping.AllPackages, channelSource) }; var stagingChannel = PackageChannel.CreateExplicitChannel( name: "staging", @@ -443,7 +450,81 @@ public async Task TryCreateTemporaryNuGetConfig_LocalIdentity_StagingRequested_E .SelectMany(c => c.Elements("add")) .FirstOrDefault(a => string.Equals(a.Attribute("key")?.Value, "globalPackagesFolder", StringComparison.OrdinalIgnoreCase)); Assert.NotNull(gpf); - Assert.False(string.IsNullOrEmpty(gpf.Attribute("value")?.Value)); + var gpfValue = gpf.Attribute("value")?.Value; + Assert.False(string.IsNullOrEmpty(gpfValue)); + Assert.True(Path.IsPathFullyQualified(gpfValue), $"globalPackagesFolder value must be an absolute path. Got: {gpfValue}"); + // The temp config directory is recursively deleted on dispose; the cache must live elsewhere + // so manifest paths produced by BundleNuGetService remain valid for the AppHost's lifetime. + var tempConfigDir = result.ConfigFile.Directory!.FullName; + Assert.False( + gpfValue!.StartsWith(tempConfigDir, StringComparison.Ordinal), + $"globalPackagesFolder must not be under the temp nuget.config dir '{tempConfigDir}'. Got: {gpfValue}"); + // The cache subdirectory must be keyed by the resolved feed URL so two different staging + // feeds (e.g. two darc builds or an overrideStagingFeed setting) get distinct caches. + var expectedCacheKey = CliPathHelper.ComputeStagingFeedCacheKey(channelSource); + Assert.NotNull(expectedCacheKey); + var expectedCachePath = Path.Combine( + CliPathHelper.GetStagingNuGetPackagesDirectory(executionContext.AspireHomeDirectory), + expectedCacheKey); + Assert.Equal(expectedCachePath, gpfValue); + } + + [Fact] + public async Task TryCreateTemporaryNuGetConfig_StagingRequested_FromRealPackagingService_EmitsStableGlobalPackagesFolderOutsideTempDir() + { + // End-to-end pin for the staging temp-config hang fix: when the real PackagingService + // synthesizes the staging channel (here driven by overrideStagingFeed on a stable-shaped + // CLI so configureGlobalPackagesFolder lands true), the temporary nuget.config used by + // PrebuiltAppHostServer must point globalPackagesFolder at an absolute path that survives + // the TemporaryNuGetConfig.Dispose recursive delete. Otherwise BundleNuGetService restores + // staging assemblies into /.nugetpackages, bakes those paths into + // integration-package-probe-manifest.json, and aspire-managed hangs in DI/assembly loading + // when it later probes the (now deleted) paths — observed on macOS osx-arm64. + using var workspace = TemporaryWorkspace.Create(outputHelper); + + const string overrideStagingFeed = "https://pkgs.dev.azure.com/dnceng/internal/_packaging/darc-pub-microsoft-aspire-deadbeef/nuget/v3/index.json"; + var executionContext = TestExecutionContextHelper.CreateExecutionContext( + workspace, + identityChannel: PackageChannelNames.Staging); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [PackagingService.OverrideStagingFeedConfigKey] = overrideStagingFeed + }) + .Build(); + var packagingService = new PackagingService( + executionContext, + new FakeNuGetPackageCache(), + new TestFeatures(), + configuration, + NullLogger.Instance, + isStableShapedCliVersion: () => true); + + var server = CreateServerWithPackagingService(workspace, packagingService, executionContext); + + using var result = await InvokeTryCreateTemporaryNuGetConfigAsync(server, PackageChannelNames.Staging); + + Assert.NotNull(result); + var doc = XDocument.Load(result.ConfigFile.FullName); + var gpf = doc.Descendants("config") + .SelectMany(c => c.Elements("add")) + .FirstOrDefault(a => string.Equals(a.Attribute("key")?.Value, "globalPackagesFolder", StringComparison.OrdinalIgnoreCase)); + Assert.NotNull(gpf); + var gpfValue = gpf.Attribute("value")?.Value; + Assert.False(string.IsNullOrEmpty(gpfValue)); + Assert.True(Path.IsPathFullyQualified(gpfValue), $"globalPackagesFolder value must be an absolute path. Got: {gpfValue}"); + var tempConfigDir = result.ConfigFile.Directory!.FullName; + Assert.False( + gpfValue!.StartsWith(tempConfigDir, StringComparison.Ordinal), + $"globalPackagesFolder must not be under the temp nuget.config dir '{tempConfigDir}'. Got: {gpfValue}"); + // The cache key is derived from the resolved staging feed URL so the same CLI talking to + // a different overrideStagingFeed gets a different cache bucket. + var expectedCacheKey = CliPathHelper.ComputeStagingFeedCacheKey(overrideStagingFeed); + Assert.NotNull(expectedCacheKey); + var expectedCachePath = Path.Combine( + CliPathHelper.GetStagingNuGetPackagesDirectory(executionContext.AspireHomeDirectory), + expectedCacheKey); + Assert.Equal(expectedCachePath, gpfValue); } [Theory] @@ -532,7 +613,8 @@ public async Task TryCreateTemporaryNuGetConfig_WithPackageSourceOverride_Preser nuGetPackageCache: new FakeNuGetPackageCache(), features: new TestFeatures(), configureGlobalPackagesFolder: true); - var server = CreateServerWithChannel(workspace, stagingChannel, CreateContextWithIdentityChannel("pr-12345")); + var executionContext = CreateContextWithIdentityChannel("pr-12345"); + var server = CreateServerWithChannel(workspace, stagingChannel, executionContext); using var result = await InvokeTryCreateTemporaryNuGetConfigAsync( server, @@ -544,9 +626,27 @@ public async Task TryCreateTemporaryNuGetConfig_WithPackageSourceOverride_Preser Assert.Equal(["Aspire*"], GetPackagePatternsForSource(doc, packageSourceOverride)); Assert.Equal(["CommunityToolkit*"], GetPackagePatternsForSource(doc, channelSource)); Assert.Equal([PackageMapping.AllPackages], GetPackagePatternsForSource(doc, NuGetOrgSource)); - Assert.NotNull(doc.Descendants("config") + var gpf = doc.Descendants("config") .SelectMany(c => c.Elements("add")) - .FirstOrDefault(a => string.Equals(a.Attribute("key")?.Value, "globalPackagesFolder", StringComparison.OrdinalIgnoreCase))); + .FirstOrDefault(a => string.Equals(a.Attribute("key")?.Value, "globalPackagesFolder", StringComparison.OrdinalIgnoreCase)); + Assert.NotNull(gpf); + // Same stability requirement as the channel-only branch: the override path must outlive + // the temp nuget.config so BundleNuGetService's manifest paths remain valid after dispose. + var gpfValue = gpf.Attribute("value")?.Value; + Assert.False(string.IsNullOrEmpty(gpfValue)); + Assert.True(Path.IsPathFullyQualified(gpfValue), $"globalPackagesFolder value must be an absolute path. Got: {gpfValue}"); + var tempConfigDir = result.ConfigFile.Directory!.FullName; + Assert.False( + gpfValue!.StartsWith(tempConfigDir, StringComparison.Ordinal), + $"globalPackagesFolder must not be under the temp nuget.config dir '{tempConfigDir}'. Got: {gpfValue}"); + // The cache key is derived from the --source override, not the channel's own mappings, + // so users running multiple overrides against the same CLI get distinct cache buckets. + var expectedCacheKey = CliPathHelper.ComputeStagingFeedCacheKey(packageSourceOverride); + Assert.NotNull(expectedCacheKey); + var expectedCachePath = Path.Combine( + CliPathHelper.GetStagingNuGetPackagesDirectory(executionContext.AspireHomeDirectory), + expectedCacheKey); + Assert.Equal(expectedCachePath, gpfValue); } [Fact] diff --git a/tests/Aspire.Cli.Tests/Utils/CliPathHelperTests.cs b/tests/Aspire.Cli.Tests/Utils/CliPathHelperTests.cs index b25d247fc27..010311d0ac6 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliPathHelperTests.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliPathHelperTests.cs @@ -54,6 +54,94 @@ public void CreateUnixDomainSocketPath_UsesRandomizedIdentifier() Assert.Matches("^h[A-Za-z0-9_-]{8}$", Path.GetFileName(socketPath2)); } + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("\t\n")] + public void ComputeStagingFeedCacheKey_ReturnsNull_ForNullOrWhitespace(string? feedUrl) + { + Assert.Null(CliPathHelper.ComputeStagingFeedCacheKey(feedUrl)); + } + + [Fact] + public void ComputeStagingFeedCacheKey_DefaultsToEightLowercaseHexChars() + { + var key = CliPathHelper.ComputeStagingFeedCacheKey("https://pkgs.dev.azure.com/dnceng/public/_packaging/darc-pub-microsoft-aspire-deadbeef/nuget/v3/index.json"); + + Assert.NotNull(key); + Assert.Matches("^[0-9a-f]{8}$", key); + } + + [Fact] + public void ComputeStagingFeedCacheKey_IsDeterministic_ForSameInput() + { + const string feedUrl = "https://pkgs.dev.azure.com/dnceng/public/_packaging/darc-pub-microsoft-aspire-deadbeef/nuget/v3/index.json"; + + var first = CliPathHelper.ComputeStagingFeedCacheKey(feedUrl); + var second = CliPathHelper.ComputeStagingFeedCacheKey(feedUrl); + + Assert.Equal(first, second); + } + + [Fact] + public void ComputeStagingFeedCacheKey_DifferentUrls_ProduceDifferentKeys() + { + var a = CliPathHelper.ComputeStagingFeedCacheKey("https://pkgs.dev.azure.com/dnceng/public/_packaging/darc-pub-microsoft-aspire-deadbeef/nuget/v3/index.json"); + var b = CliPathHelper.ComputeStagingFeedCacheKey("https://pkgs.dev.azure.com/dnceng/public/_packaging/darc-pub-microsoft-aspire-cafef00d/nuget/v3/index.json"); + + Assert.NotEqual(a, b); + } + + [Fact] + public void ComputeStagingFeedCacheKey_NormalizesWhitespaceAndCasing() + { + // Trim + lowercase normalization keeps the cache from fragmenting when the same feed + // shows up with stray whitespace from a config file or with a mixed-case hostname. + const string baseUrl = "https://pkgs.dev.azure.com/dnceng/public/_packaging/darc-pub-microsoft-aspire-deadbeef/nuget/v3/index.json"; + + var baseKey = CliPathHelper.ComputeStagingFeedCacheKey(baseUrl); + var spacedKey = CliPathHelper.ComputeStagingFeedCacheKey(" " + baseUrl + "\t\n"); + var upperKey = CliPathHelper.ComputeStagingFeedCacheKey(baseUrl.ToUpperInvariant()); + + Assert.Equal(baseKey, spacedKey); + Assert.Equal(baseKey, upperKey); + } + + [Theory] + [InlineData(1)] + [InlineData(4)] + [InlineData(16)] + public void ComputeStagingFeedCacheKey_RespectsExplicitLength(int length) + { + var key = CliPathHelper.ComputeStagingFeedCacheKey( + "https://pkgs.dev.azure.com/dnceng/public/_packaging/darc-pub-microsoft-aspire-deadbeef/nuget/v3/index.json", + length); + + Assert.NotNull(key); + Assert.Equal(length, key.Length); + Assert.Matches($"^[0-9a-f]{{{length}}}$", key); + } + + [Fact] + public void ComputeStagingFeedCacheKey_LengthAboveHashWidth_ReturnsFullHash() + { + // XxHash3 is 64 bits -> 16 hex chars. Asking for more than 16 must not crash and must + // return all available hash chars rather than padding with garbage. + var key = CliPathHelper.ComputeStagingFeedCacheKey("https://example/index.json", length: 999); + + Assert.NotNull(key); + Assert.Equal(16, key.Length); + Assert.Matches("^[0-9a-f]{16}$", key); + } + + [Fact] + public void ComputeStagingFeedCacheKey_NonZeroLength_RejectsZeroOrNegative() + { + Assert.Null(CliPathHelper.ComputeStagingFeedCacheKey("https://example/index.json", length: 0)); + Assert.Null(CliPathHelper.ComputeStagingFeedCacheKey("https://example/index.json", length: -1)); + } + [Theory] [InlineData("script")] [InlineData("localhive")] From 4c9535225f5ac19c42bbf23f7f4ece7696bed453 Mon Sep 17 00:00:00 2001 From: Jose Perez Rodriguez Date: Thu, 28 May 2026 14:09:43 -0700 Subject: [PATCH 04/15] Remove Corepack from extension build scripts (#17628) Revert the Corepack-specific Yarn invocation added to the extension build so internal Azure Pipelines builds use the configured Yarn executable instead of downloading Yarn from the public registry. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> (cherry picked from commit a0823e66fe193356312eb37cff4cc7cbbc47b9c5) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- extension/Extension.proj | 42 ++------------ extension/build.ps1 | 115 +++------------------------------------ extension/build.sh | 96 +++----------------------------- 3 files changed, 19 insertions(+), 234 deletions(-) diff --git a/extension/Extension.proj b/extension/Extension.proj index 72cef56624f..00eb8b1c2f7 100644 --- a/extension/Extension.proj +++ b/extension/Extension.proj @@ -2,31 +2,6 @@ $(DefaultTargetFramework) $(MSBuildThisFileDirectory) - - <_CorepackHome Condition="'$(COREPACK_HOME)' != ''">$(COREPACK_HOME) - <_CorepackHome Condition="'$(_CorepackHome)' == ''">$([MSBuild]::NormalizePath($(ExtensionSrcDir), '.corepack-cache')) @@ -57,14 +32,13 @@ - - - + + @@ -124,18 +98,12 @@ - - + - + diff --git a/extension/build.ps1 b/extension/build.ps1 index 94436d77491..c00d3b9f50d 100644 --- a/extension/build.ps1 +++ b/extension/build.ps1 @@ -2,54 +2,9 @@ $ErrorActionPreference = "Stop" -# Ensure we run from the extension directory so corepack/yarn pick up -# extension/.npmrc and extension/package.json (which holds the packageManager pin). +# Ensure we run from the extension directory so yarn picks up extension/.npmrc. Set-Location $PSScriptRoot -# Pinned Corepack shim version. Node.js >= 16.10 bundles a Corepack, but the -# bundled version drifts with each Node release and Corepack is on track to be -# unbundled from Node entirely (see https://github.com/nodejs/node/issues/54647). -# Installing a pinned Corepack from npm makes the build reproducible regardless -# of which Node version a developer or CI runner happens to have. -# -# The version is stored in scripts/corepack-version.txt so that this script, the -# Bash build script, the GitHub Actions workflow, and the AzDO pipelines all -# read from a single source of truth. -$CorepackVersion = (Get-Content -Raw -Path (Join-Path $PSScriptRoot 'scripts/corepack-version.txt')).Trim() -if ([string]::IsNullOrWhiteSpace($CorepackVersion)) { - Write-Error "scripts/corepack-version.txt is empty or unreadable." - exit 1 -} - -# Yarn version is pinned in extension/package.json via the "packageManager" -# field, which scripts/prepareCorepackYarn.mjs uses to seed Corepack's cache. - -# Point npm at the dnceng internal npm mirror when installing the pinned Corepack -# shim and seeding Corepack's Yarn cache. npm global installs do not use the -# project .npmrc, so pass the registry explicitly. Override locally with -# `$env:NPM_REGISTRY = ''; ./build.ps1`. -if (-not $env:NPM_REGISTRY) { - $env:NPM_REGISTRY = "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/" -} -if (-not $env:COREPACK_ENABLE_DOWNLOAD_PROMPT) { - $env:COREPACK_ENABLE_DOWNLOAD_PROMPT = "0" -} - -# Pin Corepack's cache directory to a build-scoped location. Without this, every -# build shares the user's default cache (%LOCALAPPDATA%\node\corepack on -# Windows, ~/.cache/node/corepack on Linux, ~/Library/Caches/node/corepack on -# macOS). prepareCorepackYarn.mjs rewrites that cache in place -# (rmSync(installDirectory) followed by renameSync(staging, installDirectory)), -# so concurrent builds racing on the same Corepack home can corrupt each other's -# cache. The CI pipelines already scope this per-job (e.g. AzDO uses -# Agent.TempDirectory); do the same here so multi-worktree setups stay -# isolated. Concurrent builds in the *same* worktree still race on this shared -# directory — prepareCorepackYarn.mjs handles the EEXIST/ENOTEMPTY rename -# collision but is not a substitute for a lock. The directory is gitignored. -if (-not $env:COREPACK_HOME) { - $env:COREPACK_HOME = Join-Path $PSScriptRoot '.corepack-cache' -} - Write-Host "Checking prerequisites..." # Check for Node.js @@ -58,10 +13,10 @@ if (-not (Get-Command node -ErrorAction SilentlyContinue)) { exit 1 } -# npm is required to install our pinned Corepack. It ships with every official -# Node.js distribution, so this should only fail on broken installs. -if (-not (Get-Command npm -ErrorAction SilentlyContinue)) { - Write-Error "Error: npm is not available. Reinstall Node.js so npm is on PATH." +# Check for yarn +if (-not (Get-Command yarn -ErrorAction SilentlyContinue)) { + Write-Error "Error: yarn is not installed. Please install yarn first." + Write-Host "You can install yarn by running: npm install -g yarn" exit 1 } @@ -84,65 +39,9 @@ if (-not (Get-Command dotnet -ErrorAction SilentlyContinue)) { Write-Host "All prerequisites satisfied." -Write-Host "" -Write-Host "Installing pinned Corepack $CorepackVersion..." -# Reinstall every time so we overwrite any older Corepack shim that Node.js -# may have placed on PATH ahead of npm's global prefix. npm global installs do -# not use the project .npmrc, so pass the registry explicitly. -# -# --force is required because Corepack's npm package declares `yarn`, `yarnpkg`, -# `pnpm`, `pnpx`, and `corepack` as bin entries. Without --force, npm refuses to -# overwrite bins owned by a pre-existing global yarn or pnpm (a very common -# setup, and the state this repo itself was in before this build script existed) -# and aborts with EEXIST. The CI pipelines already pass --force for the same -# reason. -npm install --global --force --registry "$env:NPM_REGISTRY" "corepack@$CorepackVersion" - -if ($LASTEXITCODE -ne 0) { - Write-Error "npm install -g corepack@$CorepackVersion failed with exit code $LASTEXITCODE" - exit $LASTEXITCODE -} - -# Verify the version actually on PATH matches our pin. On Windows the Node.js -# installer registers a `corepack.cmd` under %ProgramFiles%\nodejs which may -# shadow the npm-global shim under %APPDATA%\npm, so a successful -# `npm install -g corepack@` does NOT guarantee that subsequent -# `corepack` calls resolve to it. Fail loudly here so we don't silently run -# with the wrong tool. -$installedCorepack = $null -try { $installedCorepack = (corepack --version).Trim() } catch { } -if ($installedCorepack -ne $CorepackVersion) { - Write-Error @" -corepack version mismatch: expected $CorepackVersion, got '$installedCorepack'. -The bundled Corepack on PATH may be taking precedence over the npm-global install. -Ensure your npm global bin directory (typically %APPDATA%\npm on Windows) comes -before %ProgramFiles%\nodejs on PATH, or run `corepack disable` to remove the -bundled shim before re-running this script. -"@ - exit 1 -} - -Write-Host "" -Write-Host "Enabling Corepack package manager shims..." -corepack enable - -if ($LASTEXITCODE -ne 0) { - Write-Error "corepack enable failed with exit code $LASTEXITCODE" - exit $LASTEXITCODE -} - -Write-Host "" -Write-Host "Preparing Yarn from packageManager pin in package.json..." -node ./scripts/prepareCorepackYarn.mjs - -if ($LASTEXITCODE -ne 0) { - Write-Error "Preparing Yarn for Corepack failed with exit code $LASTEXITCODE" - exit $LASTEXITCODE -} - Write-Host "" Write-Host "Running yarn install..." -corepack yarn install --frozen-lockfile --non-interactive +yarn install --frozen-lockfile --non-interactive if ($LASTEXITCODE -ne 0) { Write-Error "yarn install failed with exit code $LASTEXITCODE" @@ -151,7 +50,7 @@ if ($LASTEXITCODE -ne 0) { Write-Host "" Write-Host "Running yarn compile..." -corepack yarn compile +yarn compile if ($LASTEXITCODE -ne 0) { Write-Error "yarn compile failed with exit code $LASTEXITCODE" diff --git a/extension/build.sh b/extension/build.sh index 1fbb702d5ce..028776bfaf3 100755 --- a/extension/build.sh +++ b/extension/build.sh @@ -1,55 +1,9 @@ #!/bin/bash set -e -# Ensure we run from the extension directory so corepack/yarn pick up -# extension/.npmrc and extension/package.json (which holds the packageManager pin). +# Ensure we run from the extension directory so yarn picks up extension/.npmrc. SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# Pinned Corepack shim version. Node.js >= 16.10 bundles a Corepack, but the -# bundled version drifts with each Node release and Corepack is on track to be -# unbundled from Node entirely (see https://github.com/nodejs/node/issues/54647). -# Installing a pinned Corepack from npm makes the build reproducible regardless -# of which Node version a developer or CI runner happens to have. -# -# The version is stored in scripts/corepack-version.txt so that this script, the -# PowerShell build script, the GitHub Actions workflow, and the AzDO pipelines -# all read from a single source of truth. -COREPACK_VERSION="$(tr -d '[:space:]' < "$SCRIPT_DIR/scripts/corepack-version.txt")" -if [ -z "$COREPACK_VERSION" ]; then - echo "Error: scripts/corepack-version.txt is empty or unreadable." - exit 1 -fi - -# Yarn version is pinned in extension/package.json via the "packageManager" -# field, which scripts/prepareCorepackYarn.mjs uses to seed Corepack's cache. - -# Point npm at the dnceng internal npm mirror when installing the pinned Corepack -# shim and seeding Corepack's Yarn cache. npm global installs do not use the -# project .npmrc, so pass the registry explicitly. Override locally with -# `NPM_REGISTRY= ./build.sh`. -# Export NPM_REGISTRY so the child `node ./scripts/prepareCorepackYarn.mjs` -# process inherits it; without `export`, the script silently falls back to its -# own DefaultNpmRegistry constant and any user override of NPM_REGISTRY would -# be ignored when seeding Corepack's Yarn cache. -: "${NPM_REGISTRY:=https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/}" -export NPM_REGISTRY -: "${COREPACK_ENABLE_DOWNLOAD_PROMPT:=0}" -export COREPACK_ENABLE_DOWNLOAD_PROMPT - -# Pin Corepack's cache directory to a build-scoped location. Without this, every -# build shares the user's default cache (~/.cache/node/corepack on Linux, -# ~/Library/Caches/node/corepack on macOS, %LOCALAPPDATA%\node\corepack on -# Windows). prepareCorepackYarn.mjs rewrites that cache in place -# (rmSync(installDirectory) followed by renameSync(staging, installDirectory)), -# so concurrent builds racing on the same Corepack home can corrupt each other's -# cache. The CI pipelines already scope this per-job (e.g. AzDO uses -# Agent.TempDirectory); do the same here so multi-worktree setups stay -# isolated. Concurrent builds in the *same* worktree still race on this shared -# directory — prepareCorepackYarn.mjs handles the EEXIST/ENOTEMPTY rename -# collision but is not a substitute for a lock. The directory is gitignored. -: "${COREPACK_HOME:=$SCRIPT_DIR/.corepack-cache}" -export COREPACK_HOME - echo "Checking prerequisites..." # Check for Node.js @@ -58,10 +12,10 @@ if ! command -v node &> /dev/null; then exit 1 fi -# npm is required to install our pinned Corepack. It ships with every official -# Node.js distribution, so this should only fail on broken installs. -if ! command -v npm &> /dev/null; then - echo "Error: npm is not available. Reinstall Node.js so npm is on PATH." +# Check for yarn +if ! command -v yarn &> /dev/null; then + echo "Error: yarn is not installed. Please install yarn first." + echo "You can install yarn by running: npm install -g yarn" exit 1 fi @@ -83,49 +37,13 @@ echo "All prerequisites satisfied." cd "$SCRIPT_DIR" -echo "" -echo "Installing pinned Corepack ${COREPACK_VERSION}..." -# Reinstall every time so we overwrite any older Corepack shim that Node.js -# may have placed on PATH ahead of npm's global prefix. npm global installs do -# not use the project .npmrc, so pass the registry explicitly. -# -# --force is required because Corepack's npm package declares `yarn`, `yarnpkg`, -# `pnpm`, `pnpx`, and `corepack` as bin entries. Without --force, npm refuses to -# overwrite bins owned by a pre-existing global yarn or pnpm (a very common -# setup, and the state this repo itself was in before this build script existed) -# and aborts with EEXIST. The CI pipelines already pass --force for the same -# reason. -npm install --global --force --registry "$NPM_REGISTRY" "corepack@${COREPACK_VERSION}" - -# Verify the version actually on PATH matches our pin. If a system-bundled -# Corepack shim shadows the npm-global install (common on Windows; possible on -# macOS/Linux when /usr/local/bin precedes the npm prefix), `npm install -g` -# can "succeed" while subsequent `corepack` calls still resolve to the bundled -# version. Fail loudly here so we don't silently run with the wrong tool. -installed_corepack=$(corepack --version 2>/dev/null || echo "") -if [ "$installed_corepack" != "$COREPACK_VERSION" ]; then - echo "Error: corepack version mismatch: expected $COREPACK_VERSION, got '$installed_corepack'." - echo "The bundled Corepack on PATH may be taking precedence over the npm-global install." - echo "Ensure your npm global bin directory comes before any other Node.js install on PATH," - echo "or use a Node version manager (nvm, asdf, fnm) that places the npm prefix appropriately." - exit 1 -fi - -echo "" -echo "Enabling Corepack package manager shims..." -corepack enable - -echo "" -echo "Preparing Yarn from packageManager pin in package.json..." -node ./scripts/prepareCorepackYarn.mjs - echo "" echo "Running yarn install..." -corepack yarn install --frozen-lockfile --non-interactive +yarn install --frozen-lockfile --non-interactive echo "" echo "Running yarn compile..." -corepack yarn compile +yarn compile echo "" echo "Building Aspire CLI..." From 2cfdc256eb1a18c7b74517815aaffe3d810781a1 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 29 May 2026 11:21:51 +1000 Subject: [PATCH 05/15] Forward IdentityChannel to TemplateInputs.Channel for dotnet-runtime templates (#17637) * Add failing tests for aspire-starter channel resolution on release/13.4 Reproduces the bug where 'aspire new aspire-starter' (and aspire-starter-csharp-typescript) silently resolve Aspire.ProjectTemplates from the Implicit (nuget.org) channel on a daily / staging / release-branch CLI, ignoring CliExecutionContext.IdentityChannel. Passing --channel works because that value is the only thing forwarded into TemplateInputs.Channel today. The new tests pin the contract for both DotNet- and CLI-runtime templates: NewCommand must forward IdentityChannel into inputs.Channel whenever --channel is not passed, so DotNetTemplateFactory.ApplyTemplateAsync uses the identity-matching channel when calling TemplateNuGetConfigService.ResolveTemplatePackageAsync. Coverage across the four shipping identities (pr-, daily, staging, stable) plus three edge cases (unregistered identity falls back to null, explicit --channel overrides identity, --version still forwards identity into inputs.Channel). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Forward IdentityChannel to TemplateInputs.Channel for dotnet-runtime templates When 'aspire new' invoked a TemplateRuntime.DotNet template (e.g. aspire-starter, aspire-starter-csharp-typescript) without an explicit --channel argument, inputs.Channel was left null. That caused TemplateNuGetConfigService.ResolveTemplatePackageAsync to fall back to the Implicit (nuget.org) channel, ignoring the CLI's IdentityChannel. Daily / staging / pr builds would therefore resolve Aspire.ProjectTemplates from nuget.org instead of the channel matching the running CLI. This change mirrors the existing CLI-runtime contract: when --channel is absent and no version-resolution channel was selected, look up the identity channel in the packaging service and forward it to inputs.Channel only when it matches a registered Explicit channel (Implicit matches still resolve to null so the project inherits ambient NuGet configuration). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Consolidate channel precedence into a single resolvedChannelName Address review feedback: collapse the explicitChannelArg / resolvedChannelName / identityChannelName three-way coalesce on TemplateInputs.Channel into a single resolvedChannelName variable with a clear precedence chain (explicit --channel > resolver result > identity). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Explain why identity-channel fallback lives at the call site Document why ResolveIdentityChannelNameAsync is invoked at the TemplateInputs assembly site rather than folded into ResolveCliTemplateVersionAsync: the two paths that need the identity hint (TemplateRuntime.DotNet templates and --version short-circuit) are precisely the ones the resolver never runs on. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> (cherry picked from commit 26b661bde83c08845b7ca3f1701cdb19bc44862a) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/NewCommand.cs | 55 ++++++- .../NewCommandChannelResolutionTests.cs | 152 ++++++++++++++++-- 2 files changed, 191 insertions(+), 16 deletions(-) diff --git a/src/Aspire.Cli/Commands/NewCommand.cs b/src/Aspire.Cli/Commands/NewCommand.cs index dfc62cc3179..9a2f47624df 100644 --- a/src/Aspire.Cli/Commands/NewCommand.cs +++ b/src/Aspire.Cli/Commands/NewCommand.cs @@ -475,6 +475,15 @@ protected override async Task ExecuteAsync(ParseResult parseResul } var version = parseResult.GetValue(s_versionOption); + // Precedence for the channel written into TemplateInputs.Channel: + // 1. Explicit --channel argument (user override always wins). + // 2. Channel returned by ResolveCliTemplateVersionAsync (CLI-runtime templates). + // 3. The running CLI's IdentityChannel, when it matches a registered Explicit + // channel — needed for TemplateRuntime.DotNet starters (aspire-starter, + // aspire-starter-csharp-typescript) which otherwise resolve + // Aspire.ProjectTemplates from the Implicit (nuget.org) channel regardless + // of CLI identity, and also for CLI-runtime templates invoked with --version + // which short-circuits the resolver below. string? resolvedChannelName = null; if (ShouldResolveCliTemplateVersion(template) && string.IsNullOrWhiteSpace(version)) @@ -489,13 +498,30 @@ protected override async Task ExecuteAsync(ParseResult parseResul resolvedChannelName = resolveResult.ChannelName; } + // Apply the channel precedence as a single coalesce. The identity fallback lives + // here, not inside ResolveCliTemplateVersionAsync, because that resolver only runs + // on the CLI-runtime / no-explicit-version branch above. The two paths that need + // the identity hint are precisely the ones the resolver does NOT visit: + // * TemplateRuntime.DotNet templates (aspire-starter family) — the bug this fix + // addresses; without forwarding, DotNetTemplateFactory searches only the + // Implicit (nuget.org) channel regardless of CLI identity. + // * CLI-runtime templates invoked with --version, which short-circuits the + // resolver and would otherwise leave inputs.Channel null. + // Keeping the fallback out of the resolver also keeps the resolver's role narrow: + // it performs version negotiation across channels and reports the channel that won; + // the identity hint is a different policy ("label the project with the CLI's own + // channel") that should not influence version selection. + resolvedChannelName = parseResult.GetValue(_channelOption) + ?? resolvedChannelName + ?? await ResolveIdentityChannelNameAsync(cancellationToken); + var inputs = new TemplateInputs { Name = parseResult.GetValue(s_nameOption), Output = parseResult.GetValue(s_outputOption), Source = source, Version = version, - Channel = parseResult.GetValue(_channelOption) ?? resolvedChannelName, + Channel = resolvedChannelName, Language = selectedLanguageId }; var templateResult = await template.ApplyTemplateAsync(inputs, parseResult, cancellationToken); @@ -519,6 +545,33 @@ private static bool ShouldResolveCliTemplateVersion(ITemplate template) return template.Runtime is TemplateRuntime.Cli; } + /// + /// Resolves to a registered channel name + /// from the packaging service. Returns the channel name when an Explicit channel matches the + /// identity (e.g. daily, staging, stable, pr-<N>); returns + /// when there is no identity, when no Explicit channel matches, or + /// when only the Implicit (nuget.org) channel is registered. A result + /// intentionally lets the downstream template path consult the Implicit channel and avoids + /// writing a per-project channel pin into the new project's NuGet configuration. + /// + private async Task ResolveIdentityChannelNameAsync(CancellationToken cancellationToken) + { + var identity = ExecutionContext.IdentityChannel; + if (string.IsNullOrWhiteSpace(identity)) + { + return null; + } + + var channels = await _packagingService.GetChannelsAsync(cancellationToken, identity); + var match = channels.FirstOrDefault(c => + string.Equals(c.Name, identity, StringComparisons.ChannelName)); + + // Only persist Explicit channel names — Implicit channels (the nuget.org fallback) + // are deliberately left unpinned so `aspire add` and later restores use ambient + // NuGet configuration. Mirrors the same rule applied at the end of + // ResolveCliTemplateVersionAsync. + return match is { Type: PackageChannelType.Explicit } ? match.Name : null; + } } internal interface INewCommandPrompter diff --git a/tests/Aspire.Cli.Tests/Commands/NewCommandChannelResolutionTests.cs b/tests/Aspire.Cli.Tests/Commands/NewCommandChannelResolutionTests.cs index 25243715c70..80f81fa7e47 100644 --- a/tests/Aspire.Cli.Tests/Commands/NewCommandChannelResolutionTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/NewCommandChannelResolutionTests.cs @@ -272,12 +272,121 @@ public async Task NewCommand_ExplicitStableChannel_NonStableCliVersion_FallsBack } /// - /// Invokes with a fake CLI-runtime template that captures the - /// handed to it. This is the contract surface the four - /// shipping CLI templates (TS/Python/Go starter + empty AppHost) all read from. Its - /// Version reflects which channel won template-version resolution; its - /// Channel reflects what the template factories will persist into the new - /// project's aspire.config.json. + /// Issue: aspire new aspire-starter (and aspire-starter-csharp-typescript) + /// run through the path, which delegates template + /// package resolution to TemplateNuGetConfigService.ResolveTemplatePackageAsync + /// using TemplateInputs.Channel as the RequestedChannel. When no + /// --channel is supplied, must forward the running CLI's + /// through inputs.Channel so the + /// DotNet template path searches the identity-matching feed for + /// Aspire.ProjectTemplates — symmetrical to the CLI-runtime path + /// (). + /// + /// Without this forwarding, a daily / staging / release-branch CLI silently resolves the + /// templates package from the Implicit (nuget.org) channel — for example, on a 13.4 + /// CLI it fails to discover the matching 13.4.0 template and falls back to the + /// latest stable on nuget.org. Passing --channel explicitly works because that + /// value is already forwarded into inputs.Channel. + /// + /// + /// All four shipping identity shapes are exercised — PR (developer dogfood build + /// against a hive), daily (nightly dnceng feed), staging (release-branch dnceng feed), + /// and stable (nuget.org via the explicit Stable channel registration). + /// + /// + [Theory] + [InlineData("pr-99999", "13.4.0-pr.99999.gabc123")] + [InlineData(PackageChannelNames.Daily, "13.4.0-preview.1.99999.1")] + [InlineData(PackageChannelNames.Staging, "13.4.0-rc.1.99999.1")] + [InlineData(PackageChannelNames.Stable, "13.5.0")] + public async Task NewCommand_DotNetRuntimeTemplate_NoChannelArg_ForwardsIdentityChannelToInputs(string identityChannel, string identityChannelVersion) + { + var captured = await CaptureTemplateInputsAsync( + identityChannel: identityChannel, + channelOptionArg: null, + identityChannelVersion: identityChannelVersion, + runtime: TemplateRuntime.DotNet); + + // DotNet-runtime templates resolve the template package version themselves inside + // DotNetTemplateFactory.ApplyTemplateAsync — NewCommand does not populate inputs.Version + // for this runtime — so only inputs.Channel is asserted here. + Assert.Equal(identityChannel, captured.Channel); + } + + /// + /// Defensive: when the identity channel is something that isn't a registered channel + /// (typo, future addition, locally-built CLI without the local hive installed, etc.), + /// must NOT blindly forward the unrecognized identity into + /// inputs.Channel — doing so would make the DotNet template path throw + /// ChannelNotFoundException inside TemplateNuGetConfigService.ResolveTemplatePackageAsync. + /// Instead, leave inputs.Channel as null so the resolver consults the Implicit + /// (nuget.org) channel and the new project inherits the user's ambient NuGet configuration. + /// Mirrors the CLI-runtime contract pinned by + /// . + /// + [Fact] + public async Task NewCommand_DotNetRuntimeTemplate_NoChannelArg_IdentityChannelNotRegistered_FallsBackToNull() + { + var captured = await CaptureTemplateInputsAsync( + identityChannel: "stalbe", // intentional typo: not registered as a channel + channelOptionArg: null, + identityChannelVersion: null, + runtime: TemplateRuntime.DotNet); + + // Implicit-fallback case: inputs.Channel stays null so DotNetTemplateFactory does not + // pin a per-project channel and the new project uses the ambient NuGet configuration. + Assert.Null(captured.Channel); + } + + /// + /// Explicit --channel on a DotNet-runtime template (aspire-starter family) must + /// still flow through inputs.Channel verbatim and override the running CLI's + /// identity. Pinned here so the identity-channel forwarding fix doesn't accidentally + /// clobber an explicit user choice (e.g. a daily CLI scaffolding a stable-channel + /// project for migration testing). + /// + [Fact] + public async Task NewCommand_DotNetRuntimeTemplate_ExplicitChannelArg_OverridesIdentityChannel() + { + var captured = await CaptureTemplateInputsAsync( + identityChannel: PackageChannelNames.Daily, + channelOptionArg: PackageChannelNames.Stable, + identityChannelVersion: "13.4.0-preview.1.99999.1", + runtime: TemplateRuntime.DotNet); + + Assert.Equal(PackageChannelNames.Stable, captured.Channel); + } + + /// + /// --version on a DotNet-runtime template must still flow through to + /// inputs.Version AND identity-channel forwarding must still populate + /// inputs.Channel. The version pin tells ResolveTemplatePackageAsync which + /// package to pick, but the channel pin still selects the feed that package is fetched + /// from and the per-project NuGet.config mappings the generated project will use. + /// Without both, a daily CLI passing --version 13.4.0-preview.1.99999.1 would + /// resolve the version against nuget.org (where prerelease daily builds aren't published) + /// and fail. + /// + [Fact] + public async Task NewCommand_DotNetRuntimeTemplate_VersionOverride_StillForwardsIdentityChannel() + { + var captured = await CaptureTemplateInputsAsync( + identityChannel: PackageChannelNames.Daily, + channelOptionArg: null, + identityChannelVersion: "13.4.0-preview.1.99999.1", + runtime: TemplateRuntime.DotNet, + versionOptionArg: "13.4.0-preview.1.99999.1"); + + Assert.Equal("13.4.0-preview.1.99999.1", captured.Version); + Assert.Equal(PackageChannelNames.Daily, captured.Channel); + } + + /// + /// Invokes with a fake template that captures the + /// handed to it. Its Version reflects which channel + /// won template-version resolution; its Channel reflects what the template + /// factories will persist into the new project's aspire.config.json (or use as + /// the RequestedChannel for DotNet-runtime templates). /// /// Identity baked into the CLI under test. /// Value passed via --channel, or null to omit the flag. @@ -285,23 +394,35 @@ public async Task NewCommand_ExplicitStableChannel_NonStableCliVersion_FallsBack /// Version returned by the channel whose name matches , /// or null when that channel is not registered. /// + /// Optional list of versions exposed by the identity channel. + /// Template runtime kind. CLI runtime drives ResolveCliTemplateVersionAsync; DotNet runtime mirrors aspire-starter. private async Task CaptureTemplateInputsAsync( string identityChannel, string? channelOptionArg, string? identityChannelVersion, - IEnumerable? identityChannelVersions = null) + IEnumerable? identityChannelVersions = null, + TemplateRuntime runtime = TemplateRuntime.Cli, + string? versionOptionArg = null) { using var workspace = TemporaryWorkspace.Create(outputHelper); var capturedInputs = new CapturedTemplateInputs(); - // A fake CLI-runtime template that intercepts the inputs and returns success - // without invoking the heavyweight template scaffolding pipeline (RPC, codegen, - // bundled NuGet restore). The template is registered via a fake ITemplateProvider - // injected through CliServiceCollectionTestOptions.TemplateProviderFactory. + // A fake template that intercepts the inputs and returns success without invoking + // the heavyweight template scaffolding pipeline (RPC, codegen, bundled NuGet restore). + // The template is registered via a fake ITemplateProvider injected through + // CliServiceCollectionTestOptions.TemplateProviderFactory. + // + // The runtime kind switches which code path inside NewCommand.ExecuteAsync produces + // inputs.Channel: TemplateRuntime.Cli walks ResolveCliTemplateVersionAsync (which + // owns the identity-channel fallback for the CLI starters), while TemplateRuntime.DotNet + // mirrors the path used by aspire-starter / aspire-starter-csharp-typescript, which + // delegate version/feed selection to DotNetTemplateFactory → TemplateNuGetConfigService. + // NewCommand is responsible for passing the right channel into inputs.Channel in both + // cases, so this helper can exercise both with a single shape. var fakeTemplate = new CallbackTemplate( - name: "fake-cli-template", - description: "Fake CLI-runtime template for channel-resolution tests", + name: "fake-template", + description: "Fake template for channel-resolution tests", pathDeriverCallback: (ctx, projectName) => Path.Combine(ctx.WorkingDirectory.FullName, projectName), applyOptionsCallback: _ => { }, applyTemplateCallback: (_, inputs, _, _) => @@ -312,7 +433,7 @@ private async Task CaptureTemplateInputsAsync( Directory.CreateDirectory(outputPath); return Task.FromResult(new TemplateResult(CliExitCodes.Success, outputPath)); }, - runtime: TemplateRuntime.Cli, + runtime: runtime, languageId: KnownLanguageId.TypeScript); var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => @@ -328,7 +449,8 @@ private async Task CaptureTemplateInputsAsync( var newCommand = serviceProvider.GetRequiredService(); var channelArg = string.IsNullOrEmpty(channelOptionArg) ? "" : $" --channel {channelOptionArg}"; - var parseResult = newCommand.Parse($"new fake-cli-template --name TestApp --output ./captured{channelArg}"); + var versionArg = string.IsNullOrEmpty(versionOptionArg) ? "" : $" --version {versionOptionArg}"; + var parseResult = newCommand.Parse($"new fake-template --name TestApp --output ./captured{channelArg}{versionArg}"); var exitCode = await parseResult.InvokeAsync().DefaultTimeout(); Assert.Equal(CliExitCodes.Success, exitCode); From 29866559a4b4a28710b054b8a42bbd1d62bde241 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 29 May 2026 12:37:49 +1000 Subject: [PATCH 06/15] Remove 'aspire new aspire-starter' step from CLI archive verifier (#17646) The signed-build verifier ran 'aspire new aspire-starter' after extracting the CLI archive, claiming to exercise 'template engine + bundle self-extraction without requiring a NuGet restore'. That claim was wrong: 'aspire new' resolves template versions via TemplateNuGetConfigService.ResolveTemplatePackageAsync, which always queries a NuGet feed. The step only ever succeeded on release branches because builds were mis-baked with AspireCliChannel=stable, which routed the implicit identity-channel lookup to nuget.org and found a previously-shipped Aspire.ProjectTemplates version. Once #17528 corrected the release-branch builds to bake AspireCliChannel=staging, the identity channel switched to a staging feed that is not reachable from the 1ES signed-build agent, and the step started failing with 'No template versions were found' in the internal build pipeline. This is the same egress reason the TypeScript starter check was already removed for (#17274, tracked in #17345). Re-adding meaningful starter coverage in the signed-build verifier requires either an internal NuGet mirror or a no-network template path. Drop the step (and the corresponding test assertion); the verifier still covers archive layout, sidecar contract, and 'aspire --version' execution. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> (cherry picked from commit d8c4e9645a029b4fca8ce20eb00def5526806eda) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- eng/scripts/verify-cli-archive.ps1 | 50 ++++++------------- .../VerifyCliArchivePowerShellTests.cs | 1 - 2 files changed, 15 insertions(+), 36 deletions(-) diff --git a/eng/scripts/verify-cli-archive.ps1 b/eng/scripts/verify-cli-archive.ps1 index 321e1cb555f..842eafcb95d 100644 --- a/eng/scripts/verify-cli-archive.ps1 +++ b/eng/scripts/verify-cli-archive.ps1 @@ -8,8 +8,7 @@ 2. Extracts the CLI archive to a temp location 3. Verifies the archive shape contains the native CLI payload and no install-route sidecar 4. Runs 'aspire --version' to validate the binary executes - 5. Runs 'aspire new aspire-starter' to test C# starter creation - 6. Cleans up temp directories + 5. Cleans up temp directories .PARAMETER ArchivePath Path to the CLI archive (.zip or .tar.gz) @@ -117,29 +116,6 @@ function Test-ArchiveSidecar { Write-Step "$ridFamily-* archive correctly omits the install-route sidecar." } -function Test-CSharpStarterProject { - param( - [Parameter(Mandatory = $true)][string]$AspireBin, - [Parameter(Mandatory = $true)][string]$ProjectRoot - ) - - Write-Step "Running 'aspire new aspire-starter --name VerifyApp --output $ProjectRoot --non-interactive --nologo --suppress-agent-init'..." - & $AspireBin new aspire-starter --name VerifyApp --output $ProjectRoot --non-interactive --nologo --suppress-agent-init 2>&1 | Write-Host - if ($LASTEXITCODE -ne 0) { - Write-Err "'aspire new aspire-starter' failed with exit code $LASTEXITCODE" - exit 1 - } - - $appHostDir = Join-Path $ProjectRoot "VerifyApp.AppHost" - if (-not (Test-Path $appHostDir)) { - Write-Err "Expected project directory 'VerifyApp.AppHost' not found after 'aspire new aspire-starter'" - Get-ChildItem $ProjectRoot | Format-Table - exit 1 - } - - Write-Ok "'aspire new aspire-starter' created project successfully" -} - $userHome = Get-UserHome $verifyTmpDir = $null $aspireBackup = $null @@ -248,16 +224,20 @@ try { Write-Host " Version: $versionOutput" Write-Ok "'aspire --version' succeeded" - # Step 4: Create starter project with aspire new. The C# starter exercises - # template engine + bundle self-extraction without requiring a NuGet restore. - # The TypeScript starter check (#17274) is intentionally not invoked here: - # its packager-managed AppHost bootstrap triggers a NuGet restore whose - # transitive deps resolve to api.nuget.org, which the 1ES signed-build agent - # has no egress to. Re-adding it requires either an internal NuGet mirror - # config or skipping the auto-restore. Tracked in #17345. - $csharpProjectDir = Join-Path $verifyTmpDir "VerifyApp" - New-Item -ItemType Directory -Path $csharpProjectDir -Force | Out-Null - Test-CSharpStarterProject -AspireBin $aspireBin -ProjectRoot $csharpProjectDir + # Note: 'aspire new aspire-starter' was previously invoked here to exercise the + # template engine + bundle self-extraction path. It has been removed because the + # template lookup is not actually offline — it queries NuGet feeds via + # TemplateNuGetConfigService.ResolveTemplatePackageAsync. The step only ever + # succeeded on release branches because builds were mis-baked with + # AspireCliChannel=stable, which routed the lookup to nuget.org and found a + # previously-shipped Aspire.ProjectTemplates version. Once #17528 corrected the + # release-branch builds to bake AspireCliChannel=staging, the implicit identity + # channel switched to a staging feed that is not reachable from the 1ES signed- + # build agent, and the step started failing with "No template versions were + # found." This mirrors the prior removal of the TypeScript starter check + # (#17274 / tracked in #17345) for the same egress reason. Re-adding meaningful + # starter coverage in the signed-build verifier requires either an internal + # NuGet mirror or a no-network template path. Write-Host "" Write-Host "==========================================" diff --git a/tests/Aspire.Acquisition.Tests/Scripts/VerifyCliArchivePowerShellTests.cs b/tests/Aspire.Acquisition.Tests/Scripts/VerifyCliArchivePowerShellTests.cs index e83ba1aeaed..fb4a9ccbb45 100644 --- a/tests/Aspire.Acquisition.Tests/Scripts/VerifyCliArchivePowerShellTests.cs +++ b/tests/Aspire.Acquisition.Tests/Scripts/VerifyCliArchivePowerShellTests.cs @@ -32,7 +32,6 @@ public async Task VerifyCliArchive_AcceptsCleanPerRidArchive() result.EnsureSuccessful(); Assert.Contains("aspire mock v1.0", result.Output); - Assert.Contains("'aspire new aspire-starter' created project successfully", result.Output); Assert.Contains("linux-* archive correctly omits the install-route sidecar", result.Output); Assert.Contains("All verification checks passed", result.Output); } From ef14eeb4264a988cbe78a10160c25445484009fd Mon Sep 17 00:00:00 2001 From: David Negstad <50252651+danegsta@users.noreply.github.com> Date: Fri, 29 May 2026 13:36:01 -0700 Subject: [PATCH 07/15] Handle process inspection race during shutdown (#17676) Treat Win32Exception while inspecting process metadata as a stale or non-targetable process so stop monitoring does not fail when an AppHost exits during shutdown. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> (cherry picked from commit e258349fd49dd0fdf08f92468d79f0cfaaf13012) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Shared/ProcessSignaler.cs | 38 ++++++++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/src/Shared/ProcessSignaler.cs b/src/Shared/ProcessSignaler.cs index 0e3e7cb7ce6..bda6a79ad2c 100644 --- a/src/Shared/ProcessSignaler.cs +++ b/src/Shared/ProcessSignaler.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.ComponentModel; using System.Diagnostics; using System.Runtime.InteropServices; using Microsoft.Extensions.Logging; @@ -49,20 +50,31 @@ public static void ForceKill(int pid, DateTimeOffset? expectedStartTime, ILogger public static Process? TryGetRunningProcess(int pid, DateTimeOffset? expectedStartTime, ILogger logger) { + Process? process = null; try { - var process = Process.GetProcessById(pid); - if (expectedStartTime is not null && !AreClose(expectedStartTime, process.StartTime)) + process = Process.GetProcessById(pid); + if (process.HasExited) { - logger.LogDebug("Process {Pid} start time {ProcessStartTime} does not match expected start time {ExpectedStartTime}", pid, process.StartTime, expectedStartTime); process.Dispose(); - return null; // Do not return processes that do not match the expected start time + return null; } - if (process.HasExited) + if (expectedStartTime is not null) { - process.Dispose(); - return null; + var processStartTime = process.StartTime; + if (!AreClose(expectedStartTime, processStartTime)) + { + logger.LogDebug("Process {Pid} start time {ProcessStartTime} does not match expected start time {ExpectedStartTime}", pid, processStartTime, expectedStartTime); + process.Dispose(); + return null; // Do not return processes that do not match the expected start time + } + + if (process.HasExited) + { + process.Dispose(); + return null; + } } return process; @@ -70,11 +82,23 @@ public static void ForceKill(int pid, DateTimeOffset? expectedStartTime, ILogger catch (ArgumentException) { // Process doesn't exist - already terminated. + process?.Dispose(); return null; } catch (InvalidOperationException) { // Process has already exited. + process?.Dispose(); + return null; + } + catch (Win32Exception ex) + { + // Process inspection can race with process exit. On macOS, StartTime can throw: + // Win32Exception (3): Unable to retrieve the specified information about the process or thread. It may have exited or may be privileged. + // If we cannot inspect the process enough to prove it is the expected target, do + // not signal or kill it. + logger.LogDebug(ex, "Could not inspect process {Pid}. Treating it as not running.", pid); + process?.Dispose(); return null; } } From d7124c63f8809eff4d57504d4e402856bcb48547 Mon Sep 17 00:00:00 2001 From: Jose Perez Rodriguez Date: Fri, 29 May 2026 15:11:39 -0700 Subject: [PATCH 08/15] [release/13.4] Mark Aspire.Hosting.Blazor as preview (#17694) * [release/13.4] Mark Aspire.Hosting.Blazor as preview The 13.4.0 stable build ships gateway scripts only under buildTransitive/net8.0/Scripts/, while the TypeScript AppHost loader resolves Gateway.cs from lib/net8.0/Scripts/. This breaks the TS polyglot AppHost flow out of the box. Mark the package as preview-shaped via SuppressFinalPackageVersion so consumers don't pick it up as stable until the packaging/loader issue is fully addressed. Fixes #17685 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Update src/Aspire.Hosting.Blazor/Aspire.Hosting.Blazor.csproj --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> (cherry picked from commit dfd226a4a2a09cb6c599b06276db1b397ef2813e) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Hosting.Blazor/Aspire.Hosting.Blazor.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Aspire.Hosting.Blazor/Aspire.Hosting.Blazor.csproj b/src/Aspire.Hosting.Blazor/Aspire.Hosting.Blazor.csproj index 757c106895d..233e1b63140 100644 --- a/src/Aspire.Hosting.Blazor/Aspire.Hosting.Blazor.csproj +++ b/src/Aspire.Hosting.Blazor/Aspire.Hosting.Blazor.csproj @@ -6,6 +6,7 @@ aspire integration hosting blazor webassembly gateway Blazor WebAssembly hosting support for Aspire. $(NoWarn);ASPIREBLAZOR001 + true From b089cb1cd97edb3ad63cf62a84e74d8e018a1339 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Sun, 31 May 2026 06:35:46 +1000 Subject: [PATCH 09/15] fix(cli): restore implicit-channel discovery + guard non-interactive fuzzy auto-pick (#17724, #17725) (#17728) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cli): restore implicit-channel discovery + guard non-interactive fuzzy auto-pick (#17724, #17725) ## #17725 — Prerelease-only integrations invisible on polyglot apphosts `IntegrationPackageSearchService.GetIntegrationPackagesWithChannelsAsync` used to narrow the channel set to whatever `configuredChannel` resolved to (from `aspire.config.json`'s `"channel"` field). For a polyglot apphost pinned to a `Quality.Stable` channel this dropped the implicit channel from discovery, so prerelease-only packages (e.g. `Aspire.Hosting.Foundry`, `Aspire.Hosting.Kubernetes`) became invisible. This narrowing was born 2026-01-13 in PR #13705 with a C#-only short-circuit in `GetConfiguredChannel`. It stayed dormant until PR #17452 (2026-05-26) started writing `"channel": ""` into the scaffolded `aspire.config.json` during `aspire init`. After #17452 every newly-init'd polyglot apphost in 13.4 had the field populated and tripped the narrowing. 13.3.5 users had no `"channel"` persisted, so the bug was invisible there — this is a 13.4 regression introduced by the activator, not the narrowing code itself. The fix removes the narrowing. The configured channel is still forwarded to `PackagingService.GetChannelsAsync` as `requestedChannelName` so out-of-tree apphost staging-channel synthesis keeps working — it just no longer constrains the post-retrieval filter pipeline. The filter pipeline is now byte-identical for C# and polyglot apphosts. ## #17724 — `aspire add --non-interactive` silently picks first match `AddCommand` falls back to fuzzy search when there's no exact match. The fuzzy candidates were passed to `GetPackageByInteractiveFlow`, which in non-interactive mode auto-selected `distinctPackages.First()` and silently installed it. Combined with #17725, `aspire add kube --non-interactive` on a TS apphost silently installed `Aspire.Hosting.Azure`. The existing guard at AddCommand.cs:181 already refused this when `--version` was supplied. This change generalizes the guard: any non-interactive invocation without an exact match now fails with a new `NonInteractiveRequiresExactPackageMatch` resource message. Fuzzy fallback remains available in interactive mode. ## Tests - IntegrationSearchCommandFormatJsonWithTypeScriptAppHostPinnedToChannelAlsoSearchesImplicitChannel - IntegrationSearchCommandFormatJsonWithTypeScriptAppHostPinnedToStagingChannelAlsoSearchesImplicitChannel - IntegrationSearchCommandFormatJsonWithTypeScriptAppHostPinnedToStableChannelStillSurfacesPrereleaseOnlyPackages (primary regression test for #17725 — Foundry case) - IntegrationSearchCommandTypeScriptAppHostProducesSameResultRegardlessOfPersistedChannel (durable structural guard: parameterized over with/without `"channel"` in aspire.config.json; asserts the result is identical, proving the narrowing is gone) - AddCommand_NonInteractive_NoExactMatchWithoutVersion_FailsInsteadOfFuzzyAutoPick_Regression17724 (primary regression test for #17724) - AddCommand_NonInteractive_ExactMatchWithoutVersion_StillSucceeds (companion guard: exact-match happy path keeps working non-interactive) Two pre-existing AddCommandFuzzySearchTests were testing the buggy auto-pick behavior implicitly (they used `add postgre` / typo input under the default non-interactive test host). Updated to opt into an interactive host environment to assert the documented interactive-fuzzy-prompt behavior. Full Aspire.Cli.Tests suite: 3900 passed, 21 skipped, 0 failed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(cli): restore explicit-channel inclusion when polyglot apphost pins a channel Address review feedback on PR #17728. The first revision dropped too much. Removing the `|| !string.IsNullOrEmpty(configuredChannel)` half of the gate caused a NEW regression: a TS apphost pinned to "daily" / "staging" / a custom channel now searched only the implicit channel, losing access to packages that live on the pinned feed. Production change in IntegrationPackageSearchService: channels = hasHives || !string.IsNullOrEmpty(configuredChannel) ? allChannels : allChannels.Where(c => c.Type is PackageChannelType.Implicit); This preserves the #17725 fix (narrowing is still gone, so the implicit channel always participates and prerelease-only packages like Foundry remain discoverable when pinned to a Stable-quality channel) while keeping pinned explicit channels in the search. Tests strengthened with per-channel invocation counters so that "channel X was searched" is asserted directly rather than inferred from the dedupe outcome. The Theory test is reframed: both arms agree on the user-visible preferred result (Redis 1.0.0, implicit wins), but the with-channel arm additionally hits the daily channel and the without-channel arm does not. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test(cli): pin staging-shipping behavior for #17724 + #17725 Adds proof-by-test that the IPSS gate behaves identically when the CLI is shipped as staging (`IdentityChannel == "staging"`) as it does today with the PR dogfood build (where the channel name was `pr-17728`): 1. Adds `[InlineData("\"staging\"", true)]` to the existing theory `…PersistedChannelExpandsDiscoveryWithoutChangingPreferredResult`. This proves the IPSS gate (`hasHives || !string.IsNullOrEmpty( configuredChannel)`) is channel-name-opaque — `"staging"` and `"daily"` produce identical gate behavior. 2. Adds a new fact `IntegrationSearchCommandStagingStampedCliWithPinnedStagingApphost QueriesBothImplicitAndStagingChannelsAndSurfacesPrereleaseOnlyPackages` that exercises the exact shipping shape: * Real PackagingService (not the fake TestPackagingService) — so the real staging-channel synthesis path is exercised. * `IdentityChannel = Staging` — the CLI binary is stamped as the staging release identity, which is how shipped staging CLIs run. * `aspire.config.json` pins `"channel": "staging"` — which is what `aspire new` writes into polyglot apphosts on a staging-stamped CLI (see CliTemplateFactory.TypeScriptStarterTemplate). * No PR hives — this is a real installed CLI, not a dogfood build. Asserts both invariants: (i) Total cache call count >= 2, proving both implicit AND staging channels were queried. Pre-fix narrowing would have produced exactly 1 call. (ii) A prerelease-only package returned only when prerelease=true surfaces to the user — proving the #17725 fix holds on a real staging release, not just on a PR build. Together with the existing `…UsesConfiguredStagingChannelWithRealPackagingService` (apphost-pin triggers staging synthesis under Stable identity) and `…UnpinnedAppHostUsesImplicitChannelUnderStagingCli` (staging identity without pin correctly falls back to implicit-only), the staging quadrant is now fully covered. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix interactive fuzzy add confirmation Prompt before adding a single fuzzy or no-match fallback candidate in interactive aspire add flows, while preserving exact-match auto-selection. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: David Fowler (cherry picked from commit 56e88661e4d6026cea423827c62c067e11fdf192) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/AddCommand.cs | 58 +- .../IntegrationPackageSearchService.cs | 21 +- .../Resources/AddCommandStrings.Designer.cs | 8 + .../Resources/AddCommandStrings.resx | 4 + .../Resources/xlf/AddCommandStrings.cs.xlf | 5 + .../Resources/xlf/AddCommandStrings.de.xlf | 5 + .../Resources/xlf/AddCommandStrings.es.xlf | 5 + .../Resources/xlf/AddCommandStrings.fr.xlf | 5 + .../Resources/xlf/AddCommandStrings.it.xlf | 5 + .../Resources/xlf/AddCommandStrings.ja.xlf | 5 + .../Resources/xlf/AddCommandStrings.ko.xlf | 5 + .../Resources/xlf/AddCommandStrings.pl.xlf | 5 + .../Resources/xlf/AddCommandStrings.pt-BR.xlf | 5 + .../Resources/xlf/AddCommandStrings.ru.xlf | 5 + .../Resources/xlf/AddCommandStrings.tr.xlf | 5 + .../xlf/AddCommandStrings.zh-Hans.xlf | 5 + .../xlf/AddCommandStrings.zh-Hant.xlf | 5 + .../Commands/AddCommandTests.cs | 685 +++++++++++++++++- 18 files changed, 818 insertions(+), 23 deletions(-) diff --git a/src/Aspire.Cli/Commands/AddCommand.cs b/src/Aspire.Cli/Commands/AddCommand.cs index cf62829b474..e8349783a1a 100644 --- a/src/Aspire.Cli/Commands/AddCommand.cs +++ b/src/Aspire.Cli/Commands/AddCommand.cs @@ -178,9 +178,17 @@ CommandResult AddCommandFromExitCode(int exitCode) ? ProfilingTelemetry.Values.AddPackageMatchKindExact : ProfilingTelemetry.Values.AddPackageMatchKindNone; - if (filteredPackagesWithShortName.Count == 0 && integrationName is not null && version is not null && !_hostEnvironment.SupportsInteractiveInput) + // Non-interactive mode never falls back to fuzzy search: in interactive mode the user picks + // from the fuzzy candidates, but a script/CI invocation would otherwise silently auto-select + // distinctPackages.First() in GetPackageByInteractiveFlow and install the wrong package + // (https://github.com/microsoft/aspire/issues/17724). Refusing with an actionable error + // forces the caller to supply an exact package id or friendly name. + if (filteredPackagesWithShortName.Count == 0 && integrationName is not null && !_hostEnvironment.SupportsInteractiveInput) { - throw new EmptyChoicesException(string.Format(CultureInfo.CurrentCulture, AddCommandStrings.SpecifiedVersionRequiresExactPackageMatch, integrationName)); + var message = version is not null + ? string.Format(CultureInfo.CurrentCulture, AddCommandStrings.SpecifiedVersionRequiresExactPackageMatch, integrationName) + : string.Format(CultureInfo.CurrentCulture, AddCommandStrings.NonInteractiveRequiresExactPackageMatch, integrationName); + throw new EmptyChoicesException(message); } if (filteredPackagesWithShortName.Count == 0 && integrationName is not null) @@ -197,16 +205,33 @@ CommandResult AddCommandFromExitCode(int exitCode) : ProfilingTelemetry.Values.AddPackageMatchKindNone; } - // If we didn't match any, show a complete list. If we matched one, and its + // If the user supplied a partial/fuzzy search term, keep the package prompt even when + // the fallback only found one candidate; otherwise `aspire add kube` can silently add + // the lone fuzzy match without asking the interactive user to confirm it. + var promptForSingleFuzzyPackage = packageMatchKind == ProfilingTelemetry.Values.AddPackageMatchKindFuzzy; + + // If we didn't match any, show a complete list. If we matched one, and it's // an exact match, then we still prompt, but it will only prompt for // the version. If there is more than one match then we prompt. (string FriendlyName, NuGetPackage Package, PackageChannel Channel) selectedNuGetPackage; selectedNuGetPackage = filteredPackagesWithShortName.Count switch { - 0 => await GetPackageByInteractiveFlowWithNoMatchesMessage(effectiveAppHostProjectFile.Directory!, packagesWithShortName, integrationName, version, cancellationToken), - 1 when filteredPackagesWithShortName[0].Package.Version == version + 0 => await GetPackageByInteractiveFlowWithNoMatchesMessage( + effectiveAppHostProjectFile.Directory!, + packagesWithShortName, + integrationName, + version, + cancellationToken, + promptForSinglePackage: integrationName is not null), + 1 when packageMatchKind == ProfilingTelemetry.Values.AddPackageMatchKindExact + && filteredPackagesWithShortName[0].Package.Version == version => filteredPackagesWithShortName[0], - _ => await GetPackageByInteractiveFlow(effectiveAppHostProjectFile.Directory!, filteredPackagesWithShortName, version, cancellationToken) + _ => await GetPackageByInteractiveFlow( + effectiveAppHostProjectFile.Directory!, + filteredPackagesWithShortName, + version, + cancellationToken, + promptForSingleFuzzyPackage) }; using (var selectPackageActivity = _profilingTelemetry.StartAddSelectPackage(integrationName, version)) { @@ -357,14 +382,21 @@ CommandResult AddCommandFromExitCode(int exitCode) return versions; } - private async Task<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)> GetPackageByInteractiveFlow(DirectoryInfo workingDirectory, IEnumerable<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)> possiblePackages, string? preferredVersion, CancellationToken cancellationToken) + private async Task<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)> GetPackageByInteractiveFlow( + DirectoryInfo workingDirectory, + IEnumerable<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)> possiblePackages, + string? preferredVersion, + CancellationToken cancellationToken, + bool promptForSinglePackage = false) { var distinctPackages = possiblePackages.DistinctBy(p => p.Package.Id).ToArray(); - // If there is only one package, we can skip the prompt and just use it. + // Exact matches can skip the package prompt when one package remains. Fuzzy/no-match + // fallbacks opt into prompting so interactive users confirm the candidate first. // In non-interactive mode, auto-select the first package. var selectedPackage = distinctPackages.Length switch { + 1 when promptForSinglePackage && _hostEnvironment.SupportsInteractiveInput => await PromptForIntegrationAsync(distinctPackages, cancellationToken), 1 => distinctPackages.First(), > 1 when !_hostEnvironment.SupportsInteractiveInput => distinctPackages.First(), > 1 => await PromptForIntegrationAsync(distinctPackages, cancellationToken), @@ -440,14 +472,20 @@ CommandResult AddCommandFromExitCode(int exitCode) return await _prompter.PromptForIntegrationVersionAsync(packages, cancellationToken); } - private async Task<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)> GetPackageByInteractiveFlowWithNoMatchesMessage(DirectoryInfo workingDirectory, IEnumerable<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)> possiblePackages, string? searchTerm, string? preferredVersion, CancellationToken cancellationToken) + private async Task<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)> GetPackageByInteractiveFlowWithNoMatchesMessage( + DirectoryInfo workingDirectory, + IEnumerable<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)> possiblePackages, + string? searchTerm, + string? preferredVersion, + CancellationToken cancellationToken, + bool promptForSinglePackage = false) { if (searchTerm is not null) { InteractionService.DisplaySubtleMessage(string.Format(CultureInfo.CurrentCulture, AddCommandStrings.NoPackagesMatchedSearchTerm, searchTerm)); } - return await GetPackageByInteractiveFlow(workingDirectory, possiblePackages, preferredVersion, cancellationToken); + return await GetPackageByInteractiveFlow(workingDirectory, possiblePackages, preferredVersion, cancellationToken, promptForSinglePackage); } } diff --git a/src/Aspire.Cli/Commands/IntegrationPackageSearchService.cs b/src/Aspire.Cli/Commands/IntegrationPackageSearchService.cs index 17646b8a434..54779e410c9 100644 --- a/src/Aspire.Cli/Commands/IntegrationPackageSearchService.cs +++ b/src/Aspire.Cli/Commands/IntegrationPackageSearchService.cs @@ -23,13 +23,24 @@ internal sealed class IntegrationPackageSearchService( public async Task> GetIntegrationPackagesWithChannelsAsync(DirectoryInfo workingDirectory, string? configuredChannel, CancellationToken cancellationToken) { + // `configuredChannel` (from a polyglot apphost's aspire.config.json) is forwarded + // as `requestedChannelName` so PackagingService can synthesize the staging channel + // for out-of-tree apphosts whose directory wasn't picked up by + // ConfigurationHelper.RegisterSettingsFiles. var allChannels = await packagingService.GetChannelsAsync(cancellationToken, configuredChannel); - if (!string.IsNullOrEmpty(configuredChannel)) - { - allChannels = allChannels.Where(c => string.Equals(c.Name, configuredChannel, StringComparison.OrdinalIgnoreCase)); - } - + // Channels included in the search: + // * Implicit channel: always. + // * Explicit channels (stable, daily, staging, custom): when PR hives exist OR the + // apphost has pinned an explicit channel via aspire.config.json. + // + // What this method MUST NOT do is narrow the explicit channel set to just the pinned + // channel. That was the root cause of https://github.com/microsoft/aspire/issues/17724 + // and https://github.com/microsoft/aspire/issues/17725: a TS apphost pinned to a + // Quality.Stable channel ended up with prerelease=false queries everywhere and + // prerelease-only packages (e.g. Aspire.Hosting.Foundry) became invisible. The implicit + // channel (Quality.Both) must always participate so prerelease packages are reachable + // even when the explicit pin is Stable-quality. var hasHives = executionContext.GetHiveCount() > 0; var channels = hasHives || !string.IsNullOrEmpty(configuredChannel) ? allChannels diff --git a/src/Aspire.Cli/Resources/AddCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/AddCommandStrings.Designer.cs index a7500a3e539..af0d2d5b50a 100644 --- a/src/Aspire.Cli/Resources/AddCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/AddCommandStrings.Designer.cs @@ -309,6 +309,14 @@ public static string SpecifiedVersionRequiresExactPackageMatch } } + public static string NonInteractiveRequiresExactPackageMatch + { + get + { + return ResourceManager.GetString("NonInteractiveRequiresExactPackageMatch", resourceCulture); + } + } + public static string UsePrereleasePackages { get diff --git a/src/Aspire.Cli/Resources/AddCommandStrings.resx b/src/Aspire.Cli/Resources/AddCommandStrings.resx index 68668c59988..57d957d69b6 100644 --- a/src/Aspire.Cli/Resources/AddCommandStrings.resx +++ b/src/Aspire.Cli/Resources/AddCommandStrings.resx @@ -223,6 +223,10 @@ When --version is specified in non-interactive mode, the integration name must exactly match a package or friendly name. No exact match was found for '{0}'. {0} is the integration name provided by the user. + + No exact match was found for '{0}'. In non-interactive mode the integration name must exactly match a package id or friendly name; fuzzy fallback is disabled to prevent silently selecting the wrong package. + {0} is the integration name provided by the user. + Use pre-release packages diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.cs.xlf index 46f20819789..e0d9d7c231f 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.cs.xlf @@ -102,6 +102,11 @@ Vašemu hledaného termínu {0} neodpovídají žádné balíčky. Zobrazují se všechny dostupné balíčky. {0} is the search term provided by the user. + + No exact match was found for '{0}'. In non-interactive mode the integration name must exactly match a package id or friendly name; fuzzy fallback is disabled to prevent silently selecting the wrong package. + No exact match was found for '{0}'. In non-interactive mode the integration name must exactly match a package id or friendly name; fuzzy fallback is disabled to prevent silently selecting the wrong package. + {0} is the integration name provided by the user. + The package {0}::{1} was added successfully. Balíček {0}::{1} byl úspěšně přidán. diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.de.xlf index 1b986286cb6..740263347ac 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.de.xlf @@ -102,6 +102,11 @@ Es stimmten keine Pakete mit Ihrem Suchbegriff „{0}“ überein. Alle verfügbaren Pakete werden angezeigt. {0} is the search term provided by the user. + + No exact match was found for '{0}'. In non-interactive mode the integration name must exactly match a package id or friendly name; fuzzy fallback is disabled to prevent silently selecting the wrong package. + No exact match was found for '{0}'. In non-interactive mode the integration name must exactly match a package id or friendly name; fuzzy fallback is disabled to prevent silently selecting the wrong package. + {0} is the integration name provided by the user. + The package {0}::{1} was added successfully. Das Paket {0}::{1} wurde erfolgreich hinzugefügt. diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.es.xlf index 0343b44bd5a..a20674b790d 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.es.xlf @@ -102,6 +102,11 @@ No se encontraron paquetes que coincidan con el término de búsqueda "{0}". Mostrando todos los paquetes disponibles. {0} is the search term provided by the user. + + No exact match was found for '{0}'. In non-interactive mode the integration name must exactly match a package id or friendly name; fuzzy fallback is disabled to prevent silently selecting the wrong package. + No exact match was found for '{0}'. In non-interactive mode the integration name must exactly match a package id or friendly name; fuzzy fallback is disabled to prevent silently selecting the wrong package. + {0} is the integration name provided by the user. + The package {0}::{1} was added successfully. El paquete {0}::{1} se agregó correctamente. diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.fr.xlf index 2f58c55a87d..7ee12de8e91 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.fr.xlf @@ -102,6 +102,11 @@ Aucun package ne correspond à votre terme de recherche « {0} ». Affichage de tous les packages disponibles. {0} is the search term provided by the user. + + No exact match was found for '{0}'. In non-interactive mode the integration name must exactly match a package id or friendly name; fuzzy fallback is disabled to prevent silently selecting the wrong package. + No exact match was found for '{0}'. In non-interactive mode the integration name must exactly match a package id or friendly name; fuzzy fallback is disabled to prevent silently selecting the wrong package. + {0} is the integration name provided by the user. + The package {0}::{1} was added successfully. Le package {0}::{1} a été ajouté avec succès. diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.it.xlf index 5370c007db5..bdadac4b1f2 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.it.xlf @@ -102,6 +102,11 @@ Nessun pacchetto corrisponde al termine di ricerca '{0}'. Visualizzazione di tutti i pacchetti disponibili. {0} is the search term provided by the user. + + No exact match was found for '{0}'. In non-interactive mode the integration name must exactly match a package id or friendly name; fuzzy fallback is disabled to prevent silently selecting the wrong package. + No exact match was found for '{0}'. In non-interactive mode the integration name must exactly match a package id or friendly name; fuzzy fallback is disabled to prevent silently selecting the wrong package. + {0} is the integration name provided by the user. + The package {0}::{1} was added successfully. Caricamento del pacchetto {0}::{1} completato. diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ja.xlf index 09ba10d2665..88bac2a1384 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ja.xlf @@ -102,6 +102,11 @@ 検索語句 '{0}' に一致するパッケージはありません。使用可能なすべてのパッケージを表示しています。 {0} is the search term provided by the user. + + No exact match was found for '{0}'. In non-interactive mode the integration name must exactly match a package id or friendly name; fuzzy fallback is disabled to prevent silently selecting the wrong package. + No exact match was found for '{0}'. In non-interactive mode the integration name must exactly match a package id or friendly name; fuzzy fallback is disabled to prevent silently selecting the wrong package. + {0} is the integration name provided by the user. + The package {0}::{1} was added successfully. パッケージ {0}::{1} が正常に追加されました。 diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ko.xlf index b451547d234..ca225a1a1c5 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ko.xlf @@ -102,6 +102,11 @@ 검색어 '{0}'와(과) 일치하는 패키지가 없습니다. 사용 가능한 모든 패키지를 표시합니다. {0} is the search term provided by the user. + + No exact match was found for '{0}'. In non-interactive mode the integration name must exactly match a package id or friendly name; fuzzy fallback is disabled to prevent silently selecting the wrong package. + No exact match was found for '{0}'. In non-interactive mode the integration name must exactly match a package id or friendly name; fuzzy fallback is disabled to prevent silently selecting the wrong package. + {0} is the integration name provided by the user. + The package {0}::{1} was added successfully. 패키지 {0}::{1}을(를) 추가했습니다. diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.pl.xlf index e7ff599247f..fad9c56ae1c 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.pl.xlf @@ -102,6 +102,11 @@ Żadne pakiety nie pasują do wyszukiwanego terminu „{0}”. Wyświetlanie wszystkich dostępnych pakietów. {0} is the search term provided by the user. + + No exact match was found for '{0}'. In non-interactive mode the integration name must exactly match a package id or friendly name; fuzzy fallback is disabled to prevent silently selecting the wrong package. + No exact match was found for '{0}'. In non-interactive mode the integration name must exactly match a package id or friendly name; fuzzy fallback is disabled to prevent silently selecting the wrong package. + {0} is the integration name provided by the user. + The package {0}::{1} was added successfully. Pakiet {0}::{1} został pomyślnie dodany. diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.pt-BR.xlf index df4ac017669..52c4a17ef42 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.pt-BR.xlf @@ -102,6 +102,11 @@ Nenhum pacote correspondeu ao termo de pesquisa '{0}'. Mostrando todos os pacotes disponíveis. {0} is the search term provided by the user. + + No exact match was found for '{0}'. In non-interactive mode the integration name must exactly match a package id or friendly name; fuzzy fallback is disabled to prevent silently selecting the wrong package. + No exact match was found for '{0}'. In non-interactive mode the integration name must exactly match a package id or friendly name; fuzzy fallback is disabled to prevent silently selecting the wrong package. + {0} is the integration name provided by the user. + The package {0}::{1} was added successfully. O pacote {0}::{1} foi adicionado com êxito. diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ru.xlf index 6a27acb343f..0f3ad17c9ec 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ru.xlf @@ -102,6 +102,11 @@ Не найдено пакетов, соответствующих вашему запросу "{0}". Показаны все доступные пакеты. {0} is the search term provided by the user. + + No exact match was found for '{0}'. In non-interactive mode the integration name must exactly match a package id or friendly name; fuzzy fallback is disabled to prevent silently selecting the wrong package. + No exact match was found for '{0}'. In non-interactive mode the integration name must exactly match a package id or friendly name; fuzzy fallback is disabled to prevent silently selecting the wrong package. + {0} is the integration name provided by the user. + The package {0}::{1} was added successfully. Пакет {0}::{1} загружен успешно. diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.tr.xlf index 5fcd28173ae..674a07014ec 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.tr.xlf @@ -102,6 +102,11 @@ '{0}' arama teriminizle eşleşen herhangi bir paket bulunamadı. Kullanılabilir tüm paketler gösteriliyor. {0} is the search term provided by the user. + + No exact match was found for '{0}'. In non-interactive mode the integration name must exactly match a package id or friendly name; fuzzy fallback is disabled to prevent silently selecting the wrong package. + No exact match was found for '{0}'. In non-interactive mode the integration name must exactly match a package id or friendly name; fuzzy fallback is disabled to prevent silently selecting the wrong package. + {0} is the integration name provided by the user. + The package {0}::{1} was added successfully. {0}::{1} paketi başarıyla eklendi. diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.zh-Hans.xlf index 9876170360d..644700e5762 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.zh-Hans.xlf @@ -102,6 +102,11 @@ 没有与搜索词“{0}”匹配的包。正在显示所有可用包。 {0} is the search term provided by the user. + + No exact match was found for '{0}'. In non-interactive mode the integration name must exactly match a package id or friendly name; fuzzy fallback is disabled to prevent silently selecting the wrong package. + No exact match was found for '{0}'. In non-interactive mode the integration name must exactly match a package id or friendly name; fuzzy fallback is disabled to prevent silently selecting the wrong package. + {0} is the integration name provided by the user. + The package {0}::{1} was added successfully. 已成功添加包 {0}::{1}。 diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.zh-Hant.xlf index 097c4c679ad..0ffbc3cd4db 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.zh-Hant.xlf @@ -102,6 +102,11 @@ 沒有任何套件符合您搜尋的字詞 '{0}'。顯示所有可用的套件。 {0} is the search term provided by the user. + + No exact match was found for '{0}'. In non-interactive mode the integration name must exactly match a package id or friendly name; fuzzy fallback is disabled to prevent silently selecting the wrong package. + No exact match was found for '{0}'. In non-interactive mode the integration name must exactly match a package id or friendly name; fuzzy fallback is disabled to prevent silently selecting the wrong package. + {0} is the integration name provided by the user. + The package {0}::{1} was added successfully. 已成功新增套件 {0}::{1}。 diff --git a/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs index 88978c21244..50aa9596927 100644 --- a/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs @@ -354,8 +354,29 @@ public async Task IntegrationSearchCommandFormatJsonUsesFuzzyIntegrationMatching } [Fact] - public async Task IntegrationSearchCommandFormatJsonWithAppHostUsesConfiguredChannel() + public async Task IntegrationSearchCommandFormatJsonWithTypeScriptAppHostPinnedToChannelAlsoSearchesImplicitChannel() { + // Regression for https://github.com/microsoft/aspire/issues/17724 + https://github.com/microsoft/aspire/issues/17725. + // + // Layer 1 (latent bug, born 2026-01-13 in PR #13705): IntegrationPackageSearchService used to + // narrow the channel set to whatever `configuredChannel` resolved to whenever the apphost was + // non-C#. This dropped the implicit channel and any other channels from discovery. + // Layer 2 (PR #17452, 2026-05-26): `aspire init` started writing `"channel": ""` into + // the scaffolded aspire.config.json for polyglot apphosts. This activated the Layer 1 bug for + // every newly-initialized TS apphost in 13.4. + // + // Fix: IntegrationPackageSearchService no longer narrows. The full channel set (implicit + + // pinned channel + any hives) is searched. + // + // This test pins the TS apphost to the "daily" channel via aspire.config.json. Pre-fix only the + // daily channel was searched and Redis 2.0.0 (daily) was the only result. Post-fix the implicit + // channel is ALSO searched, and SelectPreferredIntegrationPackage prefers the implicit channel + // when versions collide on Id, so Redis 1.0.0 (implicit) wins the dedupe. + // + // The structural guarantee asserted below — both `implicitHits` AND `dailyHits` being > 0 — is + // what defends against a regression that drops either channel from the search. Asserting only + // on the resulting Redis version is insufficient because implicit-only and daily-only searches + // both happen to produce a single result. var rawJson = string.Empty; var testInteractionService = new TestInteractionService { @@ -371,13 +392,25 @@ public async Task IntegrationSearchCommandFormatJsonWithAppHostUsesConfiguredCha } """); + // Track per-channel invocation. IntegrationPackageSearchService walks channels via + // Parallel.ForEachAsync, so callbacks may run concurrently; Interlocked guards that. + var implicitHits = 0; + var dailyHits = 0; var implicitCache = new FakeNuGetPackageCache { - GetIntegrationPackagesAsyncCallback = (_, _, _, _) => Task.FromResult>([CreatePackage("Aspire.Hosting.Redis", "1.0.0")]) + GetIntegrationPackagesAsyncCallback = (_, _, _, _) => + { + Interlocked.Increment(ref implicitHits); + return Task.FromResult>([CreatePackage("Aspire.Hosting.Redis", "1.0.0")]); + } }; var dailyCache = new FakeNuGetPackageCache { - GetIntegrationPackagesAsyncCallback = (_, _, _, _) => Task.FromResult>([CreatePackage("Aspire.Hosting.Redis", "2.0.0")]) + GetIntegrationPackagesAsyncCallback = (_, _, _, _) => + { + Interlocked.Increment(ref dailyHits); + return Task.FromResult>([CreatePackage("Aspire.Hosting.Redis", "2.0.0")]); + } }; var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => @@ -401,14 +434,22 @@ public async Task IntegrationSearchCommandFormatJsonWithAppHostUsesConfiguredCha Assert.Equal(CliExitCodes.Success, exitCode); + // Structural regression signal: BOTH channels must have been searched. + Assert.True(implicitHits > 0, "Implicit channel was not queried — discovery is dropping it."); + Assert.True(dailyHits > 0, "Daily channel was not queried — pinned channel is being dropped from discovery."); + + // Implicit channel result wins the dedupe (SelectPreferredIntegrationPackage prefers implicit). var integration = Assert.Single(ReadIntegrationResults(rawJson)); Assert.Equal("Aspire.Hosting.Redis", integration.Package); - Assert.Equal("2.0.0", integration.Version); + Assert.Equal("1.0.0", integration.Version); } [Fact] - public async Task IntegrationSearchCommandFormatJsonWithAppHostUsesConfiguredStagingChannelUnderStableCli() + public async Task IntegrationSearchCommandFormatJsonWithTypeScriptAppHostPinnedToStagingChannelAlsoSearchesImplicitChannel() { + // See companion test above for the full Layer 1 / Layer 2 regression story. + // This variant covers the staging-channel pin: a stable-shaped CLI dogfooder whose apphost + // was init'd by PR #17452 and now has `"channel": "staging"` written into aspire.config.json. var rawJson = string.Empty; var testInteractionService = new TestInteractionService { @@ -424,13 +465,23 @@ public async Task IntegrationSearchCommandFormatJsonWithAppHostUsesConfiguredSta } """); + var implicitHits = 0; + var stagingHits = 0; var implicitCache = new FakeNuGetPackageCache { - GetIntegrationPackagesAsyncCallback = (_, _, _, _) => Task.FromResult>([CreatePackage("Aspire.Hosting.Redis", "1.0.0")]) + GetIntegrationPackagesAsyncCallback = (_, _, _, _) => + { + Interlocked.Increment(ref implicitHits); + return Task.FromResult>([CreatePackage("Aspire.Hosting.Redis", "1.0.0")]); + } }; var stagingCache = new FakeNuGetPackageCache { - GetIntegrationPackagesAsyncCallback = (_, _, _, _) => Task.FromResult>([CreatePackage("Aspire.Hosting.Redis", "2.0.0")]) + GetIntegrationPackagesAsyncCallback = (_, _, _, _) => + { + Interlocked.Increment(ref stagingHits); + return Task.FromResult>([CreatePackage("Aspire.Hosting.Redis", "2.0.0")]); + } }; var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => @@ -455,9 +506,208 @@ public async Task IntegrationSearchCommandFormatJsonWithAppHostUsesConfiguredSta Assert.Equal(CliExitCodes.Success, exitCode); + Assert.True(implicitHits > 0, "Implicit channel was not queried — discovery is dropping it."); + Assert.True(stagingHits > 0, "Staging channel was not queried — pinned channel is being dropped from discovery."); + var integration = Assert.Single(ReadIntegrationResults(rawJson)); Assert.Equal("Aspire.Hosting.Redis", integration.Package); - Assert.Equal("2.0.0", integration.Version); + Assert.Equal("1.0.0", integration.Version); + } + + [Fact] + public async Task IntegrationSearchCommandFormatJsonWithTypeScriptAppHostPinnedToStableChannelStillSurfacesPrereleaseOnlyPackages() + { + // Regression for https://github.com/microsoft/aspire/issues/17725 specifically. + // + // Aspire.Hosting.Foundry has never shipped a stable version — it only exists as prerelease. + // Pre-fix, a TS apphost with `"channel": "stable"` in aspire.config.json got narrowed to the + // stable channel only. That channel is Quality.Stable, so only `prerelease: false` queries + // were issued, and Foundry never appeared in the result set. Users dogfooding the staging CLI + // (which writes `"channel": "stable"` for a stable-shaped build) could not discover Foundry. + // + // Post-fix the implicit channel (Quality.Both) is also searched, which DOES issue + // `prerelease: true` queries, and Foundry surfaces. + // + // The fake here respects the `prerelease` arg passed to GetIntegrationPackagesAsync so the + // stable channel sees Redis only, while the implicit channel sees Redis + Foundry. The + // existence of Foundry in the result is the regression signal. + var rawJson = string.Empty; + var testInteractionService = new TestInteractionService + { + DisplayRawTextCallback = text => rawJson = text + }; + + using var workspace = TemporaryWorkspace.Create(outputHelper); + var appHostFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.ts")); + File.WriteAllText(appHostFile.FullName, string.Empty); + File.WriteAllText(Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName), """ + { + "channel": "stable" + } + """); + + // Implicit channel: Quality.Both. Returns Redis when prerelease=false, Redis+Foundry when prerelease=true. + var implicitHits = 0; + var implicitCache = new FakeNuGetPackageCache + { + GetIntegrationPackagesAsyncCallback = (_, prerelease, _, _) => + { + Interlocked.Increment(ref implicitHits); + return Task.FromResult>( + prerelease + ? [CreatePackage("Aspire.Hosting.Redis", "1.0.0"), CreatePackage("Aspire.Hosting.Foundry", "1.0.0-preview.1")] + : [CreatePackage("Aspire.Hosting.Redis", "1.0.0")]); + } + }; + // Stable channel: Quality.Stable. PackageChannel only issues prerelease=false queries against it, + // so Foundry (prerelease-only) never appears regardless of what the cache could return. + var stableHits = 0; + var stableCache = new FakeNuGetPackageCache + { + GetIntegrationPackagesAsyncCallback = (_, _, _, _) => + { + Interlocked.Increment(ref stableHits); + return Task.FromResult>([CreatePackage("Aspire.Hosting.Redis", "1.0.0")]); + } + }; + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.CliExecutionContextFactory = _ => CreateExecutionContext(workspace, PackageChannelNames.Stable); + options.InteractionServiceFactory = _ => testInteractionService; + options.PackagingServiceFactory = _ => new TestPackagingService + { + GetChannelsAsyncCallback = _ => Task.FromResult>([ + PackageChannel.CreateImplicitChannel(implicitCache, new TestFeatures()), + PackageChannel.CreateExplicitChannel(PackageChannelNames.Stable, PackageChannelQuality.Stable, [new PackageMapping("Aspire*", "stable")], stableCache, new TestFeatures()) + ]) + }; + }); + services.AddSingleton(new TestTypeScriptStarterProjectFactory((_, _, _) => Task.FromResult(true))); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse($"integration search foundry --apphost \"{appHostFile.FullName}\" --format json"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + + // Both channels must be queried. The implicit channel is what surfaces Foundry (via + // prerelease=true), but the stable channel must also be searched so users who pinned to + // it don't lose stable-only packages. + Assert.True(implicitHits > 0, "Implicit channel was not queried — Foundry would not be discoverable."); + Assert.True(stableHits > 0, "Stable channel was not queried — pinned channel is being dropped from discovery."); + + var integration = Assert.Single(ReadIntegrationResults(rawJson)); + Assert.Equal("Aspire.Hosting.Foundry", integration.Package); + Assert.Equal("1.0.0-preview.1", integration.Version); + } + + [Theory] + [InlineData(null, false)] // No persisted channel — only implicit is searched, the explicit channel is excluded. + [InlineData("\"daily\"", true)] // Persisted daily channel — implicit AND daily are searched. + [InlineData("\"staging\"", true)] // Persisted staging channel — implicit AND staging are searched. Proves the gate is channel-name-opaque, + // so the post-fix behavior verified for "daily" applies equally to a staging-stamped release where + // `aspire new` would write `"channel": "staging"` into the polyglot apphost's aspire.config.json. + // (See IntegrationPackageSearchService.GetIntegrationPackagesWithChannelsAsync: the gate is + // `hasHives || !string.IsNullOrEmpty(configuredChannel)` — it never inspects the channel name.) + public async Task IntegrationSearchCommandTypeScriptAppHostPersistedChannelExpandsDiscoveryWithoutChangingPreferredResult(string? configFileChannelJson, bool expectExplicitChannelHit) + { + // Durable regression guard against re-introducing the Layer-1 narrowing bug. + // + // Pre-fix: aspire.config.json with `"channel"` set caused IntegrationPackageSearchService to + // narrow the channel set to that single channel, so the with-channel arm would have returned + // ONLY the daily channel's Redis (2.0.0) while the without-channel arm returned Redis 1.0.0. + // Post-fix two things hold simultaneously: + // (a) Both arms yield the SAME preferred Redis to the user (1.0.0, the implicit channel + // wins via SelectPreferredIntegrationPackage) — because the pin no longer overrides + // what the user sees as the top-ranked result. + // (b) The with-channel arm ALSO queries the pinned (daily) channel; the without-channel arm + // does not — because the explicit channel set is gated on `hasHives || !empty(configuredChannel)`. + // + // Both halves matter. (a) alone would pass for an implementation that incorrectly narrowed + // to implicit-only when a channel was pinned (a different regression than the original bug + // but still wrong — it would mean users who pin to `daily` lose access to packages that only + // exist on the daily feed). (b) is the new structural guarantee on top of (a). + var rawJson = string.Empty; + var testInteractionService = new TestInteractionService + { + DisplayRawTextCallback = text => rawJson = text + }; + + using var workspace = TemporaryWorkspace.Create(outputHelper); + var appHostFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.ts")); + File.WriteAllText(appHostFile.FullName, string.Empty); + if (configFileChannelJson is not null) + { + File.WriteAllText(Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName), $$""" + { + "channel": {{configFileChannelJson}} + } + """); + } + + var implicitHits = 0; + var dailyHits = 0; + var implicitCache = new FakeNuGetPackageCache + { + GetIntegrationPackagesAsyncCallback = (_, _, _, _) => + { + Interlocked.Increment(ref implicitHits); + return Task.FromResult>([CreatePackage("Aspire.Hosting.Redis", "1.0.0")]); + } + }; + var dailyCache = new FakeNuGetPackageCache + { + GetIntegrationPackagesAsyncCallback = (_, _, _, _) => + { + Interlocked.Increment(ref dailyHits); + return Task.FromResult>([CreatePackage("Aspire.Hosting.Redis", "2.0.0")]); + } + }; + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.InteractionServiceFactory = _ => testInteractionService; + options.PackagingServiceFactory = _ => new TestPackagingService + { + GetChannelsAsyncCallback = _ => Task.FromResult>([ + PackageChannel.CreateImplicitChannel(implicitCache, new TestFeatures()), + PackageChannel.CreateExplicitChannel("daily", PackageChannelQuality.Both, [new PackageMapping("Aspire*", "daily")], dailyCache, new TestFeatures()) + ]) + }; + }); + services.AddSingleton(new TestTypeScriptStarterProjectFactory((_, _, _) => Task.FromResult(true))); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse($"integration search redis --apphost \"{appHostFile.FullName}\" --format json"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + + // (a) User-visible result is identical across arms: implicit Redis 1.0.0 wins. + var integration = Assert.Single(ReadIntegrationResults(rawJson)); + Assert.Equal("Aspire.Hosting.Redis", integration.Package); + Assert.Equal("1.0.0", integration.Version); + + // (b) Per-channel search invocation differs based on whether a channel was pinned. + Assert.True(implicitHits > 0, "Implicit channel must always be searched."); + if (expectExplicitChannelHit) + { + // The explicit (daily) channel registered in the fake PackagingService gets searched + // regardless of what channel NAME the apphost pinned (the gate is channel-name-opaque — + // it only checks `!string.IsNullOrEmpty(configuredChannel)`). That's how a real CLI + // built with `AspireCliChannel=staging` (writing `"channel": "staging"` into apphosts + // via `aspire new`) will exercise the same gate path as a CLI that pinned `"daily"`. + Assert.True(dailyHits > 0, $"With-channel arm: explicit channel must also be searched when apphost pin is non-empty (configured: {configFileChannelJson})."); + } + else + { + Assert.Equal(0, dailyHits); + } } [Fact] @@ -554,6 +804,100 @@ public async Task IntegrationSearchCommandFormatJsonWithUnpinnedAppHostUsesImpli Assert.Equal("1.0.0", integration.Version); } + [Fact] + public async Task IntegrationSearchCommandStagingStampedCliWithPinnedStagingApphostQueriesBothImplicitAndStagingChannelsAndSurfacesPrereleaseOnlyPackages() + { + // High-confidence shipping-shape regression guard for #17724 and #17725. + // + // This test simulates EXACTLY what a real CLI built and shipped as staging will do when + // the user runs `aspire add ` against a polyglot apphost that `aspire new` created: + // + // * The CLI binary is stamped `AspireCliChannel=staging` -> `IdentityChannel == "staging"`. + // This triggers the real PackagingService.GetChannelsAsync to synthesize a real staging + // channel alongside implicit + stable (no fake TestPackagingService is used here). + // * `aspire new` writes `"channel": "staging"` into aspire.config.json (see + // CliTemplateFactory.TypeScriptStarterTemplate). We mirror that here. + // * There are NO PR hives. This is a real shipped install, not a dogfood/PR build. + // + // Pre-fix (the regression introduced before 13.4): the gate narrowed the search to ONLY the + // pinned staging channel. Implicit was excluded. Prerelease-only integrations (e.g., + // Aspire.Hosting.Foundry) were invisible because the only feed queried was the staging + // feed, which doesn't surface them. The `aspire add kubernetes` regression had the same + // root cause: kubernetes was reachable via implicit (nuget.org) but invisible under the + // narrowed staging-only search. + // + // Post-fix invariants verified here: + // (i) BOTH implicit AND the synthesized staging channel are queried (cache call count + // is >= 2). Pre-fix this would have been exactly 1. + // (ii) A prerelease-only package returned by the cache only when prerelease=true (which + // is what Quality.Both channels request) is reachable to the user. + var rawJson = string.Empty; + var testInteractionService = new TestInteractionService + { + DisplayRawTextCallback = text => rawJson = text + }; + + using var workspace = TemporaryWorkspace.Create(outputHelper); + var appHostFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.ts")); + File.WriteAllText(appHostFile.FullName, string.Empty); + File.WriteAllText(Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName), """ + { + "channel": "staging" + } + """); + + var totalCacheCalls = 0; + var prereleaseRequested = 0; + var cache = new FakeNuGetPackageCache + { + GetIntegrationPackagesAsyncCallback = (_, prerelease, _, _) => + { + Interlocked.Increment(ref totalCacheCalls); + if (prerelease) + { + Interlocked.Increment(ref prereleaseRequested); + return Task.FromResult>([CreatePackage("Aspire.Hosting.Foundry", "13.4.0-rc.1")]); + } + return Task.FromResult>([]); + } + }; + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + // Stamp the running CLI as the staging release identity. The real PackagingService + // (left un-overridden here) reads this from CliExecutionContext.IdentityChannel and + // synthesizes the staging channel automatically (see PackagingService.GetChannelsAsync + // -> stagingIdentityChannel branch). + options.CliExecutionContextFactory = _ => CreateExecutionContext(workspace, PackageChannelNames.Staging); + options.InteractionServiceFactory = _ => testInteractionService; + options.NuGetPackageCacheFactory = _ => cache; + }); + services.AddSingleton(new TestTypeScriptStarterProjectFactory((_, _, _) => Task.FromResult(true))); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse($"integration search foundry --apphost \"{appHostFile.FullName}\" --format json"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + + // (ii) The prerelease-only package is reachable to the user. + var integration = Assert.Single(ReadIntegrationResults(rawJson)); + Assert.Equal("Aspire.Hosting.Foundry", integration.Package); + Assert.Equal("13.4.0-rc.1", integration.Version); + + // (i) Both implicit AND staging were queried. Pre-fix narrowing would have produced exactly 1 call. + // Real PackagingService.GetChannelsAsync under IdentityChannel=Staging returns at least + // [implicit, stable, staging]; the IPSS gate now lets all of them through (hasHives=false, + // configuredChannel="staging" -> not empty -> gate evaluates true). At minimum the implicit + // and staging channels must have run, so we require >= 2 calls. Using `>= 2` rather than + // `== N` keeps the test robust to PackagingService adding additional explicit channels + // (e.g., stable) without weakening the regression guard. + Assert.True(totalCacheCalls >= 2, $"Expected >= 2 cache calls (both implicit and staging channels), got {totalCacheCalls}. Pre-fix narrowing would have produced 1 call."); + Assert.True(prereleaseRequested >= 1, $"Expected at least one channel to request prerelease=true (Quality.Both channels do); got {prereleaseRequested}."); + } + [Fact] public async Task IntegrationListCommandFormatJsonPrefersImplicitChannelWhenMultipleChannelsContainSameIntegration() { @@ -1123,6 +1467,76 @@ public async Task AddCommandInteractiveDoesNotPromptForVersionWhenSpecifiedVersi Assert.All(exactMatchQueries, query => Assert.Equal("Aspire.Hosting.Redis", query)); } + [Theory] + [InlineData("redis")] + [InlineData("Aspire.Hosting.Redis")] + public async Task AddCommandInteractiveDoesNotPromptForIntegrationWhenExactMatchIsFound(string integrationName) + { + var promptedForIntegration = false; + var promptedForVersion = false; + var selectedPackageName = string.Empty; + var selectedPackageVersion = string.Empty; + + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment(); + options.AddCommandPrompterFactory = (sp) => + { + var interactionService = sp.GetRequiredService(); + var prompter = new TestAddCommandPrompter(interactionService); + prompter.PromptForIntegrationCallback = (packages) => + { + promptedForIntegration = true; + throw new InvalidOperationException("Should not have been prompted for integration selection."); + }; + prompter.PromptForIntegrationVersionCallback = (packages) => + { + promptedForVersion = true; + return packages.Single(package => package.Package.Version == "13.2.0"); + }; + + return prompter; + }; + + options.ProjectLocatorFactory = _ => new TestProjectLocator(); + + options.DotNetCliRunnerFactory = (sp) => + { + var runner = new TestDotNetCliRunner(); + runner.SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, invocationOptions, cancellationToken) => + { + return (0, [ + new NuGetPackage { Id = "Aspire.Hosting.Redis", Source = "nuget", Version = "13.3.0" }, + new NuGetPackage { Id = "Aspire.Hosting.Redis", Source = "nuget", Version = "13.2.0" } + ]); + }; + + runner.AddPackageAsyncCallback = (projectFilePath, packageName, packageVersion, nugetSource, noRestore, invocationOptions, cancellationToken) => + { + selectedPackageName = packageName; + selectedPackageVersion = packageVersion; + return 0; + }; + + return runner; + }; + }); + + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse($"add {integrationName}"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(0, exitCode); + Assert.False(promptedForIntegration); + Assert.True(promptedForVersion); + Assert.Equal("Aspire.Hosting.Redis", selectedPackageName); + Assert.Equal("13.2.0", selectedPackageVersion); + } + [Fact] public async Task AddCommandSearchesEachPackageIdOnceWhenExactMatchFallsBackAcrossSharedChannel() { @@ -2286,6 +2700,12 @@ public async Task AddCommand_WithStartsWith_FindsMatchUsingFuzzySearch() return prompter; }; + // Fuzzy fallback only fires in interactive mode after the Layer-3 fix for #17724. + // The default test host environment is non-interactive (mirroring CI), so opt this + // fixture into the interactive path explicitly: the test asserts that an interactive + // user can still discover PostgreSQL by typing "postgre". + options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment(); + options.ProjectLocatorFactory = _ => new TestProjectLocator(); options.DotNetCliRunnerFactory = (sp) => @@ -2341,6 +2761,251 @@ public async Task AddCommand_WithStartsWith_FindsMatchUsingFuzzySearch() Assert.Equal("Aspire.Hosting.PostgreSQL", addedPackage); } + [Fact] + public async Task AddCommand_NonInteractive_NoExactMatchWithoutVersion_FailsInsteadOfFuzzyAutoPick_Regression17724() + { + // Regression for https://github.com/microsoft/aspire/issues/17724. + // + // Pre-fix: `aspire add kube --non-interactive` had no exact match for "kube" (none of the + // packages are literally named "kube"), so AddCommand fell back to fuzzy search. The fuzzy + // candidate list was then passed to GetPackageByInteractiveFlow, which in non-interactive + // mode auto-selected `distinctPackages.First()` (AddCommand.cs:368-369) and silently added + // the wrong package. In the user's report this was Aspire.Hosting.Azure because the + // companion Layer-1 bug (#17725 / IntegrationPackageSearchService narrowing) had filtered + // prerelease packages out, leaving Azure as the only fuzzy candidate. + // + // Fix: AddCommand now refuses to fall back to fuzzy search whenever the host is non-interactive + // and no exact match was found, regardless of whether --version was supplied. The error + // surfaces the new NonInteractiveRequiresExactPackageMatch resource so the user/script + // knows to supply the full package id or friendly name. + // + // This test uses the simpler C# project flow (TestDotNetCliRunner stub) because the bug is + // in AddCommand's non-interactive handling, not in package discovery — the discovery path is + // covered by the cross-language parity test above. The Aspire.Hosting.Azure and + // Aspire.Hosting.Kubernetes packages both fuzzy-match "kube"; pre-fix the first one + // (Aspire.Hosting.Azure, alphabetical) would have been silently picked. + var addPackageWasCalled = false; + var testInteractionService = new TestInteractionService(); + + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.CliHostEnvironmentFactory = _ => TestHelpers.CreateNonInteractiveHostEnvironment(); + options.InteractionServiceFactory = _ => testInteractionService; + options.ProjectLocatorFactory = _ => new TestProjectLocator(); + + options.DotNetCliRunnerFactory = (sp) => + { + var runner = new TestDotNetCliRunner(); + runner.SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => + { + return ( + 0, + new NuGetPackage[] + { + new() { Id = "Aspire.Hosting.Azure", Source = "nuget", Version = "9.2.0" }, + new() { Id = "Aspire.Hosting.Kubernetes", Source = "nuget", Version = "9.2.0" } + }); + }; + + runner.AddPackageAsyncCallback = (projectFilePath, packageName, packageVersion, nugetSource, noRestore, options, cancellationToken) => + { + addPackageWasCalled = true; + return 0; + }; + + return runner; + }; + }); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("add kube"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.FailedToAddPackage, exitCode); + Assert.False(addPackageWasCalled, "AddPackageAsync must not be called when there is no exact match in non-interactive mode."); + Assert.Contains(string.Format(AddCommandStrings.NonInteractiveRequiresExactPackageMatch, "kube"), testInteractionService.DisplayedErrors); + } + + [Fact] + public async Task AddCommand_NonInteractive_ExactMatchWithoutVersion_StillSucceeds() + { + // Companion regression guard for #17724: ensures the new non-interactive guard ONLY fires + // when there is no exact match. An exact match by package id (or friendly name) must still + // install successfully — this is the documented happy path for CI/scripted usage. + var addedPackage = string.Empty; + var testInteractionService = new TestInteractionService(); + + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.CliHostEnvironmentFactory = _ => TestHelpers.CreateNonInteractiveHostEnvironment(); + options.InteractionServiceFactory = _ => testInteractionService; + options.ProjectLocatorFactory = _ => new TestProjectLocator(); + + options.DotNetCliRunnerFactory = (sp) => + { + var runner = new TestDotNetCliRunner(); + runner.SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => + { + return ( + 0, + new NuGetPackage[] + { + new() { Id = "Aspire.Hosting.Azure", Source = "nuget", Version = "9.2.0" }, + new() { Id = "Aspire.Hosting.Kubernetes", Source = "nuget", Version = "9.2.0" } + }); + }; + + runner.AddPackageAsyncCallback = (projectFilePath, packageName, packageVersion, nugetSource, noRestore, options, cancellationToken) => + { + addedPackage = packageName; + return 0; + }; + + return runner; + }; + }); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + // "kubernetes" is the friendly name (Aspire.Hosting.Kubernetes → friendlyName "kubernetes"), + // so this is an exact match and must succeed. + var result = command.Parse("add kubernetes"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(0, exitCode); + Assert.Equal("Aspire.Hosting.Kubernetes", addedPackage); + } + + [Fact] + public async Task AddCommand_Interactive_SingleFuzzyMatchPromptsBeforeAdding_Regression17724() + { + var promptedPackages = new List<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)>(); + var addedPackage = string.Empty; + + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment(); + options.AddCommandPrompterFactory = (sp) => + { + var interactionService = sp.GetRequiredService(); + var prompter = new TestAddCommandPrompter(interactionService); + prompter.PromptForIntegrationCallback = (packages) => + { + promptedPackages.AddRange(packages); + return packages.Single(); + }; + + return prompter; + }; + options.ProjectLocatorFactory = _ => new TestProjectLocator(); + + options.DotNetCliRunnerFactory = (sp) => + { + var runner = new TestDotNetCliRunner(); + runner.SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => + { + return ( + 0, + new NuGetPackage[] + { + new() { Id = "Aspire.Hosting.Azure", Source = "nuget", Version = "9.2.0" } + }); + }; + + runner.AddPackageAsyncCallback = (projectFilePath, packageName, packageVersion, nugetSource, noRestore, options, cancellationToken) => + { + addedPackage = packageName; + return 0; + }; + + return runner; + }; + }); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("add kube"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + var promptedPackage = Assert.Single(promptedPackages); + Assert.Equal(0, exitCode); + Assert.Equal("Aspire.Hosting.Azure", promptedPackage.Package.Id); + Assert.Equal("Aspire.Hosting.Azure", addedPackage); + } + + [Fact] + public async Task AddCommand_Interactive_NoFuzzyMatchSinglePackagePromptsBeforeAdding() + { + var promptedPackages = new List<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)>(); + var displayedSubtleMessage = string.Empty; + var addedPackage = string.Empty; + var testInteractionService = new TestInteractionService + { + DisplaySubtleMessageCallback = message => displayedSubtleMessage = message + }; + + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment(); + options.InteractionServiceFactory = _ => testInteractionService; + options.AddCommandPrompterFactory = (sp) => + { + var interactionService = sp.GetRequiredService(); + var prompter = new TestAddCommandPrompter(interactionService); + prompter.PromptForIntegrationCallback = (packages) => + { + promptedPackages.AddRange(packages); + return packages.Single(); + }; + + return prompter; + }; + options.ProjectLocatorFactory = _ => new TestProjectLocator(); + + options.DotNetCliRunnerFactory = (sp) => + { + var runner = new TestDotNetCliRunner(); + runner.SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => + { + return ( + 0, + new NuGetPackage[] + { + new() { Id = "Aspire.Hosting.Redis", Source = "nuget", Version = "9.2.0" } + }); + }; + + runner.AddPackageAsyncCallback = (projectFilePath, packageName, packageVersion, nugetSource, noRestore, options, cancellationToken) => + { + addedPackage = packageName; + return 0; + }; + + return runner; + }; + }); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("add zzzzzzzzzz"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + var promptedPackage = Assert.Single(promptedPackages); + Assert.Equal(0, exitCode); + Assert.Equal(string.Format(AddCommandStrings.NoPackagesMatchedSearchTerm, "zzzzzzzzzz"), displayedSubtleMessage); + Assert.Equal("Aspire.Hosting.Redis", promptedPackage.Package.Id); + Assert.Equal("Aspire.Hosting.Redis", addedPackage); + } + [Fact] public async Task AddCommand_WithVersionAndNonExactPackageName_FailsInsteadOfUsingFuzzySearch() { @@ -2887,6 +3552,10 @@ public async Task AddCommand_WithTypo_FindsMatchUsingFuzzySearch() return new TestAddCommandPrompter(interactionService); }; + // Fuzzy fallback only fires in interactive mode after the Layer-3 fix for #17724; + // see companion comment on AddCommand_WithStartsWith_FindsMatchUsingFuzzySearch. + options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment(); + options.ProjectLocatorFactory = _ => new TestProjectLocator(); options.DotNetCliRunnerFactory = (sp) => From 2479ac0dd7399b83145150d7fb4ecbcec19c4374 Mon Sep 17 00:00:00 2001 From: Jose Perez Rodriguez Date: Sat, 30 May 2026 14:54:42 -0700 Subject: [PATCH 10/15] API review fixes for 13.4 (PR #17700) (#17706) * API review fixes for 13.4 (PR #17700) Addresses several issues found during API surface review: 1. Rename NetworkID -> NetworkId (and networkID -> networkId) on AllocatedEndpoint, EndpointAnnotation, EndpointReference, EndpointReferenceAnnotation, NetworkEndpointSnapshot, NetworkEndpointSnapshotList, and related methods/parameters. 2. Add [Experimental("ASPIREAZURE003")] to AzureRoleAssignmentResource. 3. Change EndpointReferenceAnnotation.EndpointNames from HashSet to ISet (backing field stays HashSet). 4. Add 'sealed' to new public resource classes that are not subclassed in the repo: KubernetesHelmChartResource, BlazorWasmAppResource, BunAppResource, NextJsAppResource, ViteAppResource, AzureNatGatewayResource, AzureNetworkSecurityGroupResource, AzureNetworkSecurityPerimeterResource, AzurePrivateEndpointResource, AzurePublicIPAddressResource, AzureSubnetResource, AzureVirtualNetworkResource. (GoAppResource and NodeAppResource left non-sealed because they are used as generic type constraints in the same assembly.) 5. Disambiguate WithHiddenOnCompletion overloads by removing the '= 0' default from the int overload, so calls with no argument resolve to the params overload. api/*.cs and api/*.ats.txt are intentionally not updated here - the API surface PR will regenerate them. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Revert breaking API changes; bump baseline to 13.3.5 Reverts the subset of API changes from the previous commit that would be binary-breaking against 13.3.5, and bumps PackageValidationBaselineVersion from 13.2.2 to 13.3.5 so pack validation runs against the latest shipped release. Reverts (binary-breaking against 13.3.5): - AllocatedEndpoint.NetworkID (kept ctor param 'networkId' - not breaking) - EndpointAnnotation.DefaultNetworkID (kept ctor param 'networkId') - EndpointReference.ContextNetworkID (kept ctor param 'contextNetworkId') - NetworkEndpointSnapshot.NetworkID record positional param - Removed 'sealed' from 9 shipped resource classes: - NextJsAppResource, ViteAppResource - AzureNatGatewayResource, AzureNetworkSecurityGroupResource, AzureNetworkSecurityPerimeterResource, AzurePrivateEndpointResource, AzurePublicIPAddressResource, AzureSubnetResource, AzureVirtualNetworkResource Kept (not binary-breaking): - All constructor/method parameter renames (networkID->networkId, etc.) - EndpointReferenceAnnotation.ContextNetworkId (new in 13.4) - EndpointNames type change (HashSet -> ISet) - WithHiddenOnCompletion overload disambiguation - [Experimental("ASPIREAZURE003")] on AzureRoleAssignmentResource - 'sealed' on KubernetesHelmChartResource, BlazorWasmAppResource, BunAppResource (new in 13.4) Package validation: - Bumped PackageValidationBaselineVersion 13.2.2 -> 13.3.5 - Regenerated CompatibilitySuppressions.xml in 4 projects: most legacy entries against 13.2.2 are no longer needed because those APIs already shipped in 13.3.x. The remaining suppression is PublishAsNpmScript in Aspire.Hosting.JavaScript (documented removal from PR #17382). --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> (cherry picked from commit da473d27fe558682bb716214ca154018cbb4b638) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CompatibilitySuppressions.xml | 38 +-------------- .../AzureRoleAssignmentResource.cs | 2 + .../CompatibilitySuppressions.xml | 17 +------ .../RoleAssignmentResourceAnnotation.cs | 2 + .../Resources/BlazorWasmAppResource.cs | 2 +- .../CompatibilitySuppressions.xml | 24 +--------- .../BunAppResource.cs | 2 +- .../CompatibilitySuppressions.xml | 11 +++++ .../KubernetesHelmChartResource.cs | 2 +- .../ApplicationModel/AllocatedEndpoint.cs | 6 +-- .../ApplicationModel/EndpointAnnotation.cs | 46 +++++++++---------- .../ApplicationModel/EndpointReference.cs | 18 ++++---- .../EndpointReferenceAnnotation.cs | 4 +- .../ApplicationModel/ResourceExtensions.cs | 14 +++--- .../ResourceUrlsCallbackContext.cs | 6 +-- .../CompatibilitySuppressions.xml | 31 +------------ src/Aspire.Hosting/Dcp/DcpModelUtilities.cs | 6 +-- .../ResourceBuilderExtensions.cs | 18 ++++---- src/Directory.Build.props | 2 +- src/Shared/StringComparers.cs | 4 +- .../AzurePostgresExtensionsTests.cs | 2 +- .../AzureUserAssignedIdentityTests.cs | 1 + .../RoleAssignmentTests.cs | 2 + .../AddNodeAppTests.cs | 2 +- .../AddMilvusTests.cs | 2 +- .../AddPostgresTests.cs | 2 +- .../PostgresMcpBuilderTests.cs | 2 +- .../AddQdrantTests.cs | 8 ++-- .../AddRedisTests.cs | 8 ++-- .../EndpointReferenceTests.cs | 2 +- .../WithEnvironmentTests.cs | 2 +- 31 files changed, 102 insertions(+), 186 deletions(-) create mode 100644 src/Aspire.Hosting.JavaScript/CompatibilitySuppressions.xml diff --git a/src/Aspire.Hosting.Azure.Network/CompatibilitySuppressions.xml b/src/Aspire.Hosting.Azure.Network/CompatibilitySuppressions.xml index 22fec018e7d..0497d618a92 100644 --- a/src/Aspire.Hosting.Azure.Network/CompatibilitySuppressions.xml +++ b/src/Aspire.Hosting.Azure.Network/CompatibilitySuppressions.xml @@ -1,39 +1,3 @@  - - - CP0002 - M:Aspire.Hosting.Azure.AzureNatGatewayResource.get_NameOutput - lib/net8.0/Aspire.Hosting.Azure.Network.dll - lib/net8.0/Aspire.Hosting.Azure.Network.dll - true - - - CP0002 - M:Aspire.Hosting.Azure.AzureNetworkSecurityGroupResource.get_NameOutput - lib/net8.0/Aspire.Hosting.Azure.Network.dll - lib/net8.0/Aspire.Hosting.Azure.Network.dll - true - - - CP0002 - M:Aspire.Hosting.Azure.AzurePrivateEndpointResource.get_NameOutput - lib/net8.0/Aspire.Hosting.Azure.Network.dll - lib/net8.0/Aspire.Hosting.Azure.Network.dll - true - - - CP0002 - M:Aspire.Hosting.Azure.AzurePublicIPAddressResource.get_NameOutput - lib/net8.0/Aspire.Hosting.Azure.Network.dll - lib/net8.0/Aspire.Hosting.Azure.Network.dll - true - - - CP0002 - M:Aspire.Hosting.Azure.AzureVirtualNetworkResource.get_NameOutput - lib/net8.0/Aspire.Hosting.Azure.Network.dll - lib/net8.0/Aspire.Hosting.Azure.Network.dll - true - - \ No newline at end of file + \ No newline at end of file diff --git a/src/Aspire.Hosting.Azure/AzureRoleAssignmentResource.cs b/src/Aspire.Hosting.Azure/AzureRoleAssignmentResource.cs index db0da95114b..fa14bc2273e 100644 --- a/src/Aspire.Hosting.Azure/AzureRoleAssignmentResource.cs +++ b/src/Aspire.Hosting.Azure/AzureRoleAssignmentResource.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics.CodeAnalysis; using Aspire.Hosting.ApplicationModel; namespace Aspire.Hosting.Azure; @@ -19,6 +20,7 @@ namespace Aspire.Hosting.Azure; /// The Aspire resource that owns this set of role assignments, or for global role assignments granted to the deployment principal. /// The user-assigned managed identity whose principal receives the role assignments, or for global role assignments granted to the deployment principal. /// Callback to configure the Azure role assignment resources. +[Experimental("ASPIREAZURE003", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] public sealed class AzureRoleAssignmentResource( string name, AzureProvisioningResource targetAzureResource, diff --git a/src/Aspire.Hosting.Azure/CompatibilitySuppressions.xml b/src/Aspire.Hosting.Azure/CompatibilitySuppressions.xml index 78cdfa201ac..0497d618a92 100644 --- a/src/Aspire.Hosting.Azure/CompatibilitySuppressions.xml +++ b/src/Aspire.Hosting.Azure/CompatibilitySuppressions.xml @@ -1,18 +1,3 @@  - - - CP0002 - M:Aspire.Hosting.Azure.IAzurePrivateEndpointTarget.GetPrivateDnsZoneName - lib/net8.0/Aspire.Hosting.Azure.dll - lib/net8.0/Aspire.Hosting.Azure.dll - true - - - CP0006 - M:Aspire.Hosting.Azure.IAzurePrivateEndpointTarget.GetPrivateDnsZoneNames - lib/net8.0/Aspire.Hosting.Azure.dll - lib/net8.0/Aspire.Hosting.Azure.dll - true - - \ No newline at end of file + \ No newline at end of file diff --git a/src/Aspire.Hosting.Azure/RoleAssignmentResourceAnnotation.cs b/src/Aspire.Hosting.Azure/RoleAssignmentResourceAnnotation.cs index 932becea274..a1f521c235a 100644 --- a/src/Aspire.Hosting.Azure/RoleAssignmentResourceAnnotation.cs +++ b/src/Aspire.Hosting.Azure/RoleAssignmentResourceAnnotation.cs @@ -8,6 +8,7 @@ namespace Aspire.Hosting.Azure; /// /// An annotation that points to the resource that contains the role assignments for an Azure resource. /// +#pragma warning disable ASPIREAZURE003 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. internal sealed class RoleAssignmentResourceAnnotation(AzureRoleAssignmentResource rolesResource) : IResourceAnnotation { /// @@ -15,3 +16,4 @@ internal sealed class RoleAssignmentResourceAnnotation(AzureRoleAssignmentResour /// public AzureRoleAssignmentResource RolesResource { get; } = rolesResource; } +#pragma warning restore ASPIREAZURE003 diff --git a/src/Aspire.Hosting.Blazor/Resources/BlazorWasmAppResource.cs b/src/Aspire.Hosting.Blazor/Resources/BlazorWasmAppResource.cs index a4210b92ba8..3a152f7927c 100644 --- a/src/Aspire.Hosting.Blazor/Resources/BlazorWasmAppResource.cs +++ b/src/Aspire.Hosting.Blazor/Resources/BlazorWasmAppResource.cs @@ -17,7 +17,7 @@ namespace Aspire.Hosting; /// [Experimental("ASPIREBLAZOR001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] [AspireExport] -public class BlazorWasmAppResource(string name, string projectPath) : Resource(name), IResourceWithEnvironment, IResourceWithParent +public sealed class BlazorWasmAppResource(string name, string projectPath) : Resource(name), IResourceWithEnvironment, IResourceWithParent { /// Fully-qualified path to the .csproj file. public string ProjectPath { get; } = projectPath; diff --git a/src/Aspire.Hosting.Docker/CompatibilitySuppressions.xml b/src/Aspire.Hosting.Docker/CompatibilitySuppressions.xml index bfdd2c52258..0497d618a92 100644 --- a/src/Aspire.Hosting.Docker/CompatibilitySuppressions.xml +++ b/src/Aspire.Hosting.Docker/CompatibilitySuppressions.xml @@ -1,25 +1,3 @@  - - - CP0002 - M:Aspire.Hosting.Docker.Resources.ServiceNodes.Swarm.UpdateConfig.get_FailOnError - lib/net8.0/Aspire.Hosting.Docker.dll - lib/net8.0/Aspire.Hosting.Docker.dll - true - - - CP0002 - M:Aspire.Hosting.Docker.Resources.ServiceNodes.Swarm.UpdateConfig.get_Parallelism - lib/net8.0/Aspire.Hosting.Docker.dll - lib/net8.0/Aspire.Hosting.Docker.dll - true - - - CP0002 - M:Aspire.Hosting.Docker.Resources.ServiceNodes.Swarm.UpdateConfig.set_FailOnError(System.Nullable{System.Boolean}) - lib/net8.0/Aspire.Hosting.Docker.dll - lib/net8.0/Aspire.Hosting.Docker.dll - true - - \ No newline at end of file + \ No newline at end of file diff --git a/src/Aspire.Hosting.JavaScript/BunAppResource.cs b/src/Aspire.Hosting.JavaScript/BunAppResource.cs index 1c553fea982..866a751efb8 100644 --- a/src/Aspire.Hosting.JavaScript/BunAppResource.cs +++ b/src/Aspire.Hosting.JavaScript/BunAppResource.cs @@ -12,5 +12,5 @@ namespace Aspire.Hosting.JavaScript; /// The command to execute. /// The working directory to use for the command. [AspireExport(ExposeProperties = true)] -public class BunAppResource(string name, string command, string workingDirectory) +public sealed class BunAppResource(string name, string command, string workingDirectory) : JavaScriptAppResource(name, command, workingDirectory), IResourceWithServiceDiscovery, IContainerFilesDestinationResource; diff --git a/src/Aspire.Hosting.JavaScript/CompatibilitySuppressions.xml b/src/Aspire.Hosting.JavaScript/CompatibilitySuppressions.xml new file mode 100644 index 00000000000..bfacb9bb9b6 --- /dev/null +++ b/src/Aspire.Hosting.JavaScript/CompatibilitySuppressions.xml @@ -0,0 +1,11 @@ + + + + + CP0002 + M:Aspire.Hosting.JavaScriptHostingExtensions.PublishAsNpmScript``1(Aspire.Hosting.ApplicationModel.IResourceBuilder{``0},System.String,System.String) + lib/net8.0/Aspire.Hosting.JavaScript.dll + lib/net8.0/Aspire.Hosting.JavaScript.dll + true + + \ No newline at end of file diff --git a/src/Aspire.Hosting.Kubernetes/KubernetesHelmChartResource.cs b/src/Aspire.Hosting.Kubernetes/KubernetesHelmChartResource.cs index 3580a361116..2c31a784a40 100644 --- a/src/Aspire.Hosting.Kubernetes/KubernetesHelmChartResource.cs +++ b/src/Aspire.Hosting.Kubernetes/KubernetesHelmChartResource.cs @@ -41,7 +41,7 @@ namespace Aspire.Hosting.Kubernetes; /// /// [AspireExport] -public class KubernetesHelmChartResource : Resource, IResourceWithParent +public sealed class KubernetesHelmChartResource : Resource, IResourceWithParent { /// /// Initializes a new instance of . diff --git a/src/Aspire.Hosting/ApplicationModel/AllocatedEndpoint.cs b/src/Aspire.Hosting/ApplicationModel/AllocatedEndpoint.cs index 8761dd98dab..40e6c181cac 100644 --- a/src/Aspire.Hosting/ApplicationModel/AllocatedEndpoint.cs +++ b/src/Aspire.Hosting/ApplicationModel/AllocatedEndpoint.cs @@ -45,13 +45,13 @@ public class AllocatedEndpoint /// The port number of the endpoint. /// A string representing how to retrieve the target port of the instance. /// The binding mode of the endpoint. - /// The network identifier for the network associated with the endpoint. + /// The network identifier for the network associated with the endpoint. public AllocatedEndpoint( EndpointAnnotation endpoint, string address, int port, EndpointBindingMode bindingMode, string? targetPortExpression = null, - NetworkIdentifier? networkID = null + NetworkIdentifier? networkId = null ) { ArgumentNullException.ThrowIfNull(endpoint); @@ -63,7 +63,7 @@ public AllocatedEndpoint( BindingMode = bindingMode; Port = port; TargetPortExpression = targetPortExpression; - NetworkID = networkID ?? endpoint.DefaultNetworkID; + NetworkID = networkId ?? endpoint.DefaultNetworkID; } /// diff --git a/src/Aspire.Hosting/ApplicationModel/EndpointAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/EndpointAnnotation.cs index 3d1cbc9db7c..354499973c1 100644 --- a/src/Aspire.Hosting/ApplicationModel/EndpointAnnotation.cs +++ b/src/Aspire.Hosting/ApplicationModel/EndpointAnnotation.cs @@ -25,7 +25,7 @@ public sealed class EndpointAnnotation : IResourceAnnotation private bool? _tlsEnabled; private bool _isProxied = true; private bool? _isExplicitlyProxied; - private readonly NetworkIdentifier _networkID; + private readonly NetworkIdentifier _networkId; /// /// Initializes a new instance of . @@ -97,7 +97,7 @@ bool isProxied /// Initializes a new instance of . /// /// Network protocol: TCP or UDP are supported today, others possibly in future. - /// The ID of the network that is the "default" network for the Endpoint. + /// The ID of the network that is the "default" network for the Endpoint. /// Clients connected to the same network can reach the endpoint without any routing or network address translation. /// If a service is URI-addressable, this is the URI scheme to use for constructing service URI. /// Transport that is being used (e.g. http, http2, http3 etc). @@ -108,7 +108,7 @@ bool isProxied /// Specifies if the endpoint will be proxied by DCP. Defaults to . public EndpointAnnotation( ProtocolType protocol, - NetworkIdentifier? networkID, + NetworkIdentifier? networkId, string? uriScheme = null, string? transport = null, [EndpointName] string? name = null, @@ -136,9 +136,9 @@ public EndpointAnnotation( _targetPort = targetPort; IsExternal = isExternal ?? false; IsExplicitlyProxied = isProxied; - _networkID = networkID ?? KnownNetworkIdentifiers.LocalhostNetwork; + _networkId = networkId ?? KnownNetworkIdentifiers.LocalhostNetwork; #pragma warning disable CS0618 // Type or member is obsolete - AllAllocatedEndpoints.TryAdd(_networkID, AllocatedEndpointSnapshot); + AllAllocatedEndpoints.TryAdd(_networkId, AllocatedEndpointSnapshot); #pragma warning restore CS0618 // Type or member is obsolete } @@ -146,7 +146,7 @@ public EndpointAnnotation( /// Initializes a new instance of . /// /// Network protocol: TCP or UDP are supported today, others possibly in future. - /// The ID of the network that is the "default" network for the Endpoint. + /// The ID of the network that is the "default" network for the Endpoint. /// Clients connected to the same network can reach the endpoint without any routing or network address translation. /// If a service is URI-addressable, this is the URI scheme to use for constructing service URI. /// Transport that is being used (e.g. http, http2, http3 etc). @@ -157,7 +157,7 @@ public EndpointAnnotation( /// Specifies if the endpoint will be proxied by DCP. public EndpointAnnotation( ProtocolType protocol, - NetworkIdentifier? networkID, + NetworkIdentifier? networkId, string? uriScheme, string? transport, [EndpointName] string? name, @@ -167,7 +167,7 @@ public EndpointAnnotation( bool isProxied ) : this( protocol, - networkID, + networkId, uriScheme, transport, name, @@ -334,7 +334,7 @@ public bool TlsEnabled /// /// Gets the ID of the network that is the "default" network for the Endpoint (the one the Endpoint is associated with and can be reached without routing or network address translation). /// - public NetworkIdentifier DefaultNetworkID => _networkID; + public NetworkIdentifier DefaultNetworkID => _networkId; /// /// Gets or sets the default for this Endpoint. @@ -364,9 +364,9 @@ public AllocatedEndpoint? AllocatedEndpoint } else { - if (_networkID != value.NetworkID) + if (_networkId != value.NetworkID) { - throw new InvalidOperationException($"The default AllocatedEndpoint's network ID must match the EndpointAnnotation network ID ('{_networkID}'). The attempted AllocatedEndpoint belongs to '{value.NetworkID}'."); + throw new InvalidOperationException($"The default AllocatedEndpoint's network ID must match the EndpointAnnotation network ID ('{_networkId}'). The attempted AllocatedEndpoint belongs to '{value.NetworkID}'."); } AllocatedEndpointSnapshot.SetValue(value); } @@ -419,15 +419,15 @@ IEnumerator IEnumerable.GetEnumerator() /// Adds an AllocatedEndpoint snapshot for a specific network if one does not already exist. /// [Obsolete("This method is for internal use only and will be marked internal in a future Aspire release. Use AddOrUpdateAllocatedEndpoint instead.")] - public bool TryAdd(NetworkIdentifier networkID, ValueSnapshot snapshot) + public bool TryAdd(NetworkIdentifier networkId, ValueSnapshot snapshot) { lock (_snapshots) { - if (_snapshots.Any(s => s.NetworkID.Equals(networkID))) + if (_snapshots.Any(s => s.NetworkID.Equals(networkId))) { return false; } - _snapshots.Add(new NetworkEndpointSnapshot(snapshot, networkID)); + _snapshots.Add(new NetworkEndpointSnapshot(snapshot, networkId)); return true; } } @@ -435,33 +435,33 @@ public bool TryAdd(NetworkIdentifier networkID, ValueSnapshot /// /// Adds or updates an AllocatedEndpoint value associated with a specific network in the snapshot list. /// - public void AddOrUpdateAllocatedEndpoint(NetworkIdentifier networkID, AllocatedEndpoint endpoint) + public void AddOrUpdateAllocatedEndpoint(NetworkIdentifier networkId, AllocatedEndpoint endpoint) { - if (endpoint.NetworkID != networkID) + if (endpoint.NetworkID != networkId) { - throw new ArgumentException($"AllocatedEndpoint must use the same network as the {nameof(networkID)} parameter", nameof(endpoint)); + throw new ArgumentException($"AllocatedEndpoint must use the same network as the {nameof(networkId)} parameter", nameof(endpoint)); } - var nes = GetSnapshotFor(networkID); + var nes = GetSnapshotFor(networkId); nes.Snapshot.SetValue(endpoint); } /// /// Gets an AllocatedEndpoint for a given network ID, waiting for it to appear if it is not already present. /// - public Task GetAllocatedEndpointAsync(NetworkIdentifier networkID, CancellationToken cancellationToken = default) + public Task GetAllocatedEndpointAsync(NetworkIdentifier networkId, CancellationToken cancellationToken = default) { - var nes = GetSnapshotFor(networkID); + var nes = GetSnapshotFor(networkId); return nes.Snapshot.GetValueAsync(cancellationToken); } - private NetworkEndpointSnapshot GetSnapshotFor(NetworkIdentifier networkID) + private NetworkEndpointSnapshot GetSnapshotFor(NetworkIdentifier networkId) { lock (_snapshots) { - var nes = _snapshots.FirstOrDefault(s => s.NetworkID.Equals(networkID)); + var nes = _snapshots.FirstOrDefault(s => s.NetworkID.Equals(networkId)); if (nes is null) { - nes = new NetworkEndpointSnapshot(new ValueSnapshot(), networkID); + nes = new NetworkEndpointSnapshot(new ValueSnapshot(), networkId); _snapshots.Add(nes); } return nes; diff --git a/src/Aspire.Hosting/ApplicationModel/EndpointReference.cs b/src/Aspire.Hosting/ApplicationModel/EndpointReference.cs index 6ae84bf893d..38bf5581644 100644 --- a/src/Aspire.Hosting/ApplicationModel/EndpointReference.cs +++ b/src/Aspire.Hosting/ApplicationModel/EndpointReference.cs @@ -16,7 +16,7 @@ public sealed class EndpointReference : IExpressionValue, IManifestExpressionPro // A reference to the endpoint annotation if it exists. private EndpointAnnotation? _endpointAnnotation; private bool? _isAllocated; - private readonly NetworkIdentifier? _contextNetworkID; + private readonly NetworkIdentifier? _contextNetworkId; /// /// Gets the endpoint annotation associated with the endpoint reference. @@ -129,7 +129,7 @@ private string BuildMissingEndpointMessage() /// The reference will be resolved in the context of this network, which may be different /// from the network associated with the default network of the referenced Endpoint. /// - public NetworkIdentifier? ContextNetworkID => _contextNetworkID; + public NetworkIdentifier? ContextNetworkID => _contextNetworkId; /// /// Gets the specified property expression of the endpoint. @@ -244,7 +244,7 @@ public ReferenceExpression GetTlsValue(ReferenceExpression enabledValue, Referen foreach (var nes in endpointAnnotation.AllAllocatedEndpoints) { - if (string.Equals(nes.NetworkID.Value, (_contextNetworkID ?? KnownNetworkIdentifiers.LocalhostNetwork).Value, StringComparisons.NetworkID)) + if (string.Equals(nes.NetworkID.Value, (_contextNetworkId ?? KnownNetworkIdentifiers.LocalhostNetwork).Value, StringComparisons.NetworkId)) { if (!nes.Snapshot.IsValueSet) { @@ -263,14 +263,14 @@ public ReferenceExpression GetTlsValue(ReferenceExpression enabledValue, Referen /// /// The resource with endpoints that owns the referenced endpoint. /// The endpoint annotation. - /// The ID of the network that serves as the context for the EndpointReference. + /// The ID of the network that serves as the context for the EndpointReference. /// /// Most Aspire resources are accessed in the context of the "localhost" network (host processes calling other host processes, /// or host processes calling container via mapped ports). If a is specified, the /// will always resolve in the context of that network. If the is null, the reference will attempt to resolve itself /// based on the context of the requesting resource. /// - public EndpointReference(IResourceWithEndpoints owner, EndpointAnnotation endpoint, NetworkIdentifier? contextNetworkID) + public EndpointReference(IResourceWithEndpoints owner, EndpointAnnotation endpoint, NetworkIdentifier? contextNetworkId) { ArgumentNullException.ThrowIfNull(owner); ArgumentNullException.ThrowIfNull(endpoint); @@ -278,7 +278,7 @@ public EndpointReference(IResourceWithEndpoints owner, EndpointAnnotation endpoi Resource = owner; EndpointName = endpoint.Name; _endpointAnnotation = endpoint; - _contextNetworkID = contextNetworkID; + _contextNetworkId = contextNetworkId; } /// @@ -295,21 +295,21 @@ public EndpointReference(IResourceWithEndpoints owner, EndpointAnnotation endpoi /// /// The resource with endpoints that owns the referenced endpoint. /// The name of the endpoint. - /// The ID of the network that serves as the context for the EndpointReference. + /// The ID of the network that serves as the context for the EndpointReference. /// /// Most Aspire resources are accessed in the context of the "localhost" network (host proceses calling other host processes, /// or host processes calling container via mapped ports). This is why EndpointReference assumes this /// context unless specified otherwise. However, for container-to-container, or container-to-host communication, /// you must specify a container network context for the EndpointReference to be resolved correctly. /// - public EndpointReference(IResourceWithEndpoints owner, string endpointName, NetworkIdentifier? contextNetworkID = null) + public EndpointReference(IResourceWithEndpoints owner, string endpointName, NetworkIdentifier? contextNetworkId = null) { ArgumentNullException.ThrowIfNull(owner); ArgumentNullException.ThrowIfNull(endpointName); Resource = owner; EndpointName = endpointName; - _contextNetworkID = contextNetworkID; + _contextNetworkId = contextNetworkId; } /// diff --git a/src/Aspire.Hosting/ApplicationModel/EndpointReferenceAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/EndpointReferenceAnnotation.cs index beb2c2fc1d3..ef3eae38416 100644 --- a/src/Aspire.Hosting/ApplicationModel/EndpointReferenceAnnotation.cs +++ b/src/Aspire.Hosting/ApplicationModel/EndpointReferenceAnnotation.cs @@ -24,10 +24,10 @@ public sealed class EndpointReferenceAnnotation(IResourceWithEndpoints resource) /// /// Gets the set of specific endpoint names that are referenced. When is , this set is ignored. /// - public HashSet EndpointNames { get; } = new(StringComparers.EndpointAnnotationName); + public ISet EndpointNames { get; } = new HashSet(StringComparers.EndpointAnnotationName); /// /// Gets or sets the network identifier used as context for resolving endpoint addresses. /// - public NetworkIdentifier ContextNetworkID { get; set; } = KnownNetworkIdentifiers.LocalhostNetwork; + public NetworkIdentifier ContextNetworkId { get; set; } = KnownNetworkIdentifiers.LocalhostNetwork; } diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs b/src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs index ee946810e99..8533253d008 100644 --- a/src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs +++ b/src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs @@ -721,14 +721,14 @@ public static IEnumerable GetEndpoints(this IResourceWithEndp /// Gets references to all endpoints for the specified resource. /// /// The which contains annotations. - /// The ID of the network that serves as the context context for the endpoint references. + /// The ID of the network that serves as the context context for the endpoint references. /// An enumeration of based on the annotations from the resources' collection. [AspireExportIgnore(Reason = "Network-specific endpoint enumeration is not part of the ATS surface.")] - public static IEnumerable GetEndpoints(this IResourceWithEndpoints resource, NetworkIdentifier contextNetworkID) + public static IEnumerable GetEndpoints(this IResourceWithEndpoints resource, NetworkIdentifier contextNetworkId) { if (TryGetAnnotationsOfType(resource, out var endpoints)) { - return endpoints.Select(e => new EndpointReference(resource, e, contextNetworkID)); + return endpoints.Select(e => new EndpointReference(resource, e, contextNetworkId)); } return []; @@ -761,10 +761,10 @@ public static EndpointReference GetEndpoint(this IResourceWithEndpoints resource /// /// The which contains annotations. /// The name of the endpoint. - /// The network ID of the network that provides the context for the returned + /// The network ID of the network that provides the context for the returned /// An object providing resolvable reference for the specified endpoint. [AspireExportIgnore(Reason = "Network-specific endpoint lookup is not part of the ATS surface.")] - public static EndpointReference GetEndpoint(this IResourceWithEndpoints resource, string endpointName, NetworkIdentifier contextNetworkID) + public static EndpointReference GetEndpoint(this IResourceWithEndpoints resource, string endpointName, NetworkIdentifier contextNetworkId) { var endpoint = resource.TryGetEndpoints(out var endpoints) ? @@ -772,11 +772,11 @@ public static EndpointReference GetEndpoint(this IResourceWithEndpoints resource null; if (endpoint is null) { - return new EndpointReference(resource, endpointName, contextNetworkID); + return new EndpointReference(resource, endpointName, contextNetworkId); } else { - return new EndpointReference(resource, endpoint, contextNetworkID); + return new EndpointReference(resource, endpoint, contextNetworkId); } } diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceUrlsCallbackContext.cs b/src/Aspire.Hosting/ApplicationModel/ResourceUrlsCallbackContext.cs index 56c699365e3..b36abae189b 100644 --- a/src/Aspire.Hosting/ApplicationModel/ResourceUrlsCallbackContext.cs +++ b/src/Aspire.Hosting/ApplicationModel/ResourceUrlsCallbackContext.cs @@ -41,11 +41,11 @@ public class ResourceUrlsCallbackContext(DistributedApplicationExecutionContext /// If does not implement then returns null. /// /// The name of the endpoint. - /// The identifier of the network that serves as the context for the endpoint reference. - public EndpointReference? GetEndpoint(string name, NetworkIdentifier contextNetworkID) => + /// The identifier of the network that serves as the context for the endpoint reference. + public EndpointReference? GetEndpoint(string name, NetworkIdentifier contextNetworkId) => Resource switch { - IResourceWithEndpoints resourceWithEndpoints => resourceWithEndpoints.GetEndpoint(name, contextNetworkID), + IResourceWithEndpoints resourceWithEndpoints => resourceWithEndpoints.GetEndpoint(name, contextNetworkId), _ => null }; diff --git a/src/Aspire.Hosting/CompatibilitySuppressions.xml b/src/Aspire.Hosting/CompatibilitySuppressions.xml index 8ecd3607985..0497d618a92 100644 --- a/src/Aspire.Hosting/CompatibilitySuppressions.xml +++ b/src/Aspire.Hosting/CompatibilitySuppressions.xml @@ -1,32 +1,3 @@  - - - CP0006 - M:Aspire.Hosting.Pipelines.IDeploymentStateManager.ClearAllStateAsync(System.Threading.CancellationToken) - lib/net8.0/Aspire.Hosting.dll - lib/net8.0/Aspire.Hosting.dll - true - - - CP0006 - M:Aspire.Hosting.Publishing.IContainerRuntime.ComposeDownAsync(Aspire.Hosting.Publishing.ComposeOperationContext,System.Threading.CancellationToken) - lib/net8.0/Aspire.Hosting.dll - lib/net8.0/Aspire.Hosting.dll - true - - - CP0006 - M:Aspire.Hosting.Publishing.IContainerRuntime.ComposeListServicesAsync(Aspire.Hosting.Publishing.ComposeOperationContext,System.Threading.CancellationToken) - lib/net8.0/Aspire.Hosting.dll - lib/net8.0/Aspire.Hosting.dll - true - - - CP0006 - M:Aspire.Hosting.Publishing.IContainerRuntime.ComposeUpAsync(Aspire.Hosting.Publishing.ComposeOperationContext,System.Threading.CancellationToken) - lib/net8.0/Aspire.Hosting.dll - lib/net8.0/Aspire.Hosting.dll - true - - + \ No newline at end of file diff --git a/src/Aspire.Hosting/Dcp/DcpModelUtilities.cs b/src/Aspire.Hosting/Dcp/DcpModelUtilities.cs index 6801c2b6768..f0f8171ffa0 100644 --- a/src/Aspire.Hosting/Dcp/DcpModelUtilities.cs +++ b/src/Aspire.Hosting/Dcp/DcpModelUtilities.cs @@ -312,7 +312,7 @@ ts.Service is not null && throw new InvalidDataException($"The '{endpoint.Name}' on resource '{ts.ResourceName}' should have an associated DCP Service resource already set up"); } - var networkID = new NetworkIdentifier(ts.ContainerNetworkName!); + var networkId = new NetworkIdentifier(ts.ContainerNetworkName!); var address = string.IsNullOrEmpty(ts.TunnelInstanceName) ? containerHostName : KnownHostNames.DefaultContainerTunnelHostName; var port = (int)ts.Service!.AllocatedPort!; @@ -322,9 +322,9 @@ ts.Service is not null && port, EndpointBindingMode.SingleAddress, targetPortExpression: $$$"""{{- portForServing "{{{ts.Service.Metadata.Name}}}" -}}""", - networkID + networkId ); - endpoint.AllAllocatedEndpoints.AddOrUpdateAllocatedEndpoint(networkID, tunnelAllocatedEndpoint); + endpoint.AllAllocatedEndpoints.AddOrUpdateAllocatedEndpoint(networkId, tunnelAllocatedEndpoint); } } } diff --git a/src/Aspire.Hosting/ResourceBuilderExtensions.cs b/src/Aspire.Hosting/ResourceBuilderExtensions.cs index 7eafe2ee5a0..d583bc09143 100644 --- a/src/Aspire.Hosting/ResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ResourceBuilderExtensions.cs @@ -812,7 +812,7 @@ private static Action CreateEndpointReferenceEnviron // Track per-scheme index for service discovery keys to handle multiple endpoints with the same scheme. var schemeIndexTracker = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var endpoint in annotation.Resource.GetEndpoints(annotation.ContextNetworkID)) + foreach (var endpoint in annotation.Resource.GetEndpoints(annotation.ContextNetworkId)) { if (specificEndpointName != null && !string.Equals(endpoint.EndpointName, specificEndpointName, StringComparison.OrdinalIgnoreCase)) { @@ -1407,7 +1407,7 @@ private static void ApplyEndpoints(this IResourceBuilder builder, IResourc endpointReferenceAnnotation = new EndpointReferenceAnnotation(resourceWithEndpoints); if (builder.Resource.IsContainer()) { - endpointReferenceAnnotation.ContextNetworkID = KnownNetworkIdentifiers.DefaultAspireContainerNetwork; + endpointReferenceAnnotation.ContextNetworkId = KnownNetworkIdentifiers.DefaultAspireContainerNetwork; } builder.WithAnnotation(endpointReferenceAnnotation); @@ -1482,7 +1482,7 @@ public static IResourceBuilder WithEndpoint(this IResourceBuilder build // can also be resolved in the context of container-to-container communication by using the target port // and the container name as the host. This is why we only set the context network to localhost, // for both container and non-container resources. - endpoint = new EndpointAnnotation(ProtocolType.Tcp, name: endpointName, networkID: KnownNetworkIdentifiers.LocalhostNetwork); + endpoint = new EndpointAnnotation(ProtocolType.Tcp, name: endpointName, networkId: KnownNetworkIdentifiers.LocalhostNetwork); callback(endpoint); builder.Resource.Annotations.Add(endpoint); } @@ -1612,7 +1612,7 @@ public static IResourceBuilder WithEndpoint(this IResourceBuilder build targetPort: targetPort, isExternal: isExternal, isProxied: isProxied, - networkID: KnownNetworkIdentifiers.LocalhostNetwork); + networkId: KnownNetworkIdentifiers.LocalhostNetwork); ConfigureEndpointEnvironmentVariable(builder, annotation, env); @@ -1885,15 +1885,15 @@ public static IResourceBuilder WithExternalHttpEndpoints(this IResourceBui /// The resource type. /// The the resource builder. /// The name of the endpoint. - /// The network context in which to resolve the endpoint. If null, localhost (loopback) network context will be used. + /// The network context in which to resolve the endpoint. If null, localhost (loopback) network context will be used. /// An that can be used to resolve the address of the endpoint after resource allocation has occurred. /// This method is not available in polyglot app hosts. Use the overload without NetworkIdentifier instead. [AspireExportIgnore(Reason = "NetworkIdentifier is not ATS-compatible.")] - public static EndpointReference GetEndpoint(this IResourceBuilder builder, [EndpointName] string name, NetworkIdentifier contextNetworkID) where T : IResourceWithEndpoints + public static EndpointReference GetEndpoint(this IResourceBuilder builder, [EndpointName] string name, NetworkIdentifier contextNetworkId) where T : IResourceWithEndpoints { ArgumentNullException.ThrowIfNull(builder); - return builder.Resource.GetEndpoint(name, contextNetworkID); + return builder.Resource.GetEndpoint(name, contextNetworkId); } /// @@ -4888,7 +4888,7 @@ public static IResourceBuilder WithHidden(this IResourceBuilder builder /// /// The resource type. /// The resource builder. - /// The completion exit code to treat as successful. Defaults to 0. + /// The completion exit code to treat as successful. /// The . /// /// This method is useful for one-off resources such as setup scripts, migrations, or build steps that should remain visible while running @@ -4896,7 +4896,7 @@ public static IResourceBuilder WithHidden(this IResourceBuilder builder /// Hidden resources can still be accessed directly by their name, by using Show hidden resources toggle in the dashboard or by using aspire describe --include-hidden from the CLI. /// [AspireExportIgnore(Reason = "Use ATS-friendly overload that supports a single exit code or multiple exit codes.")] - public static IResourceBuilder WithHiddenOnCompletion(this IResourceBuilder builder, int exitCode = 0) where T : IResource + public static IResourceBuilder WithHiddenOnCompletion(this IResourceBuilder builder, int exitCode) where T : IResource { ArgumentNullException.ThrowIfNull(builder); diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 95bf20eb97a..aee31ca53ab 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -4,7 +4,7 @@ true true - 13.2.2 + 13.3.5 diff --git a/src/Shared/StringComparers.cs b/src/Shared/StringComparers.cs index 41ef4ed1f9a..f57c5542866 100644 --- a/src/Shared/StringComparers.cs +++ b/src/Shared/StringComparers.cs @@ -32,7 +32,7 @@ internal static class StringComparers public static StringComparer CommandName => StringComparer.Ordinal; public static StringComparer CliInputOrOutput => StringComparer.Ordinal; public static StringComparer InteractionInputName => StringComparer.OrdinalIgnoreCase; - public static StringComparer NetworkID => StringComparer.Ordinal; + public static StringComparer NetworkId => StringComparer.Ordinal; public static StringComparer NuGetPackageId => StringComparer.OrdinalIgnoreCase; public static StringComparer FullTextSearch => StringComparer.OrdinalIgnoreCase; public static StringComparer ChannelName => StringComparer.OrdinalIgnoreCase; @@ -65,7 +65,7 @@ internal static class StringComparisons public static StringComparison CommandName => StringComparison.Ordinal; public static StringComparison CliInputOrOutput => StringComparison.Ordinal; public static StringComparison InteractionInputName => StringComparison.OrdinalIgnoreCase; - public static StringComparison NetworkID => StringComparison.Ordinal; + public static StringComparison NetworkId => StringComparison.Ordinal; public static StringComparison NuGetPackageId => StringComparison.OrdinalIgnoreCase; public static StringComparison FullTextSearch => StringComparison.OrdinalIgnoreCase; public static StringComparison ChannelName => StringComparison.OrdinalIgnoreCase; diff --git a/tests/Aspire.Hosting.Azure.Tests/AzurePostgresExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzurePostgresExtensionsTests.cs index f0b79fa665d..01062779b87 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzurePostgresExtensionsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzurePostgresExtensionsTests.cs @@ -371,7 +371,7 @@ public async Task WithPostgresMcpOnAzureDatabaseRunAsContainerAddsMcpResource() c.WithEndpoint("tcp", e => { e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 5432); - e.AllAllocatedEndpoints.AddOrUpdateAllocatedEndpoint(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, new AllocatedEndpoint(e, "postgres.dev.internal", 5432, EndpointBindingMode.SingleAddress, targetPortExpression: null, networkID: KnownNetworkIdentifiers.DefaultAspireContainerNetwork)); + e.AllAllocatedEndpoints.AddOrUpdateAllocatedEndpoint(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, new AllocatedEndpoint(e, "postgres.dev.internal", 5432, EndpointBindingMode.SingleAddress, targetPortExpression: null, networkId: KnownNetworkIdentifiers.DefaultAspireContainerNetwork)); }); }); diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureUserAssignedIdentityTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureUserAssignedIdentityTests.cs index 0710044f720..d9124efcf24 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureUserAssignedIdentityTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureUserAssignedIdentityTests.cs @@ -1,4 +1,5 @@ #pragma warning disable ASPIREAZURE001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. +#pragma warning disable ASPIREAZURE003 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. diff --git a/tests/Aspire.Hosting.Azure.Tests/RoleAssignmentTests.cs b/tests/Aspire.Hosting.Azure.Tests/RoleAssignmentTests.cs index 4bde4eea3ce..95fad316307 100644 --- a/tests/Aspire.Hosting.Azure.Tests/RoleAssignmentTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/RoleAssignmentTests.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +#pragma warning disable ASPIREAZURE003 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + using System.Text.Json.Nodes; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Utils; diff --git a/tests/Aspire.Hosting.JavaScript.Tests/AddNodeAppTests.cs b/tests/Aspire.Hosting.JavaScript.Tests/AddNodeAppTests.cs index 7d5c7449d1a..4bf0292ed27 100644 --- a/tests/Aspire.Hosting.JavaScript.Tests/AddNodeAppTests.cs +++ b/tests/Aspire.Hosting.JavaScript.Tests/AddNodeAppTests.cs @@ -564,7 +564,7 @@ public async Task WithReferenceDispatchesNodeAppServiceReference() .WithEndpoint("http", e => { e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 5031); - e.AllAllocatedEndpoints.AddOrUpdateAllocatedEndpoint(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, new AllocatedEndpoint(e, "nodeapp.dev.internal", 5031, EndpointBindingMode.SingleAddress, targetPortExpression: null, networkID: KnownNetworkIdentifiers.DefaultAspireContainerNetwork)); + e.AllAllocatedEndpoints.AddOrUpdateAllocatedEndpoint(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, new AllocatedEndpoint(e, "nodeapp.dev.internal", 5031, EndpointBindingMode.SingleAddress, targetPortExpression: null, networkId: KnownNetworkIdentifiers.DefaultAspireContainerNetwork)); }); var consumer = builder.AddContainer("consumer", "fake"); diff --git a/tests/Aspire.Hosting.Milvus.Tests/AddMilvusTests.cs b/tests/Aspire.Hosting.Milvus.Tests/AddMilvusTests.cs index 6eb39f1c5c0..723a0b01480 100644 --- a/tests/Aspire.Hosting.Milvus.Tests/AddMilvusTests.cs +++ b/tests/Aspire.Hosting.Milvus.Tests/AddMilvusTests.cs @@ -99,7 +99,7 @@ public async Task MilvusClientAppWithReferenceContainsConnectionStrings() .WithEndpoint("grpc", e => { e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", MilvusPortGrpc); - e.AllAllocatedEndpoints.AddOrUpdateAllocatedEndpoint(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, new AllocatedEndpoint(e, "my-milvus.dev.internal", MilvusPortGrpc, EndpointBindingMode.SingleAddress, targetPortExpression: null, networkID: KnownNetworkIdentifiers.DefaultAspireContainerNetwork)); + e.AllAllocatedEndpoints.AddOrUpdateAllocatedEndpoint(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, new AllocatedEndpoint(e, "my-milvus.dev.internal", MilvusPortGrpc, EndpointBindingMode.SingleAddress, targetPortExpression: null, networkId: KnownNetworkIdentifiers.DefaultAspireContainerNetwork)); }); var projectA = appBuilder.AddProject("projecta", o => o.ExcludeLaunchProfile = true) diff --git a/tests/Aspire.Hosting.PostgreSQL.Tests/AddPostgresTests.cs b/tests/Aspire.Hosting.PostgreSQL.Tests/AddPostgresTests.cs index fd64a45cb51..dcae09ba2b7 100644 --- a/tests/Aspire.Hosting.PostgreSQL.Tests/AddPostgresTests.cs +++ b/tests/Aspire.Hosting.PostgreSQL.Tests/AddPostgresTests.cs @@ -195,7 +195,7 @@ public async Task WithReferenceDispatchesPostgresDatabaseReference() .WithEndpoint("tcp", e => { e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 2000); - e.AllAllocatedEndpoints.AddOrUpdateAllocatedEndpoint(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, new AllocatedEndpoint(e, "postgres.dev.internal", 2000, EndpointBindingMode.SingleAddress, targetPortExpression: null, networkID: KnownNetworkIdentifiers.DefaultAspireContainerNetwork)); + e.AllAllocatedEndpoints.AddOrUpdateAllocatedEndpoint(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, new AllocatedEndpoint(e, "postgres.dev.internal", 2000, EndpointBindingMode.SingleAddress, targetPortExpression: null, networkId: KnownNetworkIdentifiers.DefaultAspireContainerNetwork)); }); var database = postgres.AddDatabase("db"); var consumer = appBuilder.AddContainer("consumer", "fake"); diff --git a/tests/Aspire.Hosting.PostgreSQL.Tests/PostgresMcpBuilderTests.cs b/tests/Aspire.Hosting.PostgreSQL.Tests/PostgresMcpBuilderTests.cs index 987bc894af0..c68d64b347d 100644 --- a/tests/Aspire.Hosting.PostgreSQL.Tests/PostgresMcpBuilderTests.cs +++ b/tests/Aspire.Hosting.PostgreSQL.Tests/PostgresMcpBuilderTests.cs @@ -78,7 +78,7 @@ public async Task WithPostgresMcpOnDatabaseSetsDatabaseUriEnvironmentVariable() .WithEndpoint("tcp", e => { e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 5432); - e.AllAllocatedEndpoints.AddOrUpdateAllocatedEndpoint(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, new AllocatedEndpoint(e, "postgres.dev.internal", 5432, EndpointBindingMode.SingleAddress, targetPortExpression: null, networkID: KnownNetworkIdentifiers.DefaultAspireContainerNetwork)); + e.AllAllocatedEndpoints.AddOrUpdateAllocatedEndpoint(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, new AllocatedEndpoint(e, "postgres.dev.internal", 5432, EndpointBindingMode.SingleAddress, targetPortExpression: null, networkId: KnownNetworkIdentifiers.DefaultAspireContainerNetwork)); }) .AddDatabase("db") .WithPostgresMcp(); diff --git a/tests/Aspire.Hosting.Qdrant.Tests/AddQdrantTests.cs b/tests/Aspire.Hosting.Qdrant.Tests/AddQdrantTests.cs index f7d9bec19c2..0dd939873e8 100644 --- a/tests/Aspire.Hosting.Qdrant.Tests/AddQdrantTests.cs +++ b/tests/Aspire.Hosting.Qdrant.Tests/AddQdrantTests.cs @@ -179,12 +179,12 @@ public async Task QdrantClientAppWithReferenceContainsConnectionStrings() .WithEndpoint("grpc", e => { e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 6334); - e.AllAllocatedEndpoints.AddOrUpdateAllocatedEndpoint(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, new AllocatedEndpoint(e, "my-qdrant.dev.internal", 6334, EndpointBindingMode.SingleAddress, targetPortExpression: null, networkID: KnownNetworkIdentifiers.DefaultAspireContainerNetwork)); + e.AllAllocatedEndpoints.AddOrUpdateAllocatedEndpoint(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, new AllocatedEndpoint(e, "my-qdrant.dev.internal", 6334, EndpointBindingMode.SingleAddress, targetPortExpression: null, networkId: KnownNetworkIdentifiers.DefaultAspireContainerNetwork)); }) .WithEndpoint("http", e => { e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 6333); - e.AllAllocatedEndpoints.AddOrUpdateAllocatedEndpoint(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, new AllocatedEndpoint(e, "my-qdrant.dev.internal", 6333, EndpointBindingMode.SingleAddress, targetPortExpression: null, networkID: KnownNetworkIdentifiers.DefaultAspireContainerNetwork)); + e.AllAllocatedEndpoints.AddOrUpdateAllocatedEndpoint(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, new AllocatedEndpoint(e, "my-qdrant.dev.internal", 6333, EndpointBindingMode.SingleAddress, targetPortExpression: null, networkId: KnownNetworkIdentifiers.DefaultAspireContainerNetwork)); }); var projectA = appBuilder.AddProject("projecta", o => o.ExcludeLaunchProfile = true) @@ -223,12 +223,12 @@ public async Task WithReferenceDispatchesQdrantReference() .WithEndpoint("grpc", e => { e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 6334); - e.AllAllocatedEndpoints.AddOrUpdateAllocatedEndpoint(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, new AllocatedEndpoint(e, "my-qdrant.dev.internal", 6334, EndpointBindingMode.SingleAddress, targetPortExpression: null, networkID: KnownNetworkIdentifiers.DefaultAspireContainerNetwork)); + e.AllAllocatedEndpoints.AddOrUpdateAllocatedEndpoint(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, new AllocatedEndpoint(e, "my-qdrant.dev.internal", 6334, EndpointBindingMode.SingleAddress, targetPortExpression: null, networkId: KnownNetworkIdentifiers.DefaultAspireContainerNetwork)); }) .WithEndpoint("http", e => { e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 6333); - e.AllAllocatedEndpoints.AddOrUpdateAllocatedEndpoint(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, new AllocatedEndpoint(e, "my-qdrant.dev.internal", 6333, EndpointBindingMode.SingleAddress, targetPortExpression: null, networkID: KnownNetworkIdentifiers.DefaultAspireContainerNetwork)); + e.AllAllocatedEndpoints.AddOrUpdateAllocatedEndpoint(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, new AllocatedEndpoint(e, "my-qdrant.dev.internal", 6333, EndpointBindingMode.SingleAddress, targetPortExpression: null, networkId: KnownNetworkIdentifiers.DefaultAspireContainerNetwork)); }); var consumer = appBuilder.AddContainer("consumer", "fake"); diff --git a/tests/Aspire.Hosting.Redis.Tests/AddRedisTests.cs b/tests/Aspire.Hosting.Redis.Tests/AddRedisTests.cs index 6cfea90b8c9..22524207136 100644 --- a/tests/Aspire.Hosting.Redis.Tests/AddRedisTests.cs +++ b/tests/Aspire.Hosting.Redis.Tests/AddRedisTests.cs @@ -278,17 +278,17 @@ public async Task WithRedisInsightProducesCorrectEnvironmentVariables() redis1.WithEndpoint("tcp", e => { e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 5001); - e.AllAllocatedEndpoints.AddOrUpdateAllocatedEndpoint(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, new AllocatedEndpoint(e, "myredis1.dev.internal", 5001, EndpointBindingMode.SingleAddress, targetPortExpression: null, networkID: KnownNetworkIdentifiers.DefaultAspireContainerNetwork)); + e.AllAllocatedEndpoints.AddOrUpdateAllocatedEndpoint(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, new AllocatedEndpoint(e, "myredis1.dev.internal", 5001, EndpointBindingMode.SingleAddress, targetPortExpression: null, networkId: KnownNetworkIdentifiers.DefaultAspireContainerNetwork)); }); redis2.WithEndpoint("tcp", e => { e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 5002); - e.AllAllocatedEndpoints.AddOrUpdateAllocatedEndpoint(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, new AllocatedEndpoint(e, "myredis2.dev.internal", 5002, EndpointBindingMode.SingleAddress, targetPortExpression: null, networkID: KnownNetworkIdentifiers.DefaultAspireContainerNetwork)); + e.AllAllocatedEndpoints.AddOrUpdateAllocatedEndpoint(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, new AllocatedEndpoint(e, "myredis2.dev.internal", 5002, EndpointBindingMode.SingleAddress, targetPortExpression: null, networkId: KnownNetworkIdentifiers.DefaultAspireContainerNetwork)); }); redis3.WithEndpoint("tcp", e => { e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 5003); - e.AllAllocatedEndpoints.AddOrUpdateAllocatedEndpoint(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, new AllocatedEndpoint(e, "myredis3.dev.internal", 5003, EndpointBindingMode.SingleAddress, targetPortExpression: null, networkID: KnownNetworkIdentifiers.DefaultAspireContainerNetwork)); + e.AllAllocatedEndpoints.AddOrUpdateAllocatedEndpoint(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, new AllocatedEndpoint(e, "myredis3.dev.internal", 5003, EndpointBindingMode.SingleAddress, targetPortExpression: null, networkId: KnownNetworkIdentifiers.DefaultAspireContainerNetwork)); }); var redisInsight = Assert.Single(builder.Resources.OfType()); @@ -706,7 +706,7 @@ public async Task RedisInsightEnvironmentCallbackIsIdempotent() .WithEndpoint("tcp", e => { e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 6379); - e.AllAllocatedEndpoints.AddOrUpdateAllocatedEndpoint(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, new AllocatedEndpoint(e, "redis.dev.internal", 6379, EndpointBindingMode.SingleAddress, targetPortExpression: null, networkID: KnownNetworkIdentifiers.DefaultAspireContainerNetwork)); + e.AllAllocatedEndpoints.AddOrUpdateAllocatedEndpoint(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, new AllocatedEndpoint(e, "redis.dev.internal", 6379, EndpointBindingMode.SingleAddress, targetPortExpression: null, networkId: KnownNetworkIdentifiers.DefaultAspireContainerNetwork)); }) .WithRedisInsight(); diff --git a/tests/Aspire.Hosting.Tests/EndpointReferenceTests.cs b/tests/Aspire.Hosting.Tests/EndpointReferenceTests.cs index 2419809d65d..20ebd634d62 100644 --- a/tests/Aspire.Hosting.Tests/EndpointReferenceTests.cs +++ b/tests/Aspire.Hosting.Tests/EndpointReferenceTests.cs @@ -297,7 +297,7 @@ public void AllocatedEndpoint_ThrowsWhenNetworkIdDoesNotMatch() annotation, "localhost", 8080, EndpointBindingMode.SingleAddress, targetPortExpression: null, - networkID: KnownNetworkIdentifiers.DefaultAspireContainerNetwork); + networkId: KnownNetworkIdentifiers.DefaultAspireContainerNetwork); var ex = Assert.Throws(() => annotation.AllocatedEndpoint = mismatchedEndpoint); } diff --git a/tests/Aspire.Hosting.Tests/WithEnvironmentTests.cs b/tests/Aspire.Hosting.Tests/WithEnvironmentTests.cs index 086b5f427f1..068b4facd6d 100644 --- a/tests/Aspire.Hosting.Tests/WithEnvironmentTests.cs +++ b/tests/Aspire.Hosting.Tests/WithEnvironmentTests.cs @@ -230,7 +230,7 @@ public async Task EnvironmentVariableExpressions() { ep.AllocatedEndpoint = new AllocatedEndpoint(ep, "localhost", 17454); - ep.AllAllocatedEndpoints.AddOrUpdateAllocatedEndpoint(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, new AllocatedEndpoint(ep, "container1.dev.internal", 10005, EndpointBindingMode.SingleAddress, networkID: KnownNetworkIdentifiers.DefaultAspireContainerNetwork)); + ep.AllAllocatedEndpoints.AddOrUpdateAllocatedEndpoint(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, new AllocatedEndpoint(ep, "container1.dev.internal", 10005, EndpointBindingMode.SingleAddress, networkId: KnownNetworkIdentifiers.DefaultAspireContainerNetwork)); }); var endpoint = container.GetEndpoint("primary"); From ff10a2596f495c3366cfa31e35b10148c3821480 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sat, 30 May 2026 20:12:40 -0700 Subject: [PATCH 11/15] Add Foundry hosted agent protocol selection (#17732) * Add Foundry hosted agent protocol selection Support configuring Foundry hosted agent protocols for publish output and local run-mode dashboard commands, including TypeScript AppHost coverage for the exported DTO shape. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address Foundry hosted agent review feedback Improve hosted agent protocol validation parameter names and add run-mode context when configuration callbacks fail during protocol inference. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Sync Foundry hosted agent endpoint protocols Update hosted agent deployment to patch the Foundry agent endpoint protocols after creating a hosted-agent version, so endpoint routing matches the configured container protocol versions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Skip Foundry reserved hosted agent env vars Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> (cherry picked from commit 9c260c29a6c3f9a63750a518a56440a3d855fb8f) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../HostedAgent/AzureHostedAgentResource.cs | 63 ++++++++ .../HostedAgentBuilderExtension.cs | 141 +++++++++++++++--- .../HostedAgent/HostedAgentConfiguration.cs | 25 ++++ .../HostedAgent/HostedAgentOptions.cs | 90 ++++++++++- .../HostedAgentConfigurationTests.cs | 15 ++ .../HostedAgentExtensionTests.cs | 132 +++++++++++++++- .../TypeScript/apphost.mts | 8 +- 7 files changed, 451 insertions(+), 23 deletions(-) diff --git a/src/Aspire.Hosting.Foundry/HostedAgent/AzureHostedAgentResource.cs b/src/Aspire.Hosting.Foundry/HostedAgent/AzureHostedAgentResource.cs index 0fbcfa32a56..1c4f139d339 100644 --- a/src/Aspire.Hosting.Foundry/HostedAgent/AzureHostedAgentResource.cs +++ b/src/Aspire.Hosting.Foundry/HostedAgent/AzureHostedAgentResource.cs @@ -161,12 +161,66 @@ private async Task DeployAsync(PipelineStepContext context cancellationToken: context.CancellationToken ).ConfigureAwait(false); + await UpdateAgentEndpointProtocolsAsync(projectClient.AgentAdministrationClient, def, context.CancellationToken).ConfigureAwait(false); + // Foundry should do this automatically in the future. await AssignFoundryRoleToAgentIdentityAsync(context, project, result.Value, provisioningContext).ConfigureAwait(false); return result.Value; } + private async Task UpdateAgentEndpointProtocolsAsync(AgentAdministrationClient agentsClient, HostedAgentConfiguration configuration, CancellationToken cancellationToken) + { + var endpointProtocols = GetAgentEndpointProtocols(configuration.ContainerProtocolVersions); + if (endpointProtocols.Count == 0) + { + return; + } + + var endpoint = new AgentEndpoint(); + foreach (var protocol in endpointProtocols) + { + endpoint.Protocols.Add(protocol); + } + + // Creating a hosted-agent version does not update the endpoint's advertised protocols; + // keep routing in sync so endpoint-scoped invocations can reach the selected version. + await agentsClient.PatchAgentObjectAsync( + Name, + new PatchAgentOptions + { + AgentEndpoint = endpoint + }, + cancellationToken).ConfigureAwait(false); + } + + internal static IReadOnlyList GetAgentEndpointProtocols(IEnumerable protocolVersions) + { + var endpointProtocols = new List(); + + foreach (var protocolVersion in protocolVersions) + { + var endpointProtocol = ToAgentEndpointProtocol(protocolVersion.Protocol); + if (!endpointProtocols.Contains(endpointProtocol)) + { + endpointProtocols.Add(endpointProtocol); + } + } + + return endpointProtocols; + } + + private static AgentEndpointProtocol ToAgentEndpointProtocol(ProjectsAgentProtocol protocol) + { + return protocol.ToString() switch + { + "activity_protocol" => AgentEndpointProtocol.Activity, + "invocations" => AgentEndpointProtocol.Invocations, + "responses" => AgentEndpointProtocol.Responses, + var value => new AgentEndpointProtocol(value) + }; + } + private async Task AssignFoundryRoleToAgentIdentityAsync( PipelineStepContext context, AzureCognitiveServicesProjectResource project, @@ -253,6 +307,15 @@ internal static async Task> GetResolvedEnvironmentVar var resolvedEnvVars = new Dictionary(); foreach (var (key, value) in collectedEnvVars) { + if (HostedAgentConfiguration.IsReservedEnvironmentVariableName(key)) + { + // Foundry injects platform-owned variables such as PORT itself. Some Aspire resource + // types use these variables to model local/container startup, but forwarding them in + // the hosted-agent definition causes Foundry to reject the version payload. + logger.LogDebug("Environment variable '{Key}' for resource '{Name}' is reserved by Foundry Hosted Agents and will be skipped.", key, resource.Name); + continue; + } + switch (value) { case null: diff --git a/src/Aspire.Hosting.Foundry/HostedAgent/HostedAgentBuilderExtension.cs b/src/Aspire.Hosting.Foundry/HostedAgent/HostedAgentBuilderExtension.cs index c2d0161a82d..4576f801b43 100644 --- a/src/Aspire.Hosting.Foundry/HostedAgent/HostedAgentBuilderExtension.cs +++ b/src/Aspire.Hosting.Foundry/HostedAgent/HostedAgentBuilderExtension.cs @@ -3,7 +3,6 @@ using System.Net.Http.Json; using System.Text.Json; -using System.Text.Json.Nodes; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Foundry; using Microsoft.Extensions.DependencyInjection; @@ -16,6 +15,8 @@ namespace Aspire.Hosting; public static class HostedAgentResourceBuilderExtensions { private static readonly JsonSerializerOptions s_indentedJsonOptions = new() { WriteIndented = true }; + private const string ResponsesProtocol = "responses"; + private const string InvocationsProtocol = "invocations"; /// /// Configures the resource to run locally as a Microsoft Foundry hosted agent. @@ -55,7 +56,7 @@ public static IResourceBuilder AsHostedAgent(this IResourceBuilder buil /// The type of resource being configured. /// The resource builder for the compute resource. /// The Microsoft Foundry project the hosted agent is deployed into. - /// Optional hosted agent deployment options (description, CPU, memory, metadata, environment variables) applied in publish mode. + /// Optional hosted agent deployment options. Protocols apply in run and publish mode; other options apply in publish mode. /// A reference to the for chaining. /// The resource builder. /// Thrown when or is . @@ -69,7 +70,7 @@ internal static IResourceBuilder AsHostedAgentForExport( ArgumentNullException.ThrowIfNull(project); Action? configure = options is null ? null : options.ApplyTo; - return AsHostedAgent(builder, project: project, configure: configure); + return ConfigureAsHostedAgent(builder, project: project, configure: configure); } /// @@ -80,20 +81,33 @@ internal static IResourceBuilder AsHostedAgentForExport( /// The type of resource being configured. /// The resource builder for the compute resource. /// Optional Microsoft Foundry project resource used for both run and publish mode configuration. When , an existing Foundry project in the model is reused or a new project is created in publish mode. - /// A callback to configure hosted agent deployment options in publish mode. + /// A callback to configure hosted agent deployment options. /// A reference to the for chaining. - [AspireExportIgnore(Reason = "Action callback shape is awkward for polyglot hosts; the HostedAgentOptions overload is exported instead.")] + /// + /// The setting affects both run and publish mode. + /// Other settings are used in publish mode. + /// + [AspireExportIgnore(Reason = "Action callback shape is awkward for polyglot hosts; the HostedAgentOptions DTO shape is exported instead.")] public static IResourceBuilder AsHostedAgent( this IResourceBuilder builder, IResourceBuilder? project, Action? configure = null) where T : IResourceWithEndpoints, IResourceWithEnvironment, IComputeResource + { + return ConfigureAsHostedAgent(builder, project, configure); + } + + private static IResourceBuilder ConfigureAsHostedAgent( + this IResourceBuilder builder, + IResourceBuilder? project, + Action? configure) + where T : IResourceWithEndpoints, IResourceWithEnvironment, IComputeResource { ArgumentNullException.ThrowIfNull(builder); if (builder.ApplicationBuilder.ExecutionContext.IsRunMode) { - ConfigureRunMode(builder); + ConfigureRunMode(builder, configure); if (project is not null) { @@ -116,7 +130,7 @@ public static IResourceBuilder AsHostedAgent( /// /// The type of resource being configured. /// The resource builder for the compute resource. - /// A callback to configure hosted agent deployment options in publish mode. + /// A callback to configure hosted agent deployment options. /// A reference to the for chaining. [AspireExportIgnore(Reason = "Subset of the full AsHostedAgent overload.")] public static IResourceBuilder AsHostedAgent( @@ -157,9 +171,11 @@ private static IResourceBuilder ResolvePr .AddProject($"{builder.Resource.Name}-proj"); } - private static void ConfigureRunMode(IResourceBuilder builder) + private static void ConfigureRunMode(IResourceBuilder builder, Action? configure) where T : IResourceWithEndpoints, IResourceWithEnvironment, IComputeResource { + var protocol = GetRunProtocol(configure); + // Preserve any target port already configured on an existing "http" endpoint; // fall back to the default MAF agent port (8088) when none is set. var existingHttpEndpoint = builder.Resource.Annotations.OfType().FirstOrDefault(e => e.Name == "http"); @@ -175,14 +191,14 @@ private static void ConfigureRunMode(IResourceBuilder builder) { return; } - http.DisplayText = "Responses Endpoint"; + http.DisplayText = protocol.EndpointDisplayText; http.Url = new UriBuilder(http.Url) { - Path = "/responses" + Path = protocol.Path }.ToString(); }) .WithHttpCommand( - path: "/responses", + path: protocol.Path, displayName: "Send Message", endpointName: "http", commandOptions: new() @@ -195,7 +211,7 @@ private static void ConfigureRunMode(IResourceBuilder builder) { var interactionService = ctx.ServiceProvider.GetRequiredService(); var result = await interactionService.PromptInputAsync( - title: "Responses API", + title: protocol.PromptTitle, message: "Enter a message to send to the agent.", inputLabel: "Message", placeHolder: "I would like to know the weather today.", @@ -208,7 +224,7 @@ private static void ConfigureRunMode(IResourceBuilder builder) } var request = ctx.Request; var input = result.Data.Value; - request.Content = new StringContent(new JsonObject() { ["input"] = input }.ToString(), System.Text.Encoding.UTF8, "application/json"); + request.Content = protocol.CreateRequestContent(input); }, GetCommandResult = async ctx => { @@ -224,17 +240,32 @@ private static void ConfigureRunMode(IResourceBuilder builder) CommandResultFormat.Text); } - var responseJson = await response.Content.ReadFromJsonAsync(cancellationToken: ctx.CancellationToken).ConfigureAwait(true); - if (responseJson is null) + if (protocol.ExpectsJsonResponse) + { + var responseJson = await response.Content.ReadFromJsonAsync(cancellationToken: ctx.CancellationToken).ConfigureAwait(true); + if (responseJson.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + { + return CommandResults.Failure("Agent returned an empty response."); + } + + var formattedResponse = JsonSerializer.Serialize(responseJson, s_indentedJsonOptions); + return CommandResults.Success( + message: "Agent response received.", + result: formattedResponse, + resultFormat: CommandResultFormat.Json, + displayImmediately: true); + } + + var responseText = await response.Content.ReadAsStringAsync(ctx.CancellationToken).ConfigureAwait(true); + if (string.IsNullOrEmpty(responseText)) { return CommandResults.Failure("Agent returned an empty response."); } - var formattedResponse = JsonSerializer.Serialize(responseJson, s_indentedJsonOptions); return CommandResults.Success( message: "Agent response received.", - result: formattedResponse, - resultFormat: CommandResultFormat.Json, + result: responseText, + resultFormat: CommandResultFormat.Text, displayImmediately: true); }, } @@ -261,6 +292,47 @@ private static void ConfigureRunMode(IResourceBuilder builder) }); } + private static HostedAgentRunProtocol GetRunProtocol(Action? configure) + { + var protocol = GetConfiguredRunProtocol(configure); + if (string.IsNullOrWhiteSpace(protocol) || string.Equals(protocol, ResponsesProtocol, StringComparison.OrdinalIgnoreCase)) + { + return HostedAgentRunProtocol.Responses; + } + + if (string.Equals(protocol, InvocationsProtocol, StringComparison.OrdinalIgnoreCase)) + { + return HostedAgentRunProtocol.Invocations; + } + + throw new NotSupportedException($"Foundry hosted agent protocol '{protocol}' is not supported in run mode. Supported protocols: '{ResponsesProtocol}', '{InvocationsProtocol}'."); + } + + private static string? GetConfiguredRunProtocol(Action? configure) + { + if (configure is null) + { + return null; + } + + // Run mode does not need the deployment image, but the same configuration callback is also used in + // publish mode where the image is known. Use a scratch configuration here so protocol selection has + // one C# API surface across run and publish mode. + var configuration = new HostedAgentConfiguration(image: string.Empty); + try + { + configure(configuration); + } + catch (Exception ex) + { + throw new InvalidOperationException( + $"Failed to apply the hosted agent configuration callback while determining the Foundry hosted agent protocol for run mode. In run mode, only {nameof(HostedAgentConfiguration.ContainerProtocolVersions)} is used; other options can still be validated by the callback.", + ex); + } + + return configuration.ContainerProtocolVersions.FirstOrDefault()?.Protocol.ToString(); + } + private static void ConfigurePublishMode( IResourceBuilder builder, IResourceBuilder project, @@ -348,4 +420,37 @@ private static void ConfigurePublishMode( .WithIconName("Agents") .WithReferenceRelationship(target); } + + private sealed class HostedAgentRunProtocol + { + public static HostedAgentRunProtocol Responses { get; } = new() + { + Path = "/responses", + EndpointDisplayText = "Responses Endpoint", + PromptTitle = "Responses API", + ExpectsJsonResponse = true, + CreateRequestContent = input => JsonContent.Create(new { input }) + }; + + public static HostedAgentRunProtocol Invocations { get; } = new() + { + Path = "/invocations", + EndpointDisplayText = "Invocations Endpoint", + PromptTitle = "Invocations API", + ExpectsJsonResponse = false, + // Agent Framework's invocations host expects a JSON body with a "message" field: + // https://github.com/microsoft/agent-framework/blob/main/python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py + CreateRequestContent = input => JsonContent.Create(new { message = input }) + }; + + public required string Path { get; init; } + + public required string EndpointDisplayText { get; init; } + + public required string PromptTitle { get; init; } + + public required bool ExpectsJsonResponse { get; init; } + + public required Func CreateRequestContent { get; init; } + } } diff --git a/src/Aspire.Hosting.Foundry/HostedAgent/HostedAgentConfiguration.cs b/src/Aspire.Hosting.Foundry/HostedAgent/HostedAgentConfiguration.cs index 289dbc636b7..206e1f77326 100644 --- a/src/Aspire.Hosting.Foundry/HostedAgent/HostedAgentConfiguration.cs +++ b/src/Aspire.Hosting.Foundry/HostedAgent/HostedAgentConfiguration.cs @@ -116,6 +116,7 @@ public decimal Memory internal ProjectsAgentVersionCreationOptions ToProjectsAgentVersionCreationOptions(string targetResourceName) { ValidateEnvironmentVariableNames(EnvironmentVariables.Keys, targetResourceName); + ValidateEnvironmentVariableNamesAreNotReserved(EnvironmentVariables.Keys, targetResourceName); var def = new HostedAgentDefinition( ContainerProtocolVersions, @@ -165,6 +166,30 @@ private static void ValidateEnvironmentVariableNames(IEnumerable environ $"Invalid name(s): '{string.Join("', '", invalidNames)}'"); } + private static void ValidateEnvironmentVariableNamesAreNotReserved(IEnumerable environmentVariableNames, string? targetResourceName) + { + var reservedNames = environmentVariableNames + .Where(IsReservedEnvironmentVariableName) + .Order(StringComparer.Ordinal) + .ToArray(); + + if (reservedNames.Length == 0) + { + return; + } + + throw new DistributedApplicationException( + $"Foundry hosted agent for target resource '{targetResourceName}' contains environment variable names that are reserved by Foundry Hosted Agents. " + + $"Reserved name(s): '{string.Join("', '", reservedNames)}'"); + } + + internal static bool IsReservedEnvironmentVariableName(string name) + { + return string.Equals(name, "PORT", StringComparison.OrdinalIgnoreCase) || + name.StartsWith("FOUNDRY_", StringComparison.OrdinalIgnoreCase) || + name.StartsWith("AGENT_", StringComparison.OrdinalIgnoreCase); + } + // hosted agent environment variables must contain only letters, digits, or underscores. [GeneratedRegex("^[A-Za-z0-9_]+$")] private static partial Regex EnvironmentVariableNameRegex(); diff --git a/src/Aspire.Hosting.Foundry/HostedAgent/HostedAgentOptions.cs b/src/Aspire.Hosting.Foundry/HostedAgent/HostedAgentOptions.cs index 7cf11d3c78c..0cb543793db 100644 --- a/src/Aspire.Hosting.Foundry/HostedAgent/HostedAgentOptions.cs +++ b/src/Aspire.Hosting.Foundry/HostedAgent/HostedAgentOptions.cs @@ -1,11 +1,13 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Azure.AI.Projects.Agents; + namespace Aspire.Hosting.Foundry; -// HostedAgentOptions exposes the subset of HostedAgentConfiguration that is meaningful to non-.NET -// app hosts. .NET callers should use the AsHostedAgent overload that takes Action -// to access the full configuration surface (tools, content filters, container protocol versions, etc.). +// HostedAgentOptions exposes the subset of HostedAgentConfiguration that can be shared by .NET and +// polyglot app hosts. .NET callers can use the AsHostedAgent overload that takes +// Action when they need the full Azure SDK-specific configuration surface. /// /// Options that control how a compute resource is deployed as a Microsoft Foundry hosted agent. @@ -45,6 +47,16 @@ internal sealed class HostedAgentOptions /// public IDictionary EnvironmentVariables { get; init; } = new Dictionary(); + /// + /// Protocol versions that the hosted agent container supports for ingress communication. + /// When not set, the hosted agent default responses protocol is used. + /// + /// + /// In run mode, the first protocol entry selects the dashboard URL and HTTP command protocol. + /// In publish mode, all entries are emitted to the Foundry hosted agent definition. + /// + public IList Protocols { get; init; } = []; + internal void ApplyTo(HostedAgentConfiguration configuration) { if (Description is not null) @@ -73,5 +85,77 @@ internal void ApplyTo(HostedAgentConfiguration configuration) { configuration.EnvironmentVariables[kvp.Key] = kvp.Value; } + + var protocols = ValidateProtocols(); + if (protocols.Count > 0) + { + var protocolVersionRecords = protocols.Select(ToProtocolVersionRecord).ToArray(); + + configuration.ContainerProtocolVersions.Clear(); + foreach (var record in protocolVersionRecords) + { + configuration.ContainerProtocolVersions.Add(record); + } + } } + + private IList ValidateProtocols() + { + if (Protocols is null) + { + throw new ArgumentNullException(nameof(Protocols), "Hosted agent protocols cannot be null."); + } + + foreach (var protocol in Protocols) + { + ValidateProtocol(protocol); + } + + return Protocols; + } + + private static void ValidateProtocol(HostedAgentProtocolVersion protocolVersion) + { + if (protocolVersion is null) + { + throw new ArgumentNullException(nameof(protocolVersion), "Hosted agent protocols cannot contain null entries."); + } + + if (string.IsNullOrWhiteSpace(protocolVersion.Protocol)) + { + ThrowInvalidProtocolProperty(nameof(HostedAgentProtocolVersion.Protocol), "Hosted agent protocol cannot be null, empty, or whitespace."); + } + + if (string.IsNullOrWhiteSpace(protocolVersion.Version)) + { + ThrowInvalidProtocolProperty(nameof(HostedAgentProtocolVersion.Version), "Hosted agent protocol version cannot be null, empty, or whitespace."); + } + } + + private static void ThrowInvalidProtocolProperty(string propertyName, string message) + { + throw new ArgumentException(message, propertyName); + } + + private static ProtocolVersionRecord ToProtocolVersionRecord(HostedAgentProtocolVersion protocolVersion) + { + return new ProtocolVersionRecord(new ProjectsAgentProtocol(protocolVersion.Protocol), protocolVersion.Version); + } +} + +/// +/// A protocol and version supported by a Microsoft Foundry hosted agent container. +/// +[AspireDto] +internal sealed class HostedAgentProtocolVersion +{ + /// + /// The protocol name, such as responses or invocations. + /// + public required string Protocol { get; init; } + + /// + /// The protocol version, such as 1.0.0. + /// + public required string Version { get; init; } } diff --git a/tests/Aspire.Hosting.Foundry.Tests/HostedAgentConfigurationTests.cs b/tests/Aspire.Hosting.Foundry.Tests/HostedAgentConfigurationTests.cs index 27406e4a25f..2b027da493d 100644 --- a/tests/Aspire.Hosting.Foundry.Tests/HostedAgentConfigurationTests.cs +++ b/tests/Aspire.Hosting.Foundry.Tests/HostedAgentConfigurationTests.cs @@ -98,6 +98,21 @@ public void ToProjectsAgentVersionCreationOptions_ThrowsForInvalidEnvironmentVar ex.Message); } + [Fact] + public void ToProjectsAgentVersionCreationOptions_ThrowsForReservedEnvironmentVariableNames() + { + var config = new HostedAgentConfiguration("myimage:latest"); + config.EnvironmentVariables["PORT"] = "8000"; + config.EnvironmentVariables["AGENT_NAME"] = "agent"; + config.EnvironmentVariables["FOUNDRY_MODE"] = "hosted"; + + var ex = Assert.Throws(() => config.ToProjectsAgentVersionCreationOptions("target")); + + Assert.Equal( + "Foundry hosted agent for target resource 'target' contains environment variable names that are reserved by Foundry Hosted Agents. Reserved name(s): 'AGENT_NAME', 'FOUNDRY_MODE', 'PORT'", + ex.Message); + } + [Fact] public void DefaultMetadata_ContainsDeployedByAndOn() { diff --git a/tests/Aspire.Hosting.Foundry.Tests/HostedAgentExtensionTests.cs b/tests/Aspire.Hosting.Foundry.Tests/HostedAgentExtensionTests.cs index cb1ac78563d..8405cd2e25a 100644 --- a/tests/Aspire.Hosting.Foundry.Tests/HostedAgentExtensionTests.cs +++ b/tests/Aspire.Hosting.Foundry.Tests/HostedAgentExtensionTests.cs @@ -7,7 +7,9 @@ using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Tests.Utils; using Aspire.Hosting.Utils; +using Azure.AI.Projects.Agents; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; namespace Aspire.Hosting.Foundry.Tests; @@ -74,11 +76,62 @@ public void AsHostedAgent_InRunMode_ConfiguresSendMessageCommand() var resource = builder.Resources.Single(r => r.Name == "agent"); var command = Assert.Single(resource.Annotations.OfType()); Assert.Equal("Send Message", command.DisplayName); + Assert.EndsWith("-/responses", command.Name); Assert.Equal("ChatSparkle", command.IconName); Assert.Equal(IconVariant.Regular, command.IconVariant); Assert.True(command.IsHighlighted); } + [Fact] + public async Task AsHostedAgent_InRunMode_WithInvocationsProtocol_ConfiguresEndpointAndCommand() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + var project = builder.AddFoundry("account") + .AddProject("my-project"); + builder.AddPythonApp("agent", "./app.py", "main:app") + .AsHostedAgent(project, configuration => + { + configuration.ContainerProtocolVersions.Clear(); + configuration.ContainerProtocolVersions.Add(new ProtocolVersionRecord(ProjectsAgentProtocol.Invocations, "1.0.0")); + }); + + using var app = builder.Build(); + + var resource = builder.Resources.Single(r => r.Name == "agent"); + var command = Assert.Single(resource.Annotations.OfType()); + Assert.EndsWith("-/invocations", command.Name); + + var urlsCallback = Assert.Single(resource.Annotations.OfType()); + var url = new ResourceUrlAnnotation + { + Url = "http://localhost:1234", + Endpoint = ((IResourceWithEndpoints)resource).GetEndpoint("http") + }; + var urls = new List { url }; + var context = new ResourceUrlsCallbackContext( + app.Services.GetRequiredService(), + resource, + urls); + + await urlsCallback.Callback(context); + + Assert.Equal("Invocations Endpoint", url.DisplayText); + Assert.Equal("http://localhost:1234/invocations", url.Url); + } + + [Fact] + public void AsHostedAgent_InRunMode_WrapsConfigurationCallbackFailures() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + + var ex = Assert.Throws(() => + builder.AddPythonApp("agent", "./app.py", "main:app") + .AsHostedAgent(configuration => configuration.Cpu = 4.0m)); + + Assert.Contains("run mode", ex.Message); + Assert.IsType(ex.InnerException); + } + [Fact] public void AsHostedAgent_InPublishMode_DoesNotValidateRegion() { @@ -189,7 +242,15 @@ public void AsHostedAgent_WithOptions_AppliesAllPropertiesToConfiguration() Cpu = 1m, Memory = 2m, Metadata = { ["scenario"] = "unit-test" }, - EnvironmentVariables = { ["MY_VAR"] = "my-value" } + EnvironmentVariables = { ["MY_VAR"] = "my-value" }, + Protocols = + { + new HostedAgentProtocolVersion + { + Protocol = "invocations", + Version = "1.0.0" + } + } }; builder.AddPythonApp("agent", "./app.py", "main:app") @@ -207,6 +268,75 @@ public void AsHostedAgent_WithOptions_AppliesAllPropertiesToConfiguration() Assert.Equal(2m, configuration.Memory); Assert.Equal("unit-test", configuration.Metadata["scenario"]); Assert.Equal("my-value", configuration.EnvironmentVariables["MY_VAR"]); + var protocol = Assert.Single(configuration.ContainerProtocolVersions); + Assert.Equal(ProjectsAgentProtocol.Invocations, protocol.Protocol); + Assert.Equal("1.0.0", protocol.Version); + } + + [Fact] + public async Task GetResolvedEnvironmentVariables_DoesNotForwardFoundryReservedTargetEnvironmentVariables() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var agent = builder.AddExecutable("agent", "python", ".") + .WithEnvironment("PORT", "8000") + .WithEnvironment("AGENT_NAME", "agent") + .WithEnvironment("FOUNDRY_MODE", "hosted") + .WithEnvironment("MY_VAR", "my-value"); + + using var app = builder.Build(); + var hostedAgent = new AzureHostedAgentResource("agent-ha", agent.Resource); + + var envVars = await AzureHostedAgentResource.GetResolvedEnvironmentVariablesAsync( + app.Services.GetRequiredService(), + hostedAgent, + agent.Resource, + NullLogger.Instance, + CancellationToken.None); + + Assert.DoesNotContain("PORT", envVars.Keys); + Assert.DoesNotContain("AGENT_NAME", envVars.Keys); + Assert.DoesNotContain("FOUNDRY_MODE", envVars.Keys); + Assert.Equal("my-value", envVars["MY_VAR"]); + } + + [Theory] + [InlineData("", "1.0.0", nameof(HostedAgentProtocolVersion.Protocol))] + [InlineData("invocations", "", nameof(HostedAgentProtocolVersion.Version))] + public void AsHostedAgent_WithInvalidProtocolOptions_ThrowsWithPropertyName(string protocol, string version, string expectedParamName) + { + var options = new HostedAgentOptions + { + Protocols = + { + new HostedAgentProtocolVersion + { + Protocol = protocol, + Version = version + } + } + }; + + var ex = Assert.Throws(() => options.ApplyTo(new HostedAgentConfiguration("test-image"))); + Assert.Equal(expectedParamName, ex.ParamName); + } + + [Fact] + public void GetAgentEndpointProtocols_MapsContainerProtocolsToEndpointProtocols() + { + var endpointProtocols = AzureHostedAgentResource.GetAgentEndpointProtocols( + [ + new ProtocolVersionRecord(ProjectsAgentProtocol.Invocations, "1.0.0"), + new ProtocolVersionRecord(ProjectsAgentProtocol.Responses, "1.0.0"), + new ProtocolVersionRecord(ProjectsAgentProtocol.ActivityProtocol, "1.0.0"), + new ProtocolVersionRecord(ProjectsAgentProtocol.Invocations, "1.1.0") + ]); + + Assert.Collection( + endpointProtocols, + protocol => Assert.Equal(AgentEndpointProtocol.Invocations, protocol), + protocol => Assert.Equal(AgentEndpointProtocol.Responses, protocol), + protocol => Assert.Equal(AgentEndpointProtocol.Activity, protocol)); } [Fact] diff --git a/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/TypeScript/apphost.mts b/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/TypeScript/apphost.mts index 2479a1aeba8..936eb550345 100644 --- a/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/TypeScript/apphost.mts +++ b/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/TypeScript/apphost.mts @@ -103,6 +103,11 @@ const server = http.createServer((req, res) => { res.end(JSON.stringify({ output: 'hello from validation app host' })); return; } + if (req.url === '/invocations') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ response: 'hello from validation app host' })); + return; + } res.writeHead(404); res.end(); }); @@ -115,7 +120,8 @@ await hostedAgent.asHostedAgent(project, { cpu: 1, memory: 2, metadata: { scenario: 'validation' }, - environmentVariables: { VALIDATION_MODE: 'true' } + environmentVariables: { VALIDATION_MODE: 'true' }, + protocols: [{ protocol: 'invocations', version: '1.0.0' }] }); const api = await builder.addContainer('api', 'nginx'); From d1c3c7d2d49b8b0cca9855f6dc1cac970e6f8e0f Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 1 Jun 2026 04:43:05 +1000 Subject: [PATCH 12/15] Route staging-identity CLI to its darc feed regardless of version shape (#17743) * Route staging-identity CLI to its darc feed regardless of version shape The synthesized `staging` package channel derived its feed from the CLI build's version shape: a prerelease-shaped staging build (e.g. 13.4.0-preview.1.26280.6) routed Aspire.* to the shared dnceng/dotnet9 daily feed instead of its SHA-specific darc-pub-microsoft-aspire- feed. C# apphosts masked this because the darc feed is baked into their nuget.config, but polyglot (TypeScript) apphosts resolve solely through the channel's feed, so `aspire add ` offered the wrong versions. Decouple feed provenance (identity) from version filtering (quality): - Add `ShouldUseSharedStagingFeed(...)`: a staging-identity CLI always uses its own darc feed, any version shape. Override feeds and non-staging identities keep the prior quality-based routing. - Add an injectable `cliInformationalVersionProvider` constructor seam so the derived darc feed URL is deterministic and assertable in tests. - Correct the comments that incorrectly claimed darc feeds only exist for stable-shaped builds. Add tests covering prerelease-shaped staging -> darc, stable-shaped staging -> darc, and override-wins. The prerelease repro fails before the fix (resolves dotnet9) and passes after (resolves darc). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Strengthen staging feed-routing tests and warn on underivable feed Adds a decision-table theory across PR/daily/staging/stable channel configurations, drops the override-feed crutch from the staging-identity tests so they assert the real darc feed via an injected version seam, adds coverage for the underivable-feed warning path, and adds symmetry asserts for the stable-shaped staging case. Also logs a one-time warning in CreateStagingChannel when a staging channel is permitted but no staging feed URL can be derived, so the channel is omitted visibly instead of silently. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add diagnostic overrides to validate staging feed routing locally Adds two PackagingService-scoped diagnostic config overrides so a locally built CLI (baked identity 'local', unstamped version) can simulate a staging build and validate end-to-end that 'aspire add' resolves Aspire.* from the correct SHA-specific darc-pub-microsoft-aspire- feed: - overrideCliIdentityChannel: forces the identity used for staging-feed routing decisions only (validated via IdentityChannelReader.IsValidChannel; invalid values ignored). Does not change the global identity used for hive/packages-directory lookups, keeping blast radius limited. - overrideCliInformationalVersion: forces the version that both the SHA derivation and the stable-shape/quality predicate read. All staging-feed decision points now route through GetEffectiveIdentityChannel. A one-time warning is emitted whenever either override is active. Adds docs/cli-staging-validation.md with the local-validation recipe and nine PackagingServiceTests covering the override permutations. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add debug-staging/debug-stable scripts to simulate release-branch feed routing Add eng/scripts/debug-staging.{sh,ps1} and debug-stable.{sh,ps1} (plus the shared debug-aspire-channel core) that make an easy-to-get build (an installed PR build or a local build) resolve Aspire.* packages exactly like an official staging or stable release-branch build, so the feed-routing fix can be validated end-to-end. Each script targets identity 'staging' and the SHA-specific darc-pub-microsoft-aspire- feed, differing only in version shape/quality (staging => prerelease/Both, #17744; stable => stable/Stable, #17527). Modes: - default: one-shot 'aspire add --debug' that asserts the darc feed resolves. - --print-env / -PrintEnv: emit export/$env lines to apply to the current shell. - --shell / -Shell: interactive subshell with overrides applied and the target CLI first on PATH, for a full 'aspire new'/'add'/run flow; overrides vanish on exit. Extend docs/cli-staging-validation.md with the helper-script usage, the interactive PR-build recipe, and a validation matrix; link it from docs/contributing.md. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Isolate NuGet package cache in debug-channel --shell mode When dropping into the interactive subshell (--shell / -Shell), point NUGET_PACKAGES at an isolated, per-sha directory so packages restored from the simulated staging darc feed can never contaminate the developer's real global package cache. Also commit the staging-override NOTE clarifying the overrides route to but do not create a feed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix staging-identity update test for identity-driven darc feed routing The staging identity now always routes to its build's SHA-specific darc-pub-microsoft-aspire- feed regardless of version shape, so the feed must be derivable from the CLI's + informational version. The test host assembly has no commit metadata, so the staging channel could not be synthesized and the test regressed. Stamp a staging-shaped informational version via the overrideCliInformationalVersion config so the derivation matches a real staging build. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> (cherry picked from commit e13850995727767ba26088932630342417745f40) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/cli-staging-validation.md | 148 +++++ docs/contributing.md | 2 + eng/scripts/debug-aspire-channel.ps1 | 238 ++++++++ eng/scripts/debug-aspire-channel.sh | 298 ++++++++++ eng/scripts/debug-stable.ps1 | 63 +++ eng/scripts/debug-stable.sh | 16 + eng/scripts/debug-staging.ps1 | 63 +++ eng/scripts/debug-staging.sh | 16 + src/Aspire.Cli/Packaging/PackagingService.cs | 258 +++++++-- .../Commands/UpdateCommandTests.cs | 11 + .../Packaging/PackagingServiceTests.cs | 520 +++++++++++++++++- 11 files changed, 1572 insertions(+), 61 deletions(-) create mode 100644 docs/cli-staging-validation.md create mode 100644 eng/scripts/debug-aspire-channel.ps1 create mode 100755 eng/scripts/debug-aspire-channel.sh create mode 100644 eng/scripts/debug-stable.ps1 create mode 100755 eng/scripts/debug-stable.sh create mode 100644 eng/scripts/debug-staging.ps1 create mode 100755 eng/scripts/debug-staging.sh diff --git a/docs/cli-staging-validation.md b/docs/cli-staging-validation.md new file mode 100644 index 00000000000..e29034af883 --- /dev/null +++ b/docs/cli-staging-validation.md @@ -0,0 +1,148 @@ +# Validating staging feed routing with a local CLI build + +This document describes how to make a locally built Aspire CLI resolve `Aspire.*` +packages exactly the way an official **staging** (or **stable**) build would, so the +staging feed-routing behavior can be validated end-to-end without an official build. + +## Background + +A staging-identity CLI is an official release-branch build whose own commit always has +a SHA-specific `darc-pub-microsoft-aspire-` feed carrying its matching packages +(prerelease-shaped `13.4.0-preview.*` and stable-shaped `13.4.0` alike). Feed +**provenance** is decided by the CLI's baked build **identity** (`AspireCliChannel`), +while version **filtering** (the channel quality) is decided by the CLI's **version +shape**. See `PackagingService.ShouldUseSharedStagingFeed`. + +A locally built CLI bakes a `local` identity and an unstamped informational version, so +it never synthesizes a staging channel and never derives a darc feed. The two diagnostic +overrides below let you simulate the staging path locally. + +## The two diagnostic overrides + +Both are read by `PackagingService` only (their blast radius is limited to staging +feed-routing decisions — they do **not** change the global identity used for hive or +package-directory lookups): + +| Config key | Purpose | +| --- | --- | +| `overrideCliIdentityChannel` | Forces the identity used for staging-feed routing decisions. Must be a valid channel (`stable`, `staging`, `daily`, `local`, or `pr-`); invalid values are ignored and the real identity is used. | +| `overrideCliInformationalVersion` | Forces the informational version that both the SHA-derivation provider and the version-shape (quality) predicate read. The part after `+` (truncated to 8 chars) builds the darc URL; the version part determines stable-vs-prerelease shape. | + +**Both overrides are required** to reach the darc path from a local build: + +- Identity override alone → the SHA is still unstamped, so the darc URL can't be derived. +- Version override alone → the identity stays `local`, so routing never selects the darc feed. + +When either override is set, the CLI emits a one-time warning so an overridden +identity/feed can't silently resolve packages on a normal invocation. + +## Recipe + +1. Build the CLI locally: + + ```bash + ./build.sh --build /p:SkipNativeBuild=true + ``` + +2. In the apphost directory, set `channel: staging` in `aspire.config.json` (this is what + `aspire add` filters the synthesized channels to): + + ```json + { + "channel": "staging" + } + ``` + +3. Set the two overrides (environment variables are the simplest; they are read + case-insensitively with no prefix): + + ```bash + export overrideCliIdentityChannel=staging + export overrideCliInformationalVersion=13.4.0-preview.1.26280.6+ + ``` + + Use a real release-branch build commit hash so the derived feed actually exists if you + intend to restore; any 8+ char hex suffix works for inspecting the resolved feed URL. + +4. Run `aspire add` with debug logging and confirm the resolved darc feed: + + ```bash + aspire add foundry --debug + ``` + + The logs should show the staging channel resolving `Aspire*` to + `.../darc-pub-microsoft-aspire-/...` rather than the shared + `dnceng/.../dotnet9` daily feed. + +To simulate a **stable**-shaped staging build, use a stable-shaped version override +(e.g. `13.4.0+`); the channel quality becomes `Stable` while the feed +stays the darc feed. + +## Helper scripts + +`eng/scripts/debug-staging.{sh,ps1}` and `eng/scripts/debug-stable.{sh,ps1}` wrap the +recipe above. Both target identity `staging` and expect the **same** darc feed; they +differ only in version shape/quality: + +| Script | Version shape | Expected quality | Scenario | +| --- | --- | --- | --- | +| `debug-staging` | prerelease (`13.4.0-preview.*`) | `Both` | [#17744](https://github.com/microsoft/aspire/issues/17744) — the bug this PR fixes | +| `debug-stable` | stable (`13.4.0`) | `Stable` | [#17527](https://github.com/microsoft/aspire/issues/17527) — stable-shaped release build | + +Each script computes the expected `darc-pub-microsoft-aspire-` feed and supports +three modes: + +- **Validate (default):** runs `aspire add --debug` in a throwaway directory and + asserts the darc feed appears in the resolution log. Exits non-zero if it doesn't. +- **`--print-env` / `-PrintEnv`:** emits `export`/`$env:` lines you apply to your current + shell. Every subsequent `aspire` command then behaves like the simulated build. +- **`--shell` / `-Shell`:** opens an interactive subshell with the overrides applied and + the target CLI first on `PATH`. It also points `NUGET_PACKAGES` at an isolated, per-sha + cache so restores from the simulated staging feed never contaminate your real global + package cache. Exiting the subshell restores normal behavior. + +Common flags: `--sha ` (required, 8–40 hex), `--cli ` (CLI to drive), +`--pr ` (install that PR's full-bundle build first, then target it), `--version `. + +### Interactive validation against an installed PR build + +You don't need a local source build — the easiest carrier is an installed **PR build**, +which is a real full-bundle `~/.aspire` install. Install it, then make it behave like a +staging build for a full `aspire new` / `aspire add` / run flow: + +```bash +# 1. Install the PR's full-bundle build. +./eng/scripts/get-aspire-cli-pr.sh 17743 + +# 2a. Apply staging overrides to the CURRENT shell (every aspire command is staging-flavored): +eval "$(./eng/scripts/debug-stable.sh --sha --print-env)" +aspire new # behaves like the simulated staging build +aspire add foundry +# revert when done: +unset channel overrideCliIdentityChannel overrideCliInformationalVersion + +# 2b. ...or get a throwaway subshell instead (overrides vanish on 'exit'): +./eng/scripts/debug-stable.sh --pr 17743 --sha --shell +``` + +PowerShell is identical with the `.ps1` siblings: + +```powershell +./eng/scripts/get-aspire-cli-pr.ps1 17743 +./eng/scripts/debug-stable.ps1 -Sha -PrintEnv | Invoke-Expression +# ...or: +./eng/scripts/debug-stable.ps1 -Pr 17743 -Sha -Shell +``` + +The overrides are scoped to `PackagingService` feed routing and only ever live in the +shell/subshell environment, so nothing is written to global or per-project config. + +## Validation matrix + +| Identity | Version shape | Expected feed | Expected quality | +| --- | --- | --- | --- | +| `staging` | prerelease | `darc-pub-microsoft-aspire-` | `Both` | +| `staging` | stable | `darc-pub-microsoft-aspire-` | `Stable` | +| `daily` | any | shared `dnceng/.../dotnet9` daily feed | `Both` | +| `local` / `pr-` | any | local/PR hive + implicit (no staging synthesis) | n/a | +| `stable` | stable | nuget.org | `Stable` | diff --git a/docs/contributing.md b/docs/contributing.md index 15ea02c434c..4b12c05b9bf 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -102,6 +102,8 @@ dotnet test --no-launch-profile -- \ To test changes from a specific pull request locally, see [dogfooding-pull-requests.md](/docs/dogfooding-pull-requests.md) for instructions on installing Aspire CLI and NuGet packages built by that PR's CI run. +To validate how the CLI resolves `Aspire.*` packages for **staging** and **stable** release-branch builds (including making an installed PR build behave like a staging build), see [cli-staging-validation.md](/docs/cli-staging-validation.md). + ## Coding Agents Aspire uses GitHub Copilot automatic code review on pull requests. We expect Copilot review comments to be reviewed and addressed before merging, either by making the requested change or by explaining why a suggested change is not needed. diff --git a/eng/scripts/debug-aspire-channel.ps1 b/eng/scripts/debug-aspire-channel.ps1 new file mode 100644 index 00000000000..e521d31be87 --- /dev/null +++ b/eng/scripts/debug-aspire-channel.ps1 @@ -0,0 +1,238 @@ +#!/usr/bin/env pwsh + +<# +.SYNOPSIS + Shared implementation for debug-staging.ps1 and debug-stable.ps1. + +.DESCRIPTION + Makes an EASY-TO-GET Aspire CLI build behave like an official release-branch + staging build for validating package feed routing, WITHOUT producing a real + official build or stamping a binary locally. + + The recommended carrier is a PR build (a real, full self-extracting ~/.aspire + install) acquired with eng/scripts/get-aspire-cli-pr.ps1 . Any installed + 'aspire' (or a locally built one via -Cli) works just as well, because the + behavior is driven entirely by two diagnostic config overrides read by + PackagingService (see docs/cli-staging-validation.md): + + overrideCliIdentityChannel - forces the identity used for staging-feed + routing decisions (here: 'staging'). + overrideCliInformationalVersion - forces the informational version the SHA + derivation and version-shape (quality) + checks read, e.g. 13.4.0-preview.1.x+. + + Both flow into IConfiguration from environment variables (used here) OR from + aspire.config.json, and are scoped to staging feed routing only. A CLI run + with them set emits a one-time warning so they can never silently mis-route a + normal invocation. + + The script runs 'aspire add --debug' in a throwaway directory whose + aspire.config.json pins channel: staging, then asserts the debug log contains + Resolved 'staging' channel: feed=, quality= + The 'aspire add' step is expected to fail later (there is no real apphost + project in the scratch directory); only the feed-routing log line is validated. +#> + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# Default version shapes for the 13.4 release branch. Override with -Version. +$script:DefaultStagingVersion = '13.4.0-preview.1.26280.6' +$script:DefaultStableVersion = '13.4.0' + +function Invoke-DebugChannel { + [CmdletBinding()] + param( + # 'staging' (prerelease-shaped) | 'stable' (stable-shaped) + [Parameter(Mandatory = $true)][ValidateSet('staging', 'stable')][string]$Kind, + [string]$Sha, + [string]$Pr, + [string]$Cli, + [string]$Version, + [string]$Identity = 'staging', + [string]$Package = 'foundry', + [switch]$Shell, + [switch]$PrintEnv, + [string[]]$PassThrough = @() + ) + + switch ($Kind) { + 'staging' { $kindLabel = 'staging (prerelease-shaped)'; $defaultVersion = $script:DefaultStagingVersion; $expectedQuality = 'Both' } + 'stable' { $kindLabel = 'staging (stable-shaped)'; $defaultVersion = $script:DefaultStableVersion; $expectedQuality = 'Stable' } + } + + if ([string]::IsNullOrEmpty($Sha)) { + Write-Error '-Sha is required.' + return + } + + # The darc feed name is built from the first 8 chars of the commit hash, so + # require at least that many hex characters (full hashes are accepted). + if ($Sha -notmatch '^[0-9a-fA-F]{8,40}$') { + Write-Error "-Sha must be 8-40 hexadecimal characters (got '$Sha')." + return + } + + if ([string]::IsNullOrEmpty($Version)) { $Version = $defaultVersion } + + $sha8 = $Sha.Substring(0, 8).ToLowerInvariant() + $expectedFeed = "https://pkgs.dev.azure.com/dnceng/public/_packaging/darc-pub-microsoft-aspire-$sha8/nuget/v3/index.json" + $infoVersion = "$Version+$Sha" + + # -PrintEnv: emit shell-applicable env assignments and stop. CLI-agnostic on + # purpose -- the three keys drive ANY 'aspire' on PATH. Intended use: + # ./debug-staging.ps1 -Sha -PrintEnv | Invoke-Expression + if ($PrintEnv) { + Write-Output "# $kindLabel build (sha $sha8, feed darc-pub-microsoft-aspire-$sha8, quality $expectedQuality)." + Write-Output "# Apply to your current PowerShell session, then run aspire commands normally." + Write-Output "`$env:channel = 'staging'" + Write-Output "`$env:overrideCliIdentityChannel = '$Identity'" + Write-Output "`$env:overrideCliInformationalVersion = '$infoVersion'" + Write-Output "# To revert:" + Write-Output "# Remove-Item Env:channel, Env:overrideCliIdentityChannel, Env:overrideCliInformationalVersion" + return + } + + $scriptDir = Split-Path -Parent $PSCommandPath + + # Optionally install the PR build first; it becomes the default target CLI. + if (-not [string]::IsNullOrEmpty($Pr)) { + Write-Host ">> Installing PR #$Pr build via get-aspire-cli-pr.ps1 ..." + & (Join-Path $scriptDir 'get-aspire-cli-pr.ps1') $Pr + } + + if ([string]::IsNullOrEmpty($Cli)) { + $onPath = Get-Command aspire -ErrorAction SilentlyContinue + $installed = Join-Path $HOME '.aspire/bin/aspire' + if ($onPath) { + $Cli = $onPath.Source + } + elseif (Test-Path $installed) { + $Cli = $installed + } + else { + Write-Error "No aspire CLI found. Install a PR build (-Pr ), pass -Cli , or put 'aspire' on PATH." + return + } + } + if (-not (Test-Path $Cli)) { + Write-Error "CLI path '$Cli' does not exist." + return + } + # Resolve to an absolute path because the validation step runs from a scratch + # working directory, where a relative -Cli would no longer resolve. + $Cli = (Resolve-Path $Cli).Path + + Write-Host '' + Write-Host "Simulating an official $kindLabel build" + Write-Host " CLI: $Cli" + Write-Host " identity override: $Identity" + Write-Host " version override: $infoVersion" + Write-Host " expected feed: $expectedFeed" + Write-Host " expected quality: $expectedQuality" + Write-Host '' + + # -Shell: start a child PowerShell where the target CLI behaves like this build + # for every 'aspire' command. The overrides live only in the child process' + # environment, so closing it fully restores normal behavior. The CLI's directory + # is put first on PATH so a bare 'aspire' resolves to the target build. + if ($Shell) { + $cliDir = Split-Path -Parent $Cli + # Redirect NuGet's global packages folder to an isolated, per-sha directory + # so packages restored from the simulated staging feed (which can collide in + # version with packages already cached from real feeds) never contaminate the + # developer's real global cache (~/.nuget/packages by default). The directory + # is keyed by the simulated sha so repeat sessions reuse the same isolated + # cache, and is left in place on exit (it lives under the system temp dir). + $nugetPackages = Join-Path ([System.IO.Path]::GetTempPath()) (Join-Path 'aspire-debug-nuget' $sha8) + New-Item -ItemType Directory -Path $nugetPackages -Force | Out-Null + Write-Host '>> Launching a child PowerShell. Run aspire new, aspire add, etc.' + Write-Host " 'aspire' resolves to: $Cli" + Write-Host " NuGet packages cache: $nugetPackages (isolated from your global cache)" + Write-Host " Type 'exit' to leave and restore normal CLI behavior." + Write-Host '' + $env:channel = 'staging' + $env:overrideCliIdentityChannel = $Identity + $env:overrideCliInformationalVersion = $infoVersion + $env:NUGET_PACKAGES = $nugetPackages + $env:PATH = "$cliDir$([System.IO.Path]::PathSeparator)$env:PATH" + & (Get-Process -Id $PID).Path -NoExit -NoLogo + return + } + + # Throwaway working directory pinned to channel: staging so 'aspire add' + # filters to the synthesized staging channel. No real apphost project lives + # here, so 'add' will ultimately fail after feed routing has already been + # logged -- that is expected. + $scratch = Join-Path ([System.IO.Path]::GetTempPath()) ("aspire-debug-" + [System.Guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $scratch | Out-Null + try { + @' +{ + "channel": "staging" +} +'@ | Set-Content -Path (Join-Path $scratch 'aspire.config.json') -Encoding utf8 + + $log = Join-Path $scratch 'aspire-debug.log' + Write-Host ">> Running: aspire add $Package --debug $($PassThrough -join ' ')" + Write-Host ' (feed routing is logged before the add step fails on the missing apphost)' + Write-Host '' + + # The overrides are scoped to THIS invocation only (set then removed), so + # they can't leak into the developer's other aspire commands. 'aspire add' + # is allowed to exit non-zero; success is decided by the log. + $previousChannel = $env:overrideCliIdentityChannel + $previousVersion = $env:overrideCliInformationalVersion + $previousLocation = Get-Location + try { + $env:overrideCliIdentityChannel = $Identity + $env:overrideCliInformationalVersion = $infoVersion + Set-Location $scratch + $cliArgs = @('add', $Package, '--debug') + $PassThrough + & $Cli @cliArgs *> $log + } + finally { + Set-Location $previousLocation + $env:overrideCliIdentityChannel = $previousChannel + $env:overrideCliInformationalVersion = $previousVersion + } + + $logText = Get-Content -Path $log -Raw -ErrorAction SilentlyContinue + if ($null -eq $logText) { $logText = '' } + + # Echo the resolution + override-warning lines for visibility. + Get-Content -Path $log -ErrorAction SilentlyContinue | + Where-Object { $_ -match "diagnostic overrides are active|Resolved 'staging' channel|Refusing to synthesize|Could not synthesize" } | + ForEach-Object { Write-Host $_ } + Write-Host '' + + $expectedLine = "Resolved 'staging' channel: feed=$expectedFeed" + if ($logText -notmatch [regex]::Escape($expectedLine)) { + Write-Host $logText + Write-Error "FAILED: did not resolve the expected darc feed. Expected: $expectedLine, quality=$expectedQuality" + return + } + if ($logText -notmatch [regex]::Escape("$expectedLine, quality=$expectedQuality")) { + Write-Error "FAILED: resolved the darc feed but quality was not '$expectedQuality'." + return + } + + Write-Host "PASSED: $kindLabel build resolves Aspire.* from the darc feed with quality=$expectedQuality." + Write-Host '' + Write-Host "Equivalent persistent 'config options' (drop into the apphost's aspire.config.json" + Write-Host 'to simulate this build interactively with an installed PR build):' + Write-Host '' + Write-Host @" +{ + "channel": "staging", + "overrideCliIdentityChannel": "$Identity", + "overrideCliInformationalVersion": "$infoVersion" +} +"@ + Write-Host '' + Write-Host 'Remove those override keys when you are done -- they are for local validation only.' + } + finally { + Remove-Item -Path $scratch -Recurse -Force -ErrorAction SilentlyContinue + } +} diff --git a/eng/scripts/debug-aspire-channel.sh b/eng/scripts/debug-aspire-channel.sh new file mode 100755 index 00000000000..46c799de764 --- /dev/null +++ b/eng/scripts/debug-aspire-channel.sh @@ -0,0 +1,298 @@ +#!/usr/bin/env bash + +# Shared implementation for debug-staging.sh and debug-stable.sh. +# +# Purpose +# ------- +# Make an EASY-TO-GET Aspire CLI build behave like an official release-branch +# **staging** build for the purpose of validating package feed routing, WITHOUT +# having to produce a real official build or stamp a binary locally. +# +# The recommended carrier is a PR build (a real, full self-extracting `~/.aspire` +# install) acquired with `eng/scripts/get-aspire-cli-pr.sh `. Any installed +# `aspire` (or a locally built one via `--cli`) works just as well, because the +# behavior is driven entirely by two diagnostic config overrides read by +# `PackagingService` (see docs/cli-staging-validation.md): +# +# overrideCliIdentityChannel - forces the identity used for staging-feed +# routing decisions (here: `staging`). +# overrideCliInformationalVersion - forces the informational version the SHA +# derivation and version-shape (quality) +# checks read, e.g. `13.4.0-preview.1.x+`. +# +# Both flow into IConfiguration from environment variables (used here) OR from +# aspire.config.json, and are scoped to staging feed routing only -- they do NOT +# change the global identity used for hive/packages directory lookups. A CLI run +# with them set emits a one-time warning so they can never silently mis-route a +# normal invocation. +# +# What this script asserts +# ------------------------ +# It runs `aspire add --debug` in a throwaway directory whose +# aspire.config.json pins `channel: staging`, then asserts the debug log contains +# Resolved 'staging' channel: feed=, quality= +# where the feed is the SHA-specific darc-pub-microsoft-aspire- +# feed. The `aspire add` step is expected to fail later (there is no real apphost +# project in the scratch directory); only the feed-routing log line is validated. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Default version shapes for the 13.4 release branch. Override with --version. +readonly DEFAULT_STAGING_VERSION="13.4.0-preview.1.26280.6" +readonly DEFAULT_STABLE_VERSION="13.4.0" + +say() { printf '%s\n' "$*"; } +say_err() { printf 'error: %s\n' "$*" >&2; } + +print_usage() { + local invoked_as="$1" + cat < [options] [-- ] + +Simulates an official ${KIND_LABEL} build and validates that the CLI resolves +Aspire.* packages from the SHA-specific darc feed for . + +Required: + --sha Commit hash of the darc feed to target (>= 8 hex chars). + Use a real release-branch build commit if you intend to + actually restore packages; any 8+ hex value is fine for + inspecting/asserting the resolved feed URL. + +Options: + --pr Install that PR's build first (via get-aspire-cli-pr.sh) + and target it. Omit to use an already-installed CLI. + --cli Path to the aspire CLI to drive. Default: 'aspire' on + PATH, else ~/.aspire/bin/aspire. + --version Override the informational version (without +). + Default for ${KIND_LABEL}: ${DEFAULT_VERSION}. + --identity Override the identity used for staging-feed routing. + Default: staging. + --package Package to use for the validation 'aspire add'. + Default: foundry. + --shell Instead of the one-shot validation, drop into an + interactive subshell where the target CLI behaves like + this ${KIND_LABEL} build for EVERY 'aspire' command + (aspire new, add, run, ...). The overrides are exported + only into that subshell; they vanish when you exit it. + --print-env Print the 'export' lines for this build to stdout so you + can apply them to your current shell, e.g. + eval "\$(${invoked_as} --sha --print-env)" + Every 'aspire' command in that shell then behaves like + this build until you 'unset' the variables (printed too). + -h, --help Show this help. + +Anything after '--' is passed through to the aspire invocation. + +Examples: + ${invoked_as} --sha 1a2b3c4d5e6f7a8b + ${invoked_as} --pr 17743 --sha 1a2b3c4d5e6f7a8b + ${invoked_as} --cli ./artifacts/bin/Aspire.Cli/Debug/net10.0/aspire --sha 1a2b3c4d + # Install a PR build and explore it interactively as a staging build: + ${invoked_as} --pr 17743 --sha 1a2b3c4d5e6f7a8b --shell +USAGE +} + +# run_debug_channel [args...] +# kind: "staging" (prerelease-shaped) | "stable" (stable-shaped) +run_debug_channel() { + local kind="$1"; shift + local invoked_as="$1"; shift + + case "$kind" in + staging) KIND_LABEL="staging (prerelease-shaped)"; DEFAULT_VERSION="$DEFAULT_STAGING_VERSION"; EXPECTED_QUALITY="Both" ;; + stable) KIND_LABEL="staging (stable-shaped)"; DEFAULT_VERSION="$DEFAULT_STABLE_VERSION"; EXPECTED_QUALITY="Stable" ;; + *) say_err "unknown kind '$kind'"; return 2 ;; + esac + + local sha="" pr="" cli_path="" version="" identity="staging" package="foundry" + local mode="validate" + local -a passthrough=() + + while [[ $# -gt 0 ]]; do + case "$1" in + --sha) sha="${2:-}"; shift 2 ;; + --pr) pr="${2:-}"; shift 2 ;; + --cli) cli_path="${2:-}"; shift 2 ;; + --version) version="${2:-}"; shift 2 ;; + --identity) identity="${2:-}"; shift 2 ;; + --package) package="${2:-}"; shift 2 ;; + --shell) mode="shell"; shift ;; + --print-env) mode="printenv"; shift ;; + -h|--help) print_usage "$invoked_as"; return 0 ;; + --) shift; passthrough=("$@"); break ;; + *) say_err "unknown argument '$1'"; print_usage "$invoked_as" >&2; return 2 ;; + esac + done + + if [[ -z "$sha" ]]; then + say_err "--sha is required." + print_usage "$invoked_as" >&2 + return 2 + fi + + # The darc feed name is built from the first 8 chars of the commit hash, so + # require at least that many hex characters (full hashes are accepted). + if [[ ! "$sha" =~ ^[0-9a-fA-F]{8,40}$ ]]; then + say_err "--sha must be 8-40 hexadecimal characters (got '$sha')." + return 2 + fi + + [[ -n "$version" ]] || version="$DEFAULT_VERSION" + + # Lowercase the first 8 chars to match how PackagingService derives the feed. + local sha8 + sha8="$(printf '%s' "${sha:0:8}" | tr '[:upper:]' '[:lower:]')" + local expected_feed="https://pkgs.dev.azure.com/dnceng/public/_packaging/darc-pub-microsoft-aspire-${sha8}/nuget/v3/index.json" + local info_version="${version}+${sha}" + + # --print-env: emit shell-applicable export/unset lines and stop. This mode is + # CLI-agnostic on purpose -- the three keys drive ANY 'aspire' on PATH, so the + # developer can install/upgrade the carrier build independently. Intended use: + # eval "$(debug-staging.sh --sha --print-env)" + if [[ "$mode" == "printenv" ]]; then + cat <> Installing PR #${pr} build via get-aspire-cli-pr.sh ..." + "${SCRIPT_DIR}/get-aspire-cli-pr.sh" "$pr" + fi + + if [[ -z "$cli_path" ]]; then + if command -v aspire >/dev/null 2>&1; then + cli_path="$(command -v aspire)" + elif [[ -x "$HOME/.aspire/bin/aspire" ]]; then + cli_path="$HOME/.aspire/bin/aspire" + else + say_err "No aspire CLI found. Install a PR build (--pr ), pass --cli , or put 'aspire' on PATH." + return 1 + fi + fi + if [[ ! -x "$cli_path" ]]; then + say_err "CLI path '$cli_path' is not executable." + return 1 + fi + # Resolve to an absolute path because the validation step runs from a scratch + # working directory, where a relative --cli would no longer resolve. + case "$cli_path" in + /*) : ;; + *) cli_path="$(cd "$(dirname "$cli_path")" && pwd)/$(basename "$cli_path")" ;; + esac + + say "" + say "Simulating an official ${KIND_LABEL} build" + say " CLI: $cli_path" + say " identity override: $identity" + say " version override: $info_version" + say " expected feed: $expected_feed" + say " expected quality: $EXPECTED_QUALITY" + say "" + + # --shell: drop into an interactive subshell where the target CLI behaves like + # this build for EVERY 'aspire' command. The overrides live only in this child + # shell's environment, so exiting it fully restores normal behavior -- nothing + # is written to global/aspire.config.json. The target CLI's directory is put + # first on PATH so a bare 'aspire' resolves to it (handles --cli pointing at a + # local build as well as an installed PR build). + if [[ "$mode" == "shell" ]]; then + local cli_dir + cli_dir="$(dirname "$cli_path")" + # Redirect NuGet's global packages folder to an isolated, per-sha directory + # so packages restored from the simulated staging feed (which can collide in + # version with packages already cached from real feeds) never contaminate the + # developer's real global cache (~/.nuget/packages by default). The directory + # is keyed by the simulated sha so repeat sessions reuse the same isolated + # cache, and is left in place on exit (it lives under the system temp dir). + local nuget_packages="${TMPDIR:-/tmp}/aspire-debug-nuget/${sha8}" + mkdir -p "$nuget_packages" + say ">> Launching an interactive subshell. Run 'aspire new', 'aspire add', etc." + say " 'aspire' resolves to: $cli_path" + say " NuGet packages cache: $nuget_packages (isolated from your global cache)" + say " Type 'exit' to leave and restore normal CLI behavior." + say "" + channel="staging" \ + overrideCliIdentityChannel="$identity" \ + overrideCliInformationalVersion="$info_version" \ + NUGET_PACKAGES="$nuget_packages" \ + PATH="${cli_dir}:${PATH}" \ + ASPIRE_DEBUG_BUILD_PROMPT="aspire(${kind}:${sha8})" \ + "${SHELL:-/bin/bash}" -i + return $? + fi + + # Throwaway working directory pinned to channel: staging so 'aspire add' + # filters to the synthesized staging channel. Created securely; cleaned up + # on exit. No real apphost project lives here, so 'add' will ultimately fail + # after feed routing has already been logged -- that is expected. + local scratch + scratch="$(mktemp -d)" + trap 'rm -rf "$scratch"' RETURN + cat > "${scratch}/aspire.config.json" <> Running: aspire add ${package} --debug ${passthrough[*]:-}" + say " (feed routing is logged before the add step fails on the missing apphost)" + say "" + + # The overrides are scoped to THIS invocation only (no export, no persisted + # config), so they can't leak into the developer's other aspire commands. + # 'aspire add' is allowed to exit non-zero; success is decided by the log. + set +e + ( cd "$scratch" && \ + overrideCliIdentityChannel="$identity" \ + overrideCliInformationalVersion="$info_version" \ + "$cli_path" add "$package" --debug "${passthrough[@]+"${passthrough[@]}"}" ) > "$log" 2>&1 + set -e + + # Echo the resolution + override-warning lines for visibility. + grep -E "diagnostic overrides are active|Resolved 'staging' channel|Refusing to synthesize|Could not synthesize" "$log" || true + say "" + + local resolved_line + resolved_line="$(grep -F "Resolved 'staging' channel: feed=${expected_feed}" "$log" || true)" + if [[ -z "$resolved_line" ]]; then + say_err "FAILED: did not resolve the expected darc feed." + say_err "Expected: Resolved 'staging' channel: feed=${expected_feed}, quality=${EXPECTED_QUALITY}" + say_err "See full debug log for details:" + sed 's/^/ /' "$log" >&2 + return 1 + fi + + if [[ "$resolved_line" != *"quality=${EXPECTED_QUALITY}"* ]]; then + say_err "FAILED: resolved the darc feed but quality was not '${EXPECTED_QUALITY}'." + say_err " $resolved_line" + return 1 + fi + + say "PASSED: ${KIND_LABEL} build resolves Aspire.* from the darc feed with quality=${EXPECTED_QUALITY}." + say "" + say "Equivalent persistent 'config options' (drop into the apphost's aspire.config.json" + say "to simulate this build interactively with an installed PR build):" + say "" + cat <= 8 hex chars). Use a real + release-branch build commit to actually restore packages; any 8+ hex value + is fine for inspecting/asserting the resolved feed URL. + +.PARAMETER Pr + Install that PR's build first (via get-aspire-cli-pr.ps1) and target it. + Omit to use an already-installed CLI. + +.PARAMETER Cli + Path to the aspire CLI to drive. Default: 'aspire' on PATH, else + ~/.aspire/bin/aspire. + +.PARAMETER Version + Override the informational version (without +). Default: 13.4.0. + +.PARAMETER Identity + Override the identity used for staging-feed routing. Default: staging. + +.PARAMETER Package + Package to use for the validation 'aspire add'. Default: foundry. + +.PARAMETER PassThrough + Extra arguments passed through to the aspire invocation. + +.EXAMPLE + ./debug-stable.ps1 -Sha 1a2b3c4d5e6f7a8b + +.EXAMPLE + ./debug-stable.ps1 -Pr 17743 -Sha 1a2b3c4d5e6f7a8b +#> + +[CmdletBinding()] +param( + [string]$Sha, + [string]$Pr, + [string]$Cli, + [string]$Version, + [string]$Identity = 'staging', + [string]$Package = 'foundry', + [switch]$Shell, + [switch]$PrintEnv, + [Parameter(ValueFromRemainingArguments = $true)][string[]]$PassThrough = @() +) + +. (Join-Path (Split-Path -Parent $PSCommandPath) 'debug-aspire-channel.ps1') + +Invoke-DebugChannel -Kind 'stable' -Sha $Sha -Pr $Pr -Cli $Cli -Version $Version ` + -Identity $Identity -Package $Package -Shell:$Shell -PrintEnv:$PrintEnv -PassThrough $PassThrough diff --git a/eng/scripts/debug-stable.sh b/eng/scripts/debug-stable.sh new file mode 100755 index 00000000000..47f797efeb8 --- /dev/null +++ b/eng/scripts/debug-stable.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash + +# Simulate an official STABLE-shaped staging build (e.g. 13.4.0) and validate that +# the CLI resolves Aspire.* from its SHA-specific darc feed. +# +# This is the scenario from https://github.com/microsoft/aspire/issues/17527: +# a stable-shaped release-branch build still resolves from its own darc feed +# (quality=Stable), not nuget.org. +# +# See docs/cli-staging-validation.md for the full validation matrix. + +set -euo pipefail + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/debug-aspire-channel.sh" + +run_debug_channel stable "debug-stable.sh" "$@" diff --git a/eng/scripts/debug-staging.ps1 b/eng/scripts/debug-staging.ps1 new file mode 100644 index 00000000000..d44261462f9 --- /dev/null +++ b/eng/scripts/debug-staging.ps1 @@ -0,0 +1,63 @@ +#!/usr/bin/env pwsh + +<# +.SYNOPSIS + Simulate an official PRERELEASE-shaped staging build (e.g. 13.4.0-preview.*) + and validate that the CLI resolves Aspire.* from its SHA-specific darc feed. + +.DESCRIPTION + This is the scenario from https://github.com/microsoft/aspire/issues/17744: + a prerelease-shaped staging build must use the darc-pub-microsoft-aspire- + feed (quality=Both), NOT the shared daily feed. + + See docs/cli-staging-validation.md for the full validation matrix. + +.PARAMETER Sha + Commit hash of the darc feed to target (>= 8 hex chars). Use a real + release-branch build commit to actually restore packages; any 8+ hex value + is fine for inspecting/asserting the resolved feed URL. + +.PARAMETER Pr + Install that PR's build first (via get-aspire-cli-pr.ps1) and target it. + Omit to use an already-installed CLI. + +.PARAMETER Cli + Path to the aspire CLI to drive. Default: 'aspire' on PATH, else + ~/.aspire/bin/aspire. + +.PARAMETER Version + Override the informational version (without +). Default: 13.4.0-preview.1.26280.6. + +.PARAMETER Identity + Override the identity used for staging-feed routing. Default: staging. + +.PARAMETER Package + Package to use for the validation 'aspire add'. Default: foundry. + +.PARAMETER PassThrough + Extra arguments passed through to the aspire invocation. + +.EXAMPLE + ./debug-staging.ps1 -Sha 1a2b3c4d5e6f7a8b + +.EXAMPLE + ./debug-staging.ps1 -Pr 17743 -Sha 1a2b3c4d5e6f7a8b +#> + +[CmdletBinding()] +param( + [string]$Sha, + [string]$Pr, + [string]$Cli, + [string]$Version, + [string]$Identity = 'staging', + [string]$Package = 'foundry', + [switch]$Shell, + [switch]$PrintEnv, + [Parameter(ValueFromRemainingArguments = $true)][string[]]$PassThrough = @() +) + +. (Join-Path (Split-Path -Parent $PSCommandPath) 'debug-aspire-channel.ps1') + +Invoke-DebugChannel -Kind 'staging' -Sha $Sha -Pr $Pr -Cli $Cli -Version $Version ` + -Identity $Identity -Package $Package -Shell:$Shell -PrintEnv:$PrintEnv -PassThrough $PassThrough diff --git a/eng/scripts/debug-staging.sh b/eng/scripts/debug-staging.sh new file mode 100755 index 00000000000..483fa594fd5 --- /dev/null +++ b/eng/scripts/debug-staging.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash + +# Simulate an official PRERELEASE-shaped staging build (e.g. 13.4.0-preview.*) and +# validate that the CLI resolves Aspire.* from its SHA-specific darc feed. +# +# This is the scenario from https://github.com/microsoft/aspire/issues/17744: +# a prerelease-shaped staging build must use the darc-pub-microsoft-aspire- +# feed (quality=Both), NOT the shared daily feed. +# +# See docs/cli-staging-validation.md for the full validation matrix. + +set -euo pipefail + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/debug-aspire-channel.sh" + +run_debug_channel staging "debug-staging.sh" "$@" diff --git a/src/Aspire.Cli/Packaging/PackagingService.cs b/src/Aspire.Cli/Packaging/PackagingService.cs index dc229e4cef1..c242229d0c6 100644 --- a/src/Aspire.Cli/Packaging/PackagingService.cs +++ b/src/Aspire.Cli/Packaging/PackagingService.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Aspire.Cli.Acquisition; using Aspire.Cli.Configuration; using Aspire.Cli.NuGet; using Aspire.Cli.Resources; @@ -26,9 +27,10 @@ internal interface IPackagingService /// /// On a CLI whose baked AspireCliChannel identity is daily, local, or /// pr-<N>, there is no deterministic way to produce a real staging feed: - /// the SHA-specific darc feed (darc-pub-microsoft-aspire-<hash>) only exists - /// for stable release branch builds, and falling back to the shared daily feed silently - /// resolves daily packages instead of staging ones. To avoid that downgrade + /// those identities are not officially published release-branch builds, so no SHA-specific + /// darc feed (darc-pub-microsoft-aspire-<hash>) carries their packages, and + /// falling back to the shared daily feed silently resolves daily packages instead of staging + /// ones. To avoid that downgrade /// (see ), the service refuses /// to fabricate a staging channel from those identities unless the caller has set /// overrideStagingFeed or enabled the staging feature flag. @@ -44,6 +46,27 @@ internal class PackagingService : IPackagingService // tests via InternalsVisibleTo so a single literal change can't drift. internal const string OverrideStagingFeedConfigKey = "overrideStagingFeed"; + // Diagnostic overrides for validating staging FEED ROUTING from a locally built CLI without + // having to produce a real official staging build. They are intentionally scoped to the + // staging-feed decisions in this service (they do NOT change the global + // CliExecutionContext.IdentityChannel used for hive/packages directory lookups), so a plain + // local dev build can be made to derive and resolve from a real darc-pub-microsoft-aspire- + // feed exactly the way an official staging build would. See docs/cli-staging-validation.md. + // + // overrideCliIdentityChannel - forces the identity used for staging-feed decisions + // (validated against the known channel set). Set to + // `staging` to exercise the staging-identity darc path. + // overrideCliInformationalVersion - forces the AssemblyInformationalVersion that the SHA + // derivation and version-shape (quality) checks read, + // e.g. `13.4.0-preview.1.26280.6+`. + // + // NOTE: These only route to a feed; they do not create one. They are typically useful only + // once the darc-pub-microsoft-aspire- feed actually exists for the specific commit/version + // you are emulating (i.e. an official build for that SHA has been published). Until then the + // derived feed URL resolves to nothing and restore will fail to find packages. + internal const string OverrideCliIdentityChannelConfigKey = "overrideCliIdentityChannel"; + internal const string OverrideCliInformationalVersionConfigKey = "overrideCliInformationalVersion"; + private readonly CliExecutionContext _executionContext; private readonly INuGetPackageCache _nuGetPackageCache; private readonly IFeatures _features; @@ -55,6 +78,12 @@ internal class PackagingService : IPackagingService // current Aspire.Cli assembly's InformationalVersion; tests inject a deterministic value // because the version baked into the test-host assembly varies by build configuration. private readonly Func _isStableShapedCliVersion; + // Provides the running CLI's AssemblyInformationalVersion (which carries the + + // build metadata used to derive the SHA-specific darc-pub-microsoft-aspire- staging + // feed). Defaults to reading the Aspire.Cli assembly; tests inject a deterministic value + // because the version baked into the test-host assembly varies by build configuration, which + // otherwise makes the derived darc feed URL non-deterministic (and therefore un-assertable). + private readonly Func _cliInformationalVersionProvider; // Cached result of the staging-channel availability check. The inputs (CLI identity, // overrideStagingFeed, StagingChannelEnabled feature) are effectively static for the @@ -70,7 +99,8 @@ public PackagingService( IConfiguration configuration, ILogger logger, Func? processPathProvider = null, - Func? isStableShapedCliVersion = null) + Func? isStableShapedCliVersion = null, + Func? cliInformationalVersionProvider = null) { _executionContext = executionContext; _nuGetPackageCache = nuGetPackageCache; @@ -78,7 +108,8 @@ public PackagingService( _configuration = configuration; _logger = logger; _processPathProvider = processPathProvider ?? (() => Environment.ProcessPath); - _isStableShapedCliVersion = isStableShapedCliVersion ?? IsStableShapedCliVersionFromAssembly; + _isStableShapedCliVersion = isStableShapedCliVersion ?? IsStableShapedCliVersionDefault; + _cliInformationalVersionProvider = cliInformationalVersionProvider ?? GetCliInformationalVersionDefault; _stagingUnavailableReasonCache = new Lazy(ComputeStagingChannelUnavailableReason); } @@ -89,9 +120,16 @@ public PackagingService( // a project's aspire.config.json pins `channel: staging` on a daily/local CLI. private int _stagingRefusalLogged; private int _stagingResolutionLogged; + private int _stagingFeedDerivationFailedLogged; + private int _stagingDiagnosticOverrideLogged; public Task> GetChannelsAsync(CancellationToken cancellationToken = default, string? requestedChannelName = null) { + // Emit the diagnostic-override warning up front so any invocation that has the overrides set + // leaves a trace, regardless of whether a staging channel ends up being synthesized below + // (e.g. an override that ultimately resolves to a non-staging identity still warns). + WarnIfStagingDiagnosticOverridesActive(); + var defaultChannel = PackageChannel.CreateImplicitChannel(_nuGetPackageCache, _features, _logger); var stableChannel = PackageChannel.CreateExplicitChannel(PackageChannelNames.Stable, PackageChannelQuality.Stable, new[] @@ -136,28 +174,27 @@ public Task> GetChannelsAsync(CancellationToken canc // need the channel materialized before they can match it below. var stagingChannelConfigured = string.Equals(_configuration["channel"], PackageChannelNames.Staging, StringComparisons.ChannelName); var stagingChannelRequested = string.Equals(requestedChannelName, PackageChannelNames.Staging, StringComparisons.ChannelName); - var stagingIdentityChannel = string.Equals(_executionContext.IdentityChannel, PackageChannelNames.Staging, StringComparisons.ChannelName); + var stagingIdentityChannel = string.Equals(GetEffectiveIdentityChannel(), PackageChannelNames.Staging, StringComparisons.ChannelName); var stagingFeatureEnabled = _features.IsFeatureEnabled(KnownFeatures.StagingChannelEnabled, false); if (stagingFeatureEnabled || stagingChannelConfigured || stagingChannelRequested || stagingIdentityChannel) { - // Default quality selection rules (per staging entry point): + // Default quality selection rules (per staging entry point). NOTE: quality controls + // version FILTERING only (which versions in the feed are eligible); it no longer + // selects the feed itself. Feed PROVENANCE is identity-driven inside + // ShouldUseSharedStagingFeed — a staging-identity CLI always resolves Aspire.* from its + // own SHA-specific darc-pub-microsoft-aspire- feed. // - Explicit user opt-in (`stagingChannelConfigured`, `stagingChannelRequested`): Both. // The user picked staging deliberately; they get the broadest matching window. // - `stagingFeatureEnabled` only (no other staging signal): Stable. Preserves the // pre-existing behavior of the staging feature flag. // - `stagingIdentityChannel` (the running CLI itself self-identifies as staging): - // depends on the CLI build's version shape. + // follows the CLI build's version shape so the eligible version window matches the + // packages the build actually shipped. // * Stable-shaped (e.g. "13.4.0", produced during release stabilization when - // StabilizePackageVersion=true) → Stable. The shared dotnet9 daily feed only - // carries prerelease-tagged 13.4.0-preview.* builds, so defaulting to Both - // would route Aspire.* to dotnet9 and fail to resolve the just-shipped - // stable-shaped packages — the bug from - // https://github.com/microsoft/aspire/issues/17527. Routing to Stable selects - // the SHA-derived darc-pub-microsoft-aspire- feed, where the - // stabilizing build's packages actually live. - // * Prerelease-shaped (e.g. "13.4.0-preview.1.123") → Both. SHA-specific darc - // feeds are only created for stable release-branch builds, so prerelease CLIs - // must use the shared daily feed; the historical Both default is correct. + // StabilizePackageVersion=true) → Stable, so resolution prefers the stable-shaped + // packages on the darc feed (the #17527 scenario). + // * Prerelease-shaped (e.g. "13.4.0-preview.1.123") → Both, so prerelease-tagged + // packages on the darc feed remain eligible. PackageChannelQuality defaultQuality; if (stagingIdentityChannel) { @@ -165,8 +202,8 @@ public Task> GetChannelsAsync(CancellationToken canc // quality MUST follow the CLI build's version shape regardless of how synthesis // was triggered. `init` and many other commands pass requestedChannelName=staging // when identity is staging, so checking `stagingChannelRequested` first would - // short-circuit this path and re-introduce the #17527 misroute on stabilizing - // builds. + // short-circuit this path and re-introduce the #17527 version-filtering mismatch on + // stabilizing builds. defaultQuality = _isStableShapedCliVersion() ? PackageChannelQuality.Stable : PackageChannelQuality.Both; @@ -279,6 +316,105 @@ private static bool IsStableShapedCliVersionFromAssembly() } } + // Reads the running CLI assembly's AssemblyInformationalVersion, which carries the + + // build metadata used to derive the SHA-specific darc-pub-microsoft-aspire- staging feed. + // Returns null on any error so callers degrade gracefully (no derived feed) rather than throwing. + private static string? GetCliInformationalVersionFromAssembly() + { + try + { + return Assembly.GetExecutingAssembly() + .GetCustomAttributes(typeof(AssemblyInformationalVersionAttribute), false) + .OfType() + .FirstOrDefault()?.InformationalVersion; + } + catch + { + return null; + } + } + + // Default version-shape predicate. Honors the overrideCliInformationalVersion diagnostic + // override (so a locally built CLI can present as stable- or prerelease-shaped for staging + // validation) before falling back to the real assembly version. + private bool IsStableShapedCliVersionDefault() + { + var overrideVersion = _configuration[OverrideCliInformationalVersionConfigKey]; + if (!string.IsNullOrEmpty(overrideVersion)) + { + // Stable-shaped == no semver prerelease tag. Strip build metadata (+) first so a + // commit hash that happens to contain '-' can't be misread as a prerelease tag. Example: + // "13.4.0-preview.1.26280.6+abcd-ef12" -> version part "13.4.0-preview.1.26280.6" -> prerelease + // "13.4.0+abcd-ef12" -> version part "13.4.0" -> stable + return !StripBuildMetadata(overrideVersion).Contains('-'); + } + + return IsStableShapedCliVersionFromAssembly(); + } + + // Default informational-version provider. Honors the overrideCliInformationalVersion diagnostic + // override (so the SHA-specific darc feed can be derived deterministically from a locally built + // CLI) before falling back to the real assembly informational version. + private string? GetCliInformationalVersionDefault() + { + var overrideVersion = _configuration[OverrideCliInformationalVersionConfigKey]; + if (!string.IsNullOrEmpty(overrideVersion)) + { + return overrideVersion; + } + + return GetCliInformationalVersionFromAssembly(); + } + + private static string StripBuildMetadata(string version) + { + var plusIndex = version.IndexOf('+'); + return plusIndex >= 0 ? version[..plusIndex] : version; + } + + // Returns the identity channel used for staging-feed routing decisions. Normally this is the + // CLI build's baked identity (CliExecutionContext.IdentityChannel). For local validation of + // staging feed routing, overrideCliIdentityChannel can force a different identity (validated + // against the known channel set via IdentityChannelReader.IsValidChannel) WITHOUT changing the + // global identity used elsewhere (hive/packages directory lookups), keeping the blast radius + // limited to feed provenance. Invalid override values are ignored — we fall back to the real + // identity, mirroring how overrideStagingFeed ignores malformed URLs. + private string GetEffectiveIdentityChannel() + { + var overrideChannel = _configuration[OverrideCliIdentityChannelConfigKey]; + if (!string.IsNullOrEmpty(overrideChannel) && IdentityChannelReader.IsValidChannel(overrideChannel)) + { + return overrideChannel; + } + + return _executionContext.IdentityChannel; + } + + // Emits a single warning when either staging diagnostic override is active, so a normal CLI + // invocation can't silently resolve Aspire.* from an overridden identity/feed without a trace + // in the logs. Emitted at most once per process to avoid noise across repeated GetChannelsAsync + // calls. + private void WarnIfStagingDiagnosticOverridesActive() + { + var identityOverride = _configuration[OverrideCliIdentityChannelConfigKey]; + var versionOverride = _configuration[OverrideCliInformationalVersionConfigKey]; + if (string.IsNullOrEmpty(identityOverride) && string.IsNullOrEmpty(versionOverride)) + { + return; + } + + if (Interlocked.Exchange(ref _stagingDiagnosticOverrideLogged, 1) == 0) + { + _logger.LogWarning( + "Staging feed-routing diagnostic overrides are active: {IdentityKey}={IdentityValue}, {VersionKey}={VersionValue}. " + + "These are intended only for local validation of staging feed routing and must not be set on a normal CLI.", + OverrideCliIdentityChannelConfigKey, + string.IsNullOrEmpty(identityOverride) ? "(unset)" : identityOverride, + OverrideCliInformationalVersionConfigKey, + string.IsNullOrEmpty(versionOverride) ? "(unset)" : versionOverride); + } + } + private PackageChannel? CreateStagingChannel(PackageChannelQuality defaultQuality) { // Refuse to synthesize a staging channel on CLI identities that cannot produce a real @@ -300,16 +436,29 @@ private static bool IsStableShapedCliVersionFromAssembly() var stagingQuality = GetStagingQuality(defaultQuality); var hasExplicitFeedOverride = !string.IsNullOrEmpty(_configuration[OverrideStagingFeedConfigKey]); - // 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; + // Feed PROVENANCE is decided by the CLI build identity; version FILTERING is decided by + // quality. These are independent concerns and must not be conflated (see + // https://github.com/microsoft/aspire/issues/16652 for the original misroute, and the + // staging-identity prerelease regression that motivated separating them). + var effectiveIdentityChannel = GetEffectiveIdentityChannel(); + var useSharedFeed = ShouldUseSharedStagingFeed(hasExplicitFeedOverride, stagingQuality, effectiveIdentityChannel); var stagingFeedUrl = GetStagingFeedUrl(useSharedFeed); if (stagingFeedUrl is null) { + // Reaching here means synthesis was allowed (IsStagingChannelSynthesisAllowed passed) but the + // feed URL could not be produced. The only way that happens without an explicit override is the + // darc path failing to derive a commit hash from the CLI's AssemblyInformationalVersion (null, + // or no '+' build metadata). For a staging-identity CLI this should not occur on an + // officially published build, so surface it as a warning rather than silently dropping the + // channel — otherwise the caller just sees a missing 'staging' channel with no diagnostic + // (GetStagingChannelUnavailableReason() returns null because synthesis was permitted). + if (Interlocked.Exchange(ref _stagingFeedDerivationFailedLogged, 1) == 0) + { + _logger.LogWarning( + "Could not synthesize 'staging' package channel: failed to derive a staging feed URL for CLI identity '{Identity}' (no commit hash in the CLI version and no overrideStagingFeed set).", + effectiveIdentityChannel); + } return null; } @@ -340,6 +489,42 @@ private static bool IsStableShapedCliVersionFromAssembly() /// public string? GetStagingChannelUnavailableReason() => _stagingUnavailableReasonCache.Value; + // Decides whether the synthesized staging channel routes Aspire.* at the SHARED dnceng/dotnet9 + // daily feed (true) or at the SHA-specific darc-pub-microsoft-aspire- feed (false). + // + // The rule is identity-driven, NOT version-shape-driven: + // * Explicit overrideStagingFeed -> false. The caller named an exact feed; GetStagingFeedUrl + // returns it verbatim, so the shared-vs-darc distinction is moot. + // * staging IDENTITY -> false (always its own darc feed, any version shape). A CLI + // whose baked AspireCliChannel is `staging` is an officially published release-branch build, + // and darc publishes a per-commit darc-pub-microsoft-aspire- feed for EVERY such + // build — prerelease-shaped 13.4.0-preview.* and stable-shaped 13.4.0 alike. That feed is + // derived from the CLI's own commit, so it always carries the CLI's matching packages. + // Falling back to the shared dotnet9 daily feed (which only carries main-branch daily + // packages) silently resolves the wrong packages for polyglot apphosts while C# apphosts — + // whose nuget.config has the darc feed baked in — resolve correctly. That asymmetry is the + // bug this method fixes. (A missing darc feed for an officially published staging build is a + // publish/infra failure that should surface as an unresolved package, not be masked by a + // silent downgrade to daily packages.) + // * any other identity opting into staging (stable identity via config pin / StagingChannelEnabled + // feature) -> keep the historical quality-based routing: non-Stable quality uses the shared + // feed, Stable quality uses the SHA feed. Those identities do not own a release-branch darc + // feed of their own, so this preserves prior behavior unchanged. + private static bool ShouldUseSharedStagingFeed(bool hasExplicitFeedOverride, PackageChannelQuality stagingQuality, string identityChannel) + { + if (hasExplicitFeedOverride) + { + return false; + } + + if (string.Equals(identityChannel, PackageChannelNames.Staging, StringComparisons.ChannelName)) + { + return false; + } + + return stagingQuality is not PackageChannelQuality.Stable; + } + private string? ComputeStagingChannelUnavailableReason() { if (IsStagingChannelSynthesisAllowed()) @@ -350,7 +535,7 @@ private static bool IsStableShapedCliVersionFromAssembly() return string.Format( CultureInfo.CurrentCulture, PackagingStrings.StagingChannelUnavailableOnDailyCli, - _executionContext.IdentityChannel); + GetEffectiveIdentityChannel()); } private bool IsStagingChannelSynthesisAllowed() @@ -377,8 +562,8 @@ private bool IsStagingChannelSynthesisAllowed() // For daily, local, and pr- identities, falling back to either the SHA feed (no real // darc feed exists) or the shared daily feed silently resolves daily packages — the // exact bug tracked by https://github.com/microsoft/aspire/issues/16652. - return string.Equals(_executionContext.IdentityChannel, PackageChannelNames.Stable, StringComparisons.ChannelName) - || string.Equals(_executionContext.IdentityChannel, PackageChannelNames.Staging, StringComparisons.ChannelName); + return string.Equals(GetEffectiveIdentityChannel(), PackageChannelNames.Stable, StringComparisons.ChannelName) + || string.Equals(GetEffectiveIdentityChannel(), PackageChannelNames.Staging, StringComparisons.ChannelName); } private string? GetStagingFeedUrl(bool useSharedFeed) @@ -395,19 +580,18 @@ private bool IsStagingChannelSynthesisAllowed() // Invalid URL, fall through to default behavior } - // Use the shared daily feed when builds aren't marked stable + // Use the shared daily feed when the routing policy selected it (see ShouldUseSharedStagingFeed). 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-microsoft-aspire-{commitHash}/nuget/v3/index.json - var assembly = Assembly.GetExecutingAssembly(); - var informationalVersion = assembly - .GetCustomAttributes(typeof(AssemblyInformationalVersionAttribute), false) - .OfType() - .FirstOrDefault()?.InformationalVersion; + // Derive the SHA-specific staging feed from the CLI's own commit hash, carried in the + // AssemblyInformationalVersion build metadata after '+'. Example informational version: + // 13.4.0-preview.1.26280.6+abcdef1234567890abcdef1234567890abcdef12 + // yields the feed: + // https://pkgs.dev.azure.com/dnceng/public/_packaging/darc-pub-microsoft-aspire-abcdef12/nuget/v3/index.json + var informationalVersion = _cliInformationalVersionProvider(); if (informationalVersion is null) { diff --git a/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs index 55f35c348f3..2681adcae1f 100644 --- a/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs @@ -2033,6 +2033,17 @@ public async Task UpdateCommand_WhenStagingIdentityRegistersChannel_UsesStagingF { options.CliExecutionContextFactory = _ => workspace.CreateExecutionContext(identityChannel: PackageChannelNames.Staging); + // A real staging build always bakes the build's commit hash into its + // AssemblyInformationalVersion (e.g. "13.4.0-preview.1.26280.6+"), and the staging + // identity now routes to that build's SHA-specific darc-pub-microsoft-aspire- feed + // regardless of version shape. The test host assembly has no + metadata, so the + // feed could not be derived and the staging channel would never be synthesized. Provide a + // stamped informational version override so the derivation matches a real staging build. + options.ConfigurationCallback += config => + { + config[PackagingService.OverrideCliInformationalVersionConfigKey] = "13.4.0-preview.1.26280.6+2574ef57e97fc393aff67592fd442afca6a6d02f"; + }; + options.ProjectLocatorFactory = _ => new TestProjectLocator() { UseOrFindAppHostProjectFileAsyncCallback = (projectFile, _, _) => diff --git a/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs b/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs index 1780f14cf3e..405d4ac9ae8 100644 --- a/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs @@ -8,6 +8,7 @@ using Aspire.Cli.Tests.Utils; using Aspire.Cli.Utils; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using System.Xml.Linq; @@ -90,7 +91,120 @@ public async Task GetChannelsAsync_WhenIdentityChannelIsStagingOnStableShapedCli // shared dotnet9 daily feed only carries prerelease-tagged 13.4.0-preview.* packages, // so a stabilizing staging CLI must route Aspire.* to the SHA-derived darc-pub-aspire- // feed instead — which requires defaulting the synthesized staging channel quality to - // Stable (so useSharedFeed in CreateStagingChannel resolves false). + // Stable (so useSharedFeed in CreateStagingChannel resolves false). No overrideStagingFeed + // is set: the injected informational version makes the darc derivation deterministic so the + // test exercises (and asserts) the real SHA-feed routing rather than an override crutch. + 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", identityChannel: PackageChannelNames.Staging); + + var packagingService = new PackagingService( + executionContext, + new FakeNuGetPackageCache(), + new TestFeatures(), + new ConfigurationBuilder().Build(), + NullLogger.Instance, + isStableShapedCliVersion: () => true, + cliInformationalVersionProvider: () => "13.4.0+abcdef1234567890abcdef1234567890abcdef12"); + + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + + var stagingChannel = channels.First(c => c.Name == PackageChannelNames.Staging); + Assert.Equal(PackageChannelQuality.Stable, stagingChannel.Quality); + + var aspireMapping = Assert.Single(stagingChannel.Mappings!, m => m.PackageFilter == "Aspire*"); + Assert.Equal( + "https://pkgs.dev.azure.com/dnceng/public/_packaging/darc-pub-microsoft-aspire-abcdef12/nuget/v3/index.json", + aspireMapping.Source); + } + + [Fact] + public async Task GetChannelsAsync_WhenIdentityChannelIsStagingPrereleaseShaped_RoutesAspirePackagesToDarcFeed() + { + // Reproduces the C# vs polyglot divergence: a staging-identity CLI with a prerelease-shaped + // version (e.g. "13.4.0-preview.1.26280.6") is still an officially published release-branch + // build, so Aspire.* must resolve from its own SHA-specific darc-pub-microsoft-aspire- + // feed — NOT the shared dnceng/dotnet9 daily feed (which only carries main-branch daily + // packages). Before the fix, useSharedFeed was derived from the version shape (Both quality -> + // shared daily feed), which is what broke `aspire add` for TypeScript apphosts while C# + // apphosts (with the darc feed baked into nuget.config) resolved correctly. + 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", identityChannel: PackageChannelNames.Staging); + + // No overrideStagingFeed configured, so the real darc-vs-shared-daily routing is exercised. + var packagingService = new PackagingService( + executionContext, + new FakeNuGetPackageCache(), + new TestFeatures(), + new ConfigurationBuilder().Build(), + NullLogger.Instance, + isStableShapedCliVersion: () => false, + cliInformationalVersionProvider: () => "13.4.0-preview.1.26280.6+abcdef1234567890abcdef1234567890abcdef12"); + + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + + var stagingChannel = channels.First(c => c.Name == PackageChannelNames.Staging); + Assert.Equal(PackageChannelQuality.Both, stagingChannel.Quality); + + var aspireMapping = Assert.Single(stagingChannel.Mappings!, m => m.PackageFilter == "Aspire*"); + Assert.Equal( + "https://pkgs.dev.azure.com/dnceng/public/_packaging/darc-pub-microsoft-aspire-abcdef12/nuget/v3/index.json", + aspireMapping.Source); + Assert.DoesNotContain("dotnet9", aspireMapping.Source); + + // The darc feed needs an isolated global packages folder, and it carries exactly the build's + // matching packages, so no CLI-version pin is applied. + Assert.True(stagingChannel.ConfigureGlobalPackagesFolder); + Assert.Null(stagingChannel.PinnedVersion); + } + + [Fact] + public async Task GetChannelsAsync_WhenIdentityChannelIsStagingStableShaped_RoutesAspirePackagesToDarcFeed() + { + // Regression guard for https://github.com/microsoft/aspire/issues/17527: a stable-shaped + // staging CLI ("13.4.0") must resolve Aspire.* from its SHA-specific darc feed with Stable + // quality (version filtering). The fix keeps this behavior while also covering the + // prerelease-shaped case above. + 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", identityChannel: PackageChannelNames.Staging); + + var packagingService = new PackagingService( + executionContext, + new FakeNuGetPackageCache(), + new TestFeatures(), + new ConfigurationBuilder().Build(), + NullLogger.Instance, + isStableShapedCliVersion: () => true, + cliInformationalVersionProvider: () => "13.4.0+abcdef1234567890abcdef1234567890abcdef12"); + + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + + var stagingChannel = channels.First(c => c.Name == PackageChannelNames.Staging); + Assert.Equal(PackageChannelQuality.Stable, stagingChannel.Quality); + + var aspireMapping = Assert.Single(stagingChannel.Mappings!, m => m.PackageFilter == "Aspire*"); + Assert.Equal( + "https://pkgs.dev.azure.com/dnceng/public/_packaging/darc-pub-microsoft-aspire-abcdef12/nuget/v3/index.json", + aspireMapping.Source); + + // Same darc-feed invariants as the prerelease-shaped case: isolated global packages folder + // and no CLI-version pin (the SHA feed already carries exactly the build's packages). + Assert.True(stagingChannel.ConfigureGlobalPackagesFolder); + Assert.Null(stagingChannel.PinnedVersion); + } + + [Fact] + public async Task GetChannelsAsync_WhenIdentityChannelIsStagingWithOverrideFeed_UsesOverrideFeed() + { + // An explicit overrideStagingFeed always wins over identity-based darc derivation. using var workspace = TemporaryWorkspace.Create(outputHelper); var tempDir = workspace.WorkspaceRoot; var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")); @@ -103,12 +217,372 @@ public async Task GetChannelsAsync_WhenIdentityChannelIsStagingOnStableShapedCli [PackagingService.OverrideStagingFeedConfigKey] = "https://example.com/nuget/v3/index.json" }) .Build(); - var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), new TestFeatures(), configuration, NullLogger.Instance, isStableShapedCliVersion: () => true); + var packagingService = new PackagingService( + executionContext, + new FakeNuGetPackageCache(), + new TestFeatures(), + configuration, + NullLogger.Instance, + isStableShapedCliVersion: () => false, + cliInformationalVersionProvider: () => "13.4.0-preview.1.26280.6+abcdef1234567890abcdef1234567890abcdef12"); var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); var stagingChannel = channels.First(c => c.Name == PackageChannelNames.Staging); + var aspireMapping = Assert.Single(stagingChannel.Mappings!, m => m.PackageFilter == "Aspire*"); + Assert.Equal("https://example.com/nuget/v3/index.json", aspireMapping.Source); + } + + [Fact] + public async Task GetChannelsAsync_WhenStagingIdentityCannotDeriveFeedUrl_OmitsChannelAndWarns() + { + // A staging-identity CLI whose informational version carries no '+' build metadata + // (e.g. an unstamped local/dev build) cannot derive its SHA-specific darc feed, and there is + // no override feed. Synthesis was permitted by the identity gate, so the only safe outcome is + // to omit the staging channel and surface a warning — silently routing to the shared daily + // feed would resolve the wrong (main-branch) packages, which is the bug this PR fixes. + 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", identityChannel: PackageChannelNames.Staging); + + var logger = new CapturingLogger(); + var packagingService = new PackagingService( + executionContext, + new FakeNuGetPackageCache(), + new TestFeatures(), + new ConfigurationBuilder().Build(), + logger, + isStableShapedCliVersion: () => false, + cliInformationalVersionProvider: () => "13.4.0-preview.1.26280.6"); // no '+' build metadata + + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + + Assert.DoesNotContain(PackageChannelNames.Staging, channels.Select(c => c.Name)); + // Synthesis was allowed, so the unavailable-reason API has nothing to report — the warning + // is the only diagnostic for this edge case. + Assert.Null(packagingService.GetStagingChannelUnavailableReason()); + Assert.Contains(logger.Entries, e => e.Level == LogLevel.Warning && e.Message.Contains("staging feed URL")); + } + + public enum ExpectedStagingFeed + { + Absent, + Darc, + Shared, + Override, + } + + // Locks the full ShouldUseSharedStagingFeed decision table in one place: feed PROVENANCE is + // identity-driven (staging identity and the Stable-quality feature-flag path -> SHA-specific + // darc feed), while a non-staging identity that opts into staging with Both quality keeps the + // shared dotnet9 daily feed, an explicit override always wins, and an identity with no staging + // opt-in synthesizes no channel at all. + [Theory] + [InlineData(PackageChannelNames.Staging, false, false, false, null, ExpectedStagingFeed.Darc)] // staging identity, prerelease-shaped + [InlineData(PackageChannelNames.Staging, true, false, false, null, ExpectedStagingFeed.Darc)] // staging identity, stable-shaped + [InlineData(PackageChannelNames.Staging, false, false, false, "https://example.com/o/v3/index.json", ExpectedStagingFeed.Override)] // override always wins + [InlineData(PackageChannelNames.Stable, false, false, true, null, ExpectedStagingFeed.Shared)] // stable identity + config channel=staging => Both => shared + [InlineData(PackageChannelNames.Stable, false, true, false, null, ExpectedStagingFeed.Darc)] // stable identity + feature flag only => Stable => darc + [InlineData(PackageChannelNames.Daily, false, true, false, null, ExpectedStagingFeed.Darc)] // daily identity + feature flag only => Stable => darc + [InlineData(PackageChannelNames.Local, false, false, false, null, ExpectedStagingFeed.Absent)] // local identity, no opt-in => no channel + public async Task GetChannelsAsync_StagingFeedRoutingDecisionTable( + string identityChannel, + bool isStableShaped, + bool featureEnabled, + bool configChannelStaging, + string? overrideFeed, + ExpectedStagingFeed expected) + { + const string DarcUrl = "https://pkgs.dev.azure.com/dnceng/public/_packaging/darc-pub-microsoft-aspire-abcdef12/nuget/v3/index.json"; + const string SharedUrl = "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet9/nuget/v3/index.json"; + + 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", identityChannel: identityChannel); + + var settings = new Dictionary(); + if (configChannelStaging) + { + settings["channel"] = PackageChannelNames.Staging; + } + if (overrideFeed is not null) + { + settings[PackagingService.OverrideStagingFeedConfigKey] = overrideFeed; + } + var configuration = new ConfigurationBuilder().AddInMemoryCollection(settings).Build(); + + var features = new TestFeatures(); + if (featureEnabled) + { + features.SetFeature(KnownFeatures.StagingChannelEnabled, true); + } + + var packagingService = new PackagingService( + executionContext, + new FakeNuGetPackageCache(), + features, + configuration, + NullLogger.Instance, + isStableShapedCliVersion: () => isStableShaped, + cliInformationalVersionProvider: () => "13.4.0+abcdef1234567890abcdef1234567890abcdef12"); + + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + var stagingChannel = channels.SingleOrDefault(c => c.Name == PackageChannelNames.Staging); + + if (expected == ExpectedStagingFeed.Absent) + { + Assert.Null(stagingChannel); + return; + } + + Assert.NotNull(stagingChannel); + var aspireSource = Assert.Single(stagingChannel.Mappings!, m => m.PackageFilter == "Aspire*").Source; + var expectedSource = expected switch + { + ExpectedStagingFeed.Darc => DarcUrl, + ExpectedStagingFeed.Shared => SharedUrl, + ExpectedStagingFeed.Override => overrideFeed, + _ => throw new InvalidOperationException($"Unexpected expectation: {expected}"), + }; + Assert.Equal(expectedSource, aspireSource); + } + + // The following tests exercise the diagnostic override mechanism (overrideCliIdentityChannel + + // overrideCliInformationalVersion) end-to-end through the REAL config-reading default providers + // (the seams are intentionally NOT injected), which is exactly the local-validation recipe in + // docs/cli-staging-validation.md. A locally built CLI bakes a 'local' identity, so without the + // overrides these scenarios would never synthesize a staging channel at all. + + [Fact] + public async Task GetChannelsAsync_WhenIdentityOverrideAndVersionOverrideSet_RoutesAspirePackagesToDarcFeed() + { + // Full local-validation recipe: a 'local' identity CLI is told (via config overrides) to behave + // like a prerelease-shaped staging build. Both overrides are required — the identity override + // makes ShouldUseSharedStagingFeed pick the darc feed, and the version override supplies the + // '+' the darc URL is derived from. + 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", identityChannel: PackageChannelNames.Local); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [PackagingService.OverrideCliIdentityChannelConfigKey] = PackageChannelNames.Staging, + [PackagingService.OverrideCliInformationalVersionConfigKey] = "13.4.0-preview.1.26280.6+abcdef1234567890abcdef1234567890abcdef12", + }) + .Build(); + + var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), new TestFeatures(), configuration, NullLogger.Instance); + + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + + var stagingChannel = Assert.Single(channels, c => c.Name == PackageChannelNames.Staging); + Assert.Equal(PackageChannelQuality.Both, stagingChannel.Quality); + + var aspireMapping = Assert.Single(stagingChannel.Mappings!, m => m.PackageFilter == "Aspire*"); + Assert.Equal( + "https://pkgs.dev.azure.com/dnceng/public/_packaging/darc-pub-microsoft-aspire-abcdef12/nuget/v3/index.json", + aspireMapping.Source); + Assert.DoesNotContain("dotnet9", aspireMapping.Source); + } + + [Fact] + public async Task GetChannelsAsync_WhenVersionOverrideIsStableShaped_DefaultsToStableQuality() + { + // A stable-shaped (no semver prerelease tag) version override drives the quality predicate to + // Stable, mirroring how an official stable-shaped staging build is filtered. + 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", identityChannel: PackageChannelNames.Local); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [PackagingService.OverrideCliIdentityChannelConfigKey] = PackageChannelNames.Staging, + [PackagingService.OverrideCliInformationalVersionConfigKey] = "13.4.0+abcdef1234567890abcdef1234567890abcdef12", + }) + .Build(); + + var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), new TestFeatures(), configuration, NullLogger.Instance); + + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + + var stagingChannel = Assert.Single(channels, c => c.Name == PackageChannelNames.Staging); Assert.Equal(PackageChannelQuality.Stable, stagingChannel.Quality); + Assert.Equal( + "https://pkgs.dev.azure.com/dnceng/public/_packaging/darc-pub-microsoft-aspire-abcdef12/nuget/v3/index.json", + Assert.Single(stagingChannel.Mappings!, m => m.PackageFilter == "Aspire*").Source); + } + + [Fact] + public async Task GetChannelsAsync_WhenIdentityOverrideIsInvalid_FallsBackToRealIdentity() + { + // An unrecognized identity override (rejected by IdentityChannelReader.IsValidChannel) is + // ignored and the real 'local' identity is used, so no staging channel is synthesized despite + // the version override being present. + 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", identityChannel: PackageChannelNames.Local); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [PackagingService.OverrideCliIdentityChannelConfigKey] = "not-a-real-channel", + [PackagingService.OverrideCliInformationalVersionConfigKey] = "13.4.0-preview.1.26280.6+abcdef1234567890abcdef1234567890abcdef12", + }) + .Build(); + + var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), new TestFeatures(), configuration, NullLogger.Instance); + + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + + Assert.DoesNotContain(PackageChannelNames.Staging, channels.Select(c => c.Name)); + } + + [Fact] + public async Task GetChannelsAsync_WhenOverrideStagingFeedSet_WinsOverVersionOverrideDerivation() + { + // overrideStagingFeed is the most powerful escape hatch and must win over the SHA-derived darc + // URL even when the diagnostic version override is also present. + 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", identityChannel: PackageChannelNames.Local); + + const string OverrideFeed = "https://example.com/override/v3/index.json"; + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [PackagingService.OverrideCliIdentityChannelConfigKey] = PackageChannelNames.Staging, + [PackagingService.OverrideCliInformationalVersionConfigKey] = "13.4.0-preview.1.26280.6+abcdef1234567890abcdef1234567890abcdef12", + [PackagingService.OverrideStagingFeedConfigKey] = OverrideFeed, + }) + .Build(); + + var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), new TestFeatures(), configuration, NullLogger.Instance); + + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + + var stagingChannel = Assert.Single(channels, c => c.Name == PackageChannelNames.Staging); + Assert.Equal(OverrideFeed, Assert.Single(stagingChannel.Mappings!, m => m.PackageFilter == "Aspire*").Source); + } + + [Fact] + public async Task GetChannelsAsync_WhenStagingDiagnosticOverridesActive_EmitsWarning() + { + // Any normal CLI invocation that has the diagnostic overrides set must leave a trace in the + // logs so an overridden identity/feed can't silently resolve Aspire.* packages. + 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", identityChannel: PackageChannelNames.Local); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [PackagingService.OverrideCliIdentityChannelConfigKey] = PackageChannelNames.Staging, + [PackagingService.OverrideCliInformationalVersionConfigKey] = "13.4.0-preview.1.26280.6+abcdef1234567890abcdef1234567890abcdef12", + }) + .Build(); + + var logger = new CapturingLogger(); + var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), new TestFeatures(), configuration, logger); + + await packagingService.GetChannelsAsync().DefaultTimeout(); + + Assert.Contains(logger.Entries, e => e.Level == LogLevel.Warning && e.Message.Contains("diagnostic overrides are active")); + } + + [Fact] + public async Task GetChannelsAsync_WhenOnlyVersionOverrideSet_WarnsButSynthesizesNoStagingChannel() + { + // Only the version override is set, so the identity stays 'local' and no staging channel is + // synthesized. The warning must still fire — the override is active even though it had no + // routing effect, and a silent no-op would hide a misconfiguration. + 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", identityChannel: PackageChannelNames.Local); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [PackagingService.OverrideCliInformationalVersionConfigKey] = "13.4.0-preview.1.26280.6+abcdef1234567890abcdef1234567890abcdef12", + }) + .Build(); + + var logger = new CapturingLogger(); + var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), new TestFeatures(), configuration, logger); + + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + + Assert.DoesNotContain(PackageChannelNames.Staging, channels.Select(c => c.Name)); + Assert.Contains(logger.Entries, e => e.Level == LogLevel.Warning && e.Message.Contains("diagnostic overrides are active")); + } + + [Theory] + [InlineData("13.4.0+abcd-ef1234567890", true)] // hyphen only in build metadata => stable-shaped + [InlineData("13.4.0-preview.1.26280.6+abcd-ef1234567890", false)] // semver prerelease tag => prerelease-shaped + public async Task GetChannelsAsync_VersionOverrideStableShapeIgnoresBuildMetadataHyphens(string overrideVersion, bool expectStableQuality) + { + // StripBuildMetadata removes the '+' before the prerelease-tag check, so a commit hash + // containing '-' must not be misread as a semver prerelease tag. + 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", identityChannel: PackageChannelNames.Local); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [PackagingService.OverrideCliIdentityChannelConfigKey] = PackageChannelNames.Staging, + [PackagingService.OverrideCliInformationalVersionConfigKey] = overrideVersion, + }) + .Build(); + + var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), new TestFeatures(), configuration, NullLogger.Instance); + + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + + var stagingChannel = Assert.Single(channels, c => c.Name == PackageChannelNames.Staging); + Assert.Equal(expectStableQuality ? PackageChannelQuality.Stable : PackageChannelQuality.Both, stagingChannel.Quality); + } + + [Fact] + public void GetStagingChannelUnavailableReason_WhenIdentityOverrideIsStaging_ReturnsNull() + { + // The unavailable-reason check (cached via Lazy) must also honor the identity override, so a + // local CLI with overrideCliIdentityChannel=staging reports staging as available. + 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", identityChannel: PackageChannelNames.Local); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [PackagingService.OverrideCliIdentityChannelConfigKey] = PackageChannelNames.Staging, + }) + .Build(); + + var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), new TestFeatures(), configuration, NullLogger.Instance); + + Assert.Null(packagingService.GetStagingChannelUnavailableReason()); } [Fact] @@ -273,9 +747,11 @@ public async Task GetChannelsAsync_WhenChannelStagingRequestedOnNonReleaseIdenti public async Task GetChannelsAsync_WhenChannelStagingRequestedOnDailyCliWithFeatureFlag_IncludesStagingChannel() { // Back-compat: the StagingChannelEnabled feature flag is an explicit developer/test opt-in - // and continues to bypass the identity gating. Without an override feed the SHA-specific - // path needs an AssemblyInformationalVersion to resolve, which is not guaranteed in test - // hosts, so we also supply overrideStagingFeed to make the test deterministic. + // and continues to bypass the identity gating. The feature-flag-only path defaults the + // synthesized channel quality to Stable, so a non-staging identity routes Aspire.* to the + // SHA-specific darc feed (not the shared daily feed). The informational version is injected + // so the darc derivation is deterministic — no overrideStagingFeed crutch is needed, which + // lets the assertions below isolate the feature-flag gate AND the real feed routing. using var workspace = TemporaryWorkspace.Create(outputHelper); var tempDir = workspace.WorkspaceRoot; var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")); @@ -285,30 +761,26 @@ public async Task GetChannelsAsync_WhenChannelStagingRequestedOnDailyCliWithFeat var features = new TestFeatures(); features.SetFeature(KnownFeatures.StagingChannelEnabled, true); - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - [PackagingService.OverrideStagingFeedConfigKey] = "https://example.com/staging/v3/index.json" - }) - .Build(); - - var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration, NullLogger.Instance); + var packagingService = new PackagingService( + executionContext, + new FakeNuGetPackageCache(), + features, + new ConfigurationBuilder().Build(), + NullLogger.Instance, + cliInformationalVersionProvider: () => "13.4.0+abcdef1234567890abcdef1234567890abcdef12"); var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); - Assert.Contains(PackageChannelNames.Staging, channels.Select(c => c.Name)); + var stagingChannel = Assert.Single(channels, c => c.Name == PackageChannelNames.Staging); Assert.Null(packagingService.GetStagingChannelUnavailableReason()); - // Isolate the feature-flag gate itself: IsStagingChannelSynthesisAllowed short-circuits on - // overrideStagingFeed before the feature flag is ever checked, so the assertions above - // would still pass if the feature-flag branch were removed. Build a second service whose - // only opt-in is the StagingChannelEnabled feature flag (no overrideStagingFeed) and - // assert that the gate alone reports the channel as available. We deliberately do not - // call GetChannelsAsync() here because the full channel-creation path requires an - // AssemblyInformationalVersion that is not guaranteed in test hosts. - var featureFlagOnlyConfig = new ConfigurationBuilder().Build(); - var featureFlagOnlyService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, featureFlagOnlyConfig, NullLogger.Instance); - Assert.Null(featureFlagOnlyService.GetStagingChannelUnavailableReason()); + // Feature-flag-only opt-in => Stable quality => darc feed (the gate alone, with no + // overrideStagingFeed, must both permit synthesis and route to the SHA feed). + Assert.Equal(PackageChannelQuality.Stable, stagingChannel.Quality); + var aspireMapping = Assert.Single(stagingChannel.Mappings!, m => m.PackageFilter == "Aspire*"); + Assert.Equal( + "https://pkgs.dev.azure.com/dnceng/public/_packaging/darc-pub-microsoft-aspire-abcdef12/nuget/v3/index.json", + aspireMapping.Source); } /// From 7bcb25415598b6cfe80abf40bd1fdc0079992348 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 31 May 2026 18:34:38 -0700 Subject: [PATCH 13/15] Resolve cross-compute-environment endpoint references for Foundry hosted agents (#17756) * Resolve cross-compute-environment endpoint references to Foundry hosted agents Fixes #17749. When a resource deployed to App Service, Azure Container Apps, or Kubernetes used WithReference() to reference a Foundry hosted agent, publishing failed because the publisher resolved the endpoint against its own local endpoint map, which does not contain the agent (deployed to the Foundry project compute environment). Introduce a shared ComputeEnvironmentEndpointResolver that, when an endpoint's owning resource is deployed to a different compute environment than the current publisher, delegates resolution to that owning environment's GetEndpointPropertyExpression. The three compute-environment publishers now call it in both the EndpointReference and EndpointReferenceExpression branches. Azure Front Door and the Foundry hosted-agent resolver are refactored onto the same shared lookup. AzureCognitiveServicesProjectResource gets a GetEndpointPropertyExpression override because the agent address is already a full https URL; the default scheme://host composition would produce a malformed double-scheme value. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add branch tests for ComputeEnvironmentEndpointResolver and correct misleading comment Add direct unit tests covering each branch of TryGetCrossEnvironmentEndpointExpression: cross-environment delegation, same-environment deployment target, WithComputeEnvironment binding backstop, no-compute-environment, bound multi-target (no throw), and unbound multi-target (throws). Correct the comment on the fast-path loop which incorrectly claimed it never throws on multi-target resources. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Remove redundant fast-path loop from ComputeEnvironmentEndpointResolver The first foreach loop using GetDeploymentTargetAnnotation(current) was redundant with TryGetEffectiveComputeEnvironment + the ReferenceEquals backstop for all well-formed inputs, and did not provide multi-target throw-safety. Remove it and keep the simpler resolve-then- compare flow. All six branch tests continue to pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Drop params and move out parameter last in cross-env endpoint resolver Replace the params IComputeEnvironmentResource?[] with an explicit IReadOnlyList parameter placed before the out, so the out parameter comes last per convention. Kubernetes still passes two current environments (its environment plus OwningComputeEnvironment) via a collection expression; ACA and AppService pass a single-element list. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add EndpointReference overload for cross-env endpoint resolver Add an overload taking EndpointReference that uses ep.Property(EndpointProperty.Url) internally, so the three EndpointReference call sites (ACA, AppService, Kubernetes) pass the endpoint directly instead of repeating the .Property(Url) projection. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Use hosted-agent deployment name in cross-env Foundry agent URL Hosted-agent deployment creates the Foundry agent version using the wrapper AzureHostedAgentResource.Name (e.g. "agent-ha" for a target named "agent"). The published cross-environment endpoint path was built from the bare resource name, producing /agents/agent which does not match the deployed /agents/agent-ha. Resolve the hosted-agent deployment target and use its name when present, falling back to the resource name for non-hosted agents. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Auto-wire Azure AI User role for hosted-agent consumers When a compute resource references a Foundry hosted agent's node app via WithReference, automatically grant the consumer the Azure AI User role on the owning Foundry account and provision a managed identity, removing the two manual post-deploy `az role assignment create` steps. Introduces a public, experimental ReferenceRoleAssignmentAnnotation in Aspire.Hosting.Azure. A resource that "fronts" an Azure resource (without being an IAzureResource itself) carries this annotation; AzureResourcePreparer folds its (Target, Roles) into the same role-assignment path used for direct Azure references. Foundry's AsHostedAgent stamps the annotation on the agent's node app granting only the least-privilege Azure AI User role; account defaults and explicit WithRoleAssignments suppression remain owned by the preparer and are not reintroduced. GetAllRoleAssignments now dedupes roles per target to avoid colliding bicep role-assignment identifiers. Adds preparer end-to-end tests (suppression preserved, defaults preserved, dedup) and Foundry stamp tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Regenerate cross-env Foundry snapshots for RBAC auto-wiring The cross-compute-environment Foundry hosted-agent snapshots were committed before the RBAC auto-wiring change and never regenerated. With the consumer now receiving a managed identity (web-identity) plus AZURE_CLIENT_ID and AZURE_TOKEN_CREDENTIALS env vars, the generated bicep/json changed. CI failed because the verified baselines were stale; local runs masked it because Verify auto-accepts on developer machines. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> (cherry picked from commit becb48e2d61099e35ae336d527d3875e928d6594) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Aspire.Hosting.Azure.AppContainers.csproj | 1 + .../BaseContainerAppContext.cs | 15 ++ .../Aspire.Hosting.Azure.AppService.csproj | 1 + .../AzureAppServiceWebsiteContext.cs | 15 ++ .../Aspire.Hosting.Azure.FrontDoor.csproj | 4 + .../AzureFrontDoorExtensions.cs | 7 +- .../AzureResourcePreparer.cs | 59 ++++-- .../ReferenceRoleAssignmentAnnotation.cs | 43 ++++ .../Aspire.Hosting.Foundry.csproj | 1 + .../HostedAgent/AzureHostedAgentResource.cs | 8 +- .../HostedAgentBuilderExtension.cs | 31 +++ .../Project/ProjectResource.cs | 41 +++- .../Aspire.Hosting.Kubernetes.csproj | 1 + .../KubernetesResource.cs | 17 ++ .../ComputeEnvironmentEndpointResolver.cs | 137 ++++++++++++ .../AzureKubernetesFoundryReferenceTests.cs | 70 +++++++ .../AzureAppServiceTests.cs | 54 +++++ .../AzureContainerAppsTests.cs | 54 +++++ .../AzureResourcePreparerTests.cs | 198 ++++++++++++++++++ ...edAcrossComputeEnvironments.verified.bicep | 148 +++++++++++++ ...vedAcrossComputeEnvironments.verified.json | 18 ++ ...edAcrossComputeEnvironments.verified.bicep | 99 +++++++++ ...vedAcrossComputeEnvironments.verified.json | 15 ++ .../HostedAgentExtensionTests.cs | 83 ++++++++ .../Aspire.Hosting.Tests.csproj | 1 + ...ComputeEnvironmentEndpointResolverTests.cs | 168 +++++++++++++++ 26 files changed, 1264 insertions(+), 25 deletions(-) create mode 100644 src/Aspire.Hosting.Azure/ReferenceRoleAssignmentAnnotation.cs create mode 100644 src/Shared/ComputeEnvironmentEndpointResolver.cs create mode 100644 tests/Aspire.Hosting.Azure.Kubernetes.Tests/AzureKubernetesFoundryReferenceTests.cs create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.EndpointReferenceToFoundryHostedAgentIsResolvedAcrossComputeEnvironments.verified.bicep create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.EndpointReferenceToFoundryHostedAgentIsResolvedAcrossComputeEnvironments.verified.json create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.EndpointReferenceToFoundryHostedAgentIsResolvedAcrossComputeEnvironments.verified.bicep create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.EndpointReferenceToFoundryHostedAgentIsResolvedAcrossComputeEnvironments.verified.json create mode 100644 tests/Aspire.Hosting.Tests/ComputeEnvironmentEndpointResolverTests.cs diff --git a/src/Aspire.Hosting.Azure.AppContainers/Aspire.Hosting.Azure.AppContainers.csproj b/src/Aspire.Hosting.Azure.AppContainers/Aspire.Hosting.Azure.AppContainers.csproj index 52e78c556c6..92848c8485c 100644 --- a/src/Aspire.Hosting.Azure.AppContainers/Aspire.Hosting.Azure.AppContainers.csproj +++ b/src/Aspire.Hosting.Azure.AppContainers/Aspire.Hosting.Azure.AppContainers.csproj @@ -11,6 +11,7 @@ + diff --git a/src/Aspire.Hosting.Azure.AppContainers/BaseContainerAppContext.cs b/src/Aspire.Hosting.Azure.AppContainers/BaseContainerAppContext.cs index c0d14ac555f..e252d0b7705 100644 --- a/src/Aspire.Hosting.Azure.AppContainers/BaseContainerAppContext.cs +++ b/src/Aspire.Hosting.Azure.AppContainers/BaseContainerAppContext.cs @@ -222,6 +222,15 @@ BicepValue GetHostValue(string? prefix = null, string? suffix = null) if (value is EndpointReference ep) { + // The referenced endpoint may belong to a resource deployed to a different compute + // environment (for example a Foundry hosted agent). In that case delegate to the owning + // compute environment instead of looking it up in this environment's local endpoint map. + if (ComputeEnvironmentEndpointResolver.TryGetCrossEnvironmentEndpointExpression( + ep, [_containerAppEnvironmentContext.Environment], out var crossExpr)) + { + return ProcessValue(crossExpr, secretType, parent); + } + var context = ep.Resource == resource ? this : _containerAppEnvironmentContext.GetContainerAppContext(ep.Resource); @@ -274,6 +283,12 @@ BicepValue GetHostValue(string? prefix = null, string? suffix = null) if (value is EndpointReferenceExpression epExpr) { + if (ComputeEnvironmentEndpointResolver.TryGetCrossEnvironmentEndpointExpression( + epExpr, [_containerAppEnvironmentContext.Environment], out var crossExpr)) + { + return ProcessValue(crossExpr, secretType, parent); + } + var context = epExpr.Endpoint.Resource == resource ? this : _containerAppEnvironmentContext.GetContainerAppContext(epExpr.Endpoint.Resource); diff --git a/src/Aspire.Hosting.Azure.AppService/Aspire.Hosting.Azure.AppService.csproj b/src/Aspire.Hosting.Azure.AppService/Aspire.Hosting.Azure.AppService.csproj index 84d433e5124..1570e890831 100644 --- a/src/Aspire.Hosting.Azure.AppService/Aspire.Hosting.Azure.AppService.csproj +++ b/src/Aspire.Hosting.Azure.AppService/Aspire.Hosting.Azure.AppService.csproj @@ -13,6 +13,7 @@ + diff --git a/src/Aspire.Hosting.Azure.AppService/AzureAppServiceWebsiteContext.cs b/src/Aspire.Hosting.Azure.AppService/AzureAppServiceWebsiteContext.cs index ca89a2faac7..b5d3c774560 100644 --- a/src/Aspire.Hosting.Azure.AppService/AzureAppServiceWebsiteContext.cs +++ b/src/Aspire.Hosting.Azure.AppService/AzureAppServiceWebsiteContext.cs @@ -167,6 +167,15 @@ private void ProcessEndpoints() if (value is EndpointReference ep) { + // The referenced endpoint may belong to a resource deployed to a different compute + // environment (for example a Foundry hosted agent). In that case delegate to the owning + // compute environment instead of looking it up in this environment's local endpoint map. + if (ComputeEnvironmentEndpointResolver.TryGetCrossEnvironmentEndpointExpression( + ep, [environmentContext.Environment], out var crossExpr)) + { + return ProcessValue(crossExpr, secretType, parent, isSlot); + } + var context = environmentContext.GetAppServiceContext(ep.Resource); return isSlot ? (GetEndpointValue(context._slotEndpointMapping[ep.EndpointName], EndpointProperty.Url), secretType) : @@ -206,6 +215,12 @@ private void ProcessEndpoints() if (value is EndpointReferenceExpression epExpr) { + if (ComputeEnvironmentEndpointResolver.TryGetCrossEnvironmentEndpointExpression( + epExpr, [environmentContext.Environment], out var crossExpr)) + { + return ProcessValue(crossExpr, secretType, parent, isSlot); + } + var context = environmentContext.GetAppServiceContext(epExpr.Endpoint.Resource); var mapping = isSlot ? context._slotEndpointMapping[epExpr.Endpoint.EndpointName] : context._endpointMapping[epExpr.Endpoint.EndpointName]; var val = GetEndpointValue(mapping, epExpr.Property); diff --git a/src/Aspire.Hosting.Azure.FrontDoor/Aspire.Hosting.Azure.FrontDoor.csproj b/src/Aspire.Hosting.Azure.FrontDoor/Aspire.Hosting.Azure.FrontDoor.csproj index 0715429ec9b..f5e42917159 100644 --- a/src/Aspire.Hosting.Azure.FrontDoor/Aspire.Hosting.Azure.FrontDoor.csproj +++ b/src/Aspire.Hosting.Azure.FrontDoor/Aspire.Hosting.Azure.FrontDoor.csproj @@ -15,6 +15,10 @@ + + + + diff --git a/src/Aspire.Hosting.Azure.FrontDoor/AzureFrontDoorExtensions.cs b/src/Aspire.Hosting.Azure.FrontDoor/AzureFrontDoorExtensions.cs index 167c95f44b3..73e557b2926 100644 --- a/src/Aspire.Hosting.Azure.FrontDoor/AzureFrontDoorExtensions.cs +++ b/src/Aspire.Hosting.Azure.FrontDoor/AzureFrontDoorExtensions.cs @@ -201,16 +201,11 @@ public static IResourceBuilder WithOrigin( private static IComputeEnvironmentResource GetEffectiveComputeEnvironment(IResource resource) { - if (resource.GetComputeEnvironment() is { } computeEnvironment) + if (ComputeEnvironmentEndpointResolver.TryGetEffectiveComputeEnvironment(resource, out var computeEnvironment)) { return computeEnvironment; } - if (resource.GetDeploymentTargetAnnotation()?.ComputeEnvironment is { } deploymentComputeEnvironment) - { - return deploymentComputeEnvironment; - } - throw new InvalidOperationException( $"Resource '{resource.Name}' does not have a compute environment. " + "Ensure a compute environment (e.g., Azure Container Apps, Azure App Service) is configured in the application model."); diff --git a/src/Aspire.Hosting.Azure/AzureResourcePreparer.cs b/src/Aspire.Hosting.Azure/AzureResourcePreparer.cs index ac76fc957dd..02448c02bb5 100644 --- a/src/Aspire.Hosting.Azure/AzureResourcePreparer.cs +++ b/src/Aspire.Hosting.Azure/AzureResourcePreparer.cs @@ -133,7 +133,8 @@ private async Task BuildRoleAssignmentAnnotations(DistributedApplicationModel ap foreach (var resource in resourceSnapshot) { var prerequisiteResources = new HashSet(); - var azureReferences = await GetAzureReferences(resource, cancellationToken).ConfigureAwait(false); + var directDependencies = await resource.GetResourceDependenciesAsync(executionContext, ResourceDependencyDiscoveryMode.DirectOnly, cancellationToken).ConfigureAwait(false); + var azureReferences = new HashSet(directDependencies.OfType()); var azureReferencesWithRoleAssignments = (resource.TryGetAnnotationsOfType(out var annotations) @@ -184,6 +185,42 @@ private async Task BuildRoleAssignmentAnnotations(DistributedApplicationModel ap } } + // A direct dependency that is not itself an Azure resource can still "front" one + // (e.g. a Foundry hosted agent's node app fronts its owning Foundry account). Such a + // resource carries ReferenceRoleAssignmentAnnotation(s) declaring that any resource + // referencing it should be granted roles on a transitive Azure target the normal + // IAzureResource-only reference walk above cannot reach. Fold those implied targets + // into the same role-assignment path so the consumer gets an identity + role bicep + // exactly as it would for a direct Azure reference. + foreach (var dependency in directDependencies) + { + if (!dependency.TryGetAnnotationsOfType(out var impliedRoleAssignments)) + { + continue; + } + + foreach (var impliedRoleAssignment in impliedRoleAssignments) + { + var target = impliedRoleAssignment.Target; + if (target.IsContainer() || target.IsEmulator()) + { + continue; + } + + if (executionContext.IsRunMode) + { + AppendGlobalRoleAssignments(globalRoleAssignments, target, impliedRoleAssignment.Roles); + } + else + { + // In PublishMode, materialize as an explicit RoleAssignmentAnnotation so + // GetAllRoleAssignments (which groups by target and unions roles) picks it + // up alongside any roles the consumer already declares for the same target. + resource.Annotations.Add(new RoleAssignmentAnnotation(target, impliedRoleAssignment.Roles)); + } + } + } + // in PublishMode with SupportsTargetedRoleAssignments, we need to create the identity and role assignment resources // if the resource references any Azure resources, or has role assignments to Azure resources if (executionContext.IsPublishMode) @@ -250,7 +287,12 @@ private static Dictionary { foreach (var g in roleAssignments.GroupBy(r => r.Target)) { - result[g.Key] = g.SelectMany(r => r.Roles); + // Deduplicate roles per target. A target can accumulate multiple RoleAssignmentAnnotations + // (e.g. an implied ReferenceRoleAssignmentAnnotation from two hosted agents on the same + // Foundry account, plus a direct reference). Emitting the same RoleDefinition twice would + // produce two RoleAssignment bicep resources with the same identifier ("{prefix}_{roleName}") + // and fail bicep compilation. This mirrors the RunMode path, which unions into a HashSet. + result[g.Key] = g.SelectMany(r => r.Roles).Distinct(); } } return result; @@ -359,19 +401,6 @@ private sealed class AddRoleAssignmentsContext( public DistributedApplicationExecutionContext ExecutionContext => executionContext; } - private async Task> GetAzureReferences(IResource resource, CancellationToken cancellationToken) - { - var dependencies = await resource.GetResourceDependenciesAsync(executionContext, ResourceDependencyDiscoveryMode.DirectOnly, cancellationToken).ConfigureAwait(false); - - HashSet azureReferences = []; - foreach (var azureResource in dependencies.OfType()) - { - azureReferences.Add(azureResource); - } - - return azureReferences; - } - private static void AppendGlobalRoleAssignments(Dictionary> globalRoleAssignments, AzureProvisioningResource azureResource, IEnumerable newRoles) { if (!globalRoleAssignments.TryGetValue(azureResource, out var existingRoles)) diff --git a/src/Aspire.Hosting.Azure/ReferenceRoleAssignmentAnnotation.cs b/src/Aspire.Hosting.Azure/ReferenceRoleAssignmentAnnotation.cs new file mode 100644 index 00000000000..58720eaad6e --- /dev/null +++ b/src/Aspire.Hosting.Azure/ReferenceRoleAssignmentAnnotation.cs @@ -0,0 +1,43 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Azure; + +/// +/// Declares that any compute resource referencing the annotated resource should be granted +/// on the Azure resource . +/// +/// The Azure resource that referencing resources should be granted roles on. +/// The roles that referencing resources should be assigned on . +/// +/// +/// This annotation is applied to a resource that "fronts" an Azure resource without being an +/// itself. For example, a Foundry hosted agent's node app is a plain +/// compute resource, but invoking the agent requires the caller to hold a role on the owning +/// Foundry account. The account is only a transitive dependency of a consumer, so +/// 's normal reference walk — which only acts on direct +/// dependencies — cannot reach it. +/// +/// +/// When a compute resource takes a direct dependency on a resource carrying this annotation, +/// folds (Target, Roles) into the same role-assignment +/// path used for direct Azure references, so the consumer gets a managed identity and the +/// corresponding role assignment on with no additional wiring. +/// +/// +[Experimental("ASPIREAZURE003", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public sealed class ReferenceRoleAssignmentAnnotation(AzureProvisioningResource target, IReadOnlySet roles) : IResourceAnnotation +{ + /// + /// Gets the Azure resource that resources referencing the annotated resource should be granted roles on. + /// + public AzureProvisioningResource Target { get; } = target; + + /// + /// Gets the set of roles that resources referencing the annotated resource should be assigned on . + /// + public IReadOnlySet Roles { get; } = roles; +} diff --git a/src/Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.csproj b/src/Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.csproj index 709af738f3a..d5dd4cae6ad 100644 --- a/src/Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.csproj +++ b/src/Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.csproj @@ -23,6 +23,7 @@ + diff --git a/src/Aspire.Hosting.Foundry/HostedAgent/AzureHostedAgentResource.cs b/src/Aspire.Hosting.Foundry/HostedAgent/AzureHostedAgentResource.cs index 1c4f139d339..a73b8de2ddb 100644 --- a/src/Aspire.Hosting.Foundry/HostedAgent/AzureHostedAgentResource.cs +++ b/src/Aspire.Hosting.Foundry/HostedAgent/AzureHostedAgentResource.cs @@ -25,7 +25,10 @@ namespace Aspire.Hosting.Foundry; /// public class AzureHostedAgentResource : Resource, IResourceWithEnvironment { - private const string AzureAIUserRoleDefinitionId = "53ca6127-db72-4b80-b1b0-d745d6d5456d"; + // The "Azure AI User" built-in role (data-plane access to Foundry agents/inference). Granted to + // the agent's own instance identity below, and to consumers that reference the agent (see + // HostedAgentResourceBuilderExtensions.GrantHostedAgentConsumerRoles). + internal const string AzureAIUserRoleDefinitionId = "53ca6127-db72-4b80-b1b0-d745d6d5456d"; /// /// Creates a new instance of the class. @@ -429,8 +432,7 @@ internal static async Task> GetResolvedEnvironmentVar throw CreateEndpointResolutionException(hostedAgent, resource, environmentVariableName, endpointReference, $"Endpoint '{endpoint.Name}' is internal. Foundry hosted agents can only reference externally exposed endpoints during publish."); } - var deploymentTarget = endpointReference.Resource.GetDeploymentTargetAnnotation(); - if (deploymentTarget?.ComputeEnvironment is not { } computeEnvironment) + if (!ComputeEnvironmentEndpointResolver.TryGetEffectiveComputeEnvironment(endpointReference.Resource, out var computeEnvironment)) { var reason = $"Resource '{endpointReference.Resource.Name}' does not have a compute environment deployment target."; throw CreateEndpointResolutionException(hostedAgent, resource, environmentVariableName, endpointReference, reason); diff --git a/src/Aspire.Hosting.Foundry/HostedAgent/HostedAgentBuilderExtension.cs b/src/Aspire.Hosting.Foundry/HostedAgent/HostedAgentBuilderExtension.cs index 4576f801b43..321df241285 100644 --- a/src/Aspire.Hosting.Foundry/HostedAgent/HostedAgentBuilderExtension.cs +++ b/src/Aspire.Hosting.Foundry/HostedAgent/HostedAgentBuilderExtension.cs @@ -4,6 +4,7 @@ using System.Net.Http.Json; using System.Text.Json; using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Azure; using Aspire.Hosting.Foundry; using Microsoft.Extensions.DependencyInjection; @@ -419,6 +420,35 @@ private static void ConfigurePublishMode( builder.ApplicationBuilder.AddResource(hostedAgent) .WithIconName("Agents") .WithReferenceRelationship(target); + + // Referencing a hosted agent (its node app) only injects the agent's service-discovery URL. + // Unlike referencing a first-class Azure resource, it does not give the consumer a managed + // identity or any RBAC on the Foundry account, so calls to the agent's invocation endpoint + // fail with 401/403 at runtime. Stamp a ReferenceRoleAssignmentAnnotation on the agent's + // target so AzureResourcePreparer grants the "Azure AI User" role on the owning Foundry + // account to every consumer that references this agent, and provisions the identity that + // makes ACA inject AZURE_CLIENT_ID. + StampHostedAgentConsumerRoleAnnotation(target, projectResource.Parent); + } + + private static void StampHostedAgentConsumerRoleAnnotation(IResourceWithEnvironment target, FoundryResource account) + { + // Grant only the "Azure AI User" role required to invoke the hosted agent. We deliberately do + // not union the account's default data-plane roles here: + // - A consumer that also references the account directly still receives those defaults through + // AzureResourcePreparer's normal reference walk (they are preserved when GetAllRoleAssignments + // unions per target). + // - A consumer that declares explicit role assignments on the account intentionally suppresses + // the account defaults; folding them back in here would defeat that suppression. + // So the minimal, least-privilege grant for a pure agent consumer is "Azure AI User" alone. + var roles = new HashSet + { + new(AzureHostedAgentResource.AzureAIUserRoleDefinitionId, "Azure AI User") + }; + +#pragma warning disable ASPIREAZURE003 // Type is for evaluation purposes only and is subject to change or removal in future updates. + target.Annotations.Add(new ReferenceRoleAssignmentAnnotation(account, roles)); +#pragma warning restore ASPIREAZURE003 } private sealed class HostedAgentRunProtocol @@ -454,3 +484,4 @@ private sealed class HostedAgentRunProtocol public required Func CreateRequestContent { get; init; } } } + diff --git a/src/Aspire.Hosting.Foundry/Project/ProjectResource.cs b/src/Aspire.Hosting.Foundry/Project/ProjectResource.cs index 8fcabc67d52..ac90f16f99e 100644 --- a/src/Aspire.Hosting.Foundry/Project/ProjectResource.cs +++ b/src/Aspire.Hosting.Foundry/Project/ProjectResource.cs @@ -212,9 +212,48 @@ public AzureContainerRegistryResource? ContainerRegistry /// Get the address for the particular agent's endpoint. /// ReferenceExpression IComputeEnvironmentResource.GetHostAddressExpression(EndpointReference endpointReference) + => GetAgentAddressExpression(endpointReference); + + /// + /// Produces the endpoint property expression for a hosted agent endpoint owned by this Foundry project. + /// + /// + /// The agent address is already a fully-qualified https URL + /// (for example https://{account}.services.ai.azure.com/.../agents/{name}), so it is + /// returned directly for URL-shaped properties. The default + /// implementation composes + /// {scheme}://{host}, which would produce a malformed double-scheme value + /// (for example http://https://...). Only the properties that WithReference emits for a + /// hosted agent endpoint are supported. + /// + ReferenceExpression IComputeEnvironmentResource.GetEndpointPropertyExpression(EndpointReferenceExpression endpointReferenceExpression) + { + ArgumentNullException.ThrowIfNull(endpointReferenceExpression); + + var property = endpointReferenceExpression.Property; + return property switch + { + EndpointProperty.Url => GetAgentAddressExpression(endpointReferenceExpression.Endpoint), + EndpointProperty.Scheme => ReferenceExpression.Create($"https"), + _ => throw new InvalidOperationException( + $"The endpoint property '{property}' is not supported for Foundry hosted agent endpoints. Only 'Url' and 'Scheme' are supported.") + }; + } + + private ReferenceExpression GetAgentAddressExpression(EndpointReference endpointReference) { var resource = endpointReference.Resource; - return ReferenceExpression.Create($"{Endpoint}/agents/{resource.Name}"); + + // For hosted agents, deployment creates the Foundry agent version using the wrapper + // AzureHostedAgentResource.Name (e.g. "agent-ha" for a target named "agent"), not the + // target resource name. The published cross-environment URL must point at that deployed + // agent name, so prefer the hosted-agent deployment target's name when one exists and fall + // back to the resource name for plain (non-hosted) agents. + var agentName = resource.GetDeploymentTargetAnnotation(this)?.DeploymentTarget is AzureHostedAgentResource hostedAgent + ? hostedAgent.Name + : resource.Name; + + return ReferenceExpression.Create($"{Endpoint}/agents/{agentName}"); } /// diff --git a/src/Aspire.Hosting.Kubernetes/Aspire.Hosting.Kubernetes.csproj b/src/Aspire.Hosting.Kubernetes/Aspire.Hosting.Kubernetes.csproj index 04e87db475a..a7992c280ff 100644 --- a/src/Aspire.Hosting.Kubernetes/Aspire.Hosting.Kubernetes.csproj +++ b/src/Aspire.Hosting.Kubernetes/Aspire.Hosting.Kubernetes.csproj @@ -14,6 +14,7 @@ + diff --git a/src/Aspire.Hosting.Kubernetes/KubernetesResource.cs b/src/Aspire.Hosting.Kubernetes/KubernetesResource.cs index 66921ab26ad..6f75bf8acfb 100644 --- a/src/Aspire.Hosting.Kubernetes/KubernetesResource.cs +++ b/src/Aspire.Hosting.Kubernetes/KubernetesResource.cs @@ -486,6 +486,16 @@ private async Task ProcessValueAsync(KubernetesEnvironmentContext contex if (value is EndpointReference ep) { + // The referenced endpoint may belong to a resource deployed to a different compute + // environment (for example a Foundry hosted agent). In that case delegate to the owning + // compute environment instead of looking it up in this environment's local endpoint map. + if (ComputeEnvironmentEndpointResolver.TryGetCrossEnvironmentEndpointExpression( + ep, [kubernetesEnvironmentResource, kubernetesEnvironmentResource.OwningComputeEnvironment], out var crossExpr)) + { + value = crossExpr; + continue; + } + var referencedResource = ep.Resource == this ? this : await context.CreateKubernetesResourceAsync(ep.Resource, executionContext, default).ConfigureAwait(false); @@ -516,6 +526,13 @@ private async Task ProcessValueAsync(KubernetesEnvironmentContext contex if (value is EndpointReferenceExpression epExpr) { + if (ComputeEnvironmentEndpointResolver.TryGetCrossEnvironmentEndpointExpression( + epExpr, [kubernetesEnvironmentResource, kubernetesEnvironmentResource.OwningComputeEnvironment], out var crossExpr)) + { + value = crossExpr; + continue; + } + var referencedResource = epExpr.Endpoint.Resource == this ? this : await context.CreateKubernetesResourceAsync(epExpr.Endpoint.Resource, executionContext, default).ConfigureAwait(false); diff --git a/src/Shared/ComputeEnvironmentEndpointResolver.cs b/src/Shared/ComputeEnvironmentEndpointResolver.cs new file mode 100644 index 00000000000..23dfdcf47ce --- /dev/null +++ b/src/Shared/ComputeEnvironmentEndpointResolver.cs @@ -0,0 +1,137 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting; + +/// +/// Helper for resolving endpoint references that point at a resource deployed to a different +/// compute environment than the one currently generating deployment artifacts. +/// +/// +/// Compute environment publishers (App Service, Azure Container Apps, Kubernetes) resolve +/// endpoint references against their own local endpoint map. When a resource references an +/// endpoint owned by a resource deployed to a different compute environment (for example a +/// Foundry hosted agent deployed to an AzureCognitiveServicesProjectResource), the +/// endpoint is not present in the local map and the lookup fails. In that case the owning +/// compute environment knows how to express the endpoint property, so we delegate to it. +/// This mirrors the inverse-direction logic used by Foundry hosted agents when they reference +/// endpoints owned by App Service/ACA/Kubernetes resources. +/// +internal static class ComputeEnvironmentEndpointResolver +{ + /// + /// Attempts to produce a for an endpoint's URL by + /// delegating to the compute environment that owns the endpoint's resource, when that + /// environment is different from the publisher's current compute environment(s). + /// + /// The endpoint reference to resolve. + /// + /// The compute environment(s) the current publisher is generating artifacts for. When the + /// endpoint's owning resource deploys to one of these, resolution is left to the local + /// endpoint map and this method returns . + /// + /// + /// When this method returns , contains the delegated reference expression. + /// + /// + /// if the endpoint is owned by a different compute environment and a + /// delegated expression was produced; otherwise . + /// + public static bool TryGetCrossEnvironmentEndpointExpression( + EndpointReference endpointReference, + IReadOnlyList currentComputeEnvironments, + [NotNullWhen(true)] out ReferenceExpression? expression) + { + ArgumentNullException.ThrowIfNull(endpointReference); + + return TryGetCrossEnvironmentEndpointExpression( + endpointReference.Property(EndpointProperty.Url), + currentComputeEnvironments, + out expression); + } + + /// + /// Attempts to produce a for an endpoint property by + /// delegating to the compute environment that owns the endpoint's resource, when that + /// environment is different from the publisher's current compute environment(s). + /// + /// The endpoint reference expression to resolve. + /// + /// The compute environment(s) the current publisher is generating artifacts for. When the + /// endpoint's owning resource deploys to one of these, resolution is left to the local + /// endpoint map and this method returns . + /// + /// + /// When this method returns , contains the delegated reference expression. + /// + /// + /// if the endpoint is owned by a different compute environment and a + /// delegated expression was produced; otherwise . + /// + public static bool TryGetCrossEnvironmentEndpointExpression( + EndpointReferenceExpression endpointReferenceExpression, + IReadOnlyList currentComputeEnvironments, + [NotNullWhen(true)] out ReferenceExpression? expression) + { + ArgumentNullException.ThrowIfNull(endpointReferenceExpression); + ArgumentNullException.ThrowIfNull(currentComputeEnvironments); + + expression = null; + + var owningResource = endpointReferenceExpression.Endpoint.Resource; + + // Resolve the compute environment the owning resource deploys to. A plain resource that is + // not deployed anywhere has none, so there is nothing to delegate to and the local lookup + // handles it. + if (!TryGetEffectiveComputeEnvironment(owningResource, out var owningComputeEnvironment)) + { + return false; + } + + // If the owning resource deploys to one of the current publisher's compute environments, the + // endpoint lives in the local endpoint map. Leave resolution to the existing local lookup so + // generated artifacts (bicep parameters, helm values, etc.) are unchanged. + foreach (var current in currentComputeEnvironments) + { + if (ReferenceEquals(current, owningComputeEnvironment)) + { + return false; + } + } + +#pragma warning disable ASPIRECOMPUTE002 // Experimental: compute environment endpoint expression + expression = owningComputeEnvironment.GetEndpointPropertyExpression(endpointReferenceExpression); +#pragma warning restore ASPIRECOMPUTE002 + + return true; + } + + /// + /// Resolves the compute environment that a resource is deployed to. A resource may be bound to a + /// compute environment explicitly (via ) or + /// implicitly through its deployment target. + /// + /// The resource whose compute environment should be resolved. + /// + /// When this method returns , contains the owning compute environment. + /// + /// + /// if a compute environment was resolved; otherwise . + /// + public static bool TryGetEffectiveComputeEnvironment( + IResource resource, + [NotNullWhen(true)] out IComputeEnvironmentResource? computeEnvironment) + { + ArgumentNullException.ThrowIfNull(resource); + + // Prefer an explicit compute environment binding, then fall back to the deployment target's + // compute environment. This matches how endpoint references are resolved elsewhere + // (Azure Front Door origins, Foundry hosted agents) so all call sites agree on "where is + // this resource deployed". + computeEnvironment = resource.GetComputeEnvironment() ?? resource.GetDeploymentTargetAnnotation()?.ComputeEnvironment; + return computeEnvironment is not null; + } +} diff --git a/tests/Aspire.Hosting.Azure.Kubernetes.Tests/AzureKubernetesFoundryReferenceTests.cs b/tests/Aspire.Hosting.Azure.Kubernetes.Tests/AzureKubernetesFoundryReferenceTests.cs new file mode 100644 index 00000000000..112e54fb25e --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Kubernetes.Tests/AzureKubernetesFoundryReferenceTests.cs @@ -0,0 +1,70 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable ASPIREAZURE003 +#pragma warning disable ASPIREPIPELINES003 + +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Publishing; +using Aspire.Hosting.Tests; +using Aspire.Hosting.Utils; +using Microsoft.Extensions.DependencyInjection; + +namespace Aspire.Hosting.Azure.Tests; + +public class AzureKubernetesFoundryReferenceTests +{ + [Fact] + public async Task EndpointReferenceToFoundryHostedAgentIsResolvedAcrossComputeEnvironments() + { + using var tempDir = new TestTempDirectory(); + using var builder = TestDistributedApplicationBuilder.Create( + DistributedApplicationOperation.Publish, + tempDir.Path); + + builder.Services.AddSingleton(); + + var aks = builder.AddAzureKubernetesEnvironment("aks"); + + var project = builder.AddFoundry("foundry") + .AddProject("project"); + + // The agent app is deployed to the Foundry project compute environment via AsHostedAgent. + var agent = builder.AddProject("agent", launchProfileName: null) + .WithHttpEndpoint() + .WithExternalHttpEndpoints(); + agent.AsHostedAgent(project); + + // The web app is deployed to Azure Kubernetes and references the Foundry hosted agent. + // The Kubernetes publisher must delegate endpoint resolution to the Foundry compute + // environment rather than looking the agent up in its own (local) endpoint map, which + // does not contain the cross-environment agent. See issue #17749. + // WithReference(agent) exercises the bare EndpointReference branch; the explicit + // Property(Url) environment variable exercises the EndpointReferenceExpression branch. + builder.AddProject("web", launchProfileName: null) + .WithHttpEndpoint() + .WithExternalHttpEndpoints() + .WithComputeEnvironment(aks) + .WithReference(agent) + .WithEnvironment("AGENT_URL", agent.GetEndpoint("http").Property(EndpointProperty.Url)); + + await using var app = builder.Build(); + await app.RunAsync(); + + // The resolved environment variable values are emitted into the Helm chart's values.yaml. + // The agent endpoint must resolve to the Foundry project endpoint composed with the deployed + // hosted agent path because hosted-agent deployment creates the Foundry agent version with the + // wrapper resource name. + var valuesPath = Directory.EnumerateFiles(tempDir.Path, "values.yaml", SearchOption.AllDirectories).Single(); + var values = await File.ReadAllTextAsync(valuesPath); + + Assert.Contains("AGENT_HTTP: \"{project.outputs.endpoint}/agents/agent-ha\"", values); + Assert.Contains("AGENT_URL: \"{project.outputs.endpoint}/agents/agent-ha\"", values); + Assert.Contains("services__agent__http__0: \"{project.outputs.endpoint}/agents/agent-ha\"", values); + } + + private sealed class Project : IProjectMetadata + { + public string ProjectPath => "project"; + } +} diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureAppServiceTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureAppServiceTests.cs index 2c5c185f59b..d8730e90796 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureAppServiceTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureAppServiceTests.cs @@ -6,6 +6,7 @@ using System.Text.Json.Nodes; using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Foundry; using Aspire.Hosting.Pipelines; using Aspire.Hosting.Utils; using Aspire.TestUtilities; @@ -225,6 +226,59 @@ await Verify(manifest.ToString(), "json") .AppendContentAsFile(bicep, "bicep"); } + [Fact] + public async Task EndpointReferenceToFoundryHostedAgentIsResolvedAcrossComputeEnvironments() + { + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var appServiceEnv = builder.AddAzureAppServiceEnvironment("env"); + + var project = builder.AddFoundry("foundry") + .AddProject("project"); + + // The agent app is deployed to the Foundry project compute environment via AsHostedAgent. + var agent = builder.AddProject("agent", launchProfileName: null) + .WithHttpEndpoint() + .WithExternalHttpEndpoints(); + agent.AsHostedAgent(project); + + // The web app is deployed to App Service and references the Foundry hosted agent. The App + // Service publisher must delegate endpoint resolution to the Foundry compute environment + // rather than looking the agent up in its own (App Service) endpoint map. See issue #17749. + // WithReference(agent) exercises the bare EndpointReference branch; the explicit + // Property(Url) environment variable exercises the EndpointReferenceExpression branch. + var web = builder.AddProject("web", launchProfileName: null) + .WithHttpEndpoint() + .WithExternalHttpEndpoints() + .WithComputeEnvironment(appServiceEnv) + .WithReference(agent) + .WithEnvironment("AGENT_URL", agent.GetEndpoint("http").Property(EndpointProperty.Url)); + + using var app = builder.Build(); + + await ExecuteBeforeStartHooksAsync(app, default); + + SetFoundryProjectOutputs(project.Resource); + + web.Resource.TryGetLastAnnotation(out var target); + + var resource = target?.DeploymentTarget as AzureProvisioningResource; + + Assert.NotNull(resource); + + var (manifest, bicep) = await GetManifestWithBicep(resource); + + await Verify(manifest.ToString(), "json") + .AppendContentAsFile(bicep, "bicep"); + } + + private static void SetFoundryProjectOutputs(AzureCognitiveServicesProjectResource project) + { + project.Outputs["endpoint"] = "https://account.services.ai.azure.com/api/projects/my-project"; + project.Outputs["APPLICATION_INSIGHTS_CONNECTION_STRING"] = ""; + project.ProvisioningTaskCompletionSource?.TrySetResult(); + } + [Fact] public async Task AzureAppServiceSupportBaitAndSwitchResources() { diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs index 7fbcb2451d4..1fa7da1cf95 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs @@ -10,6 +10,7 @@ using System.Text.Json.Nodes; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Azure.AppContainers; +using Aspire.Hosting.Foundry; using Aspire.Hosting.Pipelines; using Aspire.Hosting.Utils; using Azure.Provisioning; @@ -120,6 +121,59 @@ await Verify(manifest.ToString(), "json") .AppendContentAsFile(bicep, "bicep"); } + [Fact] + public async Task EndpointReferenceToFoundryHostedAgentIsResolvedAcrossComputeEnvironments() + { + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var acaEnv = builder.AddAzureContainerAppEnvironment("env"); + + var project = builder.AddFoundry("foundry") + .AddProject("project"); + + // The agent app is deployed to the Foundry project compute environment via AsHostedAgent. + var agent = builder.AddProject("agent", launchProfileName: null) + .WithHttpEndpoint() + .WithExternalHttpEndpoints(); + agent.AsHostedAgent(project); + + // The web app is deployed to Azure Container Apps and references the Foundry hosted agent. + // The ACA publisher must delegate endpoint resolution to the Foundry compute environment + // rather than looking the agent up in its own endpoint map. See issue #17749. + // WithReference(agent) exercises the bare EndpointReference branch; the explicit + // Property(Url) environment variable exercises the EndpointReferenceExpression branch. + var web = builder.AddProject("web", launchProfileName: null) + .WithHttpEndpoint() + .WithExternalHttpEndpoints() + .WithComputeEnvironment(acaEnv) + .WithReference(agent) + .WithEnvironment("AGENT_URL", agent.GetEndpoint("http").Property(EndpointProperty.Url)); + + using var app = builder.Build(); + + await ExecuteBeforeStartHooksAsync(app, default); + + SetFoundryProjectOutputs(project.Resource); + + var target = web.Resource.GetDeploymentTargetAnnotation(); + + var resource = target?.DeploymentTarget as AzureProvisioningResource; + + Assert.NotNull(resource); + + var (manifest, bicep) = await GetManifestWithBicep(resource); + + await Verify(manifest.ToString(), "json") + .AppendContentAsFile(bicep, "bicep"); + } + + private static void SetFoundryProjectOutputs(AzureCognitiveServicesProjectResource project) + { + project.Outputs["endpoint"] = "https://account.services.ai.azure.com/api/projects/my-project"; + project.Outputs["APPLICATION_INSIGHTS_CONNECTION_STRING"] = ""; + project.ProvisioningTaskCompletionSource?.TrySetResult(); + } + [Fact] public async Task AddExecutableResourceWithPublishAsDockerFileWithAppsInfrastructureAddsDeploymentTargetWithContainerAppToContainerResources() { diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureResourcePreparerTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureResourcePreparerTests.cs index 889b2c1b964..63dfc210cfd 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureResourcePreparerTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureResourcePreparerTests.cs @@ -538,6 +538,204 @@ public async Task ViteAppDoesNotGetManagedIdentity() Assert.DoesNotContain(model.Resources, r => r.Name == "frontend-identity"); } + [Fact] + public async Task ReferenceRoleAssignmentAnnotation_PublishMode_GrantsRolesOnImpliedTargetToConsumer() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + builder.AddAzureContainerAppEnvironment("env"); + + var storage = builder.AddAzureStorage("storage"); + + // A non-Azure compute resource (the "agent" node app) that fronts the storage account: any + // resource referencing it should be granted a role on storage even though storage is only a + // transitive dependency that the IAzureResource-only reference walk cannot reach. + var agent = builder.AddContainer("agent", "img:latest") + .WithHttpEndpoint(); + agent.Resource.Annotations.Add(new ReferenceRoleAssignmentAnnotation( + storage.Resource, + new HashSet { new(StorageBuiltInRole.StorageBlobDataReader.ToString(), nameof(StorageBuiltInRole.StorageBlobDataReader)) })); + + var consumer = builder.AddProject("api", launchProfileName: null) + .WithReference(agent.GetEndpoint("http")); + + using var app = builder.Build(); + await ExecuteBeforeStartHooksAsync(app, default); + + Assert.True(consumer.Resource.TryGetLastAnnotation(out var consumerRoleAssignments)); + Assert.Equal(storage.Resource, consumerRoleAssignments.Target); + Assert.Single(consumerRoleAssignments.Roles, role => role.Id == StorageBuiltInRole.StorageBlobDataReader.ToString()); + } + + [Fact] + public async Task ReferenceRoleAssignmentAnnotation_RunMode_AppliesRolesToGlobalRolesResource() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + builder.AddAzureContainerAppEnvironment("env"); + + var storage = builder.AddAzureStorage("storage"); + + var agent = builder.AddContainer("agent", "img:latest") + .WithHttpEndpoint(); + agent.Resource.Annotations.Add(new ReferenceRoleAssignmentAnnotation( + storage.Resource, + new HashSet { new(StorageBuiltInRole.StorageBlobDataReader.ToString(), nameof(StorageBuiltInRole.StorageBlobDataReader)) })); + + builder.AddProject("api", launchProfileName: null) + .WithReference(agent.GetEndpoint("http")); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + await ExecuteBeforeStartHooksAsync(app, default); + + var storageRoles = Assert.Single(model.Resources.OfType(), r => r.Name == "storage-roles"); + Assert.Same(storage.Resource, storageRoles.TargetAzureResource); + } + + [Fact] + public async Task ReferenceRoleAssignmentAnnotation_ConsumerNotReferencingFrontingResource_GetsNoRole() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + builder.AddAzureContainerAppEnvironment("env"); + + var storage = builder.AddAzureStorage("storage"); + + var agent = builder.AddContainer("agent", "img:latest") + .WithHttpEndpoint(); + agent.Resource.Annotations.Add(new ReferenceRoleAssignmentAnnotation( + storage.Resource, + new HashSet { new(StorageBuiltInRole.StorageBlobDataReader.ToString(), nameof(StorageBuiltInRole.StorageBlobDataReader)) })); + + // This compute resource does not reference the fronting agent, so it must not be granted any + // role on the implied storage target. + var bystander = builder.AddProject("bystander", launchProfileName: null); + + using var app = builder.Build(); + await ExecuteBeforeStartHooksAsync(app, default); + + Assert.False(bystander.Resource.TryGetLastAnnotation(out _)); + } + + [Fact] + public async Task ReferenceRoleAssignmentAnnotation_SameTargetFromTwoDependencies_DedupesRoles() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + builder.AddAzureContainerAppEnvironment("env"); + + var storage = builder.AddAzureStorage("storage"); + + // Two fronting resources (e.g. two hosted agents on the same Foundry account) each imply the + // same role on the same target. A consumer referencing both must end up with a single role + // assignment for that role, otherwise two RoleAssignment bicep resources collide on the same + // identifier ("{prefix}_{roleName}") and bicep compilation fails. + var role = new HashSet { new(StorageBuiltInRole.StorageBlobDataReader.ToString(), nameof(StorageBuiltInRole.StorageBlobDataReader)) }; + + var agent1 = builder.AddContainer("agent1", "img:latest").WithHttpEndpoint(); + agent1.Resource.Annotations.Add(new ReferenceRoleAssignmentAnnotation(storage.Resource, role)); + + var agent2 = builder.AddContainer("agent2", "img:latest").WithHttpEndpoint(); + agent2.Resource.Annotations.Add(new ReferenceRoleAssignmentAnnotation(storage.Resource, role)); + + var consumer = builder.AddProject("api", launchProfileName: null) + .WithReference(agent1.GetEndpoint("http")) + .WithReference(agent2.GetEndpoint("http")); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + await ExecuteBeforeStartHooksAsync(app, default); + + var roleAssignmentResource = Assert.Single(model.Resources.OfType(), r => r.Name == "api-roles-storage"); + Assert.Same(storage.Resource, roleAssignmentResource.TargetAzureResource); + Assert.Same(consumer.Resource, roleAssignmentResource.OwnerResource); + + // The generated bicep must contain exactly one role assignment, not two duplicates of the same role. + var manifest = await GetManifestWithBicep(roleAssignmentResource, skipPreparer: true); + var roleAssignmentCount = System.Text.RegularExpressions.Regex.Matches(manifest.BicepText, "Microsoft.Authorization/roleAssignments@").Count; + Assert.Equal(1, roleAssignmentCount); + } + + [Fact] + public async Task ReferenceRoleAssignmentAnnotation_ConsumerWithExplicitRoleAssignment_DoesNotReintroduceSuppressedDefaults() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + builder.AddAzureContainerAppEnvironment("env"); + + var storage = builder.AddAzureStorage("storage"); + var blobs = storage.AddBlobs("blobs"); + + // A fronting resource (e.g. a Foundry hosted agent's node app) implies the Reader role on storage. + var agent = builder.AddContainer("agent", "img:latest").WithHttpEndpoint(); + agent.Resource.Annotations.Add(new ReferenceRoleAssignmentAnnotation( + storage.Resource, + new HashSet { new(StorageBuiltInRole.StorageBlobDataReader.ToString(), nameof(StorageBuiltInRole.StorageBlobDataReader)) })); + + // The consumer references storage directly (so its default role assignments would normally be + // applied) but declares an explicit WithRoleAssignments, which intentionally suppresses those + // defaults. The implied-reference hook must add only its Reader role and must NOT re-introduce the + // suppressed defaults - that was the "pit of failure" the union-of-defaults pattern would have caused. + var consumer = builder.AddProject("api", launchProfileName: null) + .WithRoleAssignments(storage, StorageBuiltInRole.StorageBlobDelegator) + .WithReference(blobs) + .WithReference(agent.GetEndpoint("http")); + + using var app = builder.Build(); + await ExecuteBeforeStartHooksAsync(app, default); + + var roleIds = consumer.Resource.Annotations.OfType() + .Where(a => a.Target == storage.Resource) + .SelectMany(a => a.Roles) + .Select(r => r.Id) + .ToHashSet(); + + // Explicit role + implied Reader are present. + Assert.Contains(StorageBuiltInRole.StorageBlobDelegator.ToString(), roleIds); + Assert.Contains(StorageBuiltInRole.StorageBlobDataReader.ToString(), roleIds); + + // The suppressed defaults must not be re-introduced by the implied-reference hook. + Assert.DoesNotContain(StorageBuiltInRole.StorageBlobDataContributor.ToString(), roleIds); + Assert.DoesNotContain(StorageBuiltInRole.StorageTableDataContributor.ToString(), roleIds); + Assert.DoesNotContain(StorageBuiltInRole.StorageQueueDataContributor.ToString(), roleIds); + } + + [Fact] + public async Task ReferenceRoleAssignmentAnnotation_ConsumerWithDirectReference_KeepsDefaultsAndAddsImpliedRole() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + builder.AddAzureContainerAppEnvironment("env"); + + var storage = builder.AddAzureStorage("storage"); + var blobs = storage.AddBlobs("blobs"); + + // A fronting resource (e.g. a Foundry hosted agent's node app) implies the Reader role on storage. + var agent = builder.AddContainer("agent", "img:latest").WithHttpEndpoint(); + agent.Resource.Annotations.Add(new ReferenceRoleAssignmentAnnotation( + storage.Resource, + new HashSet { new(StorageBuiltInRole.StorageBlobDataReader.ToString(), nameof(StorageBuiltInRole.StorageBlobDataReader)) })); + + // The consumer references storage directly without declaring explicit role assignments, so the + // account defaults still apply. The implied-reference hook must add its Reader role alongside the + // defaults (the caller - the preparer - owns defaults; the hook never replaces or removes them). + var consumer = builder.AddProject("api", launchProfileName: null) + .WithReference(blobs) + .WithReference(agent.GetEndpoint("http")); + + using var app = builder.Build(); + await ExecuteBeforeStartHooksAsync(app, default); + + var roleIds = consumer.Resource.Annotations.OfType() + .Where(a => a.Target == storage.Resource) + .SelectMany(a => a.Roles) + .Select(r => r.Id) + .ToHashSet(); + + // Defaults are preserved by the preparer's normal reference walk. + Assert.Contains(StorageBuiltInRole.StorageBlobDataContributor.ToString(), roleIds); + Assert.Contains(StorageBuiltInRole.StorageTableDataContributor.ToString(), roleIds); + Assert.Contains(StorageBuiltInRole.StorageQueueDataContributor.ToString(), roleIds); + + // The implied Reader role is added on top of the defaults. + Assert.Contains(StorageBuiltInRole.StorageBlobDataReader.ToString(), roleIds); + } + private sealed class Project : IProjectMetadata { public string ProjectPath => "project"; diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.EndpointReferenceToFoundryHostedAgentIsResolvedAcrossComputeEnvironments.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.EndpointReferenceToFoundryHostedAgentIsResolvedAcrossComputeEnvironments.verified.bicep new file mode 100644 index 00000000000..f70d350d3a9 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.EndpointReferenceToFoundryHostedAgentIsResolvedAcrossComputeEnvironments.verified.bicep @@ -0,0 +1,148 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param env_outputs_azure_container_registry_endpoint string + +param env_outputs_planid string + +param env_outputs_azure_container_registry_managed_identity_id string + +param env_outputs_azure_container_registry_managed_identity_client_id string + +param web_containerimage string + +param web_containerport string + +param project_outputs_endpoint string + +param web_identity_outputs_id string + +param web_identity_outputs_clientid string + +param env_outputs_azure_app_service_dashboard_uri string + +param env_outputs_azure_website_contributor_managed_identity_id string + +param env_outputs_azure_website_contributor_managed_identity_principal_id string + +resource mainContainer 'Microsoft.Web/sites/sitecontainers@2025-03-01' = { + name: 'main' + properties: { + authType: 'UserAssigned' + image: web_containerimage + isMain: true + targetPort: web_containerport + userManagedIdentityClientId: env_outputs_azure_container_registry_managed_identity_client_id + } + parent: webapp +} + +resource webapp 'Microsoft.Web/sites@2025-03-01' = { + name: take('${toLower('web')}-${uniqueString(resourceGroup().id)}', 60) + location: location + properties: { + serverFarmId: env_outputs_planid + keyVaultReferenceIdentity: web_identity_outputs_id + siteConfig: { + numberOfWorkers: 30 + linuxFxVersion: 'SITECONTAINERS' + acrUseManagedIdentityCreds: true + acrUserManagedIdentityID: env_outputs_azure_container_registry_managed_identity_client_id + appSettings: [ + { + name: 'WEBSITES_PORT' + value: web_containerport + } + { + name: 'OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY' + value: 'in_memory' + } + { + name: 'ASPNETCORE_FORWARDEDHEADERS_ENABLED' + value: 'true' + } + { + name: 'HTTP_PORTS' + value: web_containerport + } + { + name: 'AGENT_HTTP' + value: '${project_outputs_endpoint}/agents/agent-ha' + } + { + name: 'services__agent__http__0' + value: '${project_outputs_endpoint}/agents/agent-ha' + } + { + name: 'AGENT_URL' + value: '${project_outputs_endpoint}/agents/agent-ha' + } + { + name: 'AZURE_CLIENT_ID' + value: web_identity_outputs_clientid + } + { + name: 'AZURE_TOKEN_CREDENTIALS' + value: 'ManagedIdentityCredential' + } + { + name: 'ASPIRE_ENVIRONMENT_NAME' + value: 'env' + } + { + name: 'OTEL_SERVICE_NAME' + value: 'web' + } + { + name: 'OTEL_EXPORTER_OTLP_PROTOCOL' + value: 'grpc' + } + { + name: 'OTEL_EXPORTER_OTLP_ENDPOINT' + value: 'http://localhost:6001' + } + { + name: 'WEBSITE_ENABLE_ASPIRE_OTEL_SIDECAR' + value: 'true' + } + { + name: 'OTEL_COLLECTOR_URL' + value: env_outputs_azure_app_service_dashboard_uri + } + { + name: 'OTEL_CLIENT_ID' + value: env_outputs_azure_container_registry_managed_identity_client_id + } + ] + } + } + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${env_outputs_azure_container_registry_managed_identity_id}': { } + '${web_identity_outputs_id}': { } + } + } +} + +resource web_website_ra 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(webapp.id, env_outputs_azure_website_contributor_managed_identity_id, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'de139f84-1756-47ae-9be6-808fbbe84772')) + properties: { + principalId: env_outputs_azure_website_contributor_managed_identity_principal_id + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'de139f84-1756-47ae-9be6-808fbbe84772') + principalType: 'ServicePrincipal' + } + scope: webapp +} + +resource slotConfigNames 'Microsoft.Web/sites/config@2025-03-01' = { + name: 'slotConfigNames' + properties: { + appSettingNames: [ + 'AGENT_HTTP' + 'services__agent__http__0' + 'OTEL_SERVICE_NAME' + ] + } + parent: webapp +} \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.EndpointReferenceToFoundryHostedAgentIsResolvedAcrossComputeEnvironments.verified.json b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.EndpointReferenceToFoundryHostedAgentIsResolvedAcrossComputeEnvironments.verified.json new file mode 100644 index 00000000000..cd9161f896c --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.EndpointReferenceToFoundryHostedAgentIsResolvedAcrossComputeEnvironments.verified.json @@ -0,0 +1,18 @@ +{ + "type": "azure.bicep.v0", + "path": "web-website.module.bicep", + "params": { + "env_outputs_azure_container_registry_endpoint": "{env.outputs.AZURE_CONTAINER_REGISTRY_ENDPOINT}", + "env_outputs_planid": "{env.outputs.planId}", + "env_outputs_azure_container_registry_managed_identity_id": "{env.outputs.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID}", + "env_outputs_azure_container_registry_managed_identity_client_id": "{env.outputs.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_CLIENT_ID}", + "web_containerimage": "{web.containerImage}", + "web_containerport": "{web.containerPort}", + "project_outputs_endpoint": "{project.outputs.endpoint}", + "web_identity_outputs_id": "{web-identity.outputs.id}", + "web_identity_outputs_clientid": "{web-identity.outputs.clientId}", + "env_outputs_azure_app_service_dashboard_uri": "{env.outputs.AZURE_APP_SERVICE_DASHBOARD_URI}", + "env_outputs_azure_website_contributor_managed_identity_id": "{env.outputs.AZURE_WEBSITE_CONTRIBUTOR_MANAGED_IDENTITY_ID}", + "env_outputs_azure_website_contributor_managed_identity_principal_id": "{env.outputs.AZURE_WEBSITE_CONTRIBUTOR_MANAGED_IDENTITY_PRINCIPAL_ID}" + } +} \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.EndpointReferenceToFoundryHostedAgentIsResolvedAcrossComputeEnvironments.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.EndpointReferenceToFoundryHostedAgentIsResolvedAcrossComputeEnvironments.verified.bicep new file mode 100644 index 00000000000..c6f7220f3f5 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.EndpointReferenceToFoundryHostedAgentIsResolvedAcrossComputeEnvironments.verified.bicep @@ -0,0 +1,99 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param env_outputs_azure_container_apps_environment_default_domain string + +param env_outputs_azure_container_apps_environment_id string + +param env_outputs_azure_container_registry_endpoint string + +param env_outputs_azure_container_registry_managed_identity_id string + +param web_containerimage string + +param web_identity_outputs_id string + +param web_containerport string + +param project_outputs_endpoint string + +param web_identity_outputs_clientid string + +resource web 'Microsoft.App/containerApps@2025-10-02-preview' = { + name: 'web' + location: location + properties: { + configuration: { + activeRevisionsMode: 'Single' + ingress: { + external: true + targetPort: int(web_containerport) + transport: 'http' + } + registries: [ + { + server: env_outputs_azure_container_registry_endpoint + identity: env_outputs_azure_container_registry_managed_identity_id + } + ] + runtime: { + dotnet: { + autoConfigureDataProtection: true + } + } + } + environmentId: env_outputs_azure_container_apps_environment_id + template: { + containers: [ + { + image: web_containerimage + name: 'web' + env: [ + { + name: 'OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY' + value: 'in_memory' + } + { + name: 'ASPNETCORE_FORWARDEDHEADERS_ENABLED' + value: 'true' + } + { + name: 'HTTP_PORTS' + value: web_containerport + } + { + name: 'AGENT_HTTP' + value: '${project_outputs_endpoint}/agents/agent-ha' + } + { + name: 'services__agent__http__0' + value: '${project_outputs_endpoint}/agents/agent-ha' + } + { + name: 'AGENT_URL' + value: '${project_outputs_endpoint}/agents/agent-ha' + } + { + name: 'AZURE_CLIENT_ID' + value: web_identity_outputs_clientid + } + { + name: 'AZURE_TOKEN_CREDENTIALS' + value: 'ManagedIdentityCredential' + } + ] + } + ] + scale: { + minReplicas: 1 + } + } + } + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${web_identity_outputs_id}': { } + '${env_outputs_azure_container_registry_managed_identity_id}': { } + } + } +} \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.EndpointReferenceToFoundryHostedAgentIsResolvedAcrossComputeEnvironments.verified.json b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.EndpointReferenceToFoundryHostedAgentIsResolvedAcrossComputeEnvironments.verified.json new file mode 100644 index 00000000000..9bba0f4e346 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.EndpointReferenceToFoundryHostedAgentIsResolvedAcrossComputeEnvironments.verified.json @@ -0,0 +1,15 @@ +{ + "type": "azure.bicep.v0", + "path": "web-containerapp.module.bicep", + "params": { + "env_outputs_azure_container_apps_environment_default_domain": "{env.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN}", + "env_outputs_azure_container_apps_environment_id": "{env.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_ID}", + "env_outputs_azure_container_registry_endpoint": "{env.outputs.AZURE_CONTAINER_REGISTRY_ENDPOINT}", + "env_outputs_azure_container_registry_managed_identity_id": "{env.outputs.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID}", + "web_containerimage": "{web.containerImage}", + "web_identity_outputs_id": "{web-identity.outputs.id}", + "web_containerport": "{web.containerPort}", + "project_outputs_endpoint": "{project.outputs.endpoint}", + "web_identity_outputs_clientid": "{web-identity.outputs.clientId}" + } +} \ No newline at end of file diff --git a/tests/Aspire.Hosting.Foundry.Tests/HostedAgentExtensionTests.cs b/tests/Aspire.Hosting.Foundry.Tests/HostedAgentExtensionTests.cs index 8405cd2e25a..cff2d20891e 100644 --- a/tests/Aspire.Hosting.Foundry.Tests/HostedAgentExtensionTests.cs +++ b/tests/Aspire.Hosting.Foundry.Tests/HostedAgentExtensionTests.cs @@ -5,6 +5,7 @@ using System.Runtime.CompilerServices; using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Azure; using Aspire.Hosting.Tests.Utils; using Aspire.Hosting.Utils; using Azure.AI.Projects.Agents; @@ -447,6 +448,88 @@ public async Task FoundryProject_DefaultRegistryDoesNotAddGlobalRegistryTargets( [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "ExecuteBeforeStartHooksAsync")] private static extern Task ExecuteBeforeStartHooksAsync(DistributedApplication app, CancellationToken cancellationToken); + [Fact] + public void AsHostedAgent_StampsReferenceRoleAssignmentAnnotationOnTarget_WithAzureAIUserRole() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + var project = builder.AddFoundry("account") + .AddProject("my-project"); + + builder.AddPythonApp("agent", "./app.py", "main:app") + .AsHostedAgent(project); + + var hostedAgent = Assert.Single(builder.Resources.OfType()); + var account = Assert.Single(builder.Resources.OfType()); + +#pragma warning disable ASPIREAZURE003 // Type is for evaluation purposes only and is subject to change or removal in future updates. + var annotation = Assert.Single(hostedAgent.Target.Annotations.OfType()); + Assert.Same(account, annotation.Target); + Assert.Contains(annotation.Roles, role => + string.Equals(role.Id, AzureHostedAgentResource.AzureAIUserRoleDefinitionId, StringComparison.OrdinalIgnoreCase)); +#pragma warning restore ASPIREAZURE003 + } + + [Fact] + public void AsHostedAgent_ReferenceRoleAssignmentAnnotation_GrantsOnlyAzureAIUserRole() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + var project = builder.AddFoundry("account") + .AddProject("my-project"); + + builder.AddPythonApp("agent", "./app.py", "main:app") + .AsHostedAgent(project); + + var hostedAgent = Assert.Single(builder.Resources.OfType()); + var account = Assert.Single(builder.Resources.OfType()); + Assert.True(account.TryGetLastAnnotation(out var defaults)); + +#pragma warning disable ASPIREAZURE003 // Type is for evaluation purposes only and is subject to change or removal in future updates. + var annotation = Assert.Single(hostedAgent.Target.Annotations.OfType()); + + // The implied grant is least-privilege: only "Azure AI User" is required to invoke the agent. + var role = Assert.Single(annotation.Roles); + Assert.Equal(AzureHostedAgentResource.AzureAIUserRoleDefinitionId, role.Id, ignoreCase: true); + + // The account's default data-plane roles must NOT be folded in here. A consumer that references + // the account directly still receives them via the preparer's normal walk, and a consumer that + // explicitly suppresses them must keep them suppressed. + foreach (var defaultRole in defaults.Roles) + { + Assert.DoesNotContain(annotation.Roles, r => string.Equals(r.Id, defaultRole.Id, StringComparison.OrdinalIgnoreCase)); + } +#pragma warning restore ASPIREAZURE003 + } + + [Fact] + public void AsHostedAgent_MultipleHostedAgents_EachTargetCarriesItsOwnReferenceRoleAssignment() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + var project = builder.AddFoundry("account") + .AddProject("my-project"); + var otherProject = builder.AddFoundry("account2") + .AddProject("other-project"); + + builder.AddPythonApp("agent", "./app.py", "main:app") + .AsHostedAgent(project); + builder.AddPythonApp("agent2", "./app.py", "main:app") + .AsHostedAgent(otherProject); + + var hostedAgents = builder.Resources.OfType().ToList(); + Assert.Equal(2, hostedAgents.Count); + + var account = Assert.Single(builder.Resources.OfType(), r => r.Name == "account"); + var account2 = Assert.Single(builder.Resources.OfType(), r => r.Name == "account2"); + +#pragma warning disable ASPIREAZURE003 // Type is for evaluation purposes only and is subject to change or removal in future updates. + var targets = hostedAgents + .Select(a => Assert.Single(a.Target.Annotations.OfType()).Target) + .ToList(); + + Assert.Contains(account, targets); + Assert.Contains(account2, targets); +#pragma warning restore ASPIREAZURE003 + } + private sealed class Project : IProjectMetadata { public string ProjectPath => "project"; diff --git a/tests/Aspire.Hosting.Tests/Aspire.Hosting.Tests.csproj b/tests/Aspire.Hosting.Tests/Aspire.Hosting.Tests.csproj index e8e803885f9..fbf3f7ff342 100644 --- a/tests/Aspire.Hosting.Tests/Aspire.Hosting.Tests.csproj +++ b/tests/Aspire.Hosting.Tests/Aspire.Hosting.Tests.csproj @@ -48,6 +48,7 @@ + diff --git a/tests/Aspire.Hosting.Tests/ComputeEnvironmentEndpointResolverTests.cs b/tests/Aspire.Hosting.Tests/ComputeEnvironmentEndpointResolverTests.cs new file mode 100644 index 00000000000..830a02ff526 --- /dev/null +++ b/tests/Aspire.Hosting.Tests/ComputeEnvironmentEndpointResolverTests.cs @@ -0,0 +1,168 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net.Sockets; +using Aspire.Hosting.Utils; +using Microsoft.AspNetCore.InternalTesting; + +namespace Aspire.Hosting.Tests; + +public class ComputeEnvironmentEndpointResolverTests +{ + [Fact] + public async Task OwningResourceInDifferentEnvironment_DelegatesToOwningEnvironment() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var currentEnv = builder.AddResource(new TestComputeEnvironmentResource("current")); + var owningEnv = builder.AddResource(new TestComputeEnvironmentResource("owning")); + var agent = builder.AddResource(new TestComputeResource("agent")); + var endpoint = AddHttpEndpoint(agent.Resource, port: 8080, targetPort: 5000); + agent.Resource.Annotations.Add(new DeploymentTargetAnnotation(owningEnv.Resource) { ComputeEnvironment = owningEnv.Resource }); + + var resolved = ComputeEnvironmentEndpointResolver.TryGetCrossEnvironmentEndpointExpression( + endpoint.Property(EndpointProperty.Url), [currentEnv.Resource], out var expression); + + Assert.True(resolved); + Assert.NotNull(expression); + // The owning environment (TestComputeEnvironmentResource) maps the host to "{name}.example.com". + Assert.Equal("http://agent.example.com:8080", await expression.GetValueAsync(default).DefaultTimeout()); + } + + [Fact] + public async Task EndpointReferenceOverload_DelegatesToOwningEnvironment() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var currentEnv = builder.AddResource(new TestComputeEnvironmentResource("current")); + var owningEnv = builder.AddResource(new TestComputeEnvironmentResource("owning")); + var agent = builder.AddResource(new TestComputeResource("agent")); + var endpoint = AddHttpEndpoint(agent.Resource, port: 8080, targetPort: 5000); + agent.Resource.Annotations.Add(new DeploymentTargetAnnotation(owningEnv.Resource) { ComputeEnvironment = owningEnv.Resource }); + + // The EndpointReference overload uses EndpointProperty.Url internally. + var resolved = ComputeEnvironmentEndpointResolver.TryGetCrossEnvironmentEndpointExpression( + endpoint, [currentEnv.Resource], out var expression); + + Assert.True(resolved); + Assert.NotNull(expression); + Assert.Equal("http://agent.example.com:8080", await expression.GetValueAsync(default).DefaultTimeout()); + } + + [Fact] + public void OwningResourceDeploysToCurrentEnvironment_ReturnsFalse() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var currentEnv = builder.AddResource(new TestComputeEnvironmentResource("current")); + var agent = builder.AddResource(new TestComputeResource("agent")); + var endpoint = AddHttpEndpoint(agent.Resource, port: 8080, targetPort: 5000); + agent.Resource.Annotations.Add(new DeploymentTargetAnnotation(currentEnv.Resource) { ComputeEnvironment = currentEnv.Resource }); + + var resolved = ComputeEnvironmentEndpointResolver.TryGetCrossEnvironmentEndpointExpression( + endpoint.Property(EndpointProperty.Url), [currentEnv.Resource], out var expression); + + Assert.False(resolved); + Assert.Null(expression); + } + + [Fact] + public void OwningResourceBoundToCurrentEnvironmentWithoutDeploymentTarget_ReturnsFalseViaBackstop() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var currentEnv = builder.AddResource(new TestComputeEnvironmentResource("current")); + // Bind via WithComputeEnvironment only (no DeploymentTargetAnnotation). The effective + // environment resolves to the current environment and the ReferenceEquals backstop catches it. + var agent = builder.AddResource(new TestComputeResource("agent")) + .WithComputeEnvironment(currentEnv); + var endpoint = AddHttpEndpoint(agent.Resource, port: 8080, targetPort: 5000); + + var resolved = ComputeEnvironmentEndpointResolver.TryGetCrossEnvironmentEndpointExpression( + endpoint.Property(EndpointProperty.Url), [currentEnv.Resource], out var expression); + + Assert.False(resolved); + Assert.Null(expression); + } + + [Fact] + public void OwningResourceHasNoComputeEnvironment_ReturnsFalse() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var currentEnv = builder.AddResource(new TestComputeEnvironmentResource("current")); + // No binding and no deployment target: nothing to delegate to. + var agent = builder.AddResource(new TestComputeResource("agent")); + var endpoint = AddHttpEndpoint(agent.Resource, port: 8080, targetPort: 5000); + + var resolved = ComputeEnvironmentEndpointResolver.TryGetCrossEnvironmentEndpointExpression( + endpoint.Property(EndpointProperty.Url), [currentEnv.Resource], out var expression); + + Assert.False(resolved); + Assert.Null(expression); + } + + [Fact] + public void OwningResourceBoundToCurrentEnvironmentWithMultipleDeploymentTargets_ReturnsFalseAndDoesNotThrow() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var currentEnv = builder.AddResource(new TestComputeEnvironmentResource("current")); + var otherEnv = builder.AddResource(new TestComputeEnvironmentResource("other")); + // Explicit binding to the current environment plus deployment targets for both environments. + // The binding lets GetComputeEnvironment() short-circuit to the current environment without + // hitting the parameterless deployment-target resolver that throws on multi-target ambiguity. + var agent = builder.AddResource(new TestComputeResource("agent")) + .WithComputeEnvironment(currentEnv); + agent.Resource.Annotations.Add(new DeploymentTargetAnnotation(currentEnv.Resource) { ComputeEnvironment = currentEnv.Resource }); + agent.Resource.Annotations.Add(new DeploymentTargetAnnotation(otherEnv.Resource) { ComputeEnvironment = otherEnv.Resource }); + var endpoint = AddHttpEndpoint(agent.Resource, port: 8080, targetPort: 5000); + + var resolved = ComputeEnvironmentEndpointResolver.TryGetCrossEnvironmentEndpointExpression( + endpoint.Property(EndpointProperty.Url), [currentEnv.Resource], out var expression); + + Assert.False(resolved); + Assert.Null(expression); + } + + [Fact] + public void OwningResourceUnboundWithMultipleDeploymentTargets_Throws() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var currentEnv = builder.AddResource(new TestComputeEnvironmentResource("current")); + var otherEnv = builder.AddResource(new TestComputeEnvironmentResource("other")); + // No binding: an unbound resource with more than one deployment target is ambiguous. + // TryGetEffectiveComputeEnvironment falls back to the parameterless GetDeploymentTargetAnnotation() + // which throws. This documents that the resolver does not swallow that ambiguity; the pipeline + // rejects this configuration earlier in practice. + var agent = builder.AddResource(new TestComputeResource("agent")); + agent.Resource.Annotations.Add(new DeploymentTargetAnnotation(currentEnv.Resource) { ComputeEnvironment = currentEnv.Resource }); + agent.Resource.Annotations.Add(new DeploymentTargetAnnotation(otherEnv.Resource) { ComputeEnvironment = otherEnv.Resource }); + var endpoint = AddHttpEndpoint(agent.Resource, port: 8080, targetPort: 5000); + + Assert.Throws(() => + ComputeEnvironmentEndpointResolver.TryGetCrossEnvironmentEndpointExpression( + endpoint.Property(EndpointProperty.Url), [currentEnv.Resource], out _)); + } + + private static EndpointReference AddHttpEndpoint(TestComputeResource resource, int? port, int? targetPort) + { + var endpoint = new EndpointAnnotation(ProtocolType.Tcp, uriScheme: "http", name: "http", port: port, targetPort: targetPort); + resource.Annotations.Add(endpoint); + + return new EndpointReference(resource, endpoint); + } + + private sealed class TestComputeEnvironmentResource(string name) : Resource(name), IComputeEnvironmentResource + { +#pragma warning disable ASPIRECOMPUTE002 + public ReferenceExpression GetHostAddressExpression(EndpointReference endpointReference) => + ReferenceExpression.Create($"{endpointReference.Resource.Name}.example.com"); +#pragma warning restore ASPIRECOMPUTE002 + } + + private sealed class TestComputeResource(string name) : Resource(name), IComputeResource, IResourceWithEndpoints + { + } +} From 7510e819deaba469a68b08404a58d7cfd7e35df2 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 31 May 2026 22:00:38 -0700 Subject: [PATCH 14/15] Align extension contributing docs with Yarn build Update the extension contributor guide after removing Corepack from the build scripts so setup, install, test, and Yarn-version instructions use the direct Yarn flow. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- extension/CONTRIBUTING.md | 42 ++++++++++----------------------------- 1 file changed, 10 insertions(+), 32 deletions(-) diff --git a/extension/CONTRIBUTING.md b/extension/CONTRIBUTING.md index c977176b044..ffae37e372e 100644 --- a/extension/CONTRIBUTING.md +++ b/extension/CONTRIBUTING.md @@ -6,11 +6,9 @@ Bug fixes, new commands, debugger-language support, walkthrough content, setting ## Install prerequisites -- Node.js (LTS version) — `npm` must be on the PATH (it ships with Node.js). The - build scripts (`build.sh` / `build.ps1`) install a pinned [Corepack](https://github.com/nodejs/corepack) - via `npm install -g corepack@` from the configured registry and seed - Corepack's cache with the Yarn release pinned by the `packageManager` field in - `extension/package.json`. You do **not** need to install Yarn yourself. +- Node.js (LTS version) — `npm` must be on the PATH (it ships with Node.js). +- Yarn Classic — the `yarn` command must be on the PATH. Use the version pinned by the + `packageManager` field in `extension/package.json`. - Visual Studio Code (latest) or Visual Studio Code Insiders - [Aspire CLI](https://aspire.dev/get-started/install-cli/) must be installed and available in the PATH @@ -23,10 +21,11 @@ For TypeScript/UI changes that don't require debugging the Aspire CLI itself, sk From `extension/`: ```bash -corepack yarn install # restore dependencies +yarn install # restore dependencies ``` Open `extension/` in VS Code and launch **Run Extension** (`F5`) to start an Extension Development Host with your build. The launch config runs the `tasks: watch extension` preLaunchTask (which executes `yarn watch`) to keep `dist/` up to date while you edit. After rebuilds, re-launch or run **Developer: Reload Window** in the host to pick up changes. + ## Project structure Source lives under `extension/src/`: @@ -69,10 +68,10 @@ You can use the `Aspire: Extension settings` command to open VS Code settings di Unit tests are `*.test.ts` files under `src/test/`, run via `@vscode/test-electron`. From `extension/`: ```bash -corepack yarn test +yarn test ``` -This compiles tests and sources, lints, then runs the suite (`corepack yarn lint` lints only). Add or update tests for behavior changes, and ensure tests and lint pass before opening a PR. +This compiles tests and sources, lints, then runs the suite (`yarn lint` lints only). Add or update tests for behavior changes, and ensure tests and lint pass before opening a PR. ## Localizing user-facing strings @@ -90,34 +89,13 @@ The extension is built with **yarn**, pinned to the version recorded in `package When pinning a transitive dependency (e.g. to address a security advisory), add the pin to `resolutions` and regenerate `yarn.lock` in the same change: ```bash -corepack yarn install +yarn install ``` The build rejects public npmjs.org URLs in `yarn.lock`; ensure regenerated entries resolve through the `dotnet-public-npm` feed (public, so no credentials are needed to consume it). ## Updating the Yarn version -Edit the `"packageManager": "yarn@x.y.z"` field in `extension/package.json`. The next `build.sh` / `build.ps1` run seeds Corepack's cache with that version before calling `corepack yarn …`. No further changes are required in `build.sh`, `build.ps1`, or `extension/Extension.proj`. - -> **npm mirror note.** `.npmrc` and the build scripts' `NPM_REGISTRY` route npm downloads through the dnceng `dotnet-public-npm` Azure Artifacts feed, a **public** pull-through cache of npmjs.org. Anonymous reads of cached versions work without credentials, covering everything pinned in `yarn.lock`. Exception: the *first* request for a never-cached version triggers a pull-through fetch that fails with HTTP 401 (subsequent reads succeed). This only affects maintainers bumping the pinned Corepack or Yarn version; pre-seed with credentials via `npm install --global --registry https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/ corepack@` or `npm pack --registry https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/ yarn@`. Don't point `COREPACK_NPM_REGISTRY` at this feed for Yarn: Corepack requests the `//` route, which Azure Artifacts returns 404 for even when the package exists. - -## Troubleshooting - -### `EACCES` from `npm install --global` on Linux/macOS - -The build scripts run `npm install --global corepack@` to pin the Corepack version. On systems where Node.js is installed from a package manager (apt, yum, the official `.pkg`), the npm global prefix is typically `/usr/lib/node_modules` or `/usr/local/lib/node_modules`, which is root-owned. The install will fail with `EACCES`. The cleanest fix is to use a Node version manager that puts the npm prefix in your home directory: - -- [`nvm`](https://github.com/nvm-sh/nvm) — installs Node and configures the npm prefix automatically. -- [`fnm`](https://github.com/Schniz/fnm), [`asdf`](https://asdf-vm.com/), [`volta`](https://volta.sh/) — same idea, different tradeoffs. - -Alternatively, point npm at a user-writable prefix without changing your Node install: - -```bash -mkdir -p ~/.npm-global -npm config set prefix ~/.npm-global -export PATH="$HOME/.npm-global/bin:$PATH" # add to ~/.bashrc or ~/.zshrc -``` - -### `corepack version mismatch` from `build.sh` / `build.ps1` +Edit the `"packageManager": "yarn@x.y.z"` field in `extension/package.json`, then install the same Yarn version on development machines and build agents before running `yarn …` commands. -This means the `corepack` resolved from `PATH` is not the one we just installed via `npm install -g`. Most often the system Node install (`/usr/bin/corepack`, `%ProgramFiles%\nodejs\corepack.cmd`) is sitting in front of the npm global bin directory. On Windows, ensure `%APPDATA%\npm` comes before `%ProgramFiles%\nodejs` on `PATH`. On Linux/macOS, follow the `EACCES` remediation above and the npm prefix will be on `PATH` ahead of the system Node directory. +> **npm mirror note.** `.npmrc` routes extension dependency downloads through the dnceng `dotnet-public-npm` Azure Artifacts feed, a **public** pull-through cache of npmjs.org. Anonymous reads of cached versions work without credentials, covering everything pinned in `yarn.lock`. Exception: the *first* request for a never-cached version triggers a pull-through fetch that fails with HTTP 401 (subsequent reads succeed). From eb5c6734f3138b75d4ea65b8e5e8382f3b20b4e3 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 31 May 2026 22:02:26 -0700 Subject: [PATCH 15/15] Refresh Corepack bootstrap comments Remove stale references to extension build entrypoints and contributor Corepack instructions after the build scripts moved to direct Yarn. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/tests.yml | 12 ++++++------ eng/pipelines/templates/install-corepack.yml | 7 +++---- extension/scripts/prepareCorepackYarn.mjs | 7 +++---- 3 files changed, 12 insertions(+), 14 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 6aacbfb4001..8f673b046d0 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -370,8 +370,8 @@ jobs: - name: Validate lockfile registries # Allowlist scoped to lines starting with "resolved" (the only lines in # yarn.lock that carry a tarball URL — `npm:` aliases don't). - # CONTRIBUTING.MD asks contributors to regenerate yarn.lock through the - # internal dotnet-public-npm feed; an allowlist catches drift to any + # extension/CONTRIBUTING.md documents the internal dotnet-public-npm feed + # requirement; an allowlist catches drift to any # other public mirror (npmmirror.com, jsr.io, github.com tarballs, ...) # rather than only npmjs.org and yarnpkg.com. run: | @@ -398,8 +398,8 @@ jobs: # through node.exe, POSIX tar) are only exercised on contributor machines # and never on a fresh CI image. extension_tests_win covers the Windows # paths; this job covers Linux/macOS. macOS is omitted because the only - # platform-specific branch beyond Linux is the cache path, which is - # explicitly overridden via COREPACK_HOME in the build entrypoints. + # platform-specific branch beyond Linux is the cache path, which this job + # explicitly overrides via COREPACK_HOME. runs-on: ubuntu-latest env: NPM_REGISTRY: https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/ @@ -434,8 +434,8 @@ jobs: corepack enable - name: Seed Corepack Yarn cache via prepareCorepackYarn.mjs # Exercises the script's non-Windows branches against a clean cache - # directory. Failing here means a contributor on Linux/macOS following - # CONTRIBUTING.MD would also be broken. + # directory. Failing here means the CI/AzDO Corepack bootstrap path would + # also be broken. run: node ./scripts/prepareCorepackYarn.mjs - name: Validate lockfile registries # Mirror of the same allowlist guard in extension_tests_win so this diff --git a/eng/pipelines/templates/install-corepack.yml b/eng/pipelines/templates/install-corepack.yml index efbaa0b7359..b9be21c559f 100644 --- a/eng/pipelines/templates/install-corepack.yml +++ b/eng/pipelines/templates/install-corepack.yml @@ -14,10 +14,9 @@ steps: targetType: 'inline' script: | # Pinned Corepack shim version, sourced from - # extension/scripts/corepack-version.txt so this script, - # extension/build.sh, extension/build.ps1, the GitHub Actions - # workflow, and every AzDO pipeline that references this template - # stay in sync. The Yarn release itself is pinned in + # extension/scripts/corepack-version.txt so this template, the GitHub + # Actions workflow, and every AzDO pipeline that references this + # template stay in sync. The Yarn release itself is pinned in # extension/package.json via the `packageManager` field. $CorepackVersion = (Get-Content -Raw -Path '$(Build.SourcesDirectory)/extension/scripts/corepack-version.txt').Trim() $corepackHome = Join-Path '$(Agent.TempDirectory)' 'corepack' diff --git a/extension/scripts/prepareCorepackYarn.mjs b/extension/scripts/prepareCorepackYarn.mjs index 4660862f9a0..f32a83d58c4 100644 --- a/extension/scripts/prepareCorepackYarn.mjs +++ b/extension/scripts/prepareCorepackYarn.mjs @@ -9,8 +9,7 @@ import process from 'node:process'; const DefaultNpmRegistry = 'https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/'; // Yarn 1.x `packageManager` strings can carry an integrity suffix when written -// by `corepack use yarn@` (the workflow CONTRIBUTING.MD points contributors -// at to update the pin), producing values like +// by `corepack use yarn@` or similar package-manager tooling, producing values like // "yarn@1.22.22+sha512.f7062e6a5ee1f3aa…". // The suffix is optional but its presence must not break us. Match an optional // `+` suffix and ignore it; only the version is needed to seed the cache. @@ -141,8 +140,8 @@ function getCorepackHome() { return process.env.COREPACK_HOME; } - // build.sh / build.ps1 / the GitHub Actions workflow / the AzDO pipelines all - // set COREPACK_HOME explicitly to a build-scoped directory. This fallback + // The GitHub Actions workflow and AzDO pipelines set COREPACK_HOME explicitly + // to a build-scoped directory. This fallback // exists only for ad-hoc invocations of this script (e.g. local debugging, // running `node ./scripts/prepareCorepackYarn.mjs` directly). It mirrors // Corepack 0.34.x's own cache-path resolution so we seed the directory