From 0f51445275cc5bfd7d209aef7453d47673676bb7 Mon Sep 17 00:00:00 2001 From: Jose Perez Rodriguez Date: Tue, 26 May 2026 20:43:43 -0700 Subject: [PATCH 01/43] [release/13.4] Stabilizing builds in preparation for 13.4 release (#17520) Flip StabilizePackageVersion default to true in eng/Versions.props. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- eng/Versions.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eng/Versions.props b/eng/Versions.props index e13c7fdabf0..6d1f8eead0a 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -21,7 +21,7 @@ 2.2.1 17.14.1 - false + true release From 1cdf6f9310a96c9065744c95a224a86ec228adb5 Mon Sep 17 00:00:00 2001 From: "aspire-repo-bot[bot]" <268009190+aspire-repo-bot[bot]@users.noreply.github.com> Date: Tue, 26 May 2026 22:54:03 -0700 Subject: [PATCH 02/43] [release/13.4] Fix remaining issue 17244 items (#17522) * Fix remaining issue 17244 items Preserve legacy auxiliary backchannel resource property serialization unless clients opt in to JSON-valued properties, and remove the unused dashboard start command filter. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address backchannel review feedback Use the existing auxiliary backchannel V3 capability for typed resource properties and stop advertising a new V4 capability. Also limit the watch-resource capability test to the first streamed snapshot so it does not wait for a never-ending watch stream. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Adam Ratzman Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AppHostAuxiliaryBackchannel.cs | 69 ++++++++ .../Controls/ResourceDetails.razor.cs | 3 +- .../Model/ResourceMenuBuilder.cs | 8 +- .../AuxiliaryBackchannelRpcTarget.cs | 71 +++++++- .../Backchannel/BackchannelDataTypes.cs | 19 ++- .../AppHostAuxiliaryBackchannelTests.cs | 157 ++++++++++++++++++ .../Model/ResourceMenuBuilderTests.cs | 6 +- .../AuxiliaryBackchannelRpcTargetTests.cs | 85 ++++++++++ .../Backchannel/AuxiliaryBackchannelTests.cs | 28 ++-- .../Backchannel/BackchannelContractTests.cs | 10 ++ 10 files changed, 425 insertions(+), 31 deletions(-) create mode 100644 tests/Aspire.Cli.Tests/Backchannel/AppHostAuxiliaryBackchannelTests.cs diff --git a/src/Aspire.Cli/Backchannel/AppHostAuxiliaryBackchannel.cs b/src/Aspire.Cli/Backchannel/AppHostAuxiliaryBackchannel.cs index 17378b5e840..d35934611a1 100644 --- a/src/Aspire.Cli/Backchannel/AppHostAuxiliaryBackchannel.cs +++ b/src/Aspire.Cli/Backchannel/AppHostAuxiliaryBackchannel.cs @@ -20,6 +20,13 @@ namespace Aspire.Cli.Backchannel; /// internal sealed class AppHostAuxiliaryBackchannel : IAppHostAuxiliaryBackchannel { + private static readonly string[] s_clientCapabilities = + [ + AuxiliaryBackchannelCapabilities.V1, + AuxiliaryBackchannelCapabilities.V2, + AuxiliaryBackchannelCapabilities.V3 + ]; + private readonly ILogger _logger; private JsonRpc? _rpc; private bool _disposed; @@ -332,6 +339,22 @@ [new WaitForAppHostReadyRequest()], /// public async Task> GetResourceSnapshotsAsync(bool includeHidden, CancellationToken cancellationToken = default) { + if (SupportsV2) + { + var response = await GetResourcesV2Async(new GetResourcesRequest + { + ClientCapabilities = s_clientCapabilities + }, cancellationToken).ConfigureAwait(false); + var snapshots = response.Resources.ToList(); + + if (!includeHidden) + { + snapshots = snapshots.Where(s => !ResourceSnapshotMapper.IsHiddenResource(s)).ToList(); + } + + return snapshots.OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase).ToList(); + } + var rpc = EnsureConnected(); _logger.LogDebug("Getting resource snapshots"); @@ -363,6 +386,24 @@ public async Task> GetResourceSnapshotsAsync(bool include /// public async IAsyncEnumerable WatchResourceSnapshotsAsync(bool includeHidden, [EnumeratorCancellation] CancellationToken cancellationToken = default) { + if (SupportsV2) + { + await foreach (var snapshot in WatchResourcesV2Async(new WatchResourcesRequest + { + ClientCapabilities = s_clientCapabilities + }, cancellationToken).ConfigureAwait(false)) + { + if (!includeHidden && ResourceSnapshotMapper.IsHiddenResource(snapshot)) + { + continue; + } + + yield return snapshot; + } + + yield break; + } + var rpc = EnsureConnected(); _logger.LogDebug("Starting resource snapshots watch"); @@ -555,6 +596,8 @@ [new GetDashboardInfoRequest()], /// The resources response. public async Task GetResourcesV2Async(GetResourcesRequest? request = null, CancellationToken cancellationToken = default) { + request = AddClientCapabilities(request); + if (!SupportsV2) { // Fall back to v1 @@ -596,6 +639,8 @@ public async IAsyncEnumerable WatchResourcesV2Async( WatchResourcesRequest? request = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { + request = AddClientCapabilities(request); + if (!SupportsV2) { // Fall back to v1 @@ -641,6 +686,30 @@ public async IAsyncEnumerable WatchResourcesV2Async( } } + private static GetResourcesRequest AddClientCapabilities(GetResourcesRequest? request) + { + return request is null + ? new GetResourcesRequest { ClientCapabilities = s_clientCapabilities } + : new GetResourcesRequest + { + TraceContext = request.TraceContext, + Filter = request.Filter, + ClientCapabilities = s_clientCapabilities + }; + } + + private static WatchResourcesRequest AddClientCapabilities(WatchResourcesRequest? request) + { + return request is null + ? new WatchResourcesRequest { ClientCapabilities = s_clientCapabilities } + : new WatchResourcesRequest + { + TraceContext = request.TraceContext, + Filter = request.Filter, + ClientCapabilities = s_clientCapabilities + }; + } + /// /// Gets console logs using the v2 API. /// Falls back to v1 if not supported. diff --git a/src/Aspire.Dashboard/Components/Controls/ResourceDetails.razor.cs b/src/Aspire.Dashboard/Components/Controls/ResourceDetails.razor.cs index b3803a39940..ffbebad6853 100644 --- a/src/Aspire.Dashboard/Components/Controls/ResourceDetails.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/ResourceDetails.razor.cs @@ -307,8 +307,7 @@ private void UpdateResourceActionsMenu() IsCommandExecuting, showViewDetails: false, showConsoleLogsItem: true, - showUrls: true, - showStartCommand: false); + showUrls: true); } private async Task ExecuteResourceCommandAsync(ResourceViewModel resource, CommandViewModel command) diff --git a/src/Aspire.Dashboard/Model/ResourceMenuBuilder.cs b/src/Aspire.Dashboard/Model/ResourceMenuBuilder.cs index 525b628c7a6..b8412e42c29 100644 --- a/src/Aspire.Dashboard/Model/ResourceMenuBuilder.cs +++ b/src/Aspire.Dashboard/Model/ResourceMenuBuilder.cs @@ -79,8 +79,7 @@ public void AddMenuItems( Func isCommandExecuting, bool showViewDetails, bool showConsoleLogsItem, - bool showUrls, - bool showStartCommand = true) + bool showUrls) { if (showViewDetails) { @@ -166,7 +165,7 @@ await _aiContextProvider.LaunchAssistantSidebarAsync( AddTelemetryMenuItems(menuItems, resource, resourceByName); - AddCommandMenuItems(menuItems, resource, commandSelected, isCommandExecuting, showStartCommand); + AddCommandMenuItems(menuItems, resource, commandSelected, isCommandExecuting); if (showUrls) { @@ -283,10 +282,9 @@ private void AddTelemetryMenuItems(List menuItems, ResourceViewM } } - private void AddCommandMenuItems(List menuItems, ResourceViewModel resource, EventCallback commandSelected, Func isCommandExecuting, bool showStartCommand) + private void AddCommandMenuItems(List menuItems, ResourceViewModel resource, EventCallback commandSelected, Func isCommandExecuting) { var menuCommands = resource.Commands - .Where(c => showStartCommand || !c.Name.Equals(CommandViewModel.StartCommand, StringComparisons.CommandName)) .Where(c => c.State != CommandViewModelState.Hidden) .ToList(); diff --git a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs index 40eebb25ced..3c771c3a348 100644 --- a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs +++ b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; +using System.Globalization; using System.Runtime.CompilerServices; using System.Text.Json; using System.Text.Json.Nodes; @@ -118,7 +119,7 @@ public async Task GetDashboardInfoAsync(GetDashboardIn public async Task GetResourcesAsync(GetResourcesRequest? request = null, CancellationToken cancellationToken = default) { using var activity = profilingTelemetry.StartJsonRpcServerCall(nameof(GetResourcesAsync), streaming: false, request?.TraceContext); - var snapshots = await GetResourceSnapshotsAsync(cancellationToken).ConfigureAwait(false); + var snapshots = await GetResourceSnapshotsAsync(SupportsJsonResourceProperties(request?.ClientCapabilities), cancellationToken).ConfigureAwait(false); // Apply filter if specified if (!string.IsNullOrEmpty(request?.Filter)) @@ -147,7 +148,7 @@ public async IAsyncEnumerable WatchResourcesAsync(WatchResourc try { - await foreach (var snapshot in WatchResourceSnapshotsAsync(cancellationToken).ConfigureAwait(false)) + await foreach (var snapshot in WatchResourceSnapshotsAsync(SupportsJsonResourceProperties(request?.ClientCapabilities), cancellationToken).ConfigureAwait(false)) { // Apply filter if specified if (!string.IsNullOrEmpty(filter) && !snapshot.Name.Contains(filter, StringComparison.OrdinalIgnoreCase)) @@ -734,6 +735,11 @@ public async Task WaitForAppHostReadyAsync(WaitForA /// A cancellation token. /// A list of resource snapshots. public async Task> GetResourceSnapshotsAsync(CancellationToken cancellationToken = default) + { + return await GetResourceSnapshotsAsync(resourcePropertiesAsJson: false, cancellationToken).ConfigureAwait(false); + } + + private async Task> GetResourceSnapshotsAsync(bool resourcePropertiesAsJson, CancellationToken cancellationToken) { var appModel = serviceProvider.GetRequiredService(); var notificationService = serviceProvider.GetRequiredService(); @@ -754,7 +760,7 @@ async Task AddResult(string resourceName) { if (notificationService.TryGetCurrentState(resourceName, out var resourceEvent)) { - var snapshot = await CreateResourceSnapshotFromEventAsync(resourceEvent, cancellationToken).ConfigureAwait(false); + var snapshot = await CreateResourceSnapshotFromEventAsync(resourceEvent, resourcePropertiesAsJson, cancellationToken).ConfigureAwait(false); if (snapshot is not null) { results.Add(snapshot); @@ -769,6 +775,16 @@ async Task AddResult(string resourceName) /// A cancellation token. /// An async enumerable of resource snapshots as they change. public async IAsyncEnumerable WatchResourceSnapshotsAsync([EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await foreach (var snapshot in WatchResourceSnapshotsAsync(resourcePropertiesAsJson: false, cancellationToken).ConfigureAwait(false)) + { + yield return snapshot; + } + } + + private async IAsyncEnumerable WatchResourceSnapshotsAsync( + bool resourcePropertiesAsJson, + [EnumeratorCancellation] CancellationToken cancellationToken) { var notificationService = serviceProvider.GetRequiredService(); @@ -776,7 +792,7 @@ public async IAsyncEnumerable WatchResourceSnapshotsAsync([Enu await foreach (var resourceEvent in resourceEvents.WithCancellation(cancellationToken).ConfigureAwait(false)) { - var snapshot = await CreateResourceSnapshotFromEventAsync(resourceEvent, cancellationToken).ConfigureAwait(false); + var snapshot = await CreateResourceSnapshotFromEventAsync(resourceEvent, resourcePropertiesAsJson, cancellationToken).ConfigureAwait(false); if (snapshot is not null) { yield return snapshot; @@ -786,6 +802,7 @@ public async IAsyncEnumerable WatchResourceSnapshotsAsync([Enu private async Task CreateResourceSnapshotFromEventAsync( ResourceEvent resourceEvent, + bool resourcePropertiesAsJson, CancellationToken cancellationToken) { var resource = resourceEvent.Resource; @@ -881,7 +898,9 @@ public async IAsyncEnumerable WatchResourceSnapshotsAsync([Enu continue; } - properties[prop.Name] = ConvertPropertyValueToJsonNode(prop.Value); + properties[prop.Name] = resourcePropertiesAsJson + ? ConvertPropertyValueToJsonNode(prop.Value) + : ConvertPropertyValueToLegacyJsonNode(prop.Value); if (string.Equals(prop.Name, KnownProperties.Resource.WaitingFor, StringComparisons.ResourcePropertyName)) { @@ -971,6 +990,43 @@ private static ResourceSnapshotCommandArgument CreateCommandArgument(Interaction }; } + private static JsonNode? ConvertPropertyValueToLegacyJsonNode(object? value) + { + var stringValue = ConvertPropertyValueToString(value); + + return stringValue is null ? null : JsonValue.Create(stringValue); + } + + private static string? ConvertPropertyValueToString(object? value) + { + return value switch + { + null => null, + JsonValue jsonValue when jsonValue.TryGetValue(out var stringValue) => stringValue, + JsonValue jsonValue when jsonValue.TryGetValue(out var boolValue) => boolValue.ToString(CultureInfo.InvariantCulture), + JsonValue jsonValue when jsonValue.TryGetValue(out var formattableValue) => formattableValue.ToString(null, CultureInfo.InvariantCulture), + JsonNode jsonNode => jsonNode.ToJsonString(), + string stringValue => stringValue, + IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture), + System.Collections.IEnumerable enumerable => ConvertEnumerablePropertyValueToString(enumerable), + _ => value.ToString() + }; + } + + private static string ConvertEnumerablePropertyValueToString(System.Collections.IEnumerable enumerable) + { + var values = new List(); + foreach (var value in enumerable) + { + if (ConvertPropertyValueToString(value) is { } stringValue) + { + values.Add(stringValue); + } + } + + return string.Join(',', values); + } + private static JsonArray ConvertEnumerablePropertyValueToJsonArray(System.Collections.IEnumerable enumerable) { var array = new JsonArray(); @@ -982,6 +1038,11 @@ private static JsonArray ConvertEnumerablePropertyValueToJsonArray(System.Collec return array; } + private static bool SupportsJsonResourceProperties(string[]? clientCapabilities) + { + return clientCapabilities?.Contains(AuxiliaryBackchannelCapabilities.V3, StringComparer.Ordinal) == true; + } + private static string[]? GetStringArrayPropertyValue(object? value) { return value switch diff --git a/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs b/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs index cc7b05372d7..e2e42e65e36 100644 --- a/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs +++ b/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs @@ -46,7 +46,8 @@ internal static class AuxiliaryBackchannelCapabilities public const string V2 = "aux.v2"; /// - /// Version 3 capabilities: Batched console log streaming and AppHost startup readiness wait. + /// Version 3 capabilities: Batched console log streaming, AppHost startup readiness wait, + /// and JSON-valued resource properties when requested by the client. /// public const string V3 = "aux.v3"; } @@ -240,11 +241,17 @@ internal sealed class GetResourcesRequest : BackchannelRequest /// public string? Filter { get; init; } + /// + /// Gets the auxiliary backchannel capabilities supported by the client. + /// + public string[] ClientCapabilities { get; init; } = []; + /// public override GetResourcesRequest WithTraceContext(BackchannelTraceContext traceContext) => new() { TraceContext = traceContext, - Filter = Filter + Filter = Filter, + ClientCapabilities = ClientCapabilities }; } @@ -269,11 +276,17 @@ internal sealed class WatchResourcesRequest : BackchannelRequest /// public string? Filter { get; init; } + /// + /// Gets the auxiliary backchannel capabilities supported by the client. + /// + public string[] ClientCapabilities { get; init; } = []; + /// public override WatchResourcesRequest WithTraceContext(BackchannelTraceContext traceContext) => new() { TraceContext = traceContext, - Filter = Filter + Filter = Filter, + ClientCapabilities = ClientCapabilities }; } diff --git a/tests/Aspire.Cli.Tests/Backchannel/AppHostAuxiliaryBackchannelTests.cs b/tests/Aspire.Cli.Tests/Backchannel/AppHostAuxiliaryBackchannelTests.cs new file mode 100644 index 00000000000..c14b9382494 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Backchannel/AppHostAuxiliaryBackchannelTests.cs @@ -0,0 +1,157 @@ +// 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; +using System.Net.Sockets; +using System.Runtime.CompilerServices; +using Aspire.Cli.Backchannel; +using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.Logging.Abstractions; +using StreamJsonRpc; + +namespace Aspire.Cli.Tests.Backchannel; + +public class AppHostAuxiliaryBackchannelTests +{ + [Fact] + public async Task GetResourceSnapshotsAsync_SendsClientCapabilitiesWithV3() + { + using var server = TestAppHostBackchannelServer.Start(); + using var backchannel = await server.ConnectAsync().DefaultTimeout(); + + var snapshots = await backchannel.GetResourceSnapshotsAsync(includeHidden: true).DefaultTimeout(); + + var snapshot = Assert.Single(snapshots); + Assert.Equal("api", snapshot.Name); + Assert.NotNull(server.Target.GetResourcesRequest); + Assert.Contains(AuxiliaryBackchannelCapabilities.V3, server.Target.GetResourcesRequest.ClientCapabilities); + } + + [Fact] + public async Task WatchResourceSnapshotsAsync_SendsClientCapabilitiesWithV3() + { + using var server = TestAppHostBackchannelServer.Start(); + using var backchannel = await server.ConnectAsync().DefaultTimeout(); + + using var watchCancellation = new CancellationTokenSource(); + await using var enumerator = backchannel.WatchResourceSnapshotsAsync(includeHidden: true, watchCancellation.Token).GetAsyncEnumerator(); + + Assert.True(await enumerator.MoveNextAsync().DefaultTimeout()); + await watchCancellation.CancelAsync(); + + var resource = enumerator.Current; + Assert.Equal("api", resource.Name); + Assert.NotNull(server.Target.WatchResourcesRequest); + Assert.Contains(AuxiliaryBackchannelCapabilities.V3, server.Target.WatchResourcesRequest.ClientCapabilities); + } + + private sealed class TestAppHostBackchannelServer : IDisposable + { + private readonly TcpListener _listener; + private readonly List _disposables = []; + + private TestAppHostBackchannelServer() + { + _listener = new TcpListener(IPAddress.Loopback, 0); + Target = new TestAppHostRpcTarget(); + } + + public TestAppHostRpcTarget Target { get; } + + public static TestAppHostBackchannelServer Start() + { + var server = new TestAppHostBackchannelServer(); + server._listener.Start(); + + return server; + } + + public async Task ConnectAsync() + { + var clientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + var acceptTask = _listener.AcceptSocketAsync(); + await clientSocket.ConnectAsync((IPEndPoint)_listener.LocalEndpoint).DefaultTimeout(); + var serverSocket = await acceptTask.DefaultTimeout(); + var serverStream = new NetworkStream(serverSocket, ownsSocket: true); + var messageHandler = new HeaderDelimitedMessageHandler(serverStream, serverStream, BackchannelJsonSerializerContext.CreateRpcMessageFormatter()); + var rpc = new JsonRpc(messageHandler, Target); + rpc.StartListening(); + _disposables.Add(rpc); + _disposables.Add(messageHandler); + _disposables.Add(serverStream); + + return await AppHostAuxiliaryBackchannel.CreateFromSocketAsync("hash1", "socket.hash1", isInScope: true, NullLogger.Instance, clientSocket).DefaultTimeout(); + } + + public void Dispose() + { + foreach (var disposable in _disposables) + { + disposable.Dispose(); + } + + _listener.Stop(); + } + } + + private sealed class TestAppHostRpcTarget + { + private readonly int _processId = Environment.ProcessId; + private readonly string[] _capabilities = + [ + AuxiliaryBackchannelCapabilities.V1, + AuxiliaryBackchannelCapabilities.V2 + ]; + + public GetResourcesRequest? GetResourcesRequest { get; private set; } + + public WatchResourcesRequest? WatchResourcesRequest { get; private set; } + + public Task GetAppHostInformationAsync(CancellationToken cancellationToken = default) + { + _ = cancellationToken; + + return Task.FromResult(new AppHostInformation + { + AppHostPath = "/path/to/AppHost.csproj", + ProcessId = _processId + }); + } + + public Task GetCapabilitiesAsync(GetCapabilitiesRequest? request = null, CancellationToken cancellationToken = default) + { + _ = request; + _ = cancellationToken; + + return Task.FromResult(new GetCapabilitiesResponse + { + Capabilities = _capabilities + }); + } + + public Task GetResourcesAsync(GetResourcesRequest? request = null, CancellationToken cancellationToken = default) + { + _ = cancellationToken; + GetResourcesRequest = request; + + return Task.FromResult(new GetResourcesResponse + { + Resources = [CreateResourceSnapshot()] + }); + } + + public async IAsyncEnumerable WatchResourcesAsync(WatchResourcesRequest? request = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + WatchResourcesRequest = request; + yield return CreateResourceSnapshot(); + await Task.CompletedTask.WaitAsync(cancellationToken).ConfigureAwait(false); + } + + private static ResourceSnapshot CreateResourceSnapshot() => + new() + { + Name = "api", + ResourceType = "Project" + }; + } +} diff --git a/tests/Aspire.Dashboard.Tests/Model/ResourceMenuBuilderTests.cs b/tests/Aspire.Dashboard.Tests/Model/ResourceMenuBuilderTests.cs index a29d60230d4..836805d289c 100644 --- a/tests/Aspire.Dashboard.Tests/Model/ResourceMenuBuilderTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/ResourceMenuBuilderTests.cs @@ -252,7 +252,7 @@ public void AddMenuItems_WithoutFromSpecEnvVars_ExportEnvMenuItemNotShown() } [Fact] - public void AddMenuItems_ShowStartCommandFalse_FiltersStartCommand() + public void AddMenuItems_IncludesStartCommandLikeOtherVisibleCommands() { var startCommand = new CommandViewModel( CommandViewModel.StartCommand, @@ -289,12 +289,12 @@ public void AddMenuItems_ShowStartCommandFalse_FiltersStartCommand() (_, _) => false, showViewDetails: false, showConsoleLogsItem: false, - showUrls: false, - showStartCommand: false); + showUrls: false); Assert.Collection(menuItems, e => Assert.Equal("Localized:ExportJson", e.Text), e => Assert.True(e.IsDivider), + e => Assert.Equal("Start", e.Text), e => Assert.Equal("Stop", e.Text)); } diff --git a/tests/Aspire.Hosting.Tests/Backchannel/AuxiliaryBackchannelRpcTargetTests.cs b/tests/Aspire.Hosting.Tests/Backchannel/AuxiliaryBackchannelRpcTargetTests.cs index dbdff9179ac..22ba5a19d4a 100644 --- a/tests/Aspire.Hosting.Tests/Backchannel/AuxiliaryBackchannelRpcTargetTests.cs +++ b/tests/Aspire.Hosting.Tests/Backchannel/AuxiliaryBackchannelRpcTargetTests.cs @@ -331,6 +331,91 @@ await notificationService.PublishUpdateAsync(custom.Resource, s => s with await app.StopAsync().DefaultTimeout(); } + [Fact] + public async Task GetResourceSnapshotsAsync_MapsNonStringPropertiesAsStringsForLegacyCallers() + { + using var builder = TestDistributedApplicationBuilder.Create(outputHelper); + + var custom = builder.AddResource(new CustomResource("myresource")); + + using var app = builder.Build(); + await app.StartAsync().DefaultTimeout(); + + var notificationService = app.Services.GetRequiredService(); + await notificationService.PublishUpdateAsync(custom.Resource, s => s with + { + Properties = + [ + new ResourcePropertySnapshot("number", 42), + new ResourcePropertySnapshot("flag", true), + new ResourcePropertySnapshot("list", new[] { "one", "two" }), + new ResourcePropertySnapshot("ConnectionString", "secret-value") { IsSensitive = true } + ] + }).DefaultTimeout(); + + var target = new AuxiliaryBackchannelRpcTarget( + NullLogger.Instance, + app.Services.GetRequiredService(), + app.Services.GetRequiredService(), + app.Services); + + var result = await target.GetResourceSnapshotsAsync().DefaultTimeout(); + + var snapshot = Assert.Single(result); + Assert.Equal("42", Assert.IsAssignableFrom(snapshot.Properties["number"]).GetValue()); + Assert.Equal(bool.TrueString, Assert.IsAssignableFrom(snapshot.Properties["flag"]).GetValue()); + Assert.Equal("one,two", Assert.IsAssignableFrom(snapshot.Properties["list"]).GetValue()); + Assert.Null(snapshot.Properties["ConnectionString"]); + + await app.StopAsync().DefaultTimeout(TestConstants.LongTimeoutTimeSpan); + } + + [Fact] + public async Task GetResourcesAsync_MapsNonStringPropertiesAsJsonForV3Callers() + { + using var builder = TestDistributedApplicationBuilder.Create(outputHelper); + + var custom = builder.AddResource(new CustomResource("myresource")); + + using var app = builder.Build(); + await app.StartAsync().DefaultTimeout(); + + var notificationService = app.Services.GetRequiredService(); + await notificationService.PublishUpdateAsync(custom.Resource, s => s with + { + Properties = + [ + new ResourcePropertySnapshot("number", 42), + new ResourcePropertySnapshot("flag", true), + new ResourcePropertySnapshot("list", new[] { "one", "two" }), + new ResourcePropertySnapshot("ConnectionString", "secret-value") { IsSensitive = true } + ] + }).DefaultTimeout(); + + var target = new AuxiliaryBackchannelRpcTarget( + NullLogger.Instance, + app.Services.GetRequiredService(), + app.Services.GetRequiredService(), + app.Services); + + var response = await target.GetResourcesAsync(new GetResourcesRequest + { + ClientCapabilities = [AuxiliaryBackchannelCapabilities.V3] + }).DefaultTimeout(); + + var snapshot = Assert.Single(response.Resources); + Assert.Equal(42, Assert.IsAssignableFrom(snapshot.Properties["number"]).GetValue()); + Assert.True(Assert.IsAssignableFrom(snapshot.Properties["flag"]).GetValue()); + var list = Assert.IsAssignableFrom(snapshot.Properties["list"]); + Assert.Collection( + list, + value => Assert.Equal("one", value?.GetValue()), + value => Assert.Equal("two", value?.GetValue())); + Assert.Null(snapshot.Properties["ConnectionString"]); + + await app.StopAsync().DefaultTimeout(TestConstants.LongTimeoutTimeSpan); + } + [Fact] public async Task WaitForResourceAsync_AcceptsResourceId() { diff --git a/tests/Aspire.Hosting.Tests/Backchannel/AuxiliaryBackchannelTests.cs b/tests/Aspire.Hosting.Tests/Backchannel/AuxiliaryBackchannelTests.cs index a10fb523d51..2207186f041 100644 --- a/tests/Aspire.Hosting.Tests/Backchannel/AuxiliaryBackchannelTests.cs +++ b/tests/Aspire.Hosting.Tests/Backchannel/AuxiliaryBackchannelTests.cs @@ -452,21 +452,23 @@ public async Task GetCapabilitiesAsyncReturnsCurrentCapabilities() var endpoint = new UnixDomainSocketEndPoint(service.SocketPath); await socket.ConnectAsync(endpoint).DefaultTimeout(); - using var stream = new NetworkStream(socket, ownsSocket: true); - using var rpc = JsonRpc.Attach(stream); + { + using var stream = new NetworkStream(socket, ownsSocket: true); + using var rpc = JsonRpc.Attach(stream); - // Invoke the GetCapabilitiesAsync RPC method - var response = await rpc.InvokeAsync( - "GetCapabilitiesAsync", - new object?[] { null } - ).DefaultTimeout(); + // Invoke the GetCapabilitiesAsync RPC method + var response = await rpc.InvokeAsync( + "GetCapabilitiesAsync", + new object?[] { null } + ).DefaultTimeout(); - // Verify the current capability set. - Assert.NotNull(response); - Assert.NotNull(response.Capabilities); - Assert.Contains(AuxiliaryBackchannelCapabilities.V1, response.Capabilities); - Assert.Contains(AuxiliaryBackchannelCapabilities.V2, response.Capabilities); - Assert.Contains(AuxiliaryBackchannelCapabilities.V3, response.Capabilities); + // Verify the current capability set. + Assert.NotNull(response); + Assert.NotNull(response.Capabilities); + Assert.Contains(AuxiliaryBackchannelCapabilities.V1, response.Capabilities); + Assert.Contains(AuxiliaryBackchannelCapabilities.V2, response.Capabilities); + Assert.Contains(AuxiliaryBackchannelCapabilities.V3, response.Capabilities); + } await app.StopAsync().DefaultTimeout(); } diff --git a/tests/Aspire.Hosting.Tests/Backchannel/BackchannelContractTests.cs b/tests/Aspire.Hosting.Tests/Backchannel/BackchannelContractTests.cs index 691f828072e..66e3af8d8df 100644 --- a/tests/Aspire.Hosting.Tests/Backchannel/BackchannelContractTests.cs +++ b/tests/Aspire.Hosting.Tests/Backchannel/BackchannelContractTests.cs @@ -333,6 +333,11 @@ private static object CreateNonDefaultValue(Type requestType, PropertyInfo prope return propertyName; } + if (property.PropertyType == typeof(string[])) + { + return new[] { propertyName }; + } + if (propertyType == typeof(bool)) { return defaultValue is bool value ? !value : true; @@ -381,6 +386,11 @@ private static bool PropertyValuesEqual(object? expected, object? actual) expectedDictionary.All(item => actualDictionary.TryGetValue(item.Key, out var actualValue) && item.Value == actualValue); } + if (expected is string[] expectedArray && actual is string[] actualArray) + { + return expectedArray.SequenceEqual(actualArray); + } + return Equals(expected, actual); } From c69a57ff63389ea1626ea055e1f52065e15fca69 Mon Sep 17 00:00:00 2001 From: "aspire-repo-bot[bot]" <268009190+aspire-repo-bot[bot]@users.noreply.github.com> Date: Wed, 27 May 2026 00:06:08 -0700 Subject: [PATCH 03/43] Fix Aspire skills attestation build type (#17525) Co-authored-by: IEvangelist <7679720+IEvangelist@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AspireSkills/AspireSkillsInstaller.cs | 2 +- .../Agents/AspireSkillsInstallerTests.cs | 23 ++++++++ .../SigstoreNpmProvenanceCheckerTests.cs | 55 +++++++++++++++++++ 3 files changed, 79 insertions(+), 1 deletion(-) diff --git a/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsInstaller.cs b/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsInstaller.cs index 3c727c1eea5..dbc2f1cfdd5 100644 --- a/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsInstaller.cs +++ b/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsInstaller.cs @@ -31,7 +31,7 @@ internal sealed class AspireSkillsInstaller( internal const string GitHubRepository = "microsoft/aspire-skills"; internal const string ExpectedSourceRepository = $"https://github.com/{GitHubRepository}"; internal const string ExpectedWorkflowPath = ".github/workflows/publish.yml"; - internal const string ExpectedBuildType = "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1"; + internal const string ExpectedBuildType = "https://actions.github.io/buildtypes/workflow/v1"; internal const string DisablePackageValidationKey = "disableAspireSkillsPackageValidation"; internal const string VersionOverrideKey = "aspireSkillsVersion"; internal const string MaxCacheAgeKey = "AspireSkillsMaxCacheAgeSeconds"; diff --git a/tests/Aspire.Cli.Tests/Agents/AspireSkillsInstallerTests.cs b/tests/Aspire.Cli.Tests/Agents/AspireSkillsInstallerTests.cs index 9aa79f45fce..f17750521e0 100644 --- a/tests/Aspire.Cli.Tests/Agents/AspireSkillsInstallerTests.cs +++ b/tests/Aspire.Cli.Tests/Agents/AspireSkillsInstallerTests.cs @@ -19,6 +19,8 @@ namespace Aspire.Cli.Tests.Agents; public class AspireSkillsInstallerTests { + private const string GitHubReleaseAssetBuildType = "https://actions.github.io/buildtypes/workflow/v1"; + [Fact] public async Task InstallAsync_WhenValidBundleIsCached_UsesCacheWithoutNetwork() { @@ -120,6 +122,11 @@ public async Task InstallAsync_WhenGitHubReleaseAssetIsAvailable_UsesGitHub() Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status); Assert.NotNull(result.Bundle); Assert.True(attestationVerifier.VerifyCalled); + Assert.Equal(AspireSkillsInstaller.GitHubRepository, attestationVerifier.Repository); + Assert.Equal(AspireSkillsInstaller.ExpectedSourceRepository, attestationVerifier.ExpectedSourceRepository); + Assert.Equal(AspireSkillsInstaller.ExpectedWorkflowPath, attestationVerifier.ExpectedWorkflowPath); + Assert.Equal(GitHubReleaseAssetBuildType, attestationVerifier.ExpectedBuildType); + Assert.Equal(AspireSkillsInstaller.Version, attestationVerifier.ExpectedVersion); Assert.NotNull(releaseRequestUri); Assert.NotNull(assetRequestUri); Assert.Contains("/microsoft/aspire-skills/releases/tags/v0.0.1", releaseRequestUri.AbsolutePath); @@ -298,6 +305,16 @@ private sealed class TestGitHubArtifactAttestationVerifier : IGitHubArtifactAtte { public bool VerifyCalled { get; private set; } + public string? Repository { get; private set; } + + public string? ExpectedSourceRepository { get; private set; } + + public string? ExpectedWorkflowPath { get; private set; } + + public string? ExpectedBuildType { get; private set; } + + public string? ExpectedVersion { get; private set; } + public ProvenanceVerificationResult Result { get; init; } = new() { Outcome = ProvenanceVerificationOutcome.Verified, @@ -314,6 +331,12 @@ public Task VerifyAsync( CancellationToken cancellationToken) { VerifyCalled = true; + Repository = repository; + ExpectedSourceRepository = expectedSourceRepository; + ExpectedWorkflowPath = expectedWorkflowPath; + ExpectedBuildType = expectedBuildType; + ExpectedVersion = expectedVersion; + return Task.FromResult(Result); } } diff --git a/tests/Aspire.Cli.Tests/Agents/SigstoreNpmProvenanceCheckerTests.cs b/tests/Aspire.Cli.Tests/Agents/SigstoreNpmProvenanceCheckerTests.cs index 0be6846b93a..89ad1ed48bc 100644 --- a/tests/Aspire.Cli.Tests/Agents/SigstoreNpmProvenanceCheckerTests.cs +++ b/tests/Aspire.Cli.Tests/Agents/SigstoreNpmProvenanceCheckerTests.cs @@ -9,6 +9,8 @@ namespace Aspire.Cli.Tests.Agents; public class SigstoreNpmProvenanceCheckerTests { + private const string GitHubReleaseAssetBuildType = "https://actions.github.io/buildtypes/workflow/v1"; + #region ExtractSlsaBundleJson Tests [Fact] @@ -259,6 +261,59 @@ public void VerifyProvenanceFields_WithAllFieldsMatching_ReturnsVerified() Assert.Equal(ProvenanceVerificationOutcome.Verified, result.Outcome); } + [Fact] + public void VerifyProvenanceFields_WithGitHubReleaseAssetBuildType_ReturnsVerified() + { + var provenance = new NpmProvenanceData + { + SourceRepository = "https://github.com/microsoft/aspire-skills", + WorkflowPath = ".github/workflows/publish.yml", + BuildType = GitHubReleaseAssetBuildType, + WorkflowRef = "refs/tags/v0.0.1", + BuilderId = "https://github.com/microsoft/aspire-skills/.github/workflows/publish.yml@refs/tags/v0.0.1" + }; + + var result = SigstoreNpmProvenanceChecker.VerifyProvenanceFields( + provenance, + "https://github.com/microsoft/aspire-skills", + ".github/workflows/publish.yml", + GitHubReleaseAssetBuildType, + refInfo => string.Equals(refInfo.Kind, "tags", StringComparison.Ordinal) && + string.Equals(refInfo.Name, "v0.0.1", StringComparison.Ordinal)); + + Assert.Equal(ProvenanceVerificationOutcome.Verified, result.Outcome); + } + + [Theory] + [InlineData("https://github.com/evil/aspire-skills", ".github/workflows/publish.yml", "refs/tags/v0.0.1", nameof(ProvenanceVerificationOutcome.SourceRepositoryMismatch))] + [InlineData("https://github.com/microsoft/aspire-skills", ".github/workflows/evil.yml", "refs/tags/v0.0.1", nameof(ProvenanceVerificationOutcome.WorkflowMismatch))] + [InlineData("https://github.com/microsoft/aspire-skills", ".github/workflows/publish.yml", "refs/heads/main", nameof(ProvenanceVerificationOutcome.WorkflowRefMismatch))] + public void VerifyProvenanceFields_WithGitHubReleaseAssetBuildType_RejectsUnexpectedProvenance( + string sourceRepository, + string workflowPath, + string workflowRef, + string expectedOutcome) + { + var provenance = new NpmProvenanceData + { + SourceRepository = sourceRepository, + WorkflowPath = workflowPath, + BuildType = GitHubReleaseAssetBuildType, + WorkflowRef = workflowRef, + BuilderId = "https://github.com/microsoft/aspire-skills/.github/workflows/publish.yml@refs/tags/v0.0.1" + }; + + var result = SigstoreNpmProvenanceChecker.VerifyProvenanceFields( + provenance, + "https://github.com/microsoft/aspire-skills", + ".github/workflows/publish.yml", + GitHubReleaseAssetBuildType, + refInfo => string.Equals(refInfo.Kind, "tags", StringComparison.Ordinal) && + string.Equals(refInfo.Name, "v0.0.1", StringComparison.Ordinal)); + + Assert.Equal(Enum.Parse(expectedOutcome), result.Outcome); + } + [Fact] public void VerifyProvenanceFields_WithSourceRepoMismatch_ReturnsSourceRepositoryMismatch() { From d7e6a8d5f40de92ea5bdc6d9bd2f9e1132245d2d Mon Sep 17 00:00:00 2001 From: "aspire-repo-bot[bot]" <268009190+aspire-repo-bot[bot]@users.noreply.github.com> Date: Wed, 27 May 2026 08:41:53 -0700 Subject: [PATCH 04/43] [release/13.4] Migrate playground/SqlServerEndToEnd to EF Hosting integration and fix found issues (#17531) * Migrate playground/SqlServerEndToEnd to EF Hosting integration - Remove the `SqlServerEndToEnd.DbSetup` worker project. Its `EnsureCreatedAsync` schema bootstrap is replaced by the `AddEFMigrations` resource model. - Check in the initial migration set. `Aspire.Hosting.EntityFrameworkCore` fixes - Connection-string resolution for the generated `Dockerfile` / bundle env-var now prefers explicit `.WithReference()` over `.WaitFor()`. Falls back to wait-based inference only when no references are declared. - `EFCoreOperationExecutor`: read exit code from the strongly-typed `snapshot.ExitCode` instead of parsing the `ExitCode` property string, and publish `ExitCode` on the tool resource's final snapshot update so consumers (including the executor itself) see it. - Tighten `AddEFMigrationsCore` duplicate-registration error messages to talk about "registered for specific DbContext types" / "registered without a context type" instead of "auto-detected". - XML doc cleanup on the public `AddEFMigrations` / `PublishAsMigrationBundle` / `WithMigrationsProject` / `WithMigrationOutputDirectory` / `WithMigrationNamespace` / `RunDatabaseUpdateOnStart` overloads (more accurate summaries, `` instead of quotes, drop obsolete "auto-detected" language, add `` notes). * Avoid concurrent tool execution --------- Co-authored-by: Andriy Svyryd --- Aspire.slnx | 1 - .../SqlServerEndToEnd.ApiService.csproj | 6 +- .../SqlServerEndToEnd.AppHost/AppHost.cs | 31 ++- .../SqlServerEndToEnd.AppHost.csproj | 2 +- .../20260511233127_Initial.Designer.cs | 41 ++++ .../Db1Migrations/20260511233127_Initial.cs | 31 +++ .../MyDb1ContextModelSnapshot.cs | 38 ++++ .../20260511233305_Initial.Designer.cs | 41 ++++ .../Db2Migrations/20260511233305_Initial.cs | 31 +++ .../MyDb2ContextModelSnapshot.cs | 38 ++++ .../SqlServerEndToEnd.DbSetup/Program.cs | 22 -- .../Properties/launchSettings.json | 12 - .../SqlServerEndToEnd.DbSetup.csproj | 19 -- .../EFCoreOperationExecutor.cs | 55 ++++- .../EFMigrationResourceBuilderExtensions.cs | 116 ++++++---- .../EFResourceBuilderExtensions.cs | 131 ++++++++--- .../EFMigrationPipelineTests.cs | 211 +++++++++++++++++- 17 files changed, 675 insertions(+), 151 deletions(-) create mode 100644 playground/SqlServerEndToEnd/SqlServerEndToEnd.Common/Db1Migrations/20260511233127_Initial.Designer.cs create mode 100644 playground/SqlServerEndToEnd/SqlServerEndToEnd.Common/Db1Migrations/20260511233127_Initial.cs create mode 100644 playground/SqlServerEndToEnd/SqlServerEndToEnd.Common/Db1Migrations/MyDb1ContextModelSnapshot.cs create mode 100644 playground/SqlServerEndToEnd/SqlServerEndToEnd.Common/Db2Migrations/20260511233305_Initial.Designer.cs create mode 100644 playground/SqlServerEndToEnd/SqlServerEndToEnd.Common/Db2Migrations/20260511233305_Initial.cs create mode 100644 playground/SqlServerEndToEnd/SqlServerEndToEnd.Common/Db2Migrations/MyDb2ContextModelSnapshot.cs delete mode 100644 playground/SqlServerEndToEnd/SqlServerEndToEnd.DbSetup/Program.cs delete mode 100644 playground/SqlServerEndToEnd/SqlServerEndToEnd.DbSetup/Properties/launchSettings.json delete mode 100644 playground/SqlServerEndToEnd/SqlServerEndToEnd.DbSetup/SqlServerEndToEnd.DbSetup.csproj diff --git a/Aspire.slnx b/Aspire.slnx index 1210f9a2c11..2bcb3d8dc11 100644 --- a/Aspire.slnx +++ b/Aspire.slnx @@ -371,7 +371,6 @@ - diff --git a/playground/SqlServerEndToEnd/SqlServerEndToEnd.ApiService/SqlServerEndToEnd.ApiService.csproj b/playground/SqlServerEndToEnd/SqlServerEndToEnd.ApiService/SqlServerEndToEnd.ApiService.csproj index 58f07f7074a..1a36efc151b 100644 --- a/playground/SqlServerEndToEnd/SqlServerEndToEnd.ApiService/SqlServerEndToEnd.ApiService.csproj +++ b/playground/SqlServerEndToEnd/SqlServerEndToEnd.ApiService/SqlServerEndToEnd.ApiService.csproj @@ -1,4 +1,4 @@ - + $(DefaultTargetFramework) @@ -10,6 +10,10 @@ + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/playground/SqlServerEndToEnd/SqlServerEndToEnd.AppHost/AppHost.cs b/playground/SqlServerEndToEnd/SqlServerEndToEnd.AppHost/AppHost.cs index e09603eaa67..093d79c12f9 100644 --- a/playground/SqlServerEndToEnd/SqlServerEndToEnd.AppHost/AppHost.cs +++ b/playground/SqlServerEndToEnd/SqlServerEndToEnd.AppHost/AppHost.cs @@ -11,19 +11,28 @@ var sql2 = builder.AddAzureSqlServer("sql2"); var db2 = sql2.AddDatabase("db2"); -var dbsetup = builder.AddProject("dbsetup") - .WithReference(db1).WaitFor(sql1) - .WithReference(db2).WaitFor(sql2); +var api = builder.AddProject("api") + .WithExternalHttpEndpoints(); -// Add EF migrations resource for the dbsetup project +// Add EF migrations resource for the api project // This adds dashboard commands for managing EF migrations -var dbMigrations = dbsetup.AddEFMigrations("db-migrations"); - -builder.AddProject("api") - .WithExternalHttpEndpoints() - .WithReference(db1).WaitFor(db1) - .WithReference(db2).WaitFor(db2) - .WaitForCompletion(dbsetup); +var db1Migrations = api.AddEFMigrations("db1-migrations", "MyDb1Context") + .WithMigrationsProject() + .WithMigrationOutputDirectory("Db1Migrations") + .RunDatabaseUpdateOnStart() // Note that this only works during local development. The migrations resource is not deployed. + .PublishAsMigrationBundle(publishContainer: true) + .WithReference(db1).WaitFor(db1); + +var db2Migrations = api.AddEFMigrations("db2-migrations", "MyDb2Context") + .WithMigrationsProject() + .WithMigrationOutputDirectory("Db2Migrations") + .RunDatabaseUpdateOnStart() // Note that this only works during local development. The migrations resource is not deployed. + .PublishAsMigrationBundle(publishContainer: true) + .WithReference(db2).WaitFor(db2); + +api + .WaitForCompletion(db1Migrations) + .WaitForCompletion(db2Migrations); #if !SKIP_DASHBOARD_REFERENCE // This project is only added in playground projects to support development/debugging diff --git a/playground/SqlServerEndToEnd/SqlServerEndToEnd.AppHost/SqlServerEndToEnd.AppHost.csproj b/playground/SqlServerEndToEnd/SqlServerEndToEnd.AppHost/SqlServerEndToEnd.AppHost.csproj index 45753dda360..f59bf76a23a 100644 --- a/playground/SqlServerEndToEnd/SqlServerEndToEnd.AppHost/SqlServerEndToEnd.AppHost.csproj +++ b/playground/SqlServerEndToEnd/SqlServerEndToEnd.AppHost/SqlServerEndToEnd.AppHost.csproj @@ -20,7 +20,7 @@ - + diff --git a/playground/SqlServerEndToEnd/SqlServerEndToEnd.Common/Db1Migrations/20260511233127_Initial.Designer.cs b/playground/SqlServerEndToEnd/SqlServerEndToEnd.Common/Db1Migrations/20260511233127_Initial.Designer.cs new file mode 100644 index 00000000000..a7af874f2de --- /dev/null +++ b/playground/SqlServerEndToEnd/SqlServerEndToEnd.Common/Db1Migrations/20260511233127_Initial.Designer.cs @@ -0,0 +1,41 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using SqlServerEndToEnd.Common; + +#nullable disable + +namespace SqlServerEndToEnd.Common.Db1 +{ + [DbContext(typeof(MyDb1Context))] + [Migration("20260511233127_Initial")] + partial class Initial + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.26") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("SqlServerEndToEnd.Common.Entry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.ToTable("Entries"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/playground/SqlServerEndToEnd/SqlServerEndToEnd.Common/Db1Migrations/20260511233127_Initial.cs b/playground/SqlServerEndToEnd/SqlServerEndToEnd.Common/Db1Migrations/20260511233127_Initial.cs new file mode 100644 index 00000000000..18ba7041a48 --- /dev/null +++ b/playground/SqlServerEndToEnd/SqlServerEndToEnd.Common/Db1Migrations/20260511233127_Initial.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SqlServerEndToEnd.Common.Db1; + +/// +public partial class Initial : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Entries", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Entries", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Entries"); + } +} diff --git a/playground/SqlServerEndToEnd/SqlServerEndToEnd.Common/Db1Migrations/MyDb1ContextModelSnapshot.cs b/playground/SqlServerEndToEnd/SqlServerEndToEnd.Common/Db1Migrations/MyDb1ContextModelSnapshot.cs new file mode 100644 index 00000000000..5e4c4b3aa2f --- /dev/null +++ b/playground/SqlServerEndToEnd/SqlServerEndToEnd.Common/Db1Migrations/MyDb1ContextModelSnapshot.cs @@ -0,0 +1,38 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using SqlServerEndToEnd.Common; + +#nullable disable + +namespace SqlServerEndToEnd.Common.Db1 +{ + [DbContext(typeof(MyDb1Context))] + partial class MyDb1ContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.26") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("SqlServerEndToEnd.Common.Entry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.ToTable("Entries"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/playground/SqlServerEndToEnd/SqlServerEndToEnd.Common/Db2Migrations/20260511233305_Initial.Designer.cs b/playground/SqlServerEndToEnd/SqlServerEndToEnd.Common/Db2Migrations/20260511233305_Initial.Designer.cs new file mode 100644 index 00000000000..4b7d66dadcd --- /dev/null +++ b/playground/SqlServerEndToEnd/SqlServerEndToEnd.Common/Db2Migrations/20260511233305_Initial.Designer.cs @@ -0,0 +1,41 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using SqlServerEndToEnd.Common; + +#nullable disable + +namespace SqlServerEndToEnd.Common.Db2 +{ + [DbContext(typeof(MyDb2Context))] + [Migration("20260511233305_Initial")] + partial class Initial + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.26") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("SqlServerEndToEnd.Common.Entry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.ToTable("Entries"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/playground/SqlServerEndToEnd/SqlServerEndToEnd.Common/Db2Migrations/20260511233305_Initial.cs b/playground/SqlServerEndToEnd/SqlServerEndToEnd.Common/Db2Migrations/20260511233305_Initial.cs new file mode 100644 index 00000000000..0cd121ab200 --- /dev/null +++ b/playground/SqlServerEndToEnd/SqlServerEndToEnd.Common/Db2Migrations/20260511233305_Initial.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SqlServerEndToEnd.Common.Db2; + +/// +public partial class Initial : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Entries", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Entries", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Entries"); + } +} diff --git a/playground/SqlServerEndToEnd/SqlServerEndToEnd.Common/Db2Migrations/MyDb2ContextModelSnapshot.cs b/playground/SqlServerEndToEnd/SqlServerEndToEnd.Common/Db2Migrations/MyDb2ContextModelSnapshot.cs new file mode 100644 index 00000000000..0ead2221a85 --- /dev/null +++ b/playground/SqlServerEndToEnd/SqlServerEndToEnd.Common/Db2Migrations/MyDb2ContextModelSnapshot.cs @@ -0,0 +1,38 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using SqlServerEndToEnd.Common; + +#nullable disable + +namespace SqlServerEndToEnd.Common.Db2 +{ + [DbContext(typeof(MyDb2Context))] + partial class MyDb2ContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.26") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("SqlServerEndToEnd.Common.Entry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.ToTable("Entries"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/playground/SqlServerEndToEnd/SqlServerEndToEnd.DbSetup/Program.cs b/playground/SqlServerEndToEnd/SqlServerEndToEnd.DbSetup/Program.cs deleted file mode 100644 index 5ff3dc07e66..00000000000 --- a/playground/SqlServerEndToEnd/SqlServerEndToEnd.DbSetup/Program.cs +++ /dev/null @@ -1,22 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Microsoft.EntityFrameworkCore; -using SqlServerEndToEnd.Common; - -var builder = WebApplication.CreateBuilder(args); -builder.AddSqlServerDbContext("db1"); -builder.AddSqlServerDbContext("db2"); -using var app = builder.Build(); -using var scope = app.Services.CreateScope(); -using var db1 = scope.ServiceProvider.GetRequiredService(); -using var db2 = scope.ServiceProvider.GetRequiredService(); - -foreach (var db in new DbContext[] { db1, db2 }) -{ - var created = await db.Database.EnsureCreatedAsync(); - if (created) - { - Console.WriteLine("Database schema created!"); - } -} diff --git a/playground/SqlServerEndToEnd/SqlServerEndToEnd.DbSetup/Properties/launchSettings.json b/playground/SqlServerEndToEnd/SqlServerEndToEnd.DbSetup/Properties/launchSettings.json deleted file mode 100644 index 53f1987f4dd..00000000000 --- a/playground/SqlServerEndToEnd/SqlServerEndToEnd.DbSetup/Properties/launchSettings.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "profiles": { - "SqlServerEndToEnd.DbSetup": { - "commandName": "Project", - "launchBrowser": true, - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - }, - "applicationUrl": "https://localhost:49994;http://localhost:49995" - } - } -} diff --git a/playground/SqlServerEndToEnd/SqlServerEndToEnd.DbSetup/SqlServerEndToEnd.DbSetup.csproj b/playground/SqlServerEndToEnd/SqlServerEndToEnd.DbSetup/SqlServerEndToEnd.DbSetup.csproj deleted file mode 100644 index 502d75191d9..00000000000 --- a/playground/SqlServerEndToEnd/SqlServerEndToEnd.DbSetup/SqlServerEndToEnd.DbSetup.csproj +++ /dev/null @@ -1,19 +0,0 @@ - - - - Exe - $(DefaultTargetFramework) - enable - enable - - - - - - - - - - - - diff --git a/src/Aspire.Hosting.EntityFrameworkCore/EFCoreOperationExecutor.cs b/src/Aspire.Hosting.EntityFrameworkCore/EFCoreOperationExecutor.cs index 552077dc3e6..e00abd5babc 100644 --- a/src/Aspire.Hosting.EntityFrameworkCore/EFCoreOperationExecutor.cs +++ b/src/Aspire.Hosting.EntityFrameworkCore/EFCoreOperationExecutor.cs @@ -45,6 +45,20 @@ internal sealed class EFCoreOperationExecutor : IDisposable private const string DataPrefix = "data: "; private const string VerbosePrefix = "verbose: "; + // Process-wide fallback that serializes every `dotnet-ef` invocation driven by this AppHost. + // The pipeline-step factory already chains migration generate steps via DependsOnSteps so two + // dotnet-ef processes won't run concurrently during `aspire publish`. This semaphore is a + // defense-in-depth backstop for: + // - run-mode resource commands (Update Database / Reset Database / ...) that the user can + // trigger from the dashboard on different migration resources at the same time, each on + // its own DotnetToolResource, + // - shared per-user state every dotnet-ef invocation touches: the `dotnet tool exec` cache, + // NuGet restore caches, and MSBuild node-reuse state under %USERPROFILE%, which are not + // safe under concurrent `dotnet ef` runs even when the projects don't overlap. + // Held only for the duration of a single dotnet-ef execution; never awaits any other pipeline + // step or command, so it cannot deadlock with the pipeline scheduler. + private static readonly SemaphoreSlim s_globalDotnetEfLock = new(1, 1); + public EFCoreOperationExecutor( ProjectResource startupProjectResource, string? targetProjectPath, @@ -180,7 +194,7 @@ private void ParseBuildSettingsFromPath(string assemblyPath) return null; } - private async Task ExecuteEfCommandAsync(string command, string subCommand, Dictionary? additionalArgs = null) + private async Task ExecuteEfCommandAsync(string command, string subCommand, Dictionary? additionalArgs = null, bool noBuild = true) { var initResult = EnsurePathsInitialized(); if (!initResult.Success) @@ -188,8 +202,19 @@ private async Task ExecuteEfCommandAsync(string command, stri return initResult; } - // Build the EF command arguments (these go after the -- in dotnet tool exec) - var efArgs = new List { command, subCommand, "--no-build", "--no-color", "--prefix-output" }; + // Build the EF command arguments (these go after the -- in dotnet tool exec). + // `--no-build` is normally added because all interactive run-mode commands assume the + // project was already built by the AppHost. Bundle generation during `aspire publish` + // intentionally omits it: the publish pipeline doesn't pre-build the startup project, + // and `dotnet ef migrations bundle` needs the migrations and startup projects compiled + // (and matching the requested target runtime) before it can package the bundle. + var efArgs = new List { command, subCommand }; + if (noBuild) + { + efArgs.Add("--no-build"); + } + efArgs.Add("--no-color"); + efArgs.Add("--prefix-output"); if (_logger.IsEnabled(LogLevel.Debug)) { @@ -242,6 +267,13 @@ private async Task ExecuteEfCommandAsync(string command, stri _logger.LogDebug("Executing dotnet tool exec dotnet-ef --yes -- {Args}", string.Join(" ", efArgs)); + // Acquire the global dotnet-ef lock outside the try/catch that maps exceptions to a failed + // EFOperationResult: an OperationCanceledException from WaitAsync must propagate so the + // caller observes cancellation rather than a generic failure result. Released in `finally` + // only when we actually acquired it (lockAcquired is set true after WaitAsync returns). + var lockAcquired = false; + await s_globalDotnetEfLock.WaitAsync(_cancellationToken).ConfigureAwait(false); + lockAcquired = true; try { // Get required services @@ -317,8 +349,8 @@ await notificationService.WaitForResourceAsync( // Check if the command succeeded var snapshot = resourceEvent.Snapshot; - var exitCode = snapshot.Properties.FirstOrDefault(p => p.Name == "ExitCode")?.Value?.ToString(); - if ((exitCode != null && exitCode != "0") || snapshot.State?.Text == KnownResourceStates.FailedToStart) + var exitCode = snapshot.ExitCode; + if ((exitCode != null && exitCode.Value != 0) || snapshot.State?.Text == KnownResourceStates.FailedToStart) { return new EFOperationResult { @@ -339,6 +371,13 @@ await notificationService.WaitForResourceAsync( { return new EFOperationResult { Success = false, ErrorMessage = $"dotnet-ef command failed: {ex.Message}" }; } + finally + { + if (lockAcquired) + { + s_globalDotnetEfLock.Release(); + } + } } private static string GetToolStartCommandName(DotnetToolResource toolResource) @@ -699,7 +738,7 @@ public async Task GenerateMigrationBundleAsync(string? output if (!string.IsNullOrEmpty(targetRuntime)) { - args["--runtime"] = targetRuntime; + args["--target-runtime"] = targetRuntime; } if (selfContained) @@ -710,7 +749,9 @@ public async Task GenerateMigrationBundleAsync(string? output // Overwrite existing bundle args["--force"] = null; - return await ExecuteEfCommandAsync("migrations", "bundle", args).ConfigureAwait(false); + // The bundle command compiles the migrations + startup project for the target runtime, + // so `--no-build` would defeat the purpose; let dotnet-ef drive the build itself. + return await ExecuteEfCommandAsync("migrations", "bundle", args, noBuild: false).ConfigureAwait(false); } public void Dispose() diff --git a/src/Aspire.Hosting.EntityFrameworkCore/EFMigrationResourceBuilderExtensions.cs b/src/Aspire.Hosting.EntityFrameworkCore/EFMigrationResourceBuilderExtensions.cs index 4f9f4b754bb..fd5f70c5d18 100644 --- a/src/Aspire.Hosting.EntityFrameworkCore/EFMigrationResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting.EntityFrameworkCore/EFMigrationResourceBuilderExtensions.cs @@ -23,9 +23,9 @@ public static class EFMigrationResourceBuilderExtensions /// The resource builder for chaining. /// /// - /// When enabled, migrations will be applied during AppHost startup. - /// This only affects local run-mode execution. The migrations resource is not deployed with the app, - /// so the command has no effect during publish or deployment. + /// When enabled, migrations are applied during AppHost startup. This only affects local + /// run-mode execution. The migrations resource is not deployed with the app, so this method + /// has no effect during publish or deployment. /// /// /// A health check is automatically registered for this resource, allowing other resources to use @@ -113,12 +113,12 @@ public static IResourceBuilder PublishAsMigrationScript( /// /// The target runtime identifier for the bundle (e.g., linux-x64, win-x64). /// If and is , - /// defaults to linux-x64 so the bundle can run inside a Linux container image. When - /// is the current runtime is used. + /// defaults to linux-x64 to match the default Linux base container image used for the + /// generated Dockerfile. If and + /// is , the bundle targets the runtime hosting aspire publish. /// /// /// If , creates a self-contained bundle that includes the .NET runtime. - /// Never defaulted by — user-specified value is always respected. /// /// /// If , the bundle is published as a container image that applies migrations @@ -141,10 +141,17 @@ public static IResourceBuilder PublishAsMigrationScript( /// under the efmigrations folder. When is /// , Aspire also generates a Dockerfile that packages the bundle into /// a container image; the container reads the connection string from a - /// ConnectionStrings__<name> environment variable provided by the referenced database - /// resource (call .WithReference(db) on the migration builder, or the connection string is - /// injected automatically for every that the migration - /// resource .WaitFors). + /// ConnectionStrings__<name> environment variable injected automatically for a + /// that the migration resource references or waits on. + /// + /// + /// The startup project (the project on which AddEFMigrations was invoked) and the + /// migrations project (configured via + /// or , if + /// different) must both list the target runtime in their <RuntimeIdentifiers> MSBuild property. + /// If = , by default this means + /// adding at minimum <RuntimeIdentifiers>linux-x64</RuntimeIdentifiers> to + /// both projects. /// /// [AspireExport] @@ -184,8 +191,8 @@ public static IResourceBuilder PublishAsMigrationBundle( /// The output directory path relative to the project root. /// The resource builder for chaining. /// - /// If not specified, migrations will be placed in the default 'Migrations' directory. - /// Example: "Data/Migrations" or "Infrastructure/Migrations". + /// If not specified, migrations will be placed in the default Migrations directory. + /// Example: Data/Migrations or Infrastructure/Migrations. /// [AspireExport] public static IResourceBuilder WithMigrationOutputDirectory(this IResourceBuilder builder, string outputDirectory) @@ -203,7 +210,7 @@ public static IResourceBuilder WithMigrationOutputDirectory /// The resource builder for chaining. /// /// If not specified, the namespace will be derived from the project's default namespace. - /// Example: "MyApp.Data.Migrations" or "MyApp.Infrastructure.Migrations". + /// Example: MyApp.Data.Migrations or MyApp.Infrastructure.Migrations. /// [AspireExport] public static IResourceBuilder WithMigrationNamespace(this IResourceBuilder builder, string @namespace) @@ -223,7 +230,8 @@ public static IResourceBuilder WithMigrationNamespace(this /// /// Use this method when the migrations are in a different project than the startup project. /// The target project's path will be used for migration operations while the startup project - /// remains the original project. + /// remains the original project. The project resource on which AddEFMigrations is invoked + /// should be the startup project (the project that contains the DbContext configuration). /// /// [AspireExportIgnore(Reason = "Polyglot app hosts use the internal withMigrationsProject dispatcher export.")] @@ -246,14 +254,9 @@ public static IResourceBuilder WithMigrationsProject(this I /// /// Use this method when the migrations are in a different project than the startup project. /// The target project's path will be used for migration operations while the startup project - /// remains the original project. + /// remains the original project. The project resource on which AddEFMigrations is invoked + /// should be the startup project (the project that contains the DbContext configuration). /// - /// - /// - /// var migrations = project.AddEFMigrations<MyDbContext>("migrations") - /// .WithMigrationsProject<Projects.MyMigrationsProject>(); - /// - /// /// [AspireExportIgnore(Reason = "Uses IProjectMetadata generic constraint which is a .NET-specific type. Polyglot app hosts use the internal withMigrationsProject dispatcher export.")] public static IResourceBuilder WithMigrationsProject(this IResourceBuilder builder) @@ -297,6 +300,10 @@ internal static IResourceBuilder WithMigrationsProjectForPo private const string WindowsImageTagSuffix = "-nanoserver-ltsc2022"; private const string ConnectionStringEnvVarPrefix = "ConnectionStrings__"; + // Mirrors Aspire.Dashboard.Model.KnownRelationshipTypes.Reference, which is internal to + // Aspire.Hosting and not visible from this project. Kept in sync with that constant. + private const string ReferenceRelationshipType = "Reference"; + private static void ConfigureBundleContainer(IResourceBuilder builder) { var migrationResource = builder.Resource; @@ -318,12 +325,13 @@ private static void ConfigureBundleContainer(IResourceBuilder for the bundle - // container the same way it does for any other compute resource. + // dependencies the user declared via WithReference or WaitFor. + // Forward them through the standard environment callback so the compute environment + // injects ConnectionStrings__ for the bundle container the same way it does for + // any other compute resource. builder.ApplicationBuilder.Eventing.Subscribe((@event, _) => { - var connectionStringResource = GetSingleWaitedOnConnectionStringResource(migrationResource); + var connectionStringResource = GetSingleConnectionStringResource(migrationResource); var envVar = connectionStringResource.ConnectionStringEnvironmentVariable ?? ConnectionStringEnvVarPrefix + connectionStringResource.Name; @@ -350,30 +358,27 @@ private static string ResolvePipelineOutputDirectory(IResourceBuilder(); - if (migrationResource.TryGetAnnotationsOfType(out var waitAnnotations)) + // Prefer explicit references declared via .WithReference(): those are the user's + // explicit statement of which connection string the bundle should target. Only when no + // such reference exists do we fall back to inferring it from .WaitFor() dependencies. + var candidates = CollectConnectionStringCandidates( + migrationResource, + annotation => annotation.Type == ReferenceRelationshipType ? annotation.Resource : null); + + if (candidates.Count == 0) { - foreach (var wait in waitAnnotations) - { - if (wait.Resource is IResourceWithConnectionString connectionStringResource - && !candidates.Any(c => ReferenceEquals(c, connectionStringResource))) - { - candidates.Add(connectionStringResource); - } - } + candidates = CollectConnectionStringCandidates( + migrationResource, + annotation => annotation.Resource); } if (candidates.Count == 0) { throw new InvalidOperationException( $"Cannot publish migration bundle '{migrationResource.Name}' as a container: add " + - $"'.WaitFor()' with a database resource that exposes a connection string."); + $"'.WithReference()' and/or '.WaitFor()' with a database resource that exposes a connection string."); } // Drop any candidate that is an ancestor (via IResourceWithParent) of another candidate. @@ -391,9 +396,30 @@ private static IResourceWithConnectionString GetSingleWaitedOnConnectionStringRe var unrelated = string.Join(", ", leaves.Select(l => $"'{l.Name}'")); throw new InvalidOperationException( $"Cannot publish migration bundle '{migrationResource.Name}' as a container: multiple " + - $"unrelated waited-on resources expose a connection string ({unrelated}). A migration " + - $"bundle targets exactly one database — only call '.WaitFor' with a single " + - $"IResourceWithConnectionString, or waited-on resources that share a parent chain."); + $"resources expose a connection string ({unrelated}). A migration " + + $"bundle targets exactly one database — only reference or wait on a single " + + $"IResourceWithConnectionString, or resources that share a parent chain."); + } + + private static List CollectConnectionStringCandidates( + EFMigrationResource migrationResource, + Func resourceSelector) + where TAnnotation : IResourceAnnotation + { + var candidates = new List(); + if (migrationResource.TryGetAnnotationsOfType(out var annotations)) + { + foreach (var annotation in annotations) + { + if (resourceSelector(annotation) is IResourceWithConnectionString connectionStringResource + && !candidates.Any(c => ReferenceEquals(c, connectionStringResource))) + { + candidates.Add(connectionStringResource); + } + } + } + + return candidates; } private static bool IsAncestorOf(IResource candidate, IResource descendant) @@ -490,7 +516,7 @@ private static bool TryExtractVersion(string? tfm, out string version) internal static string GenerateDockerfile(EFMigrationResource migrationResource) { - var primary = GetSingleWaitedOnConnectionStringResource(migrationResource); + var primary = GetSingleConnectionStringResource(migrationResource); var envVarName = primary.ConnectionStringEnvironmentVariable ?? ConnectionStringEnvVarPrefix + primary.Name; diff --git a/src/Aspire.Hosting.EntityFrameworkCore/EFResourceBuilderExtensions.cs b/src/Aspire.Hosting.EntityFrameworkCore/EFResourceBuilderExtensions.cs index 68ac5a2f7b0..aba9e3130e9 100644 --- a/src/Aspire.Hosting.EntityFrameworkCore/EFResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting.EntityFrameworkCore/EFResourceBuilderExtensions.cs @@ -31,7 +31,7 @@ private static string GetShortTypeName(string? fullTypeName) } /// - /// Adds EF Core migration management for a specific DbContext type identified by name. + /// Adds EF Core migration management for a specific DbContext type. /// /// The resource builder for the project. /// The name of the migration resource. @@ -39,14 +39,8 @@ private static string GetShortTypeName(string? fullTypeName) /// An EF migration resource builder for chaining additional configuration. /// Thrown if migrations for this context type have already been added. /// - /// /// Multiple calls to this method with different context types are supported, allowing you to manage /// migrations for multiple DbContexts in the same project. - /// - /// - /// This overload is useful when the DbContext type is not available at compile time, such as when - /// using runtime-discovered context types. - /// /// [AspireExportIgnore(Reason = "Polyglot app hosts use the internal addEFMigrations dispatcher export.")] public static IResourceBuilder AddEFMigrations( @@ -62,7 +56,7 @@ public static IResourceBuilder AddEFMigrations( } /// - /// Adds EF Core migration management for a specific DbContext type identified by name. + /// Adds EF Core migration management for a specific DbContext type. /// /// The resource builder for the project. /// The name of the migration resource. @@ -71,14 +65,8 @@ public static IResourceBuilder AddEFMigrations( /// An EF migration resource builder for chaining additional configuration. /// Thrown if migrations for this context type have already been added. /// - /// /// Multiple calls to this method with different context types are supported, allowing you to manage /// migrations for multiple DbContexts in the same project. - /// - /// - /// This overload is useful when the DbContext type is not available at compile time, such as when - /// using runtime-discovered context types. - /// /// [AspireExportIgnore(Reason = "Action> callbacks are not ATS-compatible.")] public static IResourceBuilder AddEFMigrations( @@ -95,11 +83,12 @@ public static IResourceBuilder AddEFMigrations( } /// - /// Adds EF Core migration management for auto-detected DbContext types. + /// Adds EF Core migration management for the only DbContext type in the target project. /// /// The resource builder for the project. /// The name of the migration resource. /// An EF migration resource builder for chaining additional configuration. + /// Thrown if migrations have already been added for any DbContext type on this project. [AspireExportIgnore(Reason = "Polyglot app hosts use the internal addEFMigrations dispatcher export.")] public static IResourceBuilder AddEFMigrations( this IResourceBuilder builder, @@ -132,12 +121,13 @@ internal static IResourceBuilder AddEFMigrationsForPolyglot } /// - /// Adds EF Core migration management for auto-detected DbContext types. + /// Adds EF Core migration management for the only DbContext type in the target project. /// /// The resource builder for the project. /// The name of the migration resource. /// Optional callback to configure the dotnet-ef tool resource used for migrations. /// An EF migration resource builder for chaining additional configuration. + /// Thrown if migrations have already been added for any DbContext type on this project. [AspireExportIgnore(Reason = "Action> callbacks are not ATS-compatible.")] public static IResourceBuilder AddEFMigrations( this IResourceBuilder builder, @@ -156,33 +146,35 @@ private static IResourceBuilder AddEFMigrationsCore( string? dbContextTypeName, Action>? configureToolResource) { - // Check for duplicate context types and null/non-null conflicts - var existingMigrations = builder.ApplicationBuilder.Resources + var existingMigrationResources = builder.ApplicationBuilder.Resources .OfType() .Where(r => r.ProjectResource == builder.Resource) .ToList(); if (dbContextTypeName != null) { - if (existingMigrations.Any(r => r.DbContextTypeName == dbContextTypeName)) + if (existingMigrationResources.Any(r => r.DbContextTypeName == dbContextTypeName)) { throw new InvalidOperationException( $"The DbContext type '{GetShortTypeName(dbContextTypeName)}' has already been registered for EF migrations on resource '{builder.Resource.Name}'."); } - if (existingMigrations.Any(r => r.DbContextTypeName == null)) + if (existingMigrationResources.Any(r => r.DbContextTypeName == null)) { throw new InvalidOperationException( - $"Cannot add migrations for a specific DbContext type when auto-detected migrations have already been registered on resource '{builder.Resource.Name}'."); + $"Cannot register a specific DbContext type for migrations when they have already been registered without a context type on resource '{builder.Resource.Name}'."); } } - else + else if (existingMigrationResources.Count != 0) { - if (existingMigrations.Any()) + if (existingMigrationResources.Any(r => r.DbContextTypeName == null)) { throw new InvalidOperationException( - $"Cannot add auto-detected migrations when migrations for specific DbContext types have already been registered on resource '{builder.Resource.Name}'."); + $"Cannot register migrations without a context type when they have already been registered without a context type on resource '{builder.Resource.Name}'."); } + + throw new InvalidOperationException( + $"Cannot register migrations without a context type when they have already been registered for specific DbContext types on resource '{builder.Resource.Name}'."); } var migrationResource = new EFMigrationResource(name, builder.Resource, dbContextTypeName) @@ -222,13 +214,38 @@ internal static IEnumerable CreateMigrationPipelineStep(PipelineSt ? $"{migrationResource.Name}-generate-migration-script" : null; + var bundleStepName = migrationResource.PublishAsMigrationBundle + ? $"{migrationResource.Name}-generate-migration-bundle" + : null; + + // Serialize publish-time generate steps across every EFMigrationResource in the model. + // Each `dotnet-ef` invocation triggers a `dotnet build` (the bundle step explicitly runs + // without `--no-build` because the bundle command needs the build to target a specific + // runtime). Two concurrent `dotnet-ef` runs can race on: + // - the shared obj/bin output when two migrations target the same startup project, + // - the per-user `dotnet tool exec` cache (NuGet install + extract) used by every + // DotnetToolResource regardless of project, and + // - the per-user MSBuild node-reuse / NuGet restore caches under %USERPROFILE%. + // None of those are safe under concurrent `dotnet-ef` invocations, so we chain ALL + // migration generate steps in the model — not just the ones sharing a startup project. + // + // The chain is built by deterministically ordering sibling migrations by name and pointing + // the first step of each migration at the last step of the previous migration. The + // graph is therefore: -script -> -bundle -> -script -> -bundle -> ... + // which is acyclic (the per-migration script -> bundle edge already exists and the + // cross-migration edge only flows forward in the deterministic ordering). + var crossMigrationPredecessor = GetPreviousMigrationLastStepName(context.PipelineContext.Model, migrationResource); + if (migrationResource.PublishAsMigrationScript) { + List scriptDependsOn = crossMigrationPredecessor is not null ? [crossMigrationPredecessor] : []; + steps.Add(new PipelineStep { Name = scriptStepName!, Description = $"Generate EF Core migration SQL script for {migrationResource.Name}", Resource = migrationResource, + DependsOnSteps = scriptDependsOn, RequiredBySteps = [WellKnownPipelineSteps.Publish], Action = stepContext => ExecutePublishPipelineOperationAsync( stepContext, migrationResource, "migration script", @@ -247,19 +264,32 @@ internal static IEnumerable CreateMigrationPipelineStep(PipelineSt if (migrationResource.PublishAsMigrationBundle) { - var generateStepName = $"{migrationResource.Name}-generate-migration-bundle"; - var publishesContainer = migrationResource.PublishBundleContainer; + var publishesContainer = migrationResource.PublishBundleContainer + && context.PipelineContext.ExecutionContext.IsPublishMode; List requiredBy = publishesContainer ? [WellKnownPipelineSteps.Publish, $"build-{migrationResource.Name}"] : [WellKnownPipelineSteps.Publish]; + // Prefer the per-migration script step as the dependency when present (the cross-migration + // edge is already attached to the script step in that case). Only attach the cross-migration + // edge directly to the bundle step when this migration produces no script step. + List bundleDependsOn = []; + if (scriptStepName is not null) + { + bundleDependsOn.Add(scriptStepName); + } + else if (crossMigrationPredecessor is not null) + { + bundleDependsOn.Add(crossMigrationPredecessor); + } + steps.Add(new PipelineStep { - Name = generateStepName, + Name = bundleStepName!, Description = $"Generate EF Core migration bundle for {migrationResource.Name}", Resource = migrationResource, - DependsOnSteps = scriptStepName is not null ? [scriptStepName] : [], // Make sure these don't run in parallel as the underlying tool resource is not thread safe + DependsOnSteps = bundleDependsOn, RequiredBySteps = requiredBy, Action = stepContext => ExecutePublishPipelineOperationAsync( stepContext, migrationResource, "migration bundle", @@ -279,6 +309,47 @@ internal static IEnumerable CreateMigrationPipelineStep(PipelineSt return steps; } + // Returns the name of the last publish-time step produced by the migration that immediately + // precedes in a stable ordering of all migrations in the model. + // Returns null when is the first such migration (no predecessor) + // or the only one. + private static string? GetPreviousMigrationLastStepName(DistributedApplicationModel model, EFMigrationResource current) + { + EFMigrationResource? predecessor = null; + foreach (var sibling in model.Resources.OfType()) + { + if (ReferenceEquals(sibling, current) || + (!sibling.PublishAsMigrationScript && !sibling.PublishAsMigrationBundle)) + { + continue; + } + + // Stable ordinal ordering by resource name keeps the chain deterministic regardless + // of model traversal order. Only siblings whose name sorts before this one can + // possibly act as a predecessor. + if (StringComparer.Ordinal.Compare(sibling.Name, current.Name) >= 0) + { + continue; + } + + if (predecessor is null || StringComparer.Ordinal.Compare(sibling.Name, predecessor.Name) > 0) + { + predecessor = sibling; + } + } + + if (predecessor is null) + { + return null; + } + + // The bundle step always follows the script step within the same migration, so it is the + // last step when present. + return predecessor.PublishAsMigrationBundle + ? $"{predecessor.Name}-generate-migration-bundle" + : $"{predecessor.Name}-generate-migration-script"; + } + private static async Task ExecutePublishPipelineOperationAsync( PipelineStepContext stepContext, EFMigrationResource migrationResource, @@ -343,7 +414,6 @@ private static async Task StartEfToolResourceAsync(Execute try { - var executableAnnotation = toolResource.Annotations.OfType().LastOrDefault(); if (executableAnnotation is null) { @@ -461,7 +531,8 @@ await notificationService.PublishUpdateAsync(toolResource, s => s with await notificationService.PublishUpdateAsync(toolResource, s => s with { State = finalState, - StopTimeStamp = DateTime.UtcNow + StopTimeStamp = DateTime.UtcNow, + ExitCode = process.ExitCode }).ConfigureAwait(false); if (process.ExitCode != 0) diff --git a/tests/Aspire.Hosting.EntityFrameworkCore.Tests/EFMigrationPipelineTests.cs b/tests/Aspire.Hosting.EntityFrameworkCore.Tests/EFMigrationPipelineTests.cs index 539a99bbcd9..ed841973579 100644 --- a/tests/Aspire.Hosting.EntityFrameworkCore.Tests/EFMigrationPipelineTests.cs +++ b/tests/Aspire.Hosting.EntityFrameworkCore.Tests/EFMigrationPipelineTests.cs @@ -78,6 +78,135 @@ public async Task NoPublishOptionsProducesNoSteps() Assert.Empty(steps); } + [Fact] + public async Task MultipleBundlesOnSameProjectAreSerializedByChainingSteps() + { + // Two migration resources targeting the same startup project must not generate their + // bundles in parallel: each `dotnet-ef migrations bundle` drives a `dotnet build` of + // that shared project, and concurrent builds corrupt the project's obj/bin output. + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: null); + var project = builder.AddProject("myproject"); + var m1 = project.AddEFMigrations("aaa-migrations", typeof(TestDbContext).FullName!) + .PublishAsMigrationBundle(); + var m2 = project.AddEFMigrations("bbb-migrations", typeof(AnotherDbContext).FullName!) + .PublishAsMigrationBundle(); + + var m1Steps = await CreateStepsAsync(builder, m1.Resource); + var m2Steps = await CreateStepsAsync(builder, m2.Resource); + + var m1Bundle = Assert.Single(m1Steps, s => s.Name == "aaa-migrations-generate-migration-bundle"); + var m2Bundle = Assert.Single(m2Steps, s => s.Name == "bbb-migrations-generate-migration-bundle"); + + Assert.Empty(m1Bundle.DependsOnSteps); + Assert.Contains("aaa-migrations-generate-migration-bundle", m2Bundle.DependsOnSteps); + } + + [Fact] + public async Task MultipleMigrationsOnSameProjectChainScriptAndBundleAcrossResources() + { + // The successor migration's first step (its script step when present, otherwise its bundle + // step) must depend on the predecessor's last step (its bundle step when present, otherwise + // its script step). Within a single migration the bundle already depends on the script, so + // we only need to attach the cross-migration edge to the first step. + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: null); + var project = builder.AddProject("myproject"); + var m1 = project.AddEFMigrations("aaa-migrations", typeof(TestDbContext).FullName!) + .PublishAsMigrationScript() + .PublishAsMigrationBundle(); + var m2 = project.AddEFMigrations("bbb-migrations", typeof(AnotherDbContext).FullName!) + .PublishAsMigrationScript() + .PublishAsMigrationBundle(); + + var m1Steps = await CreateStepsAsync(builder, m1.Resource); + var m2Steps = await CreateStepsAsync(builder, m2.Resource); + + var m1Script = Assert.Single(m1Steps, s => s.Name == "aaa-migrations-generate-migration-script"); + var m1Bundle = Assert.Single(m1Steps, s => s.Name == "aaa-migrations-generate-migration-bundle"); + var m2Script = Assert.Single(m2Steps, s => s.Name == "bbb-migrations-generate-migration-script"); + var m2Bundle = Assert.Single(m2Steps, s => s.Name == "bbb-migrations-generate-migration-bundle"); + + Assert.Empty(m1Script.DependsOnSteps); + Assert.Contains(m1Script.Name, m1Bundle.DependsOnSteps); + Assert.Contains(m1Bundle.Name, m2Script.DependsOnSteps); + Assert.Contains(m2Script.Name, m2Bundle.DependsOnSteps); + Assert.DoesNotContain(m1Bundle.Name, m2Bundle.DependsOnSteps); + } + + [Fact] + public async Task MigrationsOnDifferentProjectsAreAlsoChained() + { + // Migrations on different projects must still be serialized: every `dotnet-ef` invocation + // touches per-user state (`dotnet tool exec` cache, NuGet restore caches, MSBuild + // node-reuse) that is not safe under concurrent `dotnet-ef` runs even when the projects + // don't overlap. The chain spans the whole model, not just one project. + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: null); + var projectA = builder.AddProject("projecta"); + var projectB = builder.AddProject("projectb"); + var m1 = projectA.AddEFMigrations("aaa-migrations", typeof(TestDbContext).FullName!) + .PublishAsMigrationBundle(); + var m2 = projectB.AddEFMigrations("bbb-migrations", typeof(TestDbContext).FullName!) + .PublishAsMigrationBundle(); + + var m1Steps = await CreateStepsAsync(builder, m1.Resource); + var m2Steps = await CreateStepsAsync(builder, m2.Resource); + + var m1Bundle = Assert.Single(m1Steps); + var m2Bundle = Assert.Single(m2Steps); + + Assert.Empty(m1Bundle.DependsOnSteps); + Assert.Contains("aaa-migrations-generate-migration-bundle", m2Bundle.DependsOnSteps); + } + + [Fact] + public async Task MixedScriptOnlyAndBundleOnlyMigrationsAreChained() + { + // A predecessor with only a script step and a successor with only a bundle step should + // still produce a single serialized chain: bundle -> script via DependsOnSteps. + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: null); + var project = builder.AddProject("myproject"); + var m1 = project.AddEFMigrations("aaa-migrations", typeof(TestDbContext).FullName!) + .PublishAsMigrationScript(); + var m2 = project.AddEFMigrations("bbb-migrations", typeof(AnotherDbContext).FullName!) + .PublishAsMigrationBundle(); + + var m1Steps = await CreateStepsAsync(builder, m1.Resource); + var m2Steps = await CreateStepsAsync(builder, m2.Resource); + + var m1Script = Assert.Single(m1Steps); + var m2Bundle = Assert.Single(m2Steps); + + Assert.Equal("aaa-migrations-generate-migration-script", m1Script.Name); + Assert.Equal("bbb-migrations-generate-migration-bundle", m2Bundle.Name); + Assert.Contains(m1Script.Name, m2Bundle.DependsOnSteps); + } + + [Fact] + public async Task MigrationsWithoutPublishOptionsAreSkippedFromChain() + { + // A sibling migration that opted out of publish-time generation produces no pipeline + // steps, so it must not appear in the chain — otherwise the successor would depend on + // a non-existent step name and pipeline execution would fail. + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: null); + var project = builder.AddProject("myproject"); + var m1 = project.AddEFMigrations("aaa-migrations", typeof(TestDbContext).FullName!) + .PublishAsMigrationBundle(); + // bbb-migrations does NOT call PublishAsMigrationBundle/Script — it has no pipeline steps. + _ = project.AddEFMigrations("bbb-migrations", typeof(AnotherDbContext).FullName!); + var m3 = project.AddEFMigrations("ccc-migrations", typeof(ThirdDbContext).FullName!) + .PublishAsMigrationBundle(); + + var m1Steps = await CreateStepsAsync(builder, m1.Resource); + var m3Steps = await CreateStepsAsync(builder, m3.Resource); + + var m1Bundle = Assert.Single(m1Steps); + var m3Bundle = Assert.Single(m3Steps); + + Assert.Contains("aaa-migrations-generate-migration-bundle", m3Bundle.DependsOnSteps); + Assert.DoesNotContain("bbb-migrations-generate-migration-bundle", m3Bundle.DependsOnSteps); + Assert.DoesNotContain("bbb-migrations-generate-migration-script", m3Bundle.DependsOnSteps); + Assert.Empty(m1Bundle.DependsOnSteps); + } + [Fact] public async Task PublishBundleContainerProducesNoStepsInRunMode() { @@ -446,12 +575,88 @@ public void GeneratedDockerfileFailsWhenMultipleUnrelatedWaitedOnConnectionStrin var ex = Assert.Throws(() => EFMigrationResourceBuilderExtensions.GenerateDockerfile(migrations.Resource)); - Assert.Contains("multiple", ex.Message); - Assert.Contains("unrelated", ex.Message); + Assert.Contains("multiple resources", ex.Message); + Assert.Contains("'db1'", ex.Message); + Assert.Contains("'db2'", ex.Message); + } + + [Fact] + public void GeneratedDockerfileUsesReferencedConnectionStringResource() + { + // .WithReference(db) is the explicit way for the user to declare which connection + // string the bundle should target. It must be honored even when no .WaitFor is set. + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: null); + var db = builder.AddResource(new TestDatabaseResource("mydb")); + var project = builder.AddProject("myproject"); + var migrations = project.AddEFMigrations("mymigrations", typeof(TestDbContext).FullName!) + .WithReference(db) + .PublishAsMigrationBundle(publishContainer: true); + + var dockerfile = EFMigrationResourceBuilderExtensions.GenerateDockerfile(migrations.Resource); + + Assert.Contains("ConnectionStrings__mydb", dockerfile); + } + + [Fact] + public void GeneratedDockerfilePrefersReferencedResourceOverWaitedOnResource() + { + // When the user explicitly references one database via .WithReference(db) and waits on + // another via .WaitFor(other), the explicit reference is the user's stated target and + // must win — the waited-on resource is just an ordering signal, not a target selection. + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: null); + var referenced = builder.AddResource(new TestDatabaseResource("referenced")); + var waited = builder.AddResource(new TestDatabaseResource("waited")); + var project = builder.AddProject("myproject"); + var migrations = project.AddEFMigrations("mymigrations", typeof(TestDbContext).FullName!) + .WithReference(referenced) + .WaitFor(waited) + .PublishAsMigrationBundle(publishContainer: true); + + var dockerfile = EFMigrationResourceBuilderExtensions.GenerateDockerfile(migrations.Resource); + + Assert.Contains("ConnectionStrings__referenced", dockerfile); + Assert.DoesNotContain("ConnectionStrings__waited", dockerfile); + } + + [Fact] + public void GeneratedDockerfileFailsWhenMultipleUnrelatedReferencedConnectionStringResources() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: null); + var db1 = builder.AddResource(new TestDatabaseResource("db1")); + var db2 = builder.AddResource(new TestDatabaseResource("db2")); + var project = builder.AddProject("myproject"); + var migrations = project.AddEFMigrations("mymigrations", typeof(TestDbContext).FullName!) + .WithReference(db1) + .WithReference(db2) + .PublishAsMigrationBundle(publishContainer: true); + + var ex = Assert.Throws(() => + EFMigrationResourceBuilderExtensions.GenerateDockerfile(migrations.Resource)); + Assert.Contains("multiple resources", ex.Message); Assert.Contains("'db1'", ex.Message); Assert.Contains("'db2'", ex.Message); } + [Fact] + public void GeneratedDockerfilePrefersLeafWhenReferencedChildAndParent() + { + // Mirrors the WaitFor leaf-vs-ancestor test: when the user .WithReference's both a + // child database and its parent server, the leaf (child) is the one whose connection + // string targets the actual database, so it wins. + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: null); + var server = builder.AddResource(new TestDatabaseResource("sql")); + var database = builder.AddResource(new TestChildDatabaseResource("sqldata", server.Resource)); + var project = builder.AddProject("myproject"); + var migrations = project.AddEFMigrations("mymigrations", typeof(TestDbContext).FullName!) + .WithReference(server) + .WithReference(database) + .PublishAsMigrationBundle(publishContainer: true); + + var dockerfile = EFMigrationResourceBuilderExtensions.GenerateDockerfile(migrations.Resource); + + Assert.Contains("ConnectionStrings__sqldata", dockerfile); + } + [Fact] public void GeneratedDockerfilePrefersLeafWhenWaitForChildAlsoWaitsOnParent() { @@ -548,6 +753,8 @@ private static async Task> CreateStepsAsync( // Test classes for DbContext types private sealed class TestDbContext { } + private sealed class AnotherDbContext { } + private sealed class ThirdDbContext { } /// /// A minimal test resource that implements IResourceWithConnectionString and IResourceWithWaitSupport. From a3766e994fd2cba86c8ac60b8a80268cab7e6383 Mon Sep 17 00:00:00 2001 From: "aspire-repo-bot[bot]" <268009190+aspire-repo-bot[bot]@users.noreply.github.com> Date: Wed, 27 May 2026 08:42:21 -0700 Subject: [PATCH 05/43] Fix Y/n input race in ChannelUpdateWorkflowTests + add troubleshooting.md (#17541) The Spectre.Console [Y/n] confirmation prompt is a single-character reader, not a line reader. Sending TypeAsync("n") + EnterAsync() queues both bytes in the TTY input buffer; when the CLI returns on "n" and tears down its stdin handler before \n is consumed, bash receives the stray \n as an empty command and bumps CMDCOUNT via PROMPT_COMMAND. The test's SequenceCounter then ends up coincidentally agreeing with bash's drifted CMDCOUNT. The next WaitForAspireAddSuccessAsync false-positives on the typed-command prompt header line ([N OK] $ aspire add ...) that bash printed when accepting the command, returning success before aspire add has actually written aspire.config.json. The follow-on Assert reads the file too early and fails. This matches the existing convention in DeclineAgentInitPromptAsync which documents the same race and avoids it by not sending Enter after the single-character answer. Also adds .github/skills/cli-e2e-testing/troubleshooting.md describing the diagnostic recipe (right-artifact selection, cast reconstruction, prompt- counter desync, Y/n race signature) so future agent sessions can recognize the pattern, and references it from SKILL.md. Co-authored-by: Mitch Denny Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/skills/cli-e2e-testing/SKILL.md | 2 + .../skills/cli-e2e-testing/troubleshooting.md | 132 ++++++++++++++++++ .../ChannelUpdateWorkflowTests.cs | 7 +- 3 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 .github/skills/cli-e2e-testing/troubleshooting.md diff --git a/.github/skills/cli-e2e-testing/SKILL.md b/.github/skills/cli-e2e-testing/SKILL.md index 72c8412cd5b..a9ae8dbe883 100644 --- a/.github/skills/cli-e2e-testing/SKILL.md +++ b/.github/skills/cli-e2e-testing/SKILL.md @@ -586,6 +586,8 @@ Each test class runs as a separate CI job via the unified `TestEnumerationRunshe When CLI E2E tests fail in CI, follow these steps to diagnose the issue: +> **Flaky test investigation:** for recurring/intermittent failures, see [`troubleshooting.md`](./troubleshooting.md) for a catalog of known flake classes (Y/n input race, prompt-counter desync, etc.) and the recipes to identify them from `.cast` recordings. + ### Quick Start: Download and Play Recordings The fastest way to debug a CLI E2E test failure is to download and play the asciinema recording. diff --git a/.github/skills/cli-e2e-testing/troubleshooting.md b/.github/skills/cli-e2e-testing/troubleshooting.md new file mode 100644 index 00000000000..dfc2aec4a1a --- /dev/null +++ b/.github/skills/cli-e2e-testing/troubleshooting.md @@ -0,0 +1,132 @@ +# CLI E2E test troubleshooting + +This document is a catalog of recurring flake patterns observed in `tests/Aspire.Cli.EndToEnd.Tests/` and the recipes to diagnose them. The target audience is future agent sessions investigating a CLI E2E flake. It complements `SKILL.md` (which is a "how to write tests" guide) with diagnostic detail. + +## Step 1 — Get the *right* artifact for the failing attempt + +CI re-runs (manual or automatic) on a failed job upload artifacts with the **same name** as earlier attempts of the same job in the same workflow run. `gh run download` always returns the latest one, which is often a *passing* rerun. The investigation must look at the *failing* attempt's artifact. + +Recipe: + +```bash +# 1. From the failing job URL, get the run id and the failing attempt's started_at. +gh api repos/microsoft/aspire/actions/runs//attempts/ \ + --jq '{attempt:.run_attempt, started:.run_started_at}' + +# 2. List artifacts of that workflow run filtered by artifact name. +gh api -X GET "repos/microsoft/aspire/actions/artifacts" \ + -f name="logs-ChannelUpdateWorkflowTests-ubuntu-latest" \ + --jq '.artifacts[] | select(.workflow_run.id == ) | {id, created_at, name}' + +# 3. Pick the artifact whose created_at is closest to (and after) the failing +# attempt's started_at — that's the one uploaded by the failing attempt. +# Download it explicitly by id. +gh api repos/microsoft/aspire/actions/artifacts//zip > /tmp/failed.zip +unzip -q /tmp/failed.zip -d /tmp/failed +``` + +`gh run download` cannot disambiguate by attempt — only `gh api .../artifacts//zip` will reliably get the failing recording. + +## Step 2 — Reconstruct the terminal stream from the `.cast` file + +Each test that uses Hex1b records an asciinema cast at `testresults/recordings/.cast`. The file is JSONL: line 1 is the header (cols, rows, env), subsequent lines are `[time, "o", payload]` events. To see what the terminal looked like around a specific moment: + +```python +import json, re, sys + +ANSI = re.compile(r'\x1b\[[0-9;?]*[A-Za-z]|\x1b[\(\)][AB012]|\x1b\][^\x07]*\x07') +with open("/tmp/failed/.cast") as f: + header = json.loads(next(f)) + events = [json.loads(line) for line in f] +stream = "".join(e[2] for e in events if e[1] == "o") +clean = ANSI.sub("", stream) +# Show a window around the offending text. +i = clean.find("aspire.config.json") +print(clean[max(0, i - 2000): i + 2000]) +``` + +For chronological analysis (was X printed before Y?), keep the event timestamps: + +```python +needle = "Perform updates?" +acc = "" +for t, kind, payload in ((e[0], e[1], e[2]) for e in events if e[1] == "o"): + acc += payload + if needle in acc: + print(f"t={t:.3f}s after {needle!r} appeared") + break +``` + +## Step 3 — Understand the prompt-counter convention + +Tests rely on a deterministic shell prompt for "command finished" detection: + +- `tests/Shared/CliInstallStrategy.cs` configures bash with `PROMPT_COMMAND='s=$?;((CMDCOUNT++));PS1="[$CMDCOUNT $([ $s -eq 0 ] && echo OK || echo ERR:$s)] \$ "'`. Bash bumps `CMDCOUNT` and renders `[N OK] $ ` (or `[N ERR:] $ `) every time it returns to the prompt. +- `SequenceCounter` (`tests/Shared/Hex1bTestHelpers.cs`) is the test's mirror of `CMDCOUNT`. The wait helpers (`WaitForSuccessPromptAsync`, `WaitForAnyPromptAsync`, `WaitForAspireAddSuccessAsync`) search the snapshot for `[ OK] $ ` as a substring, then `Increment()` the counter on a match. +- The counters are supposed to be in lockstep. The test increments its counter once per `Wait...Prompt` call; bash increments `CMDCOUNT` once per prompt display. **Anything that causes bash to display an extra prompt the test does not account for desyncs the two counters** — and turns the next "wait for prompt N" into a potential false positive on an already-on-screen prompt line that contains the substring `N OK] $ `. + +## Step 4 — Diagnose the "Y/n input race" + +This is the most-observed flake class so far. It is what broke `ChannelUpdateWorkflowTests.UpdateProjectChannelToStable_TypeScript_PreviewsStablePackagesAndPreservesChannel` on PR #17522 (run `26489967289`, job `78006625708`). + +### Symptom + +An assertion that reads on-disk state (typically `aspire.config.json` after `aspire add`) fails saying the expected content is missing. Inspecting the cast shows the CLI eventually *did* succeed — the test read the file too early. + +### Recording signature + +In the cast around the failing read, look for an **extra empty `[N OK] $` prompt cycle** right after a `[Y/n]:` prompt was answered, followed by the next typed command at `[N+1]`: + +``` +Perform updates? [Y/n]: n +[21 OK] $ +[22 OK] $ aspire add Aspire.Hosting.PostgreSQL +``` + +A passing recording for the same test goes straight from `[Y/n]: n` to `[21 OK] $ aspire add ...` with no empty intermediate prompt. + +### Mechanism + +`TypeAsync("n") + EnterAsync()` writes two bytes into the TTY input queue: `n` and `\n`. Spectre.Console's `[Y/n]` prompt is a single-character reader — it returns on the `n` keystroke and tears down its stdin handler before the `\n` is consumed. Whichever process owns the TTY when that `\n` is delivered receives it. If the CLI has already exited (or is in the middle of its teardown when bash reclaims the TTY), bash reads the stray `\n` as an empty command line, fires `PROMPT_COMMAND`, and increments `CMDCOUNT`. + +The test's `SequenceCounter` doesn't know about that extra cycle. It advances through the next `WaitForSuccessPromptAsync` (which still matches — the real post-command prompt is on screen), ending up coincidentally equal to bash's drifted `CMDCOUNT`. The next typed command — say `aspire add Postgres` — gets a fresh prompt at `[ OK] $ aspire add Postgres`. When the test then calls `WaitForAspireAddSuccessAsync`, the substring matcher finds ` OK] $ ` *in the typed-command header line that bash printed when accepting the command*, not in the post-completion prompt, and returns success **before** `aspire add` has done any work. + +The test then reads `aspire.config.json` while the CLI is still spinning. For a polyglot (TypeScript/Java) apphost, `aspire add` writes the file via `GuestAppHostProject.SaveConfiguration` *after* a full `BuildAndGenerateSdkAsync` round-trip (which starts an `AppHostServerSession` for code generation). That step is not instant, so the early read sees the pre-add config. + +### Fix + +The Spectre.Console `[Y/n]` confirmation prompt accepts a single character — it does not require Enter. Drop the Enter: + +```diff +- await auto.TypeAsync("n"); +- await auto.EnterAsync(); ++ await auto.TypeAsync("n"); +``` + +This is the same pattern already used in `Hex1bAutomatorTestHelpers.DeclineAgentInitPromptAsync`. Its comment documents the same race. + +This fix is **only required when both a character and Enter are sent** for a single-character prompt. The following do *not* have the race: + +- `EnterAsync()` alone to accept a Y/n default — the `\n` *is* the commit byte for the line reader. +- `TypeAsync("/some/path") + EnterAsync()` for a text-input prompt — the line reader reads through `\n` to terminate the line. +- Arrow keys + `EnterAsync()` for a Spectre selection list — Enter is the commit byte for the selection. + +### Why a "wait for the prompt text to disappear" helper does *not* work + +A tempting "general" fix is to block until the prompt text is no longer visible in the snapshot. Spectre.Console typically leaves the answered prompt visible as scrollback (the question is rendered once and stays in the terminal buffer, sometimes rewritten to include the chosen answer). Such a helper would either never observe the prompt "disappear" (and time out) or would only work when subsequent output happens to scroll the prompt off-screen — neither is reliable. + +The single-character-prompt fix above is more surgical and matches the established convention in the codebase. + +## Other flake classes (placeholders — fill in as encountered) + +- **Spinner-scroll obscuring an awaited line.** When `aspire run` / `aspire add` print a spinner that updates frequently, an awaited status line (e.g., `Update successful!`) can be redrawn off the visible 160×48 grid before `WaitUntilTextAsync` runs its next snapshot poll. Document mitigations here when first observed. +- **Race between `aspire start` and dashboard readiness.** The "dashboard at " line can appear before the dashboard's `/health` actually responds. Document the canonical post-start synchronization here. +- **`aspire add` version-picker shown vs not-shown.** Some package configurations cause the picker to appear; others auto-select. Tests that always send Down/Enter break in the auto-select case. Document the "is the picker on screen?" check here. + +## Workflow-infrastructure gotchas worth knowing about + +These bit the PR #17522 investigation and are worth a callout (but are not the test's fault): + +- **Same-name artifact collision across rerun attempts.** See Step 1 above. +- **`CaptureWorkspaceOnFailureAttribute` captures live workspace state to `testresults/workspaces//`** but the CI upload globs don't include that directory, so the captured `aspire.config.json` (which would directly answer "what was on disk at failure time?") is currently lost. Worth fixing separately. +- **`testresults/recordings/.cast` is overwritten on retry.** If the test's xUnit retry policy or a manual rerun re-executes the same method, the failing recording is clobbered by the passing one. The artifact saved with the *failing attempt* is the only durable copy — another reason Step 1 matters. diff --git a/tests/Aspire.Cli.EndToEnd.Tests/ChannelUpdateWorkflowTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/ChannelUpdateWorkflowTests.cs index c97aaf8a98a..bcb737db9c2 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/ChannelUpdateWorkflowTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/ChannelUpdateWorkflowTests.cs @@ -467,8 +467,13 @@ await auto.WaitUntilAsync(snapshot => if (sawUpdatePrompt) { + // Type "n" to decline. Do NOT send Enter — the Spectre.Console [Y/n] confirmation + // prompt accepts a single character. Sending Enter risks a race: if aspire update + // returns from its line-reader on the "n" keystroke and tears down before the Enter + // is dequeued, bash receives the Enter and executes a phantom blank command, + // advancing CMDCOUNT and desyncing the test counter from the shell counter. + // See .github/skills/cli-e2e-testing/troubleshooting.md for the full failure pattern. await auto.TypeAsync("n"); - await auto.EnterAsync(); } await auto.WaitForSuccessPromptAsync(counter); From 4f778254c0a90fd4acc07a027a04abd007f3563d Mon Sep 17 00:00:00 2001 From: "aspire-repo-bot[bot]" <268009190+aspire-repo-bot[bot]@users.noreply.github.com> Date: Wed, 27 May 2026 09:09:42 -0700 Subject: [PATCH 06/43] [release/13.4] Address remaining Blazor integration PR feedback (#17540) * Address remaining PR feedback and fix Docker Compose publish PR feedback from #15691: - Add [ResourceName] attribute to all Add* API name parameters - Replace null-forgiving operator with explicit throw in EndpointsManifestTransformer - Use StringComparison.OrdinalIgnoreCase for route comparison - Fix '.NET Aspire' phrasing in README files Docker Compose publish fixes for Blazor gateway: - Add GatewayOriginReference (IValueProvider + IManifestExpressionProvider) - Use ReferenceExpression for ConfigResponse so publishers emit proper placeholders - Use TextEncoderSettings to escape braces in JSON string values - Simplify Gateway.cs.in (serve ConfigResponse directly, no Replace logic) - Add System.Text.Unicode dependency for brace-escaping encoder Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add OTLP misconfiguration warning to hosted Blazor path The standalone gateway path already logged a warning when telemetry proxying was requested but no HTTP OTLP endpoint could be resolved. The hosted model (ProxyBlazorTelemetry via BlazorHostedExtensions) was missing the equivalent diagnostic. - Add LogWarning in BlazorHostedExtensions.EnsureEnvironmentCallback - Add tests verifying the warning fires (and does not fire) in both scenarios Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Rename ASPIRE_OTLP_PATH_BASE to OTEL_EXPORTER_OTLP_ENDPOINT Use the standard OpenTelemetry environment variable name instead of a custom Aspire-specific one. The value remains a relative path that the WASM client resolves against its page origin to stay same-origin. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Use TestSink/TestLogger instead of custom ListLogger Replace private ListLogger and LogMessage with shared TestSink and TestLogger from Microsoft.Extensions.Logging.Testing. Also moves the OTLP warning in the standalone gateway path before the build step so it is testable, and adds two tests for that path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Filter OTLP proxy traffic from tracing to prevent feedback loop The gateway and hosted server YARP proxies forward /_otlp/* requests to the dashboard. Without filtering, those forwarding requests are themselves traced and exported, creating recursive telemetry entries. - Gateway (Gateway.cs.in): Simplified filter to use Contains for /_otlp/ paths (handles prefix-mounted apps like /app/_otlp/...). Removed IsStaticAssetOrOtlpRequest helper since static asset requests don't go through YARP. - Hosted server (Program.cs): Added PostConfigure to wrap existing OTEL filters with /_otlp/ and /v1/ exclusions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Javier Calvarro Nelson Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Extensions.cs | 2 +- .../BlazorHosted.Server/Program.cs | 24 +++ playground/BlazorHosted/README.md | 2 +- .../Extensions.cs | 2 +- playground/BlazorStandalone/README.md | 2 +- .../Aspire.Hosting.Blazor.csproj | 12 ++ .../BlazorGatewayExtensions.cs | 114 +++++++---- .../BlazorHostedExtensions.cs | 9 +- .../GatewayConfigurationBuilder.cs | 178 +++++++----------- .../GatewayOriginReference.cs | 21 +++ .../Manifests/EndpointsManifestTransformer.cs | 5 +- .../Scripts/Gateway.cs.in | 20 +- .../BlazorHostedExtensionsTests.cs | 101 +++++++++- .../GatewayConfigurationBuilderTests.cs | 4 +- 14 files changed, 323 insertions(+), 173 deletions(-) create mode 100644 src/Aspire.Hosting.Blazor/GatewayOriginReference.cs diff --git a/playground/BlazorHosted/BlazorHosted.ClientServiceDefaults/Extensions.cs b/playground/BlazorHosted/BlazorHosted.ClientServiceDefaults/Extensions.cs index 99f62e6ab47..3d6f519743a 100644 --- a/playground/BlazorHosted/BlazorHosted.ClientServiceDefaults/Extensions.cs +++ b/playground/BlazorHosted/BlazorHosted.ClientServiceDefaults/Extensions.cs @@ -35,7 +35,7 @@ public static WebAssemblyHostBuilder AddBlazorClientServiceDefaults(this WebAsse private static WebAssemblyHostBuilder ConfigureBlazorClientOpenTelemetry(this WebAssemblyHostBuilder builder) { // Without an OTLP path base, there's nowhere to export telemetry in WASM. - var otlpPathBase = builder.Configuration["ASPIRE_OTLP_PATH_BASE"]; + var otlpPathBase = builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]; if (string.IsNullOrEmpty(otlpPathBase)) { return builder; diff --git a/playground/BlazorHosted/BlazorHosted.Server/Program.cs b/playground/BlazorHosted/BlazorHosted.Server/Program.cs index f3aa7efe449..6acf13d0337 100644 --- a/playground/BlazorHosted/BlazorHosted.Server/Program.cs +++ b/playground/BlazorHosted/BlazorHosted.Server/Program.cs @@ -4,6 +4,30 @@ builder.AddServiceDefaults(); +// Filter out OTLP proxy traffic from tracing to prevent a feedback loop: +// YARP forwards /_otlp/* requests to the dashboard, and without filtering, +// those forwarding requests would themselves be traced and exported — creating +// recursive telemetry entries in the dashboard. +builder.Services.PostConfigure(options => +{ + var previous = options.Filter; + options.Filter = context => + { + var path = context.Request.Path.Value; + return (previous is null || previous(context)) + && (path is null || !path.Contains("/_otlp/", StringComparison.Ordinal)); + }; +}); + +builder.Services.PostConfigure(options => +{ + var previous = options.FilterHttpRequestMessage; + options.FilterHttpRequestMessage = request => + (previous is null || previous(request)) + && (request.RequestUri is null + || !request.RequestUri.AbsolutePath.StartsWith("/v1/", StringComparison.Ordinal)); +}); + builder.Services.AddRazorComponents() .AddInteractiveWebAssemblyComponents(); diff --git a/playground/BlazorHosted/README.md b/playground/BlazorHosted/README.md index aaef5f50059..7ea34ea91a9 100644 --- a/playground/BlazorHosted/README.md +++ b/playground/BlazorHosted/README.md @@ -1,6 +1,6 @@ # BlazorHosted -This sample demonstrates how to integrate a **hosted Blazor WebAssembly** application (Blazor Web App with Interactive WebAssembly render mode) with .NET Aspire, enabling full observability (logs, traces) and service discovery for both the server and the WASM client. +This sample demonstrates how to integrate a **hosted Blazor WebAssembly** application (Blazor Web App with Interactive WebAssembly render mode) with Aspire, enabling full observability (logs, traces) and service discovery for both the server and the WASM client. ## Overview diff --git a/playground/BlazorStandalone/BlazorStandalone.ClientServiceDefaults/Extensions.cs b/playground/BlazorStandalone/BlazorStandalone.ClientServiceDefaults/Extensions.cs index 99f62e6ab47..3d6f519743a 100644 --- a/playground/BlazorStandalone/BlazorStandalone.ClientServiceDefaults/Extensions.cs +++ b/playground/BlazorStandalone/BlazorStandalone.ClientServiceDefaults/Extensions.cs @@ -35,7 +35,7 @@ public static WebAssemblyHostBuilder AddBlazorClientServiceDefaults(this WebAsse private static WebAssemblyHostBuilder ConfigureBlazorClientOpenTelemetry(this WebAssemblyHostBuilder builder) { // Without an OTLP path base, there's nowhere to export telemetry in WASM. - var otlpPathBase = builder.Configuration["ASPIRE_OTLP_PATH_BASE"]; + var otlpPathBase = builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]; if (string.IsNullOrEmpty(otlpPathBase)) { return builder; diff --git a/playground/BlazorStandalone/README.md b/playground/BlazorStandalone/README.md index 2a69d4235fe..b870cdad308 100644 --- a/playground/BlazorStandalone/README.md +++ b/playground/BlazorStandalone/README.md @@ -1,6 +1,6 @@ # BlazorStandalone -This sample demonstrates how to integrate a **standalone Blazor WebAssembly** application with .NET Aspire, enabling full observability (logs, traces) and service discovery without requiring a hosted Blazor Server backend. +This sample demonstrates how to integrate a **standalone Blazor WebAssembly** application with Aspire, enabling full observability (logs, traces) and service discovery without requiring a hosted Blazor Server backend. ## Overview diff --git a/src/Aspire.Hosting.Blazor/Aspire.Hosting.Blazor.csproj b/src/Aspire.Hosting.Blazor/Aspire.Hosting.Blazor.csproj index 4a94f17a9ce..757c106895d 100644 --- a/src/Aspire.Hosting.Blazor/Aspire.Hosting.Blazor.csproj +++ b/src/Aspire.Hosting.Blazor/Aspire.Hosting.Blazor.csproj @@ -11,8 +11,20 @@ 0 false + + 10.0 + + + <_Parameter1>BlazorGatewayDotNetImageTag + <_Parameter2>$(BlazorGatewayDotNetImageTag) + + + diff --git a/src/Aspire.Hosting.Blazor/BlazorGatewayExtensions.cs b/src/Aspire.Hosting.Blazor/BlazorGatewayExtensions.cs index aa4169ac0fd..63cc01bdb25 100644 --- a/src/Aspire.Hosting.Blazor/BlazorGatewayExtensions.cs +++ b/src/Aspire.Hosting.Blazor/BlazorGatewayExtensions.cs @@ -21,10 +21,6 @@ namespace Aspire.Hosting; [Experimental("ASPIREBLAZOR001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] public static class BlazorGatewayExtensions { - // Derive the .NET image tag from the runtime version of the app host process. - // The Gateway is a file-based app compiled with the same SDK, so the major.minor - // version of the running host matches the required SDK/ASP.NET base images. - // Pre-release runtimes (preview/RC) use suffixed tags like "10.0-preview" or "11.0-rc". private static readonly string s_dotNetImageTag = GetDotNetImageTag(); private const string DotNetSdkImageRepo = "mcr.microsoft.com/dotnet/sdk"; private const string DotNetAspNetImageRepo = "mcr.microsoft.com/dotnet/aspnet"; @@ -84,7 +80,7 @@ public static IResourceBuilder AddBlazorGateway( [AspireExportIgnore(Reason = "Open generic type parameter TProject is not ATS-compatible.")] public static IResourceBuilder AddBlazorWasmProject( this IDistributedApplicationBuilder builder, - string name) + [ResourceName] string name) where TProject : IProjectMetadata, new() { var metadata = new TProject(); @@ -201,8 +197,7 @@ internal static IResourceBuilder WithBlazorApp( var annotation = GetOrAddGatewayAppsAnnotation(gateway.Resource); var gatewayOutputRoot = Path.Combine( - gateway.ApplicationBuilder.AppHostDirectory, - "obj", "Aspire.Hosting.Blazor", "gateways", gateway.Resource.Name); + GetBlazorStorePath(gateway.ApplicationBuilder), "gateways", gateway.Resource.Name); if (!annotation.IsInitialized) { @@ -217,6 +212,20 @@ internal static IResourceBuilder WithBlazorApp( var gatewayEndpoint = httpsGatewayEndpoint ?? httpGatewayEndpoint ?? throw new InvalidOperationException($"The gateway '{gateway.Resource.Name}' must define an HTTP or HTTPS endpoint."); + // Resolve the HTTP OTLP endpoint for WASM client proxying. + // WASM clients use HTTP/protobuf (not gRPC), so we need the HTTP endpoint. + // First try to resolve from the dashboard resource model (handles randomized ports + // and isolated mode). Fall back to configuration for cases where the dashboard + // resource isn't in the model (e.g. external dashboard). + var httpOtlpEndpointUrl = ResolveHttpOtlpEndpointUrl(context, gateway.ApplicationBuilder.Configuration); + + if (httpOtlpEndpointUrl is null && registeredApps.Any(a => a.ProxyBlazorTelemetry)) + { + context.Logger.LogWarning( + "OTLP telemetry proxying was requested but no dashboard HTTP endpoint could be resolved. " + + "WASM client telemetry will not be forwarded."); + } + if (context.ExecutionContext.IsPublishMode) { ConfigurePublishEnvironment(context, registeredApps, gatewayEndpoint, httpGatewayEndpoint); @@ -247,15 +256,7 @@ internal static IResourceBuilder WithBlazorApp( await EndpointsManifestTransformer.MergeRuntimeManifestsAsync(manifests, mergedRuntimePath, context.Logger, context.CancellationToken).ConfigureAwait(false); context.EnvironmentVariables["staticWebAssets"] = mergedRuntimePath; - // Resolve the HTTP OTLP endpoint for WASM client proxying. - // WASM clients use HTTP/protobuf (not gRPC), so we need the HTTP endpoint. - // First try to resolve from the dashboard resource model (handles randomized ports - // and isolated mode). Fall back to configuration for cases where the dashboard - // resource isn't in the model (e.g. external dashboard). - var httpOtlpEndpointUrl = ResolveHttpOtlpEndpointUrl(context, gateway.ApplicationBuilder.Configuration); - var resourceLoggerService = context.ExecutionContext.ServiceProvider.GetRequiredService(); - - GatewayConfigurationBuilder.EmitProxyConfiguration(context.EnvironmentVariables, registeredApps, gatewayEndpoint, httpGatewayEndpoint, httpOtlpEndpointUrl, resourceLoggerService); + GatewayConfigurationBuilder.EmitProxyConfiguration(context.EnvironmentVariables, registeredApps, gatewayEndpoint, httpGatewayEndpoint, httpOtlpEndpointUrl); }); } @@ -413,14 +414,14 @@ private static void CreatePublishCompanion( var relativeProjectPath = Path.GetRelativePath( project.SolutionRoot, wasmApp.Resource.ProjectPath).Replace('\\', '/'); - // Copy the PrefixEndpoints.cs script into a project-local build folder so it's - // available inside the Docker build context without clobbering the solution root. + // Copy PrefixEndpoints.cs into .aspire/scripts/ within the solution root so it's + // included in the Docker build context. var scriptSource = GetScriptPath("PrefixEndpoints.cs"); - var scriptRelativePath = Path.Combine(project.RelativeProjectPath, "obj", "Aspire.Hosting.Blazor", "PrefixEndpoints.cs") - .Replace('\\', '/'); - var scriptDest = Path.Combine(project.SolutionRoot, scriptRelativePath.Replace('/', Path.DirectorySeparatorChar)); + var scriptDest = Path.Combine(project.SolutionRoot, ".aspire", "scripts", "PrefixEndpoints.cs"); Directory.CreateDirectory(Path.GetDirectoryName(scriptDest)!); File.Copy(scriptSource, scriptDest, overwrite: true); + var scriptRelativePath = Path.GetRelativePath(project.SolutionRoot, scriptDest) + .Replace('\\', '/'); var companion = gateway.ApplicationBuilder.AddResource( new BlazorWasmPublishResource(publishResourceName)) @@ -466,6 +467,19 @@ private static string GetScriptPath(string scriptName) return scriptPath; } + private const string AspireStorePathKey = "Aspire:Store:Path"; + + /// + /// Gets the Blazor-specific store path under the Aspire store directory. + /// + private static string GetBlazorStorePath(IDistributedApplicationBuilder builder) + { + var storePath = builder.Configuration[AspireStorePathKey] + ?? builder.AppHostDirectory; + + return Path.Combine(storePath, ".aspire", "blazor"); + } + private static List GetServiceDiscoveryReferences(IResource resource) { // EndpointReferenceAnnotation is added by WithReference and tracks which endpoint @@ -637,30 +651,58 @@ private readonly struct ProjectInfo(string solutionRoot, string relativeProjectP } /// - /// Resolves the Docker image tag for the .NET SDK/ASP.NET base images. - /// Returns "Major.Minor" for stable releases (e.g. "10.0", "11.0"), - /// and appends "-preview" or "-rc" for pre-release runtimes to match the - /// MCR tag naming convention (e.g. "10.0-preview", "11.0-rc"). + /// Resolves the Docker image tag for .NET base images. Uses the maximum of the build-time + /// stamped version and the actual runtime version, with pre-release suffix when applicable. /// private static string GetDotNetImageTag() { - var tag = $"{Environment.Version.Major}.{Environment.Version.Minor}"; + var runtimeMajor = Environment.Version.Major; + var runtimeMinor = Environment.Version.Minor; - // The runtime's informational version contains the full pre-release label, - // e.g. "10.0.0-preview.7.25352.1+..." or "11.0.0-rc.1.25400.3+...". - // Stable/servicing builds use "10.0.6-servicing..." which we ignore. - var informationalVersion = (System.Reflection.AssemblyInformationalVersionAttribute?) - Attribute.GetCustomAttribute(typeof(object).Assembly, typeof(System.Reflection.AssemblyInformationalVersionAttribute)); + var stampedMajor = runtimeMajor; + var stampedMinor = runtimeMinor; - if (informationalVersion is not null) + var stampedValue = typeof(BlazorGatewayExtensions).Assembly + .GetCustomAttributes(typeof(System.Reflection.AssemblyMetadataAttribute), inherit: false) + .OfType() + .FirstOrDefault(a => a.Key == "BlazorGatewayDotNetImageTag") + ?.Value; + + if (!string.IsNullOrEmpty(stampedValue)) { - if (informationalVersion.InformationalVersion.Contains("-preview", StringComparison.OrdinalIgnoreCase)) + var parts = stampedValue.Split('.'); + if (parts.Length >= 2 + && int.TryParse(parts[0], out var sMajor) + && int.TryParse(parts[1], out var sMinor)) { - tag += "-preview"; + stampedMajor = sMajor; + stampedMinor = sMinor; } - else if (informationalVersion.InformationalVersion.Contains("-rc", StringComparison.OrdinalIgnoreCase)) + } + + var major = Math.Max(runtimeMajor, stampedMajor); + var minor = (major == runtimeMajor && major == stampedMajor) + ? Math.Max(runtimeMinor, stampedMinor) + : (major == runtimeMajor ? runtimeMinor : stampedMinor); + + var tag = $"{major}.{minor}"; + + // Append pre-release suffix when the runtime version won and is pre-release. + if (major == runtimeMajor && minor == runtimeMinor) + { + var informationalVersion = (System.Reflection.AssemblyInformationalVersionAttribute?) + Attribute.GetCustomAttribute(typeof(object).Assembly, typeof(System.Reflection.AssemblyInformationalVersionAttribute)); + + if (informationalVersion is not null) { - tag += "-rc"; + if (informationalVersion.InformationalVersion.Contains("-preview", StringComparison.OrdinalIgnoreCase)) + { + tag += "-preview"; + } + else if (informationalVersion.InformationalVersion.Contains("-rc", StringComparison.OrdinalIgnoreCase)) + { + tag += "-rc"; + } } } diff --git a/src/Aspire.Hosting.Blazor/BlazorHostedExtensions.cs b/src/Aspire.Hosting.Blazor/BlazorHostedExtensions.cs index ebdf29f9389..c0974c39ee5 100644 --- a/src/Aspire.Hosting.Blazor/BlazorHostedExtensions.cs +++ b/src/Aspire.Hosting.Blazor/BlazorHostedExtensions.cs @@ -3,6 +3,7 @@ using System.Diagnostics.CodeAnalysis; using Aspire.Hosting.ApplicationModel; +using Microsoft.Extensions.Logging; #pragma warning disable ASPIREATS001 // AspireExportIgnore is experimental @@ -91,6 +92,13 @@ private static void EnsureEnvironmentCallback( // WASM clients use HTTP/protobuf (not gRPC), so we need the HTTP endpoint. var httpOtlpEndpointUrl = BlazorGatewayExtensions.ResolveHttpOtlpEndpointUrl(context, host.ApplicationBuilder.Configuration); + if (httpOtlpEndpointUrl is null && annotation.ProxyBlazorTelemetry) + { + context.Logger.LogWarning( + "OTLP telemetry proxying was requested but no dashboard HTTP endpoint could be resolved. " + + "WASM client telemetry will not be forwarded."); + } + GatewayConfigurationBuilder.EmitHostedProxyConfiguration( context.EnvironmentVariables, hostEndpoint, @@ -99,7 +107,6 @@ private static void EnsureEnvironmentCallback( annotation.Services, annotation.ProxyBlazorTelemetry, httpOtlpEndpointUrl, - context.Logger, annotation.OtlpPrefix); }); } diff --git a/src/Aspire.Hosting.Blazor/GatewayConfigurationBuilder.cs b/src/Aspire.Hosting.Blazor/GatewayConfigurationBuilder.cs index ead8b400353..ed098c9a572 100644 --- a/src/Aspire.Hosting.Blazor/GatewayConfigurationBuilder.cs +++ b/src/Aspire.Hosting.Blazor/GatewayConfigurationBuilder.cs @@ -1,10 +1,10 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Text.Encodings.Web; using System.Text.Json; +using System.Text.Json.Nodes; using Aspire.Hosting.ApplicationModel; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; namespace Aspire.Hosting; @@ -23,8 +23,7 @@ public static void EmitProxyConfiguration( List apps, EndpointReference gatewayEndpoint, EndpointReference? httpGatewayEndpoint = null, - object? httpOtlpEndpoint = null, - ResourceLoggerService? resourceLoggerService = null) + object? httpOtlpEndpoint = null) { var addedClusters = new HashSet(); var httpClientEndpoint = httpGatewayEndpoint ?? (gatewayEndpoint.IsHttp ? gatewayEndpoint : null); @@ -35,8 +34,6 @@ public static void EmitProxyConfiguration( var prefix = reg.PathPrefix; var envPrefix = $"ClientApps__{reg.Resource.Name}"; - // Per-app client config: use an IValueProvider that resolves the gateway URL - // at startup and builds the final JSON response. // Flatten services: one HostedClientService per named endpoint so each gets // its own YARP cluster destination. var servicesList = new List(); @@ -56,8 +53,7 @@ public static void EmitProxyConfiguration( } var services = servicesList.ToArray(); - env[$"{envPrefix}__ConfigResponse"] = new ClientConfigValueProvider( - gatewayEndpoint, + env[$"{envPrefix}__ConfigResponse"] = BuildConfigExpression( httpClientEndpoint, httpsClientEndpoint, prefix, @@ -65,7 +61,6 @@ public static void EmitProxyConfiguration( services, reg.ProxyBlazorTelemetry, httpOtlpEndpoint, - resourceLoggerService?.GetLogger(reg.Resource) ?? NullLogger.Instance, reg.OtlpPrefix); EmitYarpRoutes(env, prefix, reg.Resource.Name, services, reg.ProxyBlazorTelemetry, addedClusters, @@ -90,14 +85,12 @@ public static void EmitHostedProxyConfiguration( IReadOnlyList services, bool proxyBlazorTelemetry, object? httpOtlpEndpoint, - ILogger? logger = null, string otlpPrefix = DefaultOtlpPrefix) { var httpClientEndpoint = httpHostEndpoint ?? (hostEndpoint.IsHttp ? hostEndpoint : null); var httpsClientEndpoint = hostEndpoint.IsHttps ? hostEndpoint : null; - env["Client__ConfigResponse"] = new ClientConfigValueProvider( - hostEndpoint, + env["Client__ConfigResponse"] = BuildConfigExpression( httpClientEndpoint, httpsClientEndpoint, prefix: null, @@ -105,7 +98,6 @@ public static void EmitHostedProxyConfiguration( services, proxyBlazorTelemetry, httpOtlpEndpoint, - logger ?? NullLogger.Instance, otlpPrefix); env["Client__ConfigEndpointPath"] = "/_blazor/_configuration"; @@ -128,6 +120,12 @@ public static void EmitHostedProxyConfiguration( /// internal const string DefaultOtlpPrefix = "_otlp"; + private static readonly JsonSerializerOptions s_jsonOptions = new() + { + WriteIndented = false, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + private static void EmitYarpRoutes( IDictionary env, string? prefix, @@ -197,15 +195,19 @@ private static void EmitOtlpCluster(IDictionary env, object? htt } } + // All string values placed into the JSON are constructed by us: a brace-free token + // (__ORIGIN__) concatenated with URL path segments (e.g. "/app/_api/weatherapi"). + // Because none of these values contain { or }, the only literal braces in the + // serialized output are structural JSON. This invariant makes the Replace("{","{{") + // step below correct — if a future change introduces braces in values, the + // string.Format template would break and tests would catch it immediately. + private const string OriginToken = "__ORIGIN__"; + /// - /// An IValueProvider that resolves an endpoint URL and builds the - /// Blazor WASM configuration JSON response. At run time, the URL is - /// resolved from the EndpointReference. At publish time, ValueExpression emits - /// the JSON with manifest expression placeholders for the deployer to resolve. - /// Used by both the standalone gateway and hosted Blazor models. + /// Builds the ConfigResponse JSON as a . In dev mode the + /// gateway origin resolves to the actual URL; in publish mode publishers emit a placeholder. /// - internal sealed class ClientConfigValueProvider( - EndpointReference primaryEndpoint, + internal static ReferenceExpression BuildConfigExpression( EndpointReference? httpEndpoint, EndpointReference? httpsEndpoint, string? prefix, @@ -213,118 +215,74 @@ internal sealed class ClientConfigValueProvider( IReadOnlyList services, bool proxyBlazorTelemetry, object? httpOtlpEndpoint, - ILogger logger, - string otlpPrefix = DefaultOtlpPrefix) : IValueProvider, IManifestExpressionProvider + string otlpPrefix = DefaultOtlpPrefix) { - string IManifestExpressionProvider.ValueExpression => - BuildJson( - ((IManifestExpressionProvider)primaryEndpoint).ValueExpression, - ResolveEndpointExpression(httpEndpoint), - ResolveEndpointExpression(httpsEndpoint)); + var pathBase = prefix != null ? $"/{prefix}" : ""; - async ValueTask IValueProvider.GetValueAsync(CancellationToken cancellationToken) - { - LogOtlpWarningIfNeeded(); - var primaryUrl = await primaryEndpoint.GetValueAsync(cancellationToken).ConfigureAwait(false); - var httpUrl = await ResolveEndpointAsync(httpEndpoint, cancellationToken).ConfigureAwait(false); - var httpsUrl = await ResolveEndpointAsync(httpsEndpoint, cancellationToken).ConfigureAwait(false); - return BuildJson(primaryUrl, httpUrl, httpsUrl); - } + var environment = new JsonObject(); - async ValueTask IValueProvider.GetValueAsync(ValueProviderContext context, CancellationToken cancellationToken) - { - LogOtlpWarningIfNeeded(); - var primaryUrl = await primaryEndpoint.GetValueAsync(context, cancellationToken).ConfigureAwait(false); - var httpUrl = await ResolveEndpointAsync(httpEndpoint, context, cancellationToken).ConfigureAwait(false); - var httpsUrl = await ResolveEndpointAsync(httpsEndpoint, context, cancellationToken).ConfigureAwait(false); - return BuildJson(primaryUrl, httpUrl, httpsUrl); - } - - private void LogOtlpWarningIfNeeded() + foreach (var svc in services) { - if (proxyBlazorTelemetry && httpOtlpEndpoint is null) + if (httpsEndpoint is not null) { - logger.LogWarning( - "OTLP telemetry proxying was requested but no dashboard HTTP endpoint could be resolved. " + - "WASM client telemetry will not be forwarded."); + environment[$"services__{svc.ServiceName}__https__0"] = $"{OriginToken}{pathBase}/{svc.ApiPrefix}/{svc.ServiceName}"; } - } - private static async ValueTask ResolveEndpointAsync(EndpointReference? endpoint, CancellationToken cancellationToken) - { - if (endpoint is null) + if (httpEndpoint is not null) { - return null; + environment[$"services__{svc.ServiceName}__http__0"] = $"{OriginToken}{pathBase}/{svc.ApiPrefix}/{svc.ServiceName}"; } - - return await endpoint.GetValueAsync(cancellationToken).ConfigureAwait(false); } - private static async ValueTask ResolveEndpointAsync(EndpointReference? endpoint, ValueProviderContext context, CancellationToken cancellationToken) + if (proxyBlazorTelemetry && httpOtlpEndpoint is not null) { - if (endpoint is null) - { - return null; - } - - return await endpoint.GetValueAsync(context, cancellationToken).ConfigureAwait(false); + environment["OTEL_SERVICE_NAME"] = resourceName; + + // Send only the OTLP path so the WASM client resolves it against its own + // page origin (HostEnvironment.BaseAddress). This avoids cross-origin issues + // when the user navigates via HTTP but the gateway also exposes HTTPS. + environment["OTEL_EXPORTER_OTLP_ENDPOINT"] = $"{pathBase}/{otlpPrefix}"; + environment["OTEL_EXPORTER_OTLP_PROTOCOL"] = "http/protobuf"; + + // NOTE: OTEL_EXPORTER_OTLP_HEADERS is intentionally NOT sent to the WASM client. + // The headers contain the dashboard OTLP API key, and this config is delivered + // to browser-visible JSON. The YARP proxy injects the headers server-side when + // forwarding telemetry to the dashboard. } - private static string? ResolveEndpointExpression(EndpointReference? endpoint) + var config = new JsonObject { - return endpoint is IManifestExpressionProvider manifestExpressionProvider - ? manifestExpressionProvider.ValueExpression - : null; - } + ["webAssembly"] = new JsonObject + { + ["environment"] = environment + } + }; - private string BuildJson(string? primaryBaseUrl, string? httpBaseUrl, string? httpsBaseUrl) - { - var pathBase = prefix != null ? $"/{prefix}" : ""; - var environment = new Dictionary(); - var normalizedPrimaryBaseUrl = NormalizeUrl(primaryBaseUrl); - var normalizedHttpBaseUrl = NormalizeUrl(httpBaseUrl ?? (primaryEndpoint.IsHttp ? normalizedPrimaryBaseUrl : null)); - var normalizedHttpsBaseUrl = NormalizeUrl(httpsBaseUrl ?? (primaryEndpoint.IsHttps ? normalizedPrimaryBaseUrl : null)); + var json = config.ToJsonString(s_jsonOptions); - foreach (var svc in services) - { - if (normalizedHttpsBaseUrl is not null) - { - environment[$"services__{svc.ServiceName}__https__0"] = $"{normalizedHttpsBaseUrl}{pathBase}/{svc.ApiPrefix}/{svc.ServiceName}"; - } + // The only literal { } in the output are structural JSON braces (we control + // all string values and they contain no braces). Escape them for string.Format, + // then swap the origin token for {0}. + var format = json + .Replace("{", "{{") + .Replace("}", "}}") + .Replace(OriginToken, "{0}"); - if (normalizedHttpBaseUrl is not null) - { - environment[$"services__{svc.ServiceName}__http__0"] = $"{normalizedHttpBaseUrl}{pathBase}/{svc.ApiPrefix}/{svc.ServiceName}"; - } - } + var originEndpoint = httpsEndpoint ?? httpEndpoint + ?? throw new InvalidOperationException("At least one gateway endpoint (HTTP or HTTPS) must be provided."); - if (proxyBlazorTelemetry && httpOtlpEndpoint is not null) + var originRef = new GatewayOriginReference(originEndpoint); + var builder = new ReferenceExpressionBuilder(); + var segments = format.Split("{0}"); + for (var i = 0; i < segments.Length; i++) + { + if (i > 0) { - environment["OTEL_SERVICE_NAME"] = resourceName; - - // Send only the OTLP path so the WASM client resolves it against its own - // page origin (HostEnvironment.BaseAddress). This avoids cross-origin issues - // when the user navigates via HTTP but the gateway also exposes HTTPS. - environment["ASPIRE_OTLP_PATH_BASE"] = $"{pathBase}/{otlpPrefix}"; - environment["OTEL_EXPORTER_OTLP_PROTOCOL"] = "http/protobuf"; - - // NOTE: OTEL_EXPORTER_OTLP_HEADERS is intentionally NOT sent to the WASM client. - // The headers contain the dashboard OTLP API key, and this config is delivered - // to browser-visible JSON. The YARP proxy injects the headers server-side when - // forwarding telemetry to the dashboard. + builder.AppendFormatted(originRef); } - - return JsonSerializer.Serialize( - new ClientConfiguration - { - WebAssembly = new WebAssemblyConfiguration { Environment = environment } - }, - ManifestJsonContext.Relaxed.ClientConfiguration); + builder.AppendLiteral(segments[i]); } - private static string? NormalizeUrl(string? url) - { - return string.IsNullOrEmpty(url) ? null : url.TrimEnd('/'); - } + return builder.Build(); } } diff --git a/src/Aspire.Hosting.Blazor/GatewayOriginReference.cs b/src/Aspire.Hosting.Blazor/GatewayOriginReference.cs new file mode 100644 index 00000000000..eff04d813ca --- /dev/null +++ b/src/Aspire.Hosting.Blazor/GatewayOriginReference.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting; + +/// +/// Wraps a gateway so publishers can emit a deployer-configurable +/// placeholder (e.g., ${GATEWAY_BINDINGS_HTTPS_URL}) while dev mode resolves the actual URL. +/// +internal sealed class GatewayOriginReference(EndpointReference endpoint) : IValueProvider, IManifestExpressionProvider +{ + public string ValueExpression => $"{{{endpoint.Resource.Name}.bindings.{endpoint.EndpointName}.url}}"; + + public async ValueTask GetValueAsync(CancellationToken cancellationToken = default) + { + var url = await endpoint.GetValueAsync(cancellationToken).ConfigureAwait(false); + return url?.TrimEnd('/'); + } +} diff --git a/src/Aspire.Hosting.Blazor/Manifests/EndpointsManifestTransformer.cs b/src/Aspire.Hosting.Blazor/Manifests/EndpointsManifestTransformer.cs index ae961a4e155..03f5b583b8b 100644 --- a/src/Aspire.Hosting.Blazor/Manifests/EndpointsManifestTransformer.cs +++ b/src/Aspire.Hosting.Blazor/Manifests/EndpointsManifestTransformer.cs @@ -21,7 +21,8 @@ public static async Task PrefixEndpointsAssetFileAsync(string manifestPa { var manifest = JsonSerializer.Deserialize( await File.ReadAllTextAsync(manifestPath, ct).ConfigureAwait(false), - ManifestJsonContext.Default.EndpointsManifest)!; + ManifestJsonContext.Default.EndpointsManifest) + ?? throw new InvalidOperationException($"Failed to deserialize endpoints manifest from '{manifestPath}'."); var fallbackEndpoints = new List(); @@ -33,7 +34,7 @@ await File.ReadAllTextAsync(manifestPath, ct).ConfigureAwait(false), // We skip compressed variants (those with Content-Encoding selectors) because the // ContentEncodingNegotiationMatcherPolicy would otherwise prefer the catch-all over // literal routes (like _blazor/_configuration) that lack encoding metadata. - if (ep.Route == "index.html") + if (string.Equals(ep.Route, "index.html", StringComparison.OrdinalIgnoreCase)) { var hasContentEncoding = ep.Selectors?.Any(s => s.Name == "Content-Encoding") == true; diff --git a/src/Aspire.Hosting.Blazor/Scripts/Gateway.cs.in b/src/Aspire.Hosting.Blazor/Scripts/Gateway.cs.in index 0c2b8958e70..9553441aa6b 100644 --- a/src/Aspire.Hosting.Blazor/Scripts/Gateway.cs.in +++ b/src/Aspire.Hosting.Blazor/Scripts/Gateway.cs.in @@ -79,7 +79,7 @@ foreach (var appConfig in appConfigs.Values) { if (!string.IsNullOrEmpty(appConfig.ConfigEndpointPath) && !string.IsNullOrEmpty(appConfig.ConfigResponse)) { - app.MapGet(appConfig.ConfigEndpointPath, () => Results.Content(appConfig.ConfigResponse, "application/json")) + app.MapGet(appConfig.ConfigEndpointPath, () => Results.Content(appConfig.ConfigResponse!, "application/json")) .WithMetadata(new ContentEncodingMetadata("identity", 1.0)); } @@ -150,9 +150,12 @@ static class ServiceDefaultsExtensions tracing.AddSource(builder.Environment.ApplicationName) .AddAspNetCoreInstrumentation(options => options.Filter = context => - !context.Request.Path.StartsWithSegments("/health") - && !context.Request.Path.StartsWithSegments("/alive") - && !IsStaticAssetOrOtlpRequest(context.Request.Path) + { + var path = context.Request.Path.Value; + return !context.Request.Path.StartsWithSegments("/health") + && !context.Request.Path.StartsWithSegments("/alive") + && (path is null || !path.Contains("/_otlp/", StringComparison.Ordinal)); + } ) .AddHttpClientInstrumentation(options => // Filter out the gateway's own OTLP export calls to the dashboard @@ -172,15 +175,6 @@ static class ServiceDefaultsExtensions return builder; } - private static bool IsStaticAssetOrOtlpRequest(PathString path) - { - var pathValue = path.Value; - return pathValue is not null - && (pathValue.Contains("/_framework/", StringComparison.Ordinal) - || pathValue.Contains("/_content/", StringComparison.Ordinal) - || pathValue.Contains("/_otlp/", StringComparison.Ordinal)); - } - public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder { builder.Services.AddHealthChecks() diff --git a/tests/Aspire.Hosting.Blazor.Tests/BlazorHostedExtensionsTests.cs b/tests/Aspire.Hosting.Blazor.Tests/BlazorHostedExtensionsTests.cs index 1498f2114c0..674503f1d31 100644 --- a/tests/Aspire.Hosting.Blazor.Tests/BlazorHostedExtensionsTests.cs +++ b/tests/Aspire.Hosting.Blazor.Tests/BlazorHostedExtensionsTests.cs @@ -3,6 +3,8 @@ using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Utils; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; namespace Aspire.Hosting.Blazor.Tests; @@ -85,7 +87,7 @@ public async Task ProxyTelemetry_EmitsOtelServiceNameInConfig() var configJson = ResolveManifestExpression(env["Client__ConfigResponse"]); Assert.Contains("OTEL_SERVICE_NAME", configJson); Assert.Contains("blazorapp", configJson); - Assert.Contains("ASPIRE_OTLP_PATH_BASE", configJson); + Assert.Contains("OTEL_EXPORTER_OTLP_ENDPOINT", configJson); Assert.Contains("/_otlp", configJson); } @@ -114,7 +116,7 @@ public async Task ProxyService_And_ProxyTelemetry_Combined() // Config response includes both service URLs and OTLP var configJson = ResolveManifestExpression(env["Client__ConfigResponse"]); Assert.Contains("services__weatherapi__https__0", configJson); - Assert.Contains("ASPIRE_OTLP_PATH_BASE", configJson); + Assert.Contains("OTEL_EXPORTER_OTLP_ENDPOINT", configJson); Assert.Contains("OTEL_SERVICE_NAME", configJson); } @@ -182,7 +184,7 @@ public async Task ProxyService_WithoutProxyTelemetry_NoOtlpInConfig() Assert.False(env.ContainsKey("ReverseProxy__Routes__route-otlp__ClusterId")); var configJson = ResolveManifestExpression(env["Client__ConfigResponse"]); - Assert.DoesNotContain("ASPIRE_OTLP_PATH_BASE", configJson); + Assert.DoesNotContain("OTEL_EXPORTER_OTLP_ENDPOINT", configJson); } [Fact] @@ -282,16 +284,105 @@ public void ProxyService_MultipleServices_AllGetEndpointReferences() Assert.Contains("catalogapi", referencedNames); } + [Fact] + public async Task ProxyTelemetry_LogsWarning_WhenOtlpEndpointNotResolvable() + { + using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper); + // Intentionally NOT setting ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL + + builder.AddProject("blazorapp") + .WithHttpsEndpoint() + .ProxyBlazorTelemetry(); + + var blazorApp = builder.Resources.Single(r => r.Name == "blazorapp"); + var (_, sink) = await GetEnvironmentVariablesWithLogs(blazorApp, builder); + + Assert.Contains(sink.Writes, msg => + msg.LogLevel == LogLevel.Warning && + msg.Message?.Contains("OTLP telemetry proxying was requested") == true && + msg.Message?.Contains("WASM client telemetry will not be forwarded") == true); + } + + [Fact] + public async Task ProxyTelemetry_DoesNotLogWarning_WhenOtlpEndpointIsConfigured() + { + using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper); + builder.Configuration["ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL"] = "http://localhost:4318"; + + builder.AddProject("blazorapp") + .WithHttpsEndpoint() + .ProxyBlazorTelemetry(); + + var blazorApp = builder.Resources.Single(r => r.Name == "blazorapp"); + var (_, sink) = await GetEnvironmentVariablesWithLogs(blazorApp, builder); + + Assert.DoesNotContain(sink.Writes, msg => + msg.LogLevel == LogLevel.Warning && + msg.Message?.Contains("OTLP telemetry proxying was requested") == true); + } + + [Fact] + public async Task WithBlazorClientApp_LogsWarning_WhenOtlpEndpointNotResolvable() + { + using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper); + + var gateway = builder.AddProject("gateway") + .WithHttpEndpoint() + .WithHttpsEndpoint(); + + var wasmApp = builder.AddBlazorWasmApp("store", "Store/Store.csproj"); + gateway.WithBlazorClientApp(wasmApp, proxyTelemetry: true); + + var (_, sink) = await GetEnvironmentVariablesWithLogs(gateway.Resource, builder); + + Assert.Contains(sink.Writes, msg => + msg.LogLevel == LogLevel.Warning && + msg.Message?.Contains("OTLP telemetry proxying was requested") == true && + msg.Message?.Contains("WASM client telemetry will not be forwarded") == true); + } + + [Fact] + public async Task WithBlazorClientApp_DoesNotLogWarning_WhenOtlpEndpointIsConfigured() + { + using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper); + builder.Configuration["ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL"] = "http://localhost:4318"; + + var gateway = builder.AddProject("gateway") + .WithHttpEndpoint() + .WithHttpsEndpoint(); + + var wasmApp = builder.AddBlazorWasmApp("store", "Store/Store.csproj"); + gateway.WithBlazorClientApp(wasmApp, proxyTelemetry: true); + + var (_, sink) = await GetEnvironmentVariablesWithLogs(gateway.Resource, builder); + + Assert.DoesNotContain(sink.Writes, msg => + msg.LogLevel == LogLevel.Warning && + msg.Message?.Contains("OTLP telemetry proxying was requested") == true); + } + private static async Task> GetEnvironmentVariables( IResource resource, IDistributedApplicationBuilder builder) + { + var (env, _) = await GetEnvironmentVariablesWithLogs(resource, builder); + return env; + } + + private static async Task<(Dictionary Env, TestSink Sink)> GetEnvironmentVariablesWithLogs( + IResource resource, IDistributedApplicationBuilder builder) { var env = new Dictionary(); - var context = new EnvironmentCallbackContext(builder.ExecutionContext, resource, env); + var sink = new TestSink(); + var logger = new TestLogger(string.Empty, sink, enabled: true); + var context = new EnvironmentCallbackContext(builder.ExecutionContext, resource, env) + { + Logger = logger + }; foreach (var callback in resource.Annotations.OfType()) { await callback.Callback(context).ConfigureAwait(false); } - return env; + return (env, sink); } private static string ResolveManifestExpression(object value) diff --git a/tests/Aspire.Hosting.Blazor.Tests/GatewayConfigurationBuilderTests.cs b/tests/Aspire.Hosting.Blazor.Tests/GatewayConfigurationBuilderTests.cs index 650dc2a581e..e92698282aa 100644 --- a/tests/Aspire.Hosting.Blazor.Tests/GatewayConfigurationBuilderTests.cs +++ b/tests/Aspire.Hosting.Blazor.Tests/GatewayConfigurationBuilderTests.cs @@ -323,7 +323,7 @@ public void EmitProxyConfiguration_DoesNotEmitOtlpProxy_WhenTelemetryDisabled() var configResponse = (IManifestExpressionProvider)env["ClientApps__store__ConfigResponse"]; var manifestExpression = configResponse.ValueExpression; - Assert.DoesNotContain("ASPIRE_OTLP_PATH_BASE", manifestExpression); + Assert.DoesNotContain("OTEL_EXPORTER_OTLP_ENDPOINT", manifestExpression); } [Fact] @@ -462,7 +462,7 @@ public void EmitProxyConfiguration_OtlpEndpoint_PrefersHttpsBaseUrl() // OTLP path base is emitted so the WASM client can resolve it against // the page's origin, avoiding cross-origin issues. - Assert.Contains("ASPIRE_OTLP_PATH_BASE", configJson); + Assert.Contains("OTEL_EXPORTER_OTLP_ENDPOINT", configJson); Assert.Contains("/_otlp", configJson); // Service discovery emits both schemes so the client can pick the right one. From 45a30c8be16b20309dcf4e5a38c711d80d04898d Mon Sep 17 00:00:00 2001 From: "aspire-repo-bot[bot]" <268009190+aspire-repo-bot[bot]@users.noreply.github.com> Date: Wed, 27 May 2026 09:14:57 -0700 Subject: [PATCH 07/43] [release/13.4] Friendly error for 'aspire do --list-steps' without a step (#17543) * Friendly error for 'aspire do --list-steps' without a step 'aspire do' is always step-targeted, so '--list-steps' with no step has no meaningful scope. The validator previously allowed it, which caused the CLI to launch the AppHost and race ahead executing the full pipeline before the CLI could fetch steps and stop. That race surfaced as 'InvalidOperationException: Sequence contains more than one matching element' from AzurePublishingContext (#17526). Tighten the DoCommand validator to always require the step argument (outside the extension host, which prompts interactively). When the user specifies '--list-steps' without a step, emit a friendly, localized error pointing at concrete examples: 'aspire do deploy --list-steps' or 'aspire do publish --list-steps'. Adds StepArgumentRequired and ListStepsRequiresStep entries to DoCommandStrings.resx + Designer.cs and refreshes all xlf translations via UpdateXlf. Tests: - DoCommandTests: new DoCommandWithListStepsAndNoStepArgumentShowsFriendlyError regression case; existing list-steps tests updated to pass a step. - ListStepsTests (E2E): single Docker-backed test now exercises 'aspire do --list-steps' (asserts friendly error and the absence of the 'Sequence contains more than one matching element' crash) plus 'aspire do deploy --list-steps', 'aspire publish --list-steps' and 'aspire deploy --list-steps' against a freshly created starter app. Fixes: #17526 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * List all well-known step names in the friendly error Expand the 'aspire do --list-steps' validation error to enumerate every well-known pipeline step instead of just naming 'deploy' and 'publish'. The step names are hand-maintained in DoCommand alongside a comment pointing at src/Aspire.Hosting/Pipelines/WellKnownPipelineSteps.cs (the CLI does not reference Aspire.Hosting). Updated unit and E2E assertions to match the new message shape. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Trim list to build/publish/deploy and add docs link Replace the long enumeration of well-known step names with a short, opinionated suggestion (build, publish, deploy) and a link to the official 'aspire do' reference page on aspire.dev for the full list. This removes the hand-maintained mirror of WellKnownPipelineSteps in DoCommand and simplifies localization since the message no longer takes a format parameter. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review: fix extension-host bypass and harden E2E asserts - DoCommand validator: fire the ListStepsRequiresStep friendly error for `aspire do --list-steps` even when running in the extension host, because --list-steps does not flow through GetRunArgumentsAsync's interactive step prompt and would otherwise still hit the original pipeline crash from #17526. - ListStepsTests (E2E): replace long, wrap-sensitive substrings with short fragments ("required when using --list-steps", "aspire.dev/") so the assertion does not fail when the friendly error wraps in a narrow Docker terminal. - DoCommandTests: add DoCommandWithListStepsAndNoStepArgumentInExtensionHostShowsFriendlyError to regression-test the extension-host path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Mitch Denny Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/DoCommand.cs | 24 ++++++- .../Resources/DoCommandStrings.Designer.cs | 18 ++++++ .../Resources/DoCommandStrings.resx | 6 ++ .../Resources/xlf/DoCommandStrings.cs.xlf | 12 +++- .../Resources/xlf/DoCommandStrings.de.xlf | 10 +++ .../Resources/xlf/DoCommandStrings.es.xlf | 10 +++ .../Resources/xlf/DoCommandStrings.fr.xlf | 10 +++ .../Resources/xlf/DoCommandStrings.it.xlf | 10 +++ .../Resources/xlf/DoCommandStrings.ja.xlf | 10 +++ .../Resources/xlf/DoCommandStrings.ko.xlf | 10 +++ .../Resources/xlf/DoCommandStrings.pl.xlf | 10 +++ .../Resources/xlf/DoCommandStrings.pt-BR.xlf | 10 +++ .../Resources/xlf/DoCommandStrings.ru.xlf | 10 +++ .../Resources/xlf/DoCommandStrings.tr.xlf | 10 +++ .../xlf/DoCommandStrings.zh-Hans.xlf | 10 +++ .../xlf/DoCommandStrings.zh-Hant.xlf | 10 +++ .../ListStepsTests.cs | 56 ++++++++++++++--- .../Commands/DoCommandTests.cs | 62 +++++++++++++++++-- 18 files changed, 284 insertions(+), 14 deletions(-) diff --git a/src/Aspire.Cli/Commands/DoCommand.cs b/src/Aspire.Cli/Commands/DoCommand.cs index 51922787000..ce14ce8b2a5 100644 --- a/src/Aspire.Cli/Commands/DoCommand.cs +++ b/src/Aspire.Cli/Commands/DoCommand.cs @@ -35,9 +35,29 @@ public DoCommand(IDotNetCliRunner runner, IInteractionService interactionService { var step = result.GetValue(_stepArgument); var listSteps = result.GetValue(s_listStepsOption); - if (string.IsNullOrEmpty(step) && !listSteps && !ExtensionHelper.IsExtensionHost(interactionService, out _, out _)) + if (!string.IsNullOrEmpty(step)) { - result.AddError("The 'step' argument is required when not using --list-steps."); + return; + } + + if (listSteps) + { + // `aspire do --list-steps` with no step has no meaningful scope: the listing for + // `do` is always relative to a target step. Surface a friendly error pointing at + // common starting steps and the docs rather than launching the AppHost and + // crashing mid-pipeline (see https://github.com/microsoft/aspire/issues/17526). + // This applies in the extension host too because `--list-steps` does not flow + // through the interactive step prompt in GetRunArgumentsAsync, so without this + // error the extension would still hit the original crash path. + result.AddError(DoCommandStrings.ListStepsRequiresStep); + return; + } + + // For a plain `aspire do` invocation, the extension host prompts the user for a step + // later in GetRunArgumentsAsync, so don't add a validation error there. + if (!ExtensionHelper.IsExtensionHost(interactionService, out _, out _)) + { + result.AddError(DoCommandStrings.StepArgumentRequired); } }); } diff --git a/src/Aspire.Cli/Resources/DoCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/DoCommandStrings.Designer.cs index c7224cb9a1c..ab8fbd526f5 100644 --- a/src/Aspire.Cli/Resources/DoCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/DoCommandStrings.Designer.cs @@ -122,5 +122,23 @@ public static string StepArgumentDescription { return ResourceManager.GetString("StepArgumentDescription", resourceCulture); } } + + /// + /// Looks up a localized string similar to The 'step' argument is required.. + /// + public static string StepArgumentRequired { + get { + return ResourceManager.GetString("StepArgumentRequired", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The 'step' argument is required when using --list-steps. Example: 'aspire do deploy --list-steps'. Common starting steps are 'build', 'publish' and 'deploy'. See https://aspire.dev/reference/cli/commands/aspire-do/ for the full list of pipeline steps.. + /// + public static string ListStepsRequiresStep { + get { + return ResourceManager.GetString("ListStepsRequiresStep", resourceCulture); + } + } } } diff --git a/src/Aspire.Cli/Resources/DoCommandStrings.resx b/src/Aspire.Cli/Resources/DoCommandStrings.resx index 2db6a8643cd..1e5862df55b 100644 --- a/src/Aspire.Cli/Resources/DoCommandStrings.resx +++ b/src/Aspire.Cli/Resources/DoCommandStrings.resx @@ -138,4 +138,10 @@ The name of the step to execute + + The 'step' argument is required. + + + The 'step' argument is required when using --list-steps. Example: 'aspire do deploy --list-steps'. Common starting steps are 'build', 'publish' and 'deploy'. See https://aspire.dev/reference/cli/commands/aspire-do/ for the full list of pipeline steps. + diff --git a/src/Aspire.Cli/Resources/xlf/DoCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/DoCommandStrings.cs.xlf index cded75496fa..1050dc23952 100644 --- a/src/Aspire.Cli/Resources/xlf/DoCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoCommandStrings.cs.xlf @@ -12,6 +12,11 @@ Provádí se kanál... + + The 'step' argument is required when using --list-steps. Example: 'aspire do deploy --list-steps'. Common starting steps are 'build', 'publish' and 'deploy'. See https://aspire.dev/reference/cli/commands/aspire-do/ for the full list of pipeline steps. + The 'step' argument is required when using --list-steps. Example: 'aspire do deploy --list-steps'. Common starting steps are 'build', 'publish' and 'deploy'. See https://aspire.dev/reference/cli/commands/aspire-do/ for the full list of pipeline steps. + + The operation was canceled. Operace byla zrušena. @@ -37,6 +42,11 @@ Název kroku, který se má provést + + The 'step' argument is required. + The 'step' argument is required. + + - + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/DoCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/DoCommandStrings.de.xlf index 86ce4dee647..b0ce2d267f9 100644 --- a/src/Aspire.Cli/Resources/xlf/DoCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoCommandStrings.de.xlf @@ -12,6 +12,11 @@ Pipeline wird ausgeführt … + + The 'step' argument is required when using --list-steps. Example: 'aspire do deploy --list-steps'. Common starting steps are 'build', 'publish' and 'deploy'. See https://aspire.dev/reference/cli/commands/aspire-do/ for the full list of pipeline steps. + The 'step' argument is required when using --list-steps. Example: 'aspire do deploy --list-steps'. Common starting steps are 'build', 'publish' and 'deploy'. See https://aspire.dev/reference/cli/commands/aspire-do/ for the full list of pipeline steps. + + The operation was canceled. Der Vorgang wurde abgebrochen. @@ -37,6 +42,11 @@ Der Name des auszuführenden Schritts. + + The 'step' argument is required. + The 'step' argument is required. + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/DoCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/DoCommandStrings.es.xlf index 5a2abd0fc26..12c5af2811a 100644 --- a/src/Aspire.Cli/Resources/xlf/DoCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoCommandStrings.es.xlf @@ -12,6 +12,11 @@ Ejecutando canalización... + + The 'step' argument is required when using --list-steps. Example: 'aspire do deploy --list-steps'. Common starting steps are 'build', 'publish' and 'deploy'. See https://aspire.dev/reference/cli/commands/aspire-do/ for the full list of pipeline steps. + The 'step' argument is required when using --list-steps. Example: 'aspire do deploy --list-steps'. Common starting steps are 'build', 'publish' and 'deploy'. See https://aspire.dev/reference/cli/commands/aspire-do/ for the full list of pipeline steps. + + The operation was canceled. Operación cancelada. @@ -37,6 +42,11 @@ Nombre del paso que se va a ejecutar. + + The 'step' argument is required. + The 'step' argument is required. + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/DoCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/DoCommandStrings.fr.xlf index f1fdc9a1809..4bffb1c76a5 100644 --- a/src/Aspire.Cli/Resources/xlf/DoCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoCommandStrings.fr.xlf @@ -12,6 +12,11 @@ Exécution du pipeline... + + The 'step' argument is required when using --list-steps. Example: 'aspire do deploy --list-steps'. Common starting steps are 'build', 'publish' and 'deploy'. See https://aspire.dev/reference/cli/commands/aspire-do/ for the full list of pipeline steps. + The 'step' argument is required when using --list-steps. Example: 'aspire do deploy --list-steps'. Common starting steps are 'build', 'publish' and 'deploy'. See https://aspire.dev/reference/cli/commands/aspire-do/ for the full list of pipeline steps. + + The operation was canceled. L'opération a été annulée. @@ -37,6 +42,11 @@ Nom de l’étape à exécuter. + + The 'step' argument is required. + The 'step' argument is required. + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/DoCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/DoCommandStrings.it.xlf index f59033ec917..19daad8d54c 100644 --- a/src/Aspire.Cli/Resources/xlf/DoCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoCommandStrings.it.xlf @@ -12,6 +12,11 @@ Esecuzione della pipeline in corso... + + The 'step' argument is required when using --list-steps. Example: 'aspire do deploy --list-steps'. Common starting steps are 'build', 'publish' and 'deploy'. See https://aspire.dev/reference/cli/commands/aspire-do/ for the full list of pipeline steps. + The 'step' argument is required when using --list-steps. Example: 'aspire do deploy --list-steps'. Common starting steps are 'build', 'publish' and 'deploy'. See https://aspire.dev/reference/cli/commands/aspire-do/ for the full list of pipeline steps. + + The operation was canceled. L'operazione è stata annullata. @@ -37,6 +42,11 @@ Nome del passaggio da eseguire. + + The 'step' argument is required. + The 'step' argument is required. + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/DoCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/DoCommandStrings.ja.xlf index 66a8f27a628..687dba75e06 100644 --- a/src/Aspire.Cli/Resources/xlf/DoCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoCommandStrings.ja.xlf @@ -12,6 +12,11 @@ パイプラインを実行しています... + + The 'step' argument is required when using --list-steps. Example: 'aspire do deploy --list-steps'. Common starting steps are 'build', 'publish' and 'deploy'. See https://aspire.dev/reference/cli/commands/aspire-do/ for the full list of pipeline steps. + The 'step' argument is required when using --list-steps. Example: 'aspire do deploy --list-steps'. Common starting steps are 'build', 'publish' and 'deploy'. See https://aspire.dev/reference/cli/commands/aspire-do/ for the full list of pipeline steps. + + The operation was canceled. 操作は取り消されました。 @@ -37,6 +42,11 @@ 実行するステップの名前です。 + + The 'step' argument is required. + The 'step' argument is required. + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/DoCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/DoCommandStrings.ko.xlf index 27f033fa266..9e341bfd91c 100644 --- a/src/Aspire.Cli/Resources/xlf/DoCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoCommandStrings.ko.xlf @@ -12,6 +12,11 @@ 파이프라인을 실행하는 중... + + The 'step' argument is required when using --list-steps. Example: 'aspire do deploy --list-steps'. Common starting steps are 'build', 'publish' and 'deploy'. See https://aspire.dev/reference/cli/commands/aspire-do/ for the full list of pipeline steps. + The 'step' argument is required when using --list-steps. Example: 'aspire do deploy --list-steps'. Common starting steps are 'build', 'publish' and 'deploy'. See https://aspire.dev/reference/cli/commands/aspire-do/ for the full list of pipeline steps. + + The operation was canceled. 작업이 취소되었습니다. @@ -37,6 +42,11 @@ 실행할 단계의 이름입니다. + + The 'step' argument is required. + The 'step' argument is required. + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/DoCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/DoCommandStrings.pl.xlf index 6a4c038846f..40869b4afe5 100644 --- a/src/Aspire.Cli/Resources/xlf/DoCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoCommandStrings.pl.xlf @@ -12,6 +12,11 @@ Wykonywanie potoku... + + The 'step' argument is required when using --list-steps. Example: 'aspire do deploy --list-steps'. Common starting steps are 'build', 'publish' and 'deploy'. See https://aspire.dev/reference/cli/commands/aspire-do/ for the full list of pipeline steps. + The 'step' argument is required when using --list-steps. Example: 'aspire do deploy --list-steps'. Common starting steps are 'build', 'publish' and 'deploy'. See https://aspire.dev/reference/cli/commands/aspire-do/ for the full list of pipeline steps. + + The operation was canceled. Operacja została anulowana. @@ -37,6 +42,11 @@ Nazwa kroku do wykonania. + + The 'step' argument is required. + The 'step' argument is required. + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/DoCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/DoCommandStrings.pt-BR.xlf index 88c144bf453..c3688ae13a0 100644 --- a/src/Aspire.Cli/Resources/xlf/DoCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoCommandStrings.pt-BR.xlf @@ -12,6 +12,11 @@ Executando pipeline... + + The 'step' argument is required when using --list-steps. Example: 'aspire do deploy --list-steps'. Common starting steps are 'build', 'publish' and 'deploy'. See https://aspire.dev/reference/cli/commands/aspire-do/ for the full list of pipeline steps. + The 'step' argument is required when using --list-steps. Example: 'aspire do deploy --list-steps'. Common starting steps are 'build', 'publish' and 'deploy'. See https://aspire.dev/reference/cli/commands/aspire-do/ for the full list of pipeline steps. + + The operation was canceled. A operação foi cancelada. @@ -37,6 +42,11 @@ O nome da etapa a ser executada. + + The 'step' argument is required. + The 'step' argument is required. + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/DoCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/DoCommandStrings.ru.xlf index 84495e31d97..7787f742110 100644 --- a/src/Aspire.Cli/Resources/xlf/DoCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoCommandStrings.ru.xlf @@ -12,6 +12,11 @@ Выполнение конвейера... + + The 'step' argument is required when using --list-steps. Example: 'aspire do deploy --list-steps'. Common starting steps are 'build', 'publish' and 'deploy'. See https://aspire.dev/reference/cli/commands/aspire-do/ for the full list of pipeline steps. + The 'step' argument is required when using --list-steps. Example: 'aspire do deploy --list-steps'. Common starting steps are 'build', 'publish' and 'deploy'. See https://aspire.dev/reference/cli/commands/aspire-do/ for the full list of pipeline steps. + + The operation was canceled. Операция была отменена. @@ -37,6 +42,11 @@ Имя выполняемого шага. + + The 'step' argument is required. + The 'step' argument is required. + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/DoCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/DoCommandStrings.tr.xlf index f9d7324b6ee..392907d6050 100644 --- a/src/Aspire.Cli/Resources/xlf/DoCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoCommandStrings.tr.xlf @@ -12,6 +12,11 @@ İşlem hattı yürütülüyor... + + The 'step' argument is required when using --list-steps. Example: 'aspire do deploy --list-steps'. Common starting steps are 'build', 'publish' and 'deploy'. See https://aspire.dev/reference/cli/commands/aspire-do/ for the full list of pipeline steps. + The 'step' argument is required when using --list-steps. Example: 'aspire do deploy --list-steps'. Common starting steps are 'build', 'publish' and 'deploy'. See https://aspire.dev/reference/cli/commands/aspire-do/ for the full list of pipeline steps. + + The operation was canceled. İşlem iptal edildi. @@ -37,6 +42,11 @@ Yürütülecek adımın adı. + + The 'step' argument is required. + The 'step' argument is required. + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/DoCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/DoCommandStrings.zh-Hans.xlf index 19934085624..44cdbd985d3 100644 --- a/src/Aspire.Cli/Resources/xlf/DoCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoCommandStrings.zh-Hans.xlf @@ -12,6 +12,11 @@ 正在执行管道... + + The 'step' argument is required when using --list-steps. Example: 'aspire do deploy --list-steps'. Common starting steps are 'build', 'publish' and 'deploy'. See https://aspire.dev/reference/cli/commands/aspire-do/ for the full list of pipeline steps. + The 'step' argument is required when using --list-steps. Example: 'aspire do deploy --list-steps'. Common starting steps are 'build', 'publish' and 'deploy'. See https://aspire.dev/reference/cli/commands/aspire-do/ for the full list of pipeline steps. + + The operation was canceled. 该操作已取消。 @@ -37,6 +42,11 @@ 要执行的步骤名称。 + + The 'step' argument is required. + The 'step' argument is required. + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/DoCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/DoCommandStrings.zh-Hant.xlf index 81f8d9ba37b..ca03fadae07 100644 --- a/src/Aspire.Cli/Resources/xlf/DoCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoCommandStrings.zh-Hant.xlf @@ -12,6 +12,11 @@ 正在執行管線... + + The 'step' argument is required when using --list-steps. Example: 'aspire do deploy --list-steps'. Common starting steps are 'build', 'publish' and 'deploy'. See https://aspire.dev/reference/cli/commands/aspire-do/ for the full list of pipeline steps. + The 'step' argument is required when using --list-steps. Example: 'aspire do deploy --list-steps'. Common starting steps are 'build', 'publish' and 'deploy'. See https://aspire.dev/reference/cli/commands/aspire-do/ for the full list of pipeline steps. + + The operation was canceled. 已取消作業。 @@ -37,6 +42,11 @@ 要執行的步驟名稱。 + + The 'step' argument is required. + The 'step' argument is required. + + \ No newline at end of file diff --git a/tests/Aspire.Cli.EndToEnd.Tests/ListStepsTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/ListStepsTests.cs index 63343a555aa..831248ddb38 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/ListStepsTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/ListStepsTests.cs @@ -9,13 +9,15 @@ namespace Aspire.Cli.EndToEnd.Tests; /// -/// End-to-end tests for the aspire do --list-steps feature. -/// Verifies that the CLI can list pipeline steps without executing them. +/// End-to-end tests for the --list-steps feature on the aspire do, publish, and deploy commands. +/// Verifies that the CLI can list pipeline steps without executing them, and that +/// invalid combinations (such as `aspire do --list-steps` without a step argument) +/// surface a friendly error instead of crashing. /// public sealed class ListStepsTests(ITestOutputHelper output) { [Fact] - public async Task DoListStepsShowsPipelineSteps() + public async Task DoPublishAndDeployListStepsWork() { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); var strategy = CliInstallStrategy.Detect(output.WriteLine); @@ -40,17 +42,57 @@ public async Task DoListStepsShowsPipelineSteps() await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter); - // Run aspire do deploy --list-steps + // 1. Regression for https://github.com/microsoft/aspire/issues/17526: + // `aspire do --list-steps` with no step argument should surface a friendly error + // pointing at concrete examples rather than crashing with + // 'Sequence contains more than one matching element'. + await auto.TypeAsync("aspire do --list-steps"); + await auto.EnterAsync(); + + await auto.WaitUntilAsync(s => + { + if (s.ContainsText("Sequence contains more than one matching element")) + { + throw new InvalidOperationException( + "aspire do --list-steps regressed: pipeline executed and crashed instead of surfacing the friendly validation error."); + } + // Match short fragments that are unlikely to straddle a wrap boundary in a narrow + // terminal. The full error message is a single long sentence, so asserting on the + // raw URL (or any 30+ char run) is flaky because the screen buffer inserts wrap + // newlines that defeat ContainsText's literal substring match. + return s.ContainsText("required when using --list-steps") + && s.ContainsText("aspire.dev/"); + }, timeout: TimeSpan.FromMinutes(2), + description: "waiting for friendly error with example and docs link"); + + // The validation error returns a non-zero exit code, but the shell prompt should come back. + await auto.WaitForAnyPromptAsync(counter); + + // 2. `aspire do --list-steps` lists pipeline steps for that step. await auto.TypeAsync("aspire do deploy --list-steps"); await auto.EnterAsync(); + await auto.WaitUntilAsync(s => + s.ContainsText("Depends on:") || s.ContainsText("No dependencies"), + timeout: TimeSpan.FromMinutes(3), + description: "waiting for aspire do deploy --list-steps output"); + await auto.WaitForSuccessPromptAsync(counter); - // Wait for the output to contain step information - // The output should contain numbered steps with dependencies + // 3. `aspire publish --list-steps` lists steps for the publish target. + await auto.TypeAsync("aspire publish --list-steps"); + await auto.EnterAsync(); await auto.WaitUntilAsync(s => s.ContainsText("Depends on:") || s.ContainsText("No dependencies"), timeout: TimeSpan.FromMinutes(3), - description: "waiting for --list-steps output with step dependency information"); + description: "waiting for aspire publish --list-steps output"); + await auto.WaitForSuccessPromptAsync(counter); + // 4. `aspire deploy --list-steps` lists steps for the deploy target. + await auto.TypeAsync("aspire deploy --list-steps"); + await auto.EnterAsync(); + await auto.WaitUntilAsync(s => + s.ContainsText("Depends on:") || s.ContainsText("No dependencies"), + timeout: TimeSpan.FromMinutes(3), + description: "waiting for aspire deploy --list-steps output"); await auto.WaitForSuccessPromptAsync(counter); // Exit the terminal diff --git a/tests/Aspire.Cli.Tests/Commands/DoCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/DoCommandTests.cs index 7356d6fd67f..84254f5246b 100644 --- a/tests/Aspire.Cli.Tests/Commands/DoCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/DoCommandTests.cs @@ -7,6 +7,7 @@ using Aspire.Cli.Backchannel; using Microsoft.Extensions.DependencyInjection; using Aspire.Cli.Utils; +using Aspire.Hosting; using Microsoft.AspNetCore.InternalTesting; namespace Aspire.Cli.Tests.Commands; @@ -325,8 +326,8 @@ public async Task DoCommandWithListStepsReturnsZero() using var provider = services.BuildServiceProvider(); var command = provider.GetRequiredService(); - // Act - no step argument needed with --list-steps - var result = command.Parse("do --list-steps"); + // Act - step argument is required, even with --list-steps + var result = command.Parse("do deploy --list-steps"); var exitCode = await result.InvokeAsync().DefaultTimeout(); // Assert @@ -335,6 +336,59 @@ public async Task DoCommandWithListStepsReturnsZero() Assert.True(requestStopCalled.Task.IsCompleted, "RequestStopAsync should have been called"); } + [Fact] + public async Task DoCommandWithListStepsAndNoStepArgumentShowsFriendlyError() + { + // Regression for https://github.com/microsoft/aspire/issues/17526: + // `aspire do --list-steps` with no step argument used to launch the AppHost + // and crash mid-pipeline. It should now fail validation with a friendly + // error pointing at concrete examples. + using var tempRepo = TemporaryWorkspace.Create(outputHelper); + + var services = CliTestHelper.CreateServiceCollection(tempRepo, outputHelper); + using var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + + var result = command.Parse("do --list-steps"); + + Assert.NotEmpty(result.Errors); + var combined = string.Join("\n", result.Errors.Select(e => e.Message)); + Assert.Contains("--list-steps", combined); + Assert.Contains("aspire do deploy --list-steps", combined); + Assert.Contains("build", combined); + Assert.Contains("publish", combined); + Assert.Contains("https://aspire.dev/reference/cli/commands/aspire-do/", combined); + } + + [Fact] + public async Task DoCommandWithListStepsAndNoStepArgumentInExtensionHostShowsFriendlyError() + { + // The extension host bypasses the plain `aspire do` step requirement because + // GetRunArgumentsAsync prompts the user interactively. But `--list-steps` does + // not flow through that prompt, so without the validator firing the extension + // would still hit the original crash from https://github.com/microsoft/aspire/issues/17526. + using var tempRepo = TemporaryWorkspace.Create(outputHelper); + + var services = CliTestHelper.CreateServiceCollection(tempRepo, outputHelper, options => + { + options.ExtensionBackchannelFactory = _ => new TestExtensionBackchannel(); + options.InteractionServiceFactory = sp => new TestExtensionInteractionService(sp); + options.ConfigurationCallback += config => + { + config[KnownConfigNames.ExtensionDebugSessionId] = "test-session-id"; + }; + }); + using var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + + var result = command.Parse("do --list-steps"); + + Assert.NotEmpty(result.Errors); + var combined = string.Join("\n", result.Errors.Select(e => e.Message)); + Assert.Contains("--list-steps", combined); + Assert.Contains("https://aspire.dev/reference/cli/commands/aspire-do/", combined); + } + [Fact] public async Task DoCommandWithListStepsAndStepArgumentReturnsZero() { @@ -431,7 +485,7 @@ public async Task DoCommandWithListStepsDoesNotExecutePipeline() using var provider = services.BuildServiceProvider(); var command = provider.GetRequiredService(); - var result = command.Parse("do --list-steps"); + var result = command.Parse("do deploy --list-steps"); var exitCode = await result.InvokeAsync().DefaultTimeout(); // Assert - pipeline should NOT have been executed @@ -489,7 +543,7 @@ public async Task DoCommandListStepsDisplaysCustomSteps() using var provider = services.BuildServiceProvider(); var command = provider.GetRequiredService(); - var result = command.Parse("do --list-steps"); + var result = command.Parse("do deploy --list-steps"); var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(0, exitCode); From fd9cee2adc68061cafdaf2683392da3959c87f88 Mon Sep 17 00:00:00 2001 From: "aspire-repo-bot[bot]" <268009190+aspire-repo-bot[bot]@users.noreply.github.com> Date: Wed, 27 May 2026 09:21:51 -0700 Subject: [PATCH 08/43] [release/13.4] Validate Helm CLI version (>= 4.2.0) before Kubernetes deploy (#17542) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Validate Helm CLI version (>= 4.2.0) before Kubernetes deploy Aspire's Kubernetes deployment pipeline shells out to 'helm upgrade --install' for the main application chart and for any AddHelmChart(...) resources on a KubernetesEnvironmentResource. Previously we only checked that 'helm' was on PATH; we never asserted the installed Helm version was new enough for the flags and behaviors we depend on (e.g. '--server-side=true --force-conflicts' in the Helm 4 form). Missing or older Helm produced confusing low-level errors like 'unknown flag: --force-conflicts', 'Flag --force has been deprecated', or raw process-spawn failures. Changes: * Add internal HelmVersionValidator that runs 'helm version --short --client', parses the SemVer, and asserts a minimum of Helm 4.2.0. Throws a clear actionable InvalidOperationException (detected vs required + link to https://helm.sh/docs/intro/install/) when the version is too old, unparseable, or the command fails. * Wire the validator into the existing check-helm-prereqs-{env} pipeline step in HelmDeploymentEngine. One check per environment covers both the main chart deploy and AddHelmChart(...) flows since they all DependsOn this step. * Update the 'Helm CLI not found' message to also mention the minimum version requirement. * Remove the now-redundant ad-hoc 'helm version --short' probe at the top of HelmDeployAsync (the prereq step covers it with a much better error). * Promote FakeHelmRunner to a file-scoped test helper that emits canned 'helm version' stdout (defaults to v4.2.0+gfa15ec0) and supports a separate VersionExitCode, so any test exercising the deploy path automatically passes the prereq check. * Add HelmVersionValidatorTests covering: SemVer parsing of v3/v4/v5 outputs with and without '+gitsha' build metadata, rejection of unparseable output, threshold behavior for too-old versions (v4.1.0, v4.0.0, v3.18.0, v3.14.4), and that error messages include the detected version, the required version, and the install docs URL. * Document the Helm 4.2.0+ requirement in the Aspire.Hosting.Kubernetes and Aspire.Hosting.Azure.Kubernetes READMEs. Fixes #16977 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Drop --client flag from helm version invocation The validator was invoking 'helm version --short --client', but the --client flag was removed in Helm 4 (it existed in Helm 2 for the real client/server split, was kept as a no-op in Helm 3, and is unknown in Helm 4). Since this validator's purpose is to enforce Helm 4.2.0 or later, passing --client guarantees a failure against the very minimum version we require, surfacing the exact kind of confusing prereq error this step exists to prevent. Caught by dogfood testing of PR #17491 against a local Helm 4.2.0 install, which produced: Step 'check-helm-prereqs-k8s' failed: 'helm version --short --client' failed (Error: unknown flag: --client). Aspire requires Helm 4.2.0 or later. Switch to 'helm version --short', which produces identical output shape (e.g. v4.2.0+gfa15ec0) on Helm 3 and Helm 4. Add a regression test that records the arguments passed to the runner and asserts --client is never included. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR review feedback Three review items from the automated PR reviewer: 1. Gate destroy/uninstall on the same Helm prereq check as deploy. Both 'destroy-helm-{env}' and 'helm-uninstall-{env}' invoke 'helm uninstall', so a missing or too-old Helm would surface as a raw process-spawn / unknown-flag error during teardown instead of the actionable validator message. Add a 'DependsOn(check-helm-prereqs- {env})' on both, and add a regression test that asserts the dependency edge exists. 2. Fix the misleading comment above HelmVersionRegex. The regex is intentionally unanchored so we tolerate banner/shim lines that some shells, oh-my-zsh plugins, or asdf-style shims can prepend to the version output. Update the comment to describe that intent instead of claiming a start anchor that isn't there. 3. Shorten the Helm prerequisite bullets in both Kubernetes README files. Keep the bullet to the requirement itself and move the 'why we validate up front' narrative into a short paragraph below, matching the scannable style of the other hosting READMEs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Bump E2E Helm install version to v4.2.0 to match new floor The 11 DeployK8s* CLI E2E tests failed on commit d03916c because the container install scripts default HELM_VERSION to v3.17.3 — below the new HelmVersionValidator.MinimumHelmVersion (v4.2.0) that the check-helm-prereqs-{env} pipeline step now enforces. Centralize the version constants in a new tests/Aspire.Cli.EndToEnd.Tests/Helpers/KubernetesE2EVersions.cs so the default lives in one place (and points at the validator's documented minimum), then bump HelmVersion default v3.17.3 -> v4.2.0 (used by every DeployK8s* test and by the quarantined KubernetesPublishTests). HELM_VERSION / KIND_VERSION / KUBECTL_VERSION env-var overrides are preserved so CI can still bump to a newer point release without a code change. The AKS deployment workflow (deployment-tests.yml) still pins azure/setup-helm to v4.1.4 and needs the same bump to v4.2.0 to avoid breaking AKS scenarios under the new validator; that workflow file edit will land in a separate push that has 'workflow' OAuth scope. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Bump deployment-tests.yml Helm pin to v4.2.0 Match Aspire.Hosting.Kubernetes' new minimum supported Helm version (HelmVersionValidator.MinimumHelmVersion). The check-helm-prereqs-{env} pipeline step now fails fast on older Helm CLIs, so leaving the AKS deployment workflow pinned to v4.1.4 would break every AKS deployment scenario. Also refresh the surrounding rationale comment, which still referred to the historical v3.18 server-side narrative. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Gate AddHelmChart uninstall on prereqs and unify Helm CLI check Address self-review follow-ups on PR #17491: - Per-chart `helm-uninstall-{name}` step (AddHelmChart(...).WithDestroy()) now depends on `check-helm-prereqs-{env}`. Previously only the env-level destroy/uninstall steps were gated, so chart teardown could still hit the cryptic spawn / unknown-flag error the validator exists to prevent. - Drop the standalone PathLookupHelper probe from the prereq step. The validator already wraps spawn failures with the same actionable hint, and routing everything through IHelmRunner lets tests inject a fake without needing real Helm on PATH (fixes 3 pre-existing K8s test failures in environments without helm installed). - Refresh validator catch comment + error wording accordingly. - Drop stale `--client` mention in FakeHelmRunner comment. - Add regression test PerChartHelmUninstallStep_DependsOnCheckHelmPrereqs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Defer Helm version check in destroy-helm-{env} until state is found Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Mitch Denny Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/deployment-tests.yml | 14 +- src/Aspire.Hosting.Azure.Kubernetes/README.md | 3 + .../Deployment/HelmDeploymentEngine.cs | 49 ++++--- .../Deployment/HelmVersionValidator.cs | 136 ++++++++++++++++++ .../KubernetesHelmChartExtensions.cs | 6 + src/Aspire.Hosting.Kubernetes/README.md | 6 + .../Helpers/KubernetesDeployTestHelpers.cs | 6 +- .../Helpers/KubernetesE2EVersions.cs | 33 +++++ .../KubernetesPublishTests.cs | 4 +- .../FakeHelmRunner.cs | 73 ++++++++++ .../HelmVersionValidatorTests.cs | 121 ++++++++++++++++ .../KubernetesDeployTests.cs | 106 +++++++++++--- 12 files changed, 496 insertions(+), 61 deletions(-) create mode 100644 src/Aspire.Hosting.Kubernetes/Deployment/HelmVersionValidator.cs create mode 100644 tests/Aspire.Cli.EndToEnd.Tests/Helpers/KubernetesE2EVersions.cs create mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/FakeHelmRunner.cs create mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/HelmVersionValidatorTests.cs diff --git a/.github/workflows/deployment-tests.yml b/.github/workflows/deployment-tests.yml index 45d1a02ae3c..f44343e91da 100644 --- a/.github/workflows/deployment-tests.yml +++ b/.github/workflows/deployment-tests.yml @@ -251,17 +251,15 @@ jobs: docker info | head -20 echo "✅ Docker is available" - # Pin helm to a version that supports `helm install --server-side`/--force-conflicts - # (added in helm v3.18.0; helm v4 keeps both but `--server-side` is now a string flag - # accepting true|false|auto, which is why our code passes `--server-side=true` rather - # than the bare flag — that form parses identically on v3.18+ and v4). The default - # helm preinstalled on the runner image lags behind and rejects these flags, which - # breaks AKS cert-manager scenarios that need server-side apply to coexist with the - # AKS Azure Policy add-on's webhook mutations. + # Pin Helm to satisfy Aspire.Hosting.Kubernetes' minimum supported version + # (HelmVersionValidator.MinimumHelmVersion, currently v4.2.0). The new + # check-helm-prereqs-{env} pipeline step now fails fast on older Helm + # CLIs, so the runner image's preinstalled Helm — which lags behind — + # would otherwise break every AKS deployment scenario. - name: Setup Helm uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1 with: - version: v4.1.4 + version: v4.2.0 - name: Run deployment test (${{ matrix.shortname }}) id: run_tests diff --git a/src/Aspire.Hosting.Azure.Kubernetes/README.md b/src/Aspire.Hosting.Azure.Kubernetes/README.md index c31a66af4d0..99ff5691bf0 100644 --- a/src/Aspire.Hosting.Azure.Kubernetes/README.md +++ b/src/Aspire.Hosting.Azure.Kubernetes/README.md @@ -7,6 +7,9 @@ Provides extension methods and resource definitions for an Aspire AppHost to con ### Prerequisites - An Azure subscription - [create one for free](https://azure.microsoft.com/free/) +- [Helm](https://helm.sh/docs/intro/install/) **v4.2.0 or later** on your `PATH`. + +Aspire shells out to `helm upgrade --install` to deploy the generated chart and any `AddHelmChart(...)` releases, and validates the Helm version up front so missing or older installs produce a clear actionable error instead of cryptic flag failures. ### Install the package diff --git a/src/Aspire.Hosting.Kubernetes/Deployment/HelmDeploymentEngine.cs b/src/Aspire.Hosting.Kubernetes/Deployment/HelmDeploymentEngine.cs index d6b7663f5c0..31175562bfc 100644 --- a/src/Aspire.Hosting.Kubernetes/Deployment/HelmDeploymentEngine.cs +++ b/src/Aspire.Hosting.Kubernetes/Deployment/HelmDeploymentEngine.cs @@ -120,23 +120,25 @@ internal static Task> CreateStepsAsync( var model = factoryContext.PipelineContext.Model; var steps = new List(); - // Step 0: Check prerequisites — verify Helm CLI is available + // Step 0: Check prerequisites — verify Helm CLI is available and meets the + // minimum supported version. Doing this once per environment, before any + // helm invocation in either the main chart deploy or AddHelmChart(...) flows, + // turns confusing low-level errors (unknown-flag, deprecated-flag, raw + // spawn errors) into a single actionable message. + // + // The validator drives everything through IHelmRunner: a missing binary + // surfaces as a spawn failure that the validator wraps with the same + // "install Helm" hint, so we deliberately don't do a separate + // PathLookupHelper probe here. That also lets tests inject a fake runner + // without needing real Helm on PATH. var checkPrereqStep = new PipelineStep { Name = $"check-helm-prereqs-{environment.Name}", Description = $"Verifies Helm CLI is available for {environment.Name}.", - Action = ctx => + Action = async ctx => { - var helmPath = PathLookupHelper.FindFullPathFromPath("helm"); - if (helmPath is null) - { - throw new InvalidOperationException( - "Helm CLI not found. Install it from https://helm.sh/docs/intro/install/ " + - "and ensure it is available on your PATH."); - } - - ctx.Logger.LogDebug("Helm CLI found at: {HelmPath}", helmPath); - return Task.CompletedTask; + var helmRunner = ctx.Services.GetRequiredService(); + await HelmVersionValidator.EnsureMinimumVersionAsync(helmRunner, ctx.CancellationToken).ConfigureAwait(false); } }; steps.Add(checkPrereqStep); @@ -202,6 +204,12 @@ await ctx.ReportingStep.CompleteAsync( // Use saved state for the confirmation message (more accurate than recomputing) var @namespace = savedNamespace ?? "default"; await ConfirmDestroyAsync(ctx, $"Uninstall Helm release '{savedReleaseName}' from namespace '{@namespace}'? This action cannot be undone.").ConfigureAwait(false); + + var helmRunner = ctx.Services.GetRequiredService(); + // Defer the prereq check until state exists so `aspire destroy` against a + // never-deployed environment can still report "Nothing to destroy" without + // requiring Helm on PATH. + await HelmVersionValidator.EnsureMinimumVersionAsync(helmRunner, ctx.CancellationToken).ConfigureAwait(false); await HelmUninstallAsync(ctx, environment, savedReleaseName, @namespace).ConfigureAwait(false); ctx.Summary.Add("🗑️ Helm Release", savedReleaseName); @@ -223,6 +231,7 @@ await ctx.ReportingStep.CompleteAsync( Tags = [HelmUninstallTag], Action = ctx => HelmUninstallAsync(ctx, environment) }; + helmUninstallStep.DependsOn($"check-helm-prereqs-{environment.Name}"); steps.Add(helmUninstallStep); return Task.FromResult>(steps); @@ -415,19 +424,9 @@ private static async Task HelmDeployAsync(PipelineStepContext context, Kubernete { var helmRunner = context.Services.GetRequiredService(); - // Verify helm is available - try - { - var versionExitCode = await helmRunner.RunAsync("version --short", cancellationToken: context.CancellationToken).ConfigureAwait(false); - if (versionExitCode != 0) - { - throw new InvalidOperationException("'helm' is installed but returned an error. Ensure 'helm' is properly configured and your cluster is accessible."); - } - } - catch (Exception ex) when (ex is not InvalidOperationException and not OperationCanceledException) - { - throw new InvalidOperationException("'helm' was not found. Please install 'helm' and ensure it is available on your PATH to deploy to Kubernetes.", ex); - } + // Helm presence + version validation already ran in + // check-helm-prereqs-{env}, which is a transitive predecessor of this + // step. No need to re-verify here. var valuesFilePath = Path.Combine(outputPath, "values.yaml"); var arguments = new StringBuilder(); diff --git a/src/Aspire.Hosting.Kubernetes/Deployment/HelmVersionValidator.cs b/src/Aspire.Hosting.Kubernetes/Deployment/HelmVersionValidator.cs new file mode 100644 index 00000000000..ebd9de8b8b4 --- /dev/null +++ b/src/Aspire.Hosting.Kubernetes/Deployment/HelmVersionValidator.cs @@ -0,0 +1,136 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; +using System.Text; +using System.Text.RegularExpressions; + +namespace Aspire.Hosting.Kubernetes; + +/// +/// Validates that the installed Helm CLI is new enough for the flags and behaviors +/// Aspire's Kubernetes deployment pipeline depends on. +/// +/// +/// Aspire authors `helm upgrade --install` invocations against Helm 4.x. In particular +/// the `--server-side=true --force-conflicts` combination emitted by +/// WithForceConflicts() matches the Helm 4 form of --server-side (string +/// valued: "true" | "false" | "auto", https://github.com/helm/helm/pull/13649). +/// Validating up-front turns errors like unknown flag: --force-conflicts or +/// Flag --force has been deprecated, use --force-replace instead into a single +/// clear actionable message. +/// +internal static partial class HelmVersionValidator +{ + /// + /// Minimum supported Helm version. See class remarks for rationale. + /// + public static readonly Version MinimumHelmVersion = new(4, 2, 0); + + private const string InstallDocsUrl = "https://helm.sh/docs/intro/install/"; + + // `helm version --short` returns a single line in the shape + // v4.2.0+gfa15ec0 + // v4.0.0 + // v3.18.0+gb88f836 + // Match the optional `v` followed by MAJOR.MINOR.PATCH; ignore any `+gitsha` + // build metadata. Intentionally unanchored: some shells / wrappers (oh-my-zsh + // plugins, asdf shims, alias output, etc.) can prefix banner or shim lines + // before the actual version token, so we accept the first valid token anywhere + // in the captured output rather than requiring it at column 0. + [GeneratedRegex(@"v?(?\d+)\.(?\d+)\.(?\d+)")] + private static partial Regex HelmVersionRegex(); + + /// + /// Runs helm version --short, parses the SemVer, and throws + /// if the installed version is older than + /// or if the output cannot be parsed. + /// + /// + /// We deliberately do not pass --client. That flag existed in Helm 2 (where + /// Tiller meant there was a separate server version), was kept as a no-op in + /// Helm 3, and was removed entirely in Helm 4 — which is our minimum. Passing + /// --client against Helm 4 fails with Error: unknown flag: --client, + /// which is exactly the cryptic failure mode this validator exists to prevent. + /// + public static async Task EnsureMinimumVersionAsync( + IHelmRunner helmRunner, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(helmRunner); + + var stdout = new StringBuilder(); + var stderr = new StringBuilder(); + + int exitCode; + try + { + exitCode = await helmRunner.RunAsync( + "version --short", + onOutputData: line => stdout.AppendLine(line), + onErrorData: line => stderr.AppendLine(line), + cancellationToken: cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // ProcessUtil throws when the process itself can't be spawned (helm not on + // PATH, permission denied, etc.). The exception message from the runner + // is typically a low-level "No such file or directory" or + // "permission denied" that doesn't tell users what to do, so wrap it. + throw new InvalidOperationException( + $"Helm CLI not found or could not be invoked. Aspire requires Helm {MinimumHelmVersion} or later. Install it from {InstallDocsUrl} and ensure it is available on your PATH.", + ex); + } + + if (exitCode != 0) + { + var errorText = stderr.ToString().Trim(); + var detail = string.IsNullOrEmpty(errorText) ? $"exit code {exitCode}" : errorText; + throw new InvalidOperationException( + $"'helm version --short' failed ({detail}). Aspire requires Helm {MinimumHelmVersion} or later. See {InstallDocsUrl}."); + } + + var rawOutput = stdout.ToString().Trim(); + if (!TryParseHelmVersion(rawOutput, out var detected)) + { + throw new InvalidOperationException( + $"Could not parse Helm version from 'helm version --short' output: '{rawOutput}'. Aspire requires Helm {MinimumHelmVersion} or later. See {InstallDocsUrl}."); + } + + if (detected < MinimumHelmVersion) + { + throw new InvalidOperationException( + string.Format( + CultureInfo.InvariantCulture, + "Helm {0} was detected, but Aspire requires Helm {1} or later to deploy Kubernetes resources. Upgrade Helm from {2}.", + detected, + MinimumHelmVersion, + InstallDocsUrl)); + } + } + + /// + /// Extracts the first MAJOR.MINOR.PATCH token from the given Helm version + /// output. Returns if no version token is present. + /// + internal static bool TryParseHelmVersion(string output, out Version version) + { + version = new Version(0, 0, 0); + if (string.IsNullOrWhiteSpace(output)) + { + return false; + } + + var match = HelmVersionRegex().Match(output); + if (!match.Success) + { + return false; + } + + var major = int.Parse(match.Groups["major"].ValueSpan, CultureInfo.InvariantCulture); + var minor = int.Parse(match.Groups["minor"].ValueSpan, CultureInfo.InvariantCulture); + var patch = int.Parse(match.Groups["patch"].ValueSpan, CultureInfo.InvariantCulture); + version = new Version(major, minor, patch); + return true; + } +} diff --git a/src/Aspire.Hosting.Kubernetes/KubernetesHelmChartExtensions.cs b/src/Aspire.Hosting.Kubernetes/KubernetesHelmChartExtensions.cs index c0a49ebc44d..702d4de5470 100644 --- a/src/Aspire.Hosting.Kubernetes/KubernetesHelmChartExtensions.cs +++ b/src/Aspire.Hosting.Kubernetes/KubernetesHelmChartExtensions.cs @@ -113,6 +113,12 @@ public static IResourceBuilder AddHelmChart( Action = ctx => UninstallHelmChartAsync(ctx, environment, resource, releaseName, @namespace), DependsOnSteps = [WellKnownPipelineSteps.DestroyPrereq] }; + // The uninstall path shells out to `helm uninstall`, so it must observe the same + // Helm CLI / version preflight as the deploy path. Without this dep, a missing or + // too-old Helm during teardown would surface as the raw spawn / unknown-flag error + // the env-wide `check-helm-prereqs-{env}` step exists to convert into an actionable + // message. Install is already covered transitively via `helm-deploy-{env}`. + uninstallStep.DependsOn($"check-helm-prereqs-{environment.Name}"); uninstallStep.RequiredBy(WellKnownPipelineSteps.Destroy); steps.Add(uninstallStep); } diff --git a/src/Aspire.Hosting.Kubernetes/README.md b/src/Aspire.Hosting.Kubernetes/README.md index 07a9022a188..d9f3ed3b7ed 100644 --- a/src/Aspire.Hosting.Kubernetes/README.md +++ b/src/Aspire.Hosting.Kubernetes/README.md @@ -4,6 +4,12 @@ Provides publishing extensions to Aspire for Kubernetes. ## Getting started +### Prerequisites + +- [Helm](https://helm.sh/docs/intro/install/) **v4.2.0 or later** on your `PATH`. + +Aspire shells out to `helm upgrade --install` to deploy the generated chart and validates the Helm version up front, so missing or older installs produce a clear actionable error instead of cryptic flag failures. + ### Install the package In your AppHost project, install the Aspire Kubernetes Hosting library with [NuGet](https://www.nuget.org): diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/KubernetesDeployTestHelpers.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/KubernetesDeployTestHelpers.cs index 33773f0bb9f..71f90ede8a6 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/KubernetesDeployTestHelpers.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/KubernetesDeployTestHelpers.cs @@ -11,9 +11,9 @@ namespace Aspire.Cli.EndToEnd.Tests.Helpers; /// internal static class KubernetesDeployTestHelpers { - private static string KindVersion => Environment.GetEnvironmentVariable("KIND_VERSION") ?? "v0.31.0"; - private static string HelmVersion => Environment.GetEnvironmentVariable("HELM_VERSION") ?? "v3.17.3"; - private static string KubectlVersion => Environment.GetEnvironmentVariable("KUBECTL_VERSION") ?? "v1.34.3"; + private static string KindVersion => KubernetesE2EVersions.KindVersion; + private static string HelmVersion => KubernetesE2EVersions.HelmVersion; + private static string KubectlVersion => KubernetesE2EVersions.KubectlVersion; /// /// Generates a unique KinD cluster name (max 32 chars). diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/KubernetesE2EVersions.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/KubernetesE2EVersions.cs new file mode 100644 index 00000000000..bcd81655525 --- /dev/null +++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/KubernetesE2EVersions.cs @@ -0,0 +1,33 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Cli.EndToEnd.Tests.Helpers; + +/// +/// Single source of truth for the tool versions installed into the E2E test +/// container during Kubernetes scenarios. +/// +/// +/// Each value falls back to an environment variable so CI can pin or bump a +/// version without a code change. The defaults are chosen to satisfy Aspire's +/// own minimums: +/// +/// +/// +/// HelmVersion must be at least +/// Aspire.Hosting.Kubernetes.HelmVersionValidator.MinimumHelmVersion +/// (currently 4.2.0). The Kubernetes deployment pipeline now +/// fails fast at check-helm-prereqs-{env} for older Helm CLIs, +/// so an older default here would break every DeployK8s* test. +/// +/// +/// +/// +internal static class KubernetesE2EVersions +{ + public static string KindVersion => Environment.GetEnvironmentVariable("KIND_VERSION") ?? "v0.31.0"; + + public static string HelmVersion => Environment.GetEnvironmentVariable("HELM_VERSION") ?? "v4.2.0"; + + public static string KubectlVersion => Environment.GetEnvironmentVariable("KUBECTL_VERSION") ?? "v1.34.3"; +} diff --git a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishTests.cs index e8d963110a1..02960bd64a3 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishTests.cs @@ -20,8 +20,8 @@ public sealed class KubernetesPublishTests(ITestOutputHelper output) private const string ProjectName = "AspireKubernetesPublishTest"; private const string ClusterNamePrefix = "aspire-e2e"; - private static string KindVersion => Environment.GetEnvironmentVariable("KIND_VERSION") ?? "v0.31.0"; - private static string HelmVersion => Environment.GetEnvironmentVariable("HELM_VERSION") ?? "v3.17.3"; + private static string KindVersion => KubernetesE2EVersions.KindVersion; + private static string HelmVersion => KubernetesE2EVersions.HelmVersion; private static string GenerateUniqueClusterName() => $"{ClusterNamePrefix}-{Guid.NewGuid():N}"[..32]; // KinD cluster names max 32 chars diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/FakeHelmRunner.cs b/tests/Aspire.Hosting.Kubernetes.Tests/FakeHelmRunner.cs new file mode 100644 index 00000000000..721f025d98c --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/FakeHelmRunner.cs @@ -0,0 +1,73 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting.Kubernetes.Tests; + +/// +/// In-memory fake of for tests. Records arguments, +/// returns a configurable exit code, and emits canned stdout for +/// helm version probes so the prereq validator sees a valid response by +/// default. +/// +internal sealed class FakeHelmRunner : IHelmRunner +{ + public bool WasUninstallCalled { get; private set; } + + public bool WasVersionCalled { get; private set; } + + public bool ThrowOnVersion { get; set; } + + public string? LastArguments { get; private set; } + + public int ExitCode { get; set; } + + /// + /// Output emitted to onOutputData when arguments start with + /// "version". Defaults to a recent stable Helm 4.x release so the + /// prereq version validator passes. + /// + public string VersionOutput { get; set; } = "v4.2.0+gfa15ec0"; + + /// + /// Exit code returned specifically for helm version calls. Defaults to + /// 0 so version probing succeeds regardless of , which + /// is used to model failures in the main command under test. + /// + public int VersionExitCode { get; set; } + + public Task RunAsync( + string arguments, + string? workingDirectory = null, + Action? onOutputData = null, + Action? onErrorData = null, + CancellationToken cancellationToken = default) + { + LastArguments = arguments; + + // Match any `helm version ...` probe (the validator passes + // `version --short`). + if (arguments.StartsWith("version", StringComparison.OrdinalIgnoreCase)) + { + WasVersionCalled = true; + + if (ThrowOnVersion) + { + throw new InvalidOperationException("Helm version should not be probed."); + } + + if (onOutputData is not null && !string.IsNullOrEmpty(VersionOutput)) + { + onOutputData(VersionOutput); + } + + return Task.FromResult(VersionExitCode); + } + + if (arguments.StartsWith("uninstall", StringComparison.OrdinalIgnoreCase)) + { + WasUninstallCalled = true; + } + + return Task.FromResult(ExitCode); + } +} diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/HelmVersionValidatorTests.cs b/tests/Aspire.Hosting.Kubernetes.Tests/HelmVersionValidatorTests.cs new file mode 100644 index 00000000000..134498cfc38 --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/HelmVersionValidatorTests.cs @@ -0,0 +1,121 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting.Kubernetes.Tests; + +public class HelmVersionValidatorTests +{ + [Theory] + [InlineData("v4.2.0+gfa15ec0", 4, 2, 0)] + [InlineData("v4.0.0", 4, 0, 0)] + [InlineData("v4.5.1+g123abc", 4, 5, 1)] + [InlineData("v5.0.0", 5, 0, 0)] + [InlineData("v3.18.0+gb88f836", 3, 18, 0)] + [InlineData("4.2.0", 4, 2, 0)] + public void TryParseHelmVersion_ValidOutput_ReturnsTrueAndVersion(string output, int major, int minor, int patch) + { + Assert.True(HelmVersionValidator.TryParseHelmVersion(output, out var version)); + Assert.Equal(new Version(major, minor, patch), version); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("not a version")] + [InlineData("helm: command not found")] + public void TryParseHelmVersion_InvalidOutput_ReturnsFalse(string output) + { + Assert.False(HelmVersionValidator.TryParseHelmVersion(output, out _)); + } + + [Fact] + public async Task EnsureMinimumVersionAsync_AtMinimum_Passes() + { + var runner = new FakeHelmRunner { VersionOutput = "v4.2.0+gfa15ec0" }; + await HelmVersionValidator.EnsureMinimumVersionAsync(runner, CancellationToken.None); + } + + [Fact] + public async Task EnsureMinimumVersionAsync_NewerVersion_Passes() + { + var runner = new FakeHelmRunner { VersionOutput = "v5.1.0+g111111" }; + await HelmVersionValidator.EnsureMinimumVersionAsync(runner, CancellationToken.None); + } + + [Theory] + [InlineData("v4.1.0+gfa15ec0")] + [InlineData("v4.0.0")] + [InlineData("v3.18.0+gb88f836")] + [InlineData("v3.14.4+gb88f836")] + public async Task EnsureMinimumVersionAsync_TooOld_ThrowsWithDetectedAndRequired(string oldVersion) + { + var runner = new FakeHelmRunner { VersionOutput = oldVersion }; + var ex = await Assert.ThrowsAsync( + () => HelmVersionValidator.EnsureMinimumVersionAsync(runner, CancellationToken.None)); + + // Detected version (without leading 'v' and without build metadata) + var trimmed = oldVersion.TrimStart('v').Split('+')[0]; + Assert.Contains(trimmed, ex.Message, StringComparison.Ordinal); + Assert.Contains(HelmVersionValidator.MinimumHelmVersion.ToString(), ex.Message, StringComparison.Ordinal); + Assert.Contains("https://helm.sh/docs/intro/install/", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task EnsureMinimumVersionAsync_UnparseableOutput_ThrowsWithRawOutput() + { + var runner = new FakeHelmRunner { VersionOutput = "garbage banner" }; + var ex = await Assert.ThrowsAsync( + () => HelmVersionValidator.EnsureMinimumVersionAsync(runner, CancellationToken.None)); + + Assert.Contains("garbage banner", ex.Message, StringComparison.Ordinal); + Assert.Contains(HelmVersionValidator.MinimumHelmVersion.ToString(), ex.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task EnsureMinimumVersionAsync_NonZeroExitCode_ThrowsWithInstallHint() + { + var runner = new FakeHelmRunner + { + VersionOutput = string.Empty, + VersionExitCode = 1, + }; + + var ex = await Assert.ThrowsAsync( + () => HelmVersionValidator.EnsureMinimumVersionAsync(runner, CancellationToken.None)); + + Assert.Contains("https://helm.sh/docs/intro/install/", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task EnsureMinimumVersionAsync_DoesNotPassClientFlag() + { + // Regression: --client was removed in Helm 4 (the minimum version Aspire + // requires), so passing it makes the validator's own probe fail with + // "Error: unknown flag: --client" against the very baseline it is meant + // to validate. + var runner = new RecordingHelmRunner(); + await HelmVersionValidator.EnsureMinimumVersionAsync(runner, CancellationToken.None); + + Assert.NotNull(runner.LastArguments); + Assert.DoesNotContain("--client", runner.LastArguments, StringComparison.Ordinal); + Assert.Contains("version", runner.LastArguments, StringComparison.Ordinal); + Assert.Contains("--short", runner.LastArguments, StringComparison.Ordinal); + } + + private sealed class RecordingHelmRunner : IHelmRunner + { + public string? LastArguments { get; private set; } + + public Task RunAsync( + string arguments, + string? workingDirectory = null, + Action? onOutputData = null, + Action? onErrorData = null, + CancellationToken cancellationToken = default) + { + LastArguments = arguments; + onOutputData?.Invoke("v4.2.0+gfa15ec0"); + return Task.FromResult(0); + } + } +} diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesDeployTests.cs b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesDeployTests.cs index 3510288da0e..6be1948ad35 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesDeployTests.cs +++ b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesDeployTests.cs @@ -532,6 +532,87 @@ public async Task HelmUninstallStep_RequiredByDestroy() Assert.Contains(helmUninstallLines, msg => msg.Contains("destroy-helm-env")); } + [Fact] + public async Task HelmUninstallStep_DependsOnCheckHelmPrereqs() + { + // Regression coverage for PR #17491 review feedback: direct uninstall + // invokes `helm`, so it must gate on the same prereq check as deploy. + // `destroy-helm-{env}` defers the check until saved state exists so the + // no-state path can still report "Nothing to destroy" without Helm. + using var tempDir = new TestTempDirectory(); + + var builder = TestDistributedApplicationBuilder.Create( + DistributedApplicationOperation.Publish, + tempDir.Path, + step: WellKnownPipelineSteps.Diagnostics); + var mockActivityReporter = new TestPipelineActivityReporter(output); + + builder.Services.AddSingleton(); + builder.Services.AddSingleton(mockActivityReporter); + + builder.AddKubernetesEnvironment("env"); + builder.AddContainer("api", "myimage"); + + using var app = builder.Build(); + await app.RunAsync(); + + var logs = mockActivityReporter.LoggedMessages + .Where(s => s.StepTitle == "diagnostics") + .Select(s => s.Message) + .ToList(); + + var diagnosticLines = string.Join('\n', logs) + .Split('\n') + .Select(l => l.Trim()) + .ToList(); + + var destroyTargetLine = diagnosticLines.IndexOf("If targeting 'destroy-helm-env':"); + Assert.InRange(destroyTargetLine, 0, diagnosticLines.Count - 2); + Assert.Equal("Direct dependencies: destroy-prereq", diagnosticLines[destroyTargetLine + 1]); + + var uninstallTargetLine = diagnosticLines.IndexOf("If targeting 'helm-uninstall-env':"); + Assert.InRange(uninstallTargetLine, 0, diagnosticLines.Count - 2); + Assert.Equal("Direct dependencies: check-helm-prereqs-env", diagnosticLines[uninstallTargetLine + 1]); + } + + [Fact] + public async Task PerChartHelmUninstallStep_DependsOnCheckHelmPrereqs() + { + // Regression coverage for PR #17491 review feedback: per-chart + // `helm-uninstall-{name}` steps created by `AddHelmChart(...).WithDestroy()` + // must depend on `check-helm-prereqs-{env}`. The install side is covered + // transitively (via `helm-deploy-{env}`), but the uninstall side previously + // only set `DependsOnSteps = [DestroyPrereq]`, so a missing or too-old + // Helm during chart teardown would bypass the validator and surface as + // the cryptic spawn / unknown-flag error this PR exists to prevent. + using var tempDir = new TestTempDirectory(); + + var builder = TestDistributedApplicationBuilder.Create( + DistributedApplicationOperation.Publish, + tempDir.Path, + step: WellKnownPipelineSteps.Diagnostics); + var mockActivityReporter = new TestPipelineActivityReporter(output); + + builder.Services.AddSingleton(); + builder.Services.AddSingleton(mockActivityReporter); + + var k8s = builder.AddKubernetesEnvironment("env"); + k8s.AddHelmChart("podinfo", "oci://ghcr.io/stefanprodan/charts/podinfo", "6.7.1") + .WithDestroy(); + + using var app = builder.Build(); + await app.RunAsync(); + + var logs = mockActivityReporter.LoggedMessages + .Where(s => s.StepTitle == "diagnostics") + .Select(s => s.Message) + .ToList(); + + var chartUninstallLines = logs.Where(l => l.Contains("helm-uninstall-podinfo")).ToList(); + Assert.NotEmpty(chartUninstallLines); + Assert.Contains(chartUninstallLines, msg => msg.Contains("check-helm-prereqs-env")); + } + [Fact] public async Task MultipleContainersGenerateMultiplePrintSummarySteps() { @@ -1657,7 +1738,7 @@ public async Task DestroyHelm_WithNoState_ReportsNothingToDestroy() { using var tempDir = new TestTempDirectory(); - var fakeHelm = new FakeHelmRunner(); + var fakeHelm = new FakeHelmRunner { ThrowOnVersion = true }; var stateManager = new InMemoryDeploymentStateManager(); var mockActivityReporter = new TestPipelineActivityReporter(output); @@ -1679,6 +1760,7 @@ public async Task DestroyHelm_WithNoState_ReportsNothingToDestroy() await app.RunAsync(); // Verify helm was NOT called + Assert.False(fakeHelm.WasVersionCalled); Assert.False(fakeHelm.WasUninstallCalled); // Verify it reported nothing to destroy @@ -1686,28 +1768,6 @@ public async Task DestroyHelm_WithNoState_ReportsNothingToDestroy() Assert.Contains(completedSteps, s => s.CompletionText.Contains("Nothing to destroy", StringComparison.OrdinalIgnoreCase)); } - private sealed class FakeHelmRunner : IHelmRunner - { - public bool WasUninstallCalled { get; private set; } - public string? LastArguments { get; private set; } - public int ExitCode { get; set; } - - public Task RunAsync( - string arguments, - string? workingDirectory = null, - Action? onOutputData = null, - Action? onErrorData = null, - CancellationToken cancellationToken = default) - { - LastArguments = arguments; - if (arguments.StartsWith("uninstall", StringComparison.OrdinalIgnoreCase)) - { - WasUninstallCalled = true; - } - return Task.FromResult(ExitCode); - } - } - [Fact] public async Task DestroyHelm_WhenUninstallFails_PreservesState() { From f18d66b6147aea5ff816d10cc4ad62a4c0ef8f3a Mon Sep 17 00:00:00 2001 From: David Pine Date: Wed, 27 May 2026 12:48:31 -0500 Subject: [PATCH 09/43] [release/13.4] Add embedded Aspire skills fallback (#17548) * Add embedded Aspire skills fallback (#17537) Embed a checked-in Aspire skills bundle snapshot for CLI fallback, make agent init warn instead of fail when bundle acquisition is unavailable, and add automation to refresh the snapshot via a draft PR. Co-authored-by: IEvangelist <7679720+IEvangelist@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix release backport resource strings Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix Docker compose prepare test runtime dependency Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: IEvangelist <7679720+IEvangelist@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../actions/create-pull-request/action.yml | 11 ++ .../workflows/update-aspire-skills-bundle.yml | 77 ++++++++ .../workflows/verify-aspire-skills-bundle.yml | 26 +++ eng/scripts/update-aspire-skills-bundle.ps1 | 164 +++++++++++++++++ eng/scripts/verify-aspire-skills-bundle.ps1 | 63 +++++++ .../Agents/AspireSkills/AspireSkillsBundle.cs | 2 +- .../AspireSkills/AspireSkillsInstaller.cs | 144 ++++++++++++++- .../Embedded/aspire-skills-v0.0.1.tgz | Bin 0 -> 95516 bytes .../Embedded/aspire-skills.metadata.json | 7 + .../EmbeddedAspireSkillsBundleProvider.cs | 80 ++++++++ .../AspireSkills/SkillBundleManifest.cs | 17 ++ src/Aspire.Cli/Aspire.Cli.csproj | 6 + src/Aspire.Cli/Commands/AgentInitCommand.cs | 81 ++++++-- src/Aspire.Cli/Program.cs | 1 + .../Resources/AgentCommandStrings.Designer.cs | 26 ++- .../Resources/AgentCommandStrings.resx | 12 +- .../Resources/xlf/AgentCommandStrings.cs.xlf | 20 +- .../Resources/xlf/AgentCommandStrings.de.xlf | 20 +- .../Resources/xlf/AgentCommandStrings.es.xlf | 20 +- .../Resources/xlf/AgentCommandStrings.fr.xlf | 20 +- .../Resources/xlf/AgentCommandStrings.it.xlf | 20 +- .../Resources/xlf/AgentCommandStrings.ja.xlf | 20 +- .../Resources/xlf/AgentCommandStrings.ko.xlf | 20 +- .../Resources/xlf/AgentCommandStrings.pl.xlf | 20 +- .../xlf/AgentCommandStrings.pt-BR.xlf | 20 +- .../Resources/xlf/AgentCommandStrings.ru.xlf | 20 +- .../Resources/xlf/AgentCommandStrings.tr.xlf | 20 +- .../xlf/AgentCommandStrings.zh-Hans.xlf | 20 +- .../xlf/AgentCommandStrings.zh-Hant.xlf | 20 +- .../Agents/AspireSkillsInstallerTests.cs | 174 +++++++++++++++++- .../Commands/AgentInitCommandTests.cs | 57 +++++- .../DockerComposePublisherTests.cs | 4 + 32 files changed, 1109 insertions(+), 103 deletions(-) create mode 100644 .github/workflows/update-aspire-skills-bundle.yml create mode 100644 .github/workflows/verify-aspire-skills-bundle.yml create mode 100644 eng/scripts/update-aspire-skills-bundle.ps1 create mode 100644 eng/scripts/verify-aspire-skills-bundle.ps1 create mode 100644 src/Aspire.Cli/Agents/AspireSkills/Embedded/aspire-skills-v0.0.1.tgz create mode 100644 src/Aspire.Cli/Agents/AspireSkills/Embedded/aspire-skills.metadata.json create mode 100644 src/Aspire.Cli/Agents/AspireSkills/EmbeddedAspireSkillsBundleProvider.cs diff --git a/.github/actions/create-pull-request/action.yml b/.github/actions/create-pull-request/action.yml index c211663474f..d58dd17aeb8 100644 --- a/.github/actions/create-pull-request/action.yml +++ b/.github/actions/create-pull-request/action.yml @@ -29,6 +29,10 @@ inputs: description: 'Set to true if the branch is already pushed remotely (skips commit/push)' required: false default: 'false' + draft: + description: 'Create the pull request as a draft' + required: false + default: 'false' outputs: pull-request-number: description: 'The pull request number' @@ -91,6 +95,7 @@ runs: PR_TITLE: ${{ inputs.title }} PR_BODY: ${{ inputs.body }} LABELS: ${{ inputs.labels }} + DRAFT: ${{ inputs.draft }} run: | # Check if a PR already exists for this branch EXISTING_PR=$(gh pr list --head "$BRANCH" --base "$BASE" --json number,url --jq '.[0] // empty') @@ -133,12 +138,18 @@ runs: trap 'rm -f "$BODY_FILE"' EXIT printf '%s\n' "$PR_BODY" > "$BODY_FILE" + DRAFT_ARGS=() + if [ "$DRAFT" = "true" ]; then + DRAFT_ARGS+=(--draft) + fi + # Create the pull request without eval — all args are properly quoted PR_URL=$(gh pr create \ --title "$PR_TITLE" \ --body-file "$BODY_FILE" \ --base "$BASE" \ --head "$BRANCH" \ + "${DRAFT_ARGS[@]}" \ "${LABEL_ARGS[@]}") rm -f "$BODY_FILE" diff --git a/.github/workflows/update-aspire-skills-bundle.yml b/.github/workflows/update-aspire-skills-bundle.yml new file mode 100644 index 00000000000..ac28ca9dad8 --- /dev/null +++ b/.github/workflows/update-aspire-skills-bundle.yml @@ -0,0 +1,77 @@ +name: Update Aspire Skills Bundle + +on: + workflow_dispatch: + inputs: + version: + description: "Aspire skills release version to embed. Defaults to the currently pinned version." + required: false + type: string + schedule: + - cron: '0 17 * * *' # 9am PT / 17:00 UTC + +permissions: + contents: write + pull-requests: write + +jobs: + update-and-pr: + runs-on: ubuntu-latest + if: ${{ github.repository_owner == 'microsoft' }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Generate GitHub App Token for bundle update + id: update-token + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + with: + app-id: ${{ secrets.ASPIRE_BOT_APP_ID }} + private-key: ${{ secrets.ASPIRE_BOT_PRIVATE_KEY }} + + - name: Update embedded Aspire skills bundle + shell: pwsh + env: + GH_TOKEN: ${{ steps.update-token.outputs.token }} + VERSION: ${{ inputs.version }} + run: | + if ([string]::IsNullOrWhiteSpace($env:VERSION)) { + ./eng/scripts/update-aspire-skills-bundle.ps1 + } + else { + ./eng/scripts/update-aspire-skills-bundle.ps1 -Version $env:VERSION + } + + - name: Restore solution + run: ./restore.sh + + - name: Test Aspire skills installer + run: > + dotnet test --project tests/Aspire.Cli.Tests/Aspire.Cli.Tests.csproj + --no-launch-profile + -- + --filter-class "*.AspireSkillsInstallerTests" + --filter-class "*.AspireSkillsBundleTests" + --filter-class "*.AgentInitCommandTests" + --filter-not-trait "quarantined=true" + --filter-not-trait "outerloop=true" + + - name: Generate GitHub App Token for pull request + id: pr-token + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + with: + app-id: ${{ secrets.ASPIRE_BOT_APP_ID }} + private-key: ${{ secrets.ASPIRE_BOT_PRIVATE_KEY }} + + - name: Create or update pull request + uses: ./.github/actions/create-pull-request + with: + token: ${{ steps.pr-token.outputs.token }} + branch: update-aspire-skills-bundle + base: main + draft: true + commit-message: "[Automated] Update Aspire skills bundle" + labels: | + area-cli + area-engineering-systems + title: "[Automated] Update Aspire skills bundle" + body: "Auto-generated update to refresh the embedded Aspire skills bundle fallback used by the Aspire CLI." diff --git a/.github/workflows/verify-aspire-skills-bundle.yml b/.github/workflows/verify-aspire-skills-bundle.yml new file mode 100644 index 00000000000..db82170a3dc --- /dev/null +++ b/.github/workflows/verify-aspire-skills-bundle.yml @@ -0,0 +1,26 @@ +name: Verify Aspire Skills Bundle + +on: + workflow_dispatch: + pull_request: + paths: + - 'src/Aspire.Cli/Agents/AspireSkills/Embedded/**' + - 'eng/scripts/verify-aspire-skills-bundle.ps1' + - '.github/workflows/verify-aspire-skills-bundle.yml' + +permissions: + contents: read + attestations: read + +jobs: + verify: + runs-on: ubuntu-latest + if: ${{ github.repository_owner == 'microsoft' }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Verify embedded Aspire skills bundle attestation + shell: pwsh + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: ./eng/scripts/verify-aspire-skills-bundle.ps1 diff --git a/eng/scripts/update-aspire-skills-bundle.ps1 b/eng/scripts/update-aspire-skills-bundle.ps1 new file mode 100644 index 00000000000..94c2212c641 --- /dev/null +++ b/eng/scripts/update-aspire-skills-bundle.ps1 @@ -0,0 +1,164 @@ +#!/usr/bin/env pwsh + +[CmdletBinding()] +param( + [string]$Version, + [string]$Repository = 'microsoft/aspire-skills' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$PSNativeCommandUseErrorActionPreference = $true + +$scriptDir = $PSScriptRoot +$repoRoot = (Resolve-Path (Join-Path $scriptDir '..\..')).Path +$embeddedDir = Join-Path $repoRoot 'src\Aspire.Cli\Agents\AspireSkills\Embedded' +$metadataPath = Join-Path $embeddedDir 'aspire-skills.metadata.json' +$installerPath = Join-Path $repoRoot 'src\Aspire.Cli\Agents\AspireSkills\AspireSkillsInstaller.cs' +$cliProjectPath = Join-Path $repoRoot 'src\Aspire.Cli\Aspire.Cli.csproj' + +function Invoke-GitHubCli { + param( + [Parameter(Mandatory = $true, ValueFromRemainingArguments = $true)] + [string[]]$Arguments + ) + + & gh @Arguments + if ($LASTEXITCODE -ne 0) { + throw "gh $($Arguments -join ' ') failed with exit code $LASTEXITCODE." + } +} + +function Get-UnprefixedVersion([string]$Value) { + if ([string]::IsNullOrWhiteSpace($Value)) { + throw 'A version is required.' + } + + if ($Value.StartsWith('v', [System.StringComparison]::OrdinalIgnoreCase)) { + return $Value.Substring(1) + } + + return $Value +} + +function Get-CurrentEmbeddedVersion { + if (-not (Test-Path $metadataPath)) { + throw "Embedded Aspire skills metadata was not found at '$metadataPath'. Pass -Version to choose the initial version." + } + + $metadata = Get-Content -Raw -Path $metadataPath | ConvertFrom-Json + return Get-UnprefixedVersion $metadata.version +} + +function Set-TextFile { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$Content + ) + + $utf8NoBom = [System.Text.UTF8Encoding]::new($false) + [System.IO.File]::WriteAllText($Path, $Content.TrimEnd("`r", "`n") + [System.Environment]::NewLine, $utf8NoBom) +} + +function Get-GitHubRelease([string]$NormalizedVersion) { + $tagCandidates = @("v$NormalizedVersion", $NormalizedVersion) | Select-Object -Unique + + foreach ($tag in $tagCandidates) { + try { + $json = Invoke-GitHubCli release view $tag --repo $Repository --json 'tagName,assets' + return $json | ConvertFrom-Json + } + catch { + Write-Host "Release '$tag' was not found in '$Repository'." + } + } + + throw "Could not find an Aspire skills release for version '$NormalizedVersion' in '$Repository'." +} + +function Get-ReleaseAsset($Release, [string]$NormalizedVersion) { + $assetNameCandidates = foreach ($archiveExtension in @('.zip', '.tar.gz', '.tgz')) { + "aspire-skills-v$NormalizedVersion$archiveExtension" + "aspire-skills-$NormalizedVersion$archiveExtension" + } + + foreach ($assetName in $assetNameCandidates) { + $asset = $Release.assets | Where-Object { $_.name -ieq $assetName } | Select-Object -First 1 + if ($null -ne $asset) { + return $asset + } + } + + throw "Release '$($Release.tagName)' does not contain a supported Aspire skills archive asset for version '$NormalizedVersion'." +} + +if (-not (Get-Command gh -ErrorAction SilentlyContinue)) { + throw "The GitHub CLI ('gh') is required to update the embedded Aspire skills bundle." +} + +$normalizedVersion = if ([string]::IsNullOrWhiteSpace($Version)) { + Get-CurrentEmbeddedVersion +} +else { + Get-UnprefixedVersion $Version +} + +New-Item -ItemType Directory -Force -Path $embeddedDir | Out-Null + +Write-Host "Resolving Aspire skills release '$normalizedVersion' from '$Repository'..." +$release = Get-GitHubRelease $normalizedVersion +$asset = Get-ReleaseAsset $release $normalizedVersion + +$tempDir = [System.IO.Directory]::CreateTempSubdirectory('aspire-skills-update-').FullName +try { + Write-Host "Downloading '$($asset.name)' from '$Repository' release '$($release.tagName)'..." + Invoke-GitHubCli release download $release.tagName --repo $Repository --pattern $asset.name --dir $tempDir --clobber + + $archivePath = Join-Path $tempDir $asset.name + if (-not (Test-Path $archivePath)) { + throw "Expected downloaded asset '$archivePath' was not found." + } + + $certIdentity = "https://github.com/$Repository/.github/workflows/publish.yml@refs/tags/$($release.tagName)" + Write-Host "Verifying GitHub artifact attestation for '$($asset.name)'..." + Invoke-GitHubCli attestation verify $archivePath --repo $Repository --cert-identity $certIdentity --cert-oidc-issuer 'https://token.actions.githubusercontent.com' + + $hash = (Get-FileHash -Algorithm SHA256 $archivePath).Hash.ToLowerInvariant() + $targetArchivePath = Join-Path $embeddedDir $asset.name + + Get-ChildItem -Path $embeddedDir -File -Force | + Where-Object { $_.Name -match '^aspire-skills-.*\.(zip|tar\.gz|tgz)$' -and $_.Name -ne $asset.name } | + Remove-Item -Force + + Copy-Item -Path $archivePath -Destination $targetArchivePath -Force + + $metadata = [ordered]@{ + version = $normalizedVersion + repository = $Repository + tag = $release.tagName + assetName = $asset.name + sha256 = $hash + } + Set-TextFile -Path $metadataPath -Content ($metadata | ConvertTo-Json) + + $installerContent = Get-Content -Raw -Path $installerPath + $installerContent = [regex]::Replace( + $installerContent, + 'internal const string Version = "[^"]+";', + "internal const string Version = ""$normalizedVersion"";") + Set-TextFile -Path $installerPath -Content $installerContent + + $cliProjectContent = Get-Content -Raw -Path $cliProjectPath + $cliProjectContent = [regex]::Replace( + $cliProjectContent, + 'Agents\\AspireSkills\\Embedded\\aspire-skills-[^"]+\.(zip|tar\.gz|tgz)', + "Agents\AspireSkills\Embedded\$($asset.name)") + Set-TextFile -Path $cliProjectPath -Content $cliProjectContent + + Write-Host "Embedded Aspire skills bundle updated to '$($asset.name)' with SHA-256 '$hash'." +} +finally { + if (Test-Path $tempDir) { + Remove-Item -Path $tempDir -Recurse -Force + } +} diff --git a/eng/scripts/verify-aspire-skills-bundle.ps1 b/eng/scripts/verify-aspire-skills-bundle.ps1 new file mode 100644 index 00000000000..550a5e34b03 --- /dev/null +++ b/eng/scripts/verify-aspire-skills-bundle.ps1 @@ -0,0 +1,63 @@ +#!/usr/bin/env pwsh + +[CmdletBinding()] +param( + [string]$Repository = 'microsoft/aspire-skills' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$PSNativeCommandUseErrorActionPreference = $true + +$scriptDir = $PSScriptRoot +$repoRoot = (Resolve-Path (Join-Path $scriptDir '..\..')).Path +$embeddedDir = Join-Path $repoRoot 'src\Aspire.Cli\Agents\AspireSkills\Embedded' +$metadataPath = Join-Path $embeddedDir 'aspire-skills.metadata.json' + +if (-not (Get-Command gh -ErrorAction SilentlyContinue)) { + throw "The GitHub CLI ('gh') is required to verify the embedded Aspire skills bundle." +} + +if (-not (Test-Path $metadataPath)) { + throw "Embedded Aspire skills metadata was not found at '$metadataPath'." +} + +$metadata = Get-Content -Raw -Path $metadataPath | ConvertFrom-Json + +if ($metadata.repository -ne $Repository) { + throw "Unexpected embedded bundle repository '$($metadata.repository)'. Expected '$Repository'." +} + +if ([string]::IsNullOrWhiteSpace($metadata.tag)) { + throw "Embedded Aspire skills metadata must specify a GitHub release tag." +} + +if ([string]::IsNullOrWhiteSpace($metadata.assetName)) { + throw "Embedded Aspire skills metadata must specify a release asset name." +} + +if ($metadata.assetName -ne [System.IO.Path]::GetFileName($metadata.assetName)) { + throw "Embedded Aspire skills asset name '$($metadata.assetName)' must not contain path separators." +} + +if ([string]::IsNullOrWhiteSpace($metadata.sha256)) { + throw "Embedded Aspire skills metadata must specify the release asset SHA-256 hash." +} + +$archivePath = Join-Path $embeddedDir $metadata.assetName +if (-not (Test-Path $archivePath)) { + throw "Embedded Aspire skills archive was not found at '$archivePath'." +} + +$actualHash = (Get-FileHash -Algorithm SHA256 $archivePath).Hash.ToLowerInvariant() +if ($actualHash -ne $metadata.sha256) { + throw "Embedded bundle SHA-256 mismatch. Expected '$($metadata.sha256)', got '$actualHash'." +} + +$certIdentity = "https://github.com/$($metadata.repository)/.github/workflows/publish.yml@refs/tags/$($metadata.tag)" +gh attestation verify $archivePath ` + --repo $metadata.repository ` + --cert-identity $certIdentity ` + --cert-oidc-issuer 'https://token.actions.githubusercontent.com' + +Write-Host "Embedded Aspire skills bundle '$($metadata.assetName)' verified against GitHub artifact attestation." diff --git a/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsBundle.cs b/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsBundle.cs index dc47c324cce..49c726ea960 100644 --- a/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsBundle.cs +++ b/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsBundle.cs @@ -302,7 +302,7 @@ internal static string NormalizeRelativePath(string? relativePath) return Path.Combine(segments); } - private static string NormalizeSha256(string sha256) + internal static string NormalizeSha256(string sha256) { const string prefix = "sha256-"; return sha256.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) diff --git a/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsInstaller.cs b/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsInstaller.cs index dbc2f1cfdd5..dec2cca8e41 100644 --- a/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsInstaller.cs +++ b/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsInstaller.cs @@ -6,6 +6,7 @@ using System.Globalization; using System.IO.Compression; using System.Net; +using System.Security.Cryptography; using System.Text.Json; using Aspire.Cli.Interaction; using Aspire.Cli.Resources; @@ -21,6 +22,7 @@ namespace Aspire.Cli.Agents.AspireSkills; internal sealed class AspireSkillsInstaller( IGitHubArtifactAttestationVerifier githubArtifactAttestationVerifier, IHttpClientFactory httpClientFactory, + IEmbeddedAspireSkillsBundleProvider embeddedBundleProvider, IInteractionService interactionService, CliExecutionContext executionContext, IConfiguration configuration, @@ -46,7 +48,6 @@ public Task InstallAsync(CancellationToken cancellati AgentCommandStrings.AspireSkillsInstaller_InstallingStatus, () => InstallCoreAsync(cancellationToken)); } - private async Task InstallCoreAsync(CancellationToken cancellationToken) { using var activity = telemetry.StartReportedActivity("AspireSkillsInstaller.Install"); @@ -80,12 +81,24 @@ private async Task InstallCoreAsync(CancellationToken if (githubResult.Status == AcquisitionStatus.Failed) { - activity?.SetStatus(ActivityStatusCode.Error, githubResult.Message); - return AspireSkillsInstallResult.Failed(githubResult.Message ?? AgentCommandStrings.AspireSkillsInstaller_InvalidBundle); + logger.LogDebug("Aspire skills GitHub acquisition failed for version {Version}; falling back to embedded snapshot. Failure: {Failure}", effectiveVersion, githubResult.Message); + } + + var embeddedResult = await InstallFromEmbeddedAsync(cacheRoot, effectiveVersion, activity, cancellationToken).ConfigureAwait(false); + if (embeddedResult.Status == AcquisitionStatus.Installed) + { + CleanupStaleCacheEntries(cacheRoot, effectiveVersion); + return AspireSkillsInstallResult.Installed(embeddedResult.Bundle!); } - activity?.SetStatus(ActivityStatusCode.Error, "GitHub acquisition is unavailable."); - return AspireSkillsInstallResult.Failed(AgentCommandStrings.AspireSkillsInstaller_GitHubUnavailable); + var failureMessage = embeddedResult.Status == AcquisitionStatus.Failed + ? embeddedResult.Message ?? AgentCommandStrings.AspireSkillsInstaller_GitHubUnavailable + : githubResult.Status == AcquisitionStatus.Failed + ? githubResult.Message ?? AgentCommandStrings.AspireSkillsInstaller_GitHubUnavailable + : AgentCommandStrings.AspireSkillsInstaller_GitHubUnavailable; + + activity?.SetStatus(ActivityStatusCode.Error, failureMessage); + return AspireSkillsInstallResult.Failed(failureMessage); } private async Task InstallFromGitHubAsync( @@ -167,6 +180,127 @@ private async Task InstallFromGitHubAsync( } } + private async Task InstallFromEmbeddedAsync( + string cacheRoot, + string version, + Activity? activity, + CancellationToken cancellationToken) + { + var metadata = embeddedBundleProvider.Metadata; + if (metadata is null) + { + logger.LogDebug("No embedded Aspire skills bundle metadata is available."); + return AcquisitionResult.Unavailable(); + } + + if (ValidateEmbeddedMetadata(metadata) is { } metadataError) + { + return AcquisitionResult.Failed($"Embedded Aspire skills bundle metadata is invalid: {metadataError}"); + } + + if (!string.Equals(metadata.Version, version, StringComparison.OrdinalIgnoreCase)) + { + logger.LogDebug( + "Embedded Aspire skills bundle version {EmbeddedVersion} does not match requested version {Version}.", + metadata.Version, + version); + return AcquisitionResult.Unavailable(); + } + + var tempDir = Path.Combine(cacheRoot, $".embedded-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDir); + + try + { + var archivePath = Path.Combine(tempDir, GetSafeFileName(metadata.AssetName!)); + var archiveStream = embeddedBundleProvider.OpenArchive(); + if (archiveStream is null) + { + logger.LogDebug("Embedded Aspire skills archive is unavailable for version {Version}.", version); + return AcquisitionResult.Unavailable(); + } + + await using (archiveStream) + { + await using var fileStream = File.Create(archivePath); + await archiveStream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false); + } + + ValidateArchiveSha256(archivePath, metadata.Sha256!); + + try + { + var bundle = await CacheArchiveAsync(cacheRoot, archivePath, version, cancellationToken).ConfigureAwait(false); + activity?.SetTag("aspire.skills.source", "embedded"); + activity?.SetTag("aspire.skills.cache_hit", false); + return AcquisitionResult.Installed(bundle); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or InvalidOperationException) + { + logger.LogWarning(ex, "Embedded Aspire skills bundle {AssetName} is invalid.", metadata.AssetName); + return AcquisitionResult.Failed(string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.AspireSkillsInstaller_InvalidBundle, ex.Message)); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException) + { + logger.LogDebug(ex, "Embedded Aspire skills bundle could not be staged for version {Version}.", version); + return AcquisitionResult.Failed(string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.AspireSkillsInstaller_InvalidBundle, ex.Message)); + } + finally + { + TryDeleteDirectory(tempDir); + } + } + + private static string? ValidateEmbeddedMetadata(EmbeddedAspireSkillsBundleMetadata metadata) + { + if (string.IsNullOrWhiteSpace(metadata.Version)) + { + return "Embedded Aspire skills metadata must specify a version."; + } + + if (!string.Equals(metadata.Repository, GitHubRepository, StringComparison.OrdinalIgnoreCase)) + { + return string.Format(CultureInfo.InvariantCulture, "Embedded Aspire skills metadata repository '{0}' does not match expected repository '{1}'.", metadata.Repository, GitHubRepository); + } + + if (string.IsNullOrWhiteSpace(metadata.Tag)) + { + return "Embedded Aspire skills metadata must specify a GitHub release tag."; + } + + if (string.IsNullOrWhiteSpace(metadata.AssetName)) + { + return "Embedded Aspire skills metadata must specify a release asset name."; + } + + if (string.IsNullOrWhiteSpace(metadata.Sha256)) + { + return "Embedded Aspire skills metadata must specify the release asset SHA-256 hash."; + } + + return null; + } + + private static void ValidateArchiveSha256(string archivePath, string expectedSha256) + { + var expectedHash = AspireSkillsBundle.NormalizeSha256(expectedSha256); + string actualHash; + using (var stream = File.OpenRead(archivePath)) + { + actualHash = Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant(); + } + + if (!string.Equals(expectedHash, actualHash, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException(string.Format( + CultureInfo.InvariantCulture, + "Embedded Aspire skills archive failed SHA-256 verification. Expected '{0}', got '{1}'.", + expectedHash, + actualHash)); + } + } + private async Task TryGetGitHubReleaseAsync(HttpClient httpClient, string version, CancellationToken cancellationToken) { foreach (var tag in GetGitHubTagCandidates(version)) diff --git a/src/Aspire.Cli/Agents/AspireSkills/Embedded/aspire-skills-v0.0.1.tgz b/src/Aspire.Cli/Agents/AspireSkills/Embedded/aspire-skills-v0.0.1.tgz new file mode 100644 index 0000000000000000000000000000000000000000..e25af3228df2f9c81ea780a9e1853e5532e9fd0d GIT binary patch literal 95516 zcmV(*K;FL}iwFP!000001MFMda@d0NoAVS{F**aIE`*Y@iB-tgOtFd9tcayC|D5 zYu}bt)y~fHy?l@CfAMKQIX+T|^q)vUrvF!;FNmPI;6+{#{zXoBF3A^}_{4JD_*u4{ zX|n8#W;vgS=JtN?YX6OX%uVaxU(HU<{=a_VWIrb*DrB_Qp==^UE-LZ*l1J*Z#5L4Y)ryry)H#3^NXj`#!X;DHq)+ z=T~37dGYGIZ=bz*lO2Bd^x3nozRG6V>v}P(!da-Yre1cT$zt7P2mL@0l~_GEos&dbfn^KicYZoHp# z>cVkj(i&uVy4!DqY20Rzb2AxaSTMVhXwh!kOnYK$C^dsyZSK~ttDE)Za^X$4dAzER zHv`mNsCKj2tem@Q>BH=#^ggUdDkCQPrtD6>Y3lPfG|&1+9bCOW-Mo8$rL)Y{rvN*T zK6}Fe`=_;ER#@UDxEkJp;a*ne;(S=@KfijGR&8j33BAakzj(b_^?5y?jVqs4FIzPA zkG+lEU$$wI-8{e+(CoVE;=yiq@E^;hou)o3lNXlrv!;bWykcgAib2%5~$*xoIvkyDTfe53}vE&a`h~`cI$#<;8bT4j+z6 zn@vri*Wh>`u5gm9r(gH=-NC+2!+ibB>=eRd0WpESq>1eC>GMB6dy>6)`Q+8X>!&ZC z<5jPc=d35c@}N`Gxsx>SjT3Dgzic0eXqHt6x9^%|SU12~s?r%-g=c0C7d;L=ocwL` zZp1x-|Gr*$pdNV*Qt8lsJKi1#S+)M^cnAC(AnlJ}& z?2j5DY{q7`3Fa+kH*+VDGqiiBem%Q>oc6>}QS_jv5D+7YxipM=%6JUa@*qQ?N^7lz zmO?W|r3_p<<|&URL~fWC#ubIUw;uMF-S?BZ2hS}M1#Ywz)SGC9V=72Ond2hlPB5*4 z=Az)z8=;-zF+yU?Xvk%u7$?l;WFpZ@y*m^6Zh`%Yb$v(#_~(EA4*)$qx$>L@@>y)) z_A3yEXNRiC?#H!zix}~!K85E1XTDq7(7-oCJNjhup6uYw;a>LUPfwmd%zpUchkO3& z@ag7m-!ww%x~=KTXyEso9^=D!_u%u7RxX^RoAf&lxCiW;h;-9%9ncqeaz0 ze9gm$cyq7@Tg(#yh=`J508Qx_gK^KMC_5 zoGypO@DoBw`FjwX4eTyv=lElmi)x~OtOz0W<- zf;gd=`EbXcik22>8|vhqhN2+e5goN=))s{pRz`2(`Z8Lsbx{ZsgARn|+NucoF+LpJ z6_(veI9EEqMYfzfS?pb$R<|O%_ksj4^THZpu#lcQ8zq)FCq8%D=2RHW1nMO& zgqR!WY@rM(l=YJ6PcL(Ku^1tKo7~Y$R^*ZfOBst%sYqayP(0U&i(FyRy$-R^niN5% zhzk4Bvhd2#7@4?}+&`K#Gm5yyW*JqC7J|B*5)+`xs5pmxDpoLOm4!ML^AbM93#kk= zoqXOA*tE4I+)2W>Ybf3p)h(5I^i)Kki*tk*oRC~v=Zq2|3hlwcS(2Sb=a^JNGHSck*i=_C^(JMT1IlLF~En=g0sNW96bC>0YvS`CPFDG z|1c8#ZP~Ql47@6fRej!5U~ssUU5{}W>4)j*!d~{WbZ^`2XhZvsvirwP0RJsRFy7H5(Za1@a-6hhq81L>*Xw`(t zj~U05Hm$NZf0a#Mr;4lbhUp_q%FKKt-KuqHW0!6aU{&lzZCYGFwXv)aQiz ztK@Xrb(1clU!vFhcr2AYrkysV4xN&|u&k!bOa)fsFR;9Al6e&M#*{O=KdfTEd;`aS zFyWcm{bVA^KukUCB=-$1b-Vj zW}4^hyl&nm&)$Yr$SV}lJ{l&1AO;zTWH#Zs@P&xLaO)%$ieL!~0SX6p=>m8qbAWtR z+zH;haJoR_*7$=wLg$P~%5oEl5RB(MiAX7!rerxc!V^m@Q?lS7ma@=_M2@VKF&>ov z^zaUy2xQzc*Iq*ko=3ry)127cFhoep9iScznB@*+IOnd=E)SL%qL}dEBjJ7HU0Xo@ z;Q7I`e?NSBI05+=whhf$n9|&bV})$+;oa;sbYN)h?gnIGx2c}i!M{@D1&}5cc!708 znQYTClsOZsN;PMg`#og-M_#m-bKSq75%-C>ptwW^JW|C!@C(4m(m z4Lkr30CJ$gYXMJ!sk)QvQQ%tX?}j_8X;1I93o~!ut01}3#Le}|>vhJJ#*!R>pUnd| z)E$A1+0%KOGM+a3`?9YlCjBgGKh9%Ays?n|z3ZXk<1v4Kto`1O{i{IZY7bTT5^`4M z+i-O%_a9sjMK=^LZ>Uz@Qi>QlP0S;^f7vwff|Sn!yt99qR7+Oc+!`E`^-hA&SmWK) z9#j7D_azdbr0^6xR`!AG`t5RYTSelx6D}r-o!O@J$J;2Zu&E>wBMmtTz+EaCgGX7% z1gD_>Irt2c-e_VWxePj_fZ_%C0*xZ_yV1c(<#-E3tDGSOFv3;grByPjnBL6Tgl9qM(e>SEoTO`#)c&iXfkordvIFAvU!WRS9 znv`sr1aplXcev-eK(GM^(|AIF>U@UqCbe?lOiCJ_3l)7NIjANlX`wA<$!+1Vs8Mp8 zThEx3(y(9xD2~;ZijNF$mw@R*E4kmBIo#=LwXiB?QxA`%@a2b1to8DiqyVgOV) zTW!Q{k ztJ@-AG|IEcO|F7;*4UWKLW0T&$64C0;|jbiMbZ#CQF)x&N?=5#)p`J~u)bqc9O_!3=?(Xjst6T)9(ZQ1RAU(b*0e%E%a z1R}4P0|!#pfk2d)a-HJ*?u~jqPvx?C_zCYm@cucz{9AjFDvY@LOWB3~`!&)y(HwyB zB-Pe>_B?)1Da~?T#uDvGDNp8SC4?D{W5CH01-p8dC9TmeqW^KfwY(SRXZ>5+&Anx! zv~6%r=x$=5)56>(GMOfNy)3!EN`tmh)eT4ODeF6_>U)$aZ%HcpG-1pvhMZzk5J;Qh z&~2Hm4m71nOo;JlDZQ2NW>*Y#UA%seN@Z_k2amox_~WE5In?O0dJgr(I8APT^PA#> zxz0unZ2@?xSx`I%p%M#4x>0ah_yU+OB(e^fBMyNzG0;3|om5Uq6tcaN0IE_@vYfK_g z@+)me>yX$x*H9XWp70bwM{-ZS^2CD(hTxnw&T`5e$SSASGOw5o77Y82_20Oxs##lC z%VfZ_(E_#w_uO&?#_44qK!B+<2=dZ61b3@*5{!^qst}QiBBf%5p@{Yh)8f)mD#J(Y z_1(CkJL?aGZmC%ZCSrkg6vhOkmoc4K_SD$IXfADZ+NnSUbyVOZwIK}YfFxR(;H9{m ziPj6i->N#hO-LGqMvj*1_^>>sm_w?b`-r?A%bpX7X*j`M%IO^vXKHDVJSyd;EULTd z5}_3fKKHXRQP?IY(Gp2y;o!qsk{szAY_o{Ah=%*zBbOpBFGNs=Dh1JD%3f%$tYKL5 zyCumZ9iNTlbZg@2bSOw+a?0S?d4WqIb6yakbe=Qn80Ff!JizItL;`4yEU1lXf5WkG zw+wl69^$>Qi3jK?$E0AIoWv+Jr7>stZxkaWL4s7)5(h^z%nHzx7+HXwG8cuv8#awO z+B>Kb$(ew)k(g5&kegd!3*tr0Mf4s7G87;pa580c=S6=6fB*uNq7e^$#Hzn5jn~Xb zE?5v2-W(!Tkx*GE5`qXYO%m{Ok&1Q^mI&6{la3$@rl4Eu;;m`?PWtxC&j0oC{&#+E z|NTe%$$kd(;lKY8Ow!+f|MP2nwypoQ)cPYW;)Bv$}V7 zW6P@SE`SHUvsD*geYKZ;+simAD51da@I%Q|9Ze^~Frm{~y9lm~r5k=1vIhaa%pOlv z{7rvOq2K>~a{_Xay{@xonD5Tc&)M^Ufq%{>wN6~U#yI%*XjI?+_!G0AcYdDrpYh+N zi%a*0J3DEJd0SV5Z)3z~{h=lw&hUMrj-S`HyuRNbR$%e=~*fmSn!8rVTkF$o4 z?60fA{x%Huc=FTGvF7={~j(7D479f1klt_IuRVVzZ;G z2zHc}ak{louD7Zm^lyB9B~rZ4JI9{a)Nn%_Nkhy5Mw?^44{n{w6pAq&SavvuthAIy-(T%9OoW+=e7I~P zR@!H0rCIN!B;ix7V0>Y}m2z|qG6z+x5O#m^>oI(dsivQH^U-5fd%*W=7@hUGB3l0W zU;n+*&!qWi)t=rojluUfaVCB1jVMj=@fu;#`{%9rnigc$KKLR=`ub>hb9arH=@qNi z8wNtC93uY+f`hm>J+9NCgXf1Y(;ivB|CC|H7XQoMzcoj4WNCun zJo~S(L6l4YNWa9LOpuIh5P=|(i~vvo$mrax6uQCP02ssJ?&;&5 zP2MC<79GF%hhY5R$r5^i*$Q(9RoMJQkHQ7g(~a%gl!VEircB~*l0(VcgB{c|8BTqm zDUqMVcP*EQBaTF8Yjg7j91VCxf$%DdttQ$B!>}TVg8UQ{qCo+*8i6};qE)-O z`J3envfFwjO_Rb3?V^5k^L8C+R*+;I*P8V)|b3Z`K|F-Jv z(cuXk9F$=WE9naM>^UNa^ad2tD8fm>#EWokZPDthpIs=|L8}F|EVO0FkkDOU;qt1V z?^ao$^C40e&Mc}3< zC~^)u9nN@odh*qODyLJNeRl{~<9v$emCQ*oMMGhnkFKk-q8Z^sOcb*U6b6~xR?Dkv zH`(E0CbDGr$NX|SGOAmksoey+TC)!3)VOLsgb@NMHY{edSZ&LvrMx(wg29iS&e>!H ziw9c+eXhta9nKp*j5_o3vS@D5-rh=RdD5y*{K2#sd(B3~cW3FmD&r1`+J1lH_~T=OhHxRdLD`H%nQ zzx~($0}T^Q13DY!sOT+kG(R0YeDt{a=}tB3EM_Bo`ahy&i>Q{A#2HHm-}nWmWFK7 zyS~Cot*qF+uvcE>#mau$%el7dMfLmWWoMW9Jo{}m8K>K>c`tAAa@xiaX8W&HYCIf7HT7}`~lXd!O@mhPk;D3`i(K7Qcp zZ>UlnWG(y`fg(nVpMudcveL|5-D#LgkMXBe(iWbU<{_8)>@74egIA72=-O&4Z=;)?Tol?rJ&sEeb^|^b z$2cn^(lN_1IzK2xirBA|0gMb8pm=mZ=RRGWpQA3~`cjp{Vvx^zi9W$|T(`0xCa0hj zcEFg$xui7Jz@}akqk4@&^JY|0X^V#yTRNG6GRArl;oeA3z=ulODzvviIxGabUFHZ= ze>WMuL;Ty>EO$DNRat4~Ps%B4z}pB=2A!eb?}H4!Xx+>H@}K_K?At05A$09!j!Z$& z-F!wM_H|(?D~4O-JM|7#QrS0JOQljvo<&NcaXnm57BYCo3xVEi+%mL14S(^zV~15YZt#N=3eab)cJQEgBo@Yb3j8vq;0L; z{TGJ^d;2F}{KI-Y1d*;1b*(dl-nl3J{=px*aDv?5Xa&F^U&9Suxv7V%fYirt#>i}AeS>?n&QN`%f1w<@zMLYb*9|HB zImsTQKc=)t)*nxf-EM!ef1I6xo`Xwt{rC4CKKP(fu zSdZ6zkM7ur^@M)5wm-?L#Q;P(JZ6nnZ-28pEZ%k9F^sYl2ecm_*#4kCJUBWLX$%Vs zG+R`n`b1-7#5i{R*~15)+^yqy2X*}5WcL-P%-~9zVS>|T`6mz@AQ|1Yt$zDiI+tY&fB7 zpsRax6Z;7Air)6&0LfE4dCvIZ78!u#$W`L^R5^9ss4e$ah=)9vRhQx&q;@U4mV-v zdgn)nJE@zVJbw6zUi0534;LF zz`?PYjkCpg4)zotE*LLEugdz)d^YOrfGWX z-Hea!>Yi!!W*&ctQvxtCI4E!fP)P75P2WBtTH4nh?=tq zC+K#=7Q5`Upr6cuMQz}iu=1FKiOyFZ$yR-E-}6zt0V$@acUP3qS)S7v&_drn+(RRs z;+@b5CxnXbdAA)S27Elbyu`#(Y5@ii!@y|VVWuWDhDix9!`26oVFTx=fa}((AKCq_ znlx)3Z9Ob5ul*0*g8g53bY$+?+1fw6*gpRH%Y*Hs7vDne2Z|9|jC|^70L%NU)*C}k8{Dtvkd#ds z6@i2T^E3*Qag0a!?2-+ zArVOn#snv94qb_1|33?fDw+b0aDXF~Bqk}LK;>6857Z!pvyUWPepb#<1i3LsGN2O8 z6kIacqaAXTTicqn;BF!oIT?qKkUiY@SyJn3`k=LqL<<~aCBr(=ypUdL|0!6pxoP>R zC@v(C5>ArSxaS6mZ8)h=ImQ@4_(r(wC`6_4Gmbf^h3bb94s2NPmrlw@G$OJT>Es*^ zDUJmT7Bv+@fMBXS%0Q4!lR-%uxjXD@tDbtC2teqC9pf@*IT~hf!Kfn#$L8k#WR5@(5dfIpFvHOYEq9ca1L6;x zOglumz!TuMDm<3*t|n*xeVx09R20V%0K0vRxomsmQ8{*3;cZipFNItBB>ON?3N{qB zds+}3@nDu?&_kwJT>aC0T#UN(4o3yiDr>t!T;QZ8E^33KVx0}SpuM#C4E{-NutLGY zTe^rkT@~&M)yd^oT3_({)y2fy-CZ=g&|(_VfQU>R*N2j-aleW*pN z?eG3r3>Nc3Vmkcrz89kgjm*(T>NlpH1Mnom}ZK(zIu%lOY+Zuqv;{T>?Bg zkb}IgdpZiNgh0U^TwxoI!wuysj+X;H1@Z+EQYT(YlzakjGM);ayUB^7&47{fmp)dk@QQ}Q2rnqq1$sCs>Q(UOjHbl_Iy`B)ayoOfDX=gVjZ6S+fz9ftaeJ?0qkDVD^Rh|837dUiVc~Yz&b_y{o2qo zO6Luqo^c1`y8BuP`;IWL81M(+c1v3p)`achG+8f}tG?kT#1C zBflu7Xdda+5A#ko)A^i}|hO(3~aC@c3=*_sGS z3sF}oh*#^T4m8NNU_NY&~$(OmT{aKbBz%F~jaFUnyKxAB&9=!puor}qcY zpbX~Meph)xD@YoN_~QeDt_BSUzi@*m#+O=Nlg?Wf}XRsQal|6^w94$ruI{@3G&&py`w`bGZUd;XL1 zCH;(B>VG|Z^f=D{`s5M({4xLQ7x-(;zHo%*{t?Q#w8rAfS z$!y?Y@Qy|sI70nf0t)PHP%!XII1EAIn$5|-+S*f#m>wmt;FUnZziIvD&;JYj|9|?+ zpZ{n0zwCFr$D}zNseRD|ZePcv%&BU0Z2Nqq4Qchy((3*JHEOWM5wbX2M}6PoiZ^~k zIpf$4|2OU-hKRlGrWXg72FWY@hj*r7lF1h0%$2YOXVfI`Jz-D3{fqCF_(1Z>lwh*- zZUP-9Z;tX%qMIG)AmgNy8a4y0Kut19HowanjMvYC=2^c<-uRMXM_~Ul+Oc6cW5gt^ z4g-e_TK=<}sKB)!zp)KCWob}WwA{?nb%yVT*&6r`R42%h9r)K|4c^o#(hP-lFc>z> z4dLhO0iaIhMB2^A-uuzHOtP{NdB}u;vkbQ#H1}!*Kf;Y|jH-OQ|3+QEThZ zVOyQ6AauZ~1h?2fw#~%gA9#rWa)AGx`t7DxvOeQ&`6q9HX<{fj(qKB^x2xh*zRk#% zXlng6rv%qF{hu8!^k@onTn)Az=+?eZPjZorXqqgGVRL(gdHV zsXxc#9m;NLYKj4?sM%@7Ay3X-)f|HtCa6bdB8KISv-6lSf)bd~bJ=Sg?r5&3{Ip}h zflG(QE)jMoNQvDNR4c5Ky*Z)M>b_U>3@yf}_$Z|zX*8i1);M7|H|q{qbla`uf)yVw z7m3_E+<|R{mzC8e4OKZuUnB;$l+jHzDu)0cOfc{7CAGuF z@cu?JJfv9e&z9$rXWmoal=6W?e+?6<`RTt%3WyCdH0Dw?<7BFzHI*WnWbl(^Rn+nd zVzhgRasNruEAE#5z*nOv{5f(UM4_MlRH+b3nGv_(u09ky5LQtF*DSVh0r~0oBzC;JF;UBB# zq3V+i_F33RxT+dK!0Jvyom4@{TwnF)?_}762CZ)?U zxnPWy@bq^>|2i!1O6Xs~NfF#P#QARA2K4REzvzDu+Bd{0F9Z7~K)ko0cL_Xrmx#Ip z05w2)*^Ou8=2sUnUXHW3nlYvu2!Pkl`xt%$3b($0y8K>0JD%7zQQaL^9N`Z55wuA7 z+kZ7+V!|O2A5;wkd=~_WO!c+Fcv16O719Haqb+vGy3?hP-0hEw2moGp@#+Ov?`92K*0p~g|$JR$z{-B zB=u@W14kLTKOeI9#;;V5yYY$)XV*Av$h{=!4$t_nL7fFNow?g$kI(%o@F?7tzHGfc z?pQ-X#SqkA7?Gz|fw;`nZmv1T#EnECIwFjb!3dFeTbLyDvm@{7Zh!{8p?=}ICJQc8 z6Hug-GfLdq2(f%Tg7DS}oL%vw77JMkXy;GrS0(7!WRKDGNXj#LE8TE~Hb8iWuXtkD z4i@rGTW5dOEhq8Oy6PeQeyyW^J`)NCb4a@r_dSoy0{Yivhes?K!v)6$;dda6g}a1S&2=giV3$zfl=9{N0+px{mO zUPgZcXm!@QV;yP??R7fHQ~*2gFzcLWM_)yra1G|&1fy#McyNPT7)QIu2j3p;?0&!Z z!f_BL*3>OCg}W8$rU-$=->iM}kFVAudP>Jnqtp6&=a2y@BzB|Fp7cjKF3@Km^+SH@ z;7|NwI39f8Dp5afPIXh#k2*uRLsq8#Foi*Y@hfJ*f~3`QIGYR?tnPWG8W+sAntGg@ z1!f3MZ>uGzG@61PXQ5QV5^%#$ryXWp(QY=E=oO@%kv=l4>9j8_a$nJ(VHku|-uWb) z^T0@LON3Didp7a?rMjFItx(G}=^feW3If%A6G-yIe+a^a1Uf`Hc}eEX_= zSGYyX>FLOrV#ecjK1EzTy8-8u=>(d*2K!`xVw1B5cAO3K0nG*vI@dU3G!(O^OeiCs z;I?dKc)(HjUkCGrW|aMu3>c0V`6Cs^zkVu=25(n&fgzC+#$xg#XT7YF@nT%&3oxc; z*IPm@J@?eo|A-QX>24jQz*3(!C9}jj+}fLqe15-D$I*O2S&Em}e#(BcmI$eV*@5dN zO^NJDkVUg28m|$pmgvCdBdnKJkjnAm=FK!hB5zDWQTbjp_fpxmXGQi zBEOOo1u4v&r;Hw6i><8FVJrkJ!@vX#$y?m4t3KZgN2S-4HnUcPE>f96Vd%6Y;)A0> z%W{18GSI3-<)U_*X{>cQnb%}_lW^OJCy4Vp_IukXai(6gx%rr{@r(SuH~v3J16ux! zTk!u6o;`RN;s2jLd-lmk{QocUcP}XX@GUtR%gXz_=6UrtbM)Ib$sdm!_DPI_S}z9B z0`dk+Ym#Hnh*F`jlAO`4Y>(~QlL=|Q8Hv)p3Xv5Ihy(pXYC%AUc3qHvdsb+Vi^`;> zyS@{hGV&qP(J;~8XM@FThEb-d7!6#{8D!jZ%so>aMF)vC=r!DHlizvF4qG%Q69H^F zCG@jX0hTc0F`wS87ep8ca!h?w6jK=r86c3Tm+(qtM15 zG3AK)4lg%j{Uct*$Btth*6thT82=LX%%0&H|B{DoS9r#Ez>HYFgmwGj(DDXC9=MDA zU5Pwdd=0`U?4(@gZ9j^+J|hn#_Q>#ZtIiY+RLY9Pc)Cp-4#8m;cL@OF`G?hYpZNUS-U3gZZK#(g|qxB2xjtvm`=bD zATp+KKMOK~inlu^c)7hJw6sVJFXhRJy`Ezz^&V*3wD@RR92YNoycFBwM22z@T_8*( z7nmoaH1VQ*JeOU@W;3=>VbLNZA9(yIj3-RJO7C)tz=}a$VSVumwDJ-QdqPitM3&h( z?pI>4hc1lZ)&eP1)-RcC1Y^B(F*7ipPxVO`g`QG9qBq}rVGEL>e-l}D3&f_*udz`4 zWGY{8`1l!}D|v-U#TfUpF#Hb;EKIKHZ;j*(NgkC$qm95!T82wl+># zIfjX@LyR&VUo<(4+=15_`Gf7VY;sx7<$AQvab^tbZ^zCzM$Jx1He*&MXswY7}WSYv38DCC^B3>C<)fp9Y&!ErSIUDI-lb)06o8)KJUU!U`LA+19#wJ zO5H@%IlC1n7;(VDY4DQn`8|eLp>OH8#|QiPS_MvB$j0P9F^nFB5R*D%_2za0A z-ocv0Nx-k?gD%yggD!~dM!9Y}F&#noJ0$;_x?+wE88uv0l{D++ebn-@*9+lc(EB+> zXJEMzGa2tqc>rq(D>htr1Tj8`PU^V%4#N;sbP4U1tQzarXp@*FFoc~*R&@Eo3{^@z z4++9B&W1B0WI|5baOB&GA!FR6zGqO5re@)IH^{4UjCnw=779zYItHW;cx)$KW9>Sg zLDDstf1P(@fcR7*uyV$;!t0}(9bjJ2f{|!~iOX4Rz(HN2!=1hP3`30cRKtshBS(W3 zKNgcas$z83mGrCJTmY-{E#p7r5gznmFg+6sRKf)ar!y$I?)dWuxSa2Bw6pQa$2_VL z@G@^n+#&P-5aTQK>_w$($#nrbtz`*`l^riQa;BTq6S|mO;rCGqAW3_N(VRI_cjpQg z)kSF02IY+S=`JTfl-U$if$~QZ-T(bR|L6agTE3bQa?RiWKbba(en8eF4siYV|NQ6w zZ}yT_AP5muLfhW)3BUj4KmNbrz6loZX(Iq^j+y+p9wXI9I$s)yWHu#`V;%m2xDuE( z)NsbNv2vllXsjZfX9BT{jB7vlSQ^b;?itsh&Oy^Nud!0Nm|TGb@0;-$k4B_ac5Ap&yVez1N(2iG8VlfKVP)F{ zek=|?^87`GA$vjH$aF!-Tk%p+Qhz@xAHAep<|<$>)6_;o4<#^nD|#zkXcX?#&-8?! zp{nTnG1>jZNV31(vJijoWgyKdO(|1T6MEk!n^QeK%-w`ywXA08KpY!u?w0yu_GKL%h zoJkt`jNQM6-`gb(aXz`nKgikpH&4dx5`TU+B5oIgSi;8*&9ct(_s7WHM8IuxeVm1| zYMReq8UOL#_@5+8Z~7U_@jvkI2ajX^&&Llw@_+s!fA?&(P>VN{1) zn3?GmK=O4GIUxsgmY?THDrG8!sC#ERZ9)Y>n@9BQmzLyNkQDxeXOidD!4!-v@c;^U z3b{Bb>CoGb3`|PAQ^??g{{?av`vUFxA!^0}rGX8rVtmYg8;nZ$5s}`j1Ow#_^o>~s z_AiOPzR&CwFH}v|I|?%+j_AOLJZXR&VTqv7jz!c3Ma>QR^-5@PVWgpgo zR?D$P4P{8(0!8mUt>KEI@eFK3Bg7qMv=}>T7!h$yeR)*Q5N3BNy~Jp4*0hV8?w$Eg zX*<#@cHIVxj8`Y4o$&M3MG2=Wj+vPxDW?NBf8YtSg8UDPFWiOqTmSCP|NZdk<4->F zfBzDHcbPp2udOUK+bx#moVlMp@3gXP zbMwu?(Kjz&9lXhCDF}wJvCLXl)@Gx>Thj40tVmq|BDvXTdHt2VSszI4W#TMq_ zUMB8T{K<8XgJs;>>*FsUKl$}T{C(&6(SwJN@LvL4VTA||UXaQ7^;mJy?=X>CJwH&boLJ7wetligpC#}n3o?~(;!g$zL zhwnA*489Y7NmxB3 zi_A&=7o1VBh-OqwQrecPZ&eqX{jS7RK?!SDh^j_zfI&`bm)R7F<-c*oRDxMO2OW3dGV(5iCIS*AxEPNR4qxsv8N7bgnS$b;xpcYpUJdkABj9l!Vn z?$XW-C-ZSJ@4Ajqw-A9r;TiJN_&Hu5fTi6#oLm*NV^DBMlv|45Wu5Bym1~pV1v5!| zC3mh{U1a}sLo{gpTBG|E4BS3A$<9ZUQ!w69k8Ep?H#qWwy=1_pG>Mtr;t$*zl@wyx zElCa43ow>A;XKHSea@+Nu=3sfLXw*>9Gv<7oR(niT({PlYFD`57E^RFWxw4!fn7Gc zTuhlcuhhy1xDhaztlkLLd*yAcc_x4a4MWa3OxtQvemm=yaO3v=?=o}?~rtB{6+T%vXD$8v&m#mxxeQZ_Wr7%*cwGC)!)~5AVxRi_Rg;g4CLVgL~!+DG{q=^`oi%Z-{$3>C-eyA(5R=djl zun~gnU{KGC8HH>-xHms5=GWO*i+nbmAq11x?tGC;N-xo+zIwC$yJIx!;Pe0Wzi0cq z-|ZgJ$5ul=Z)yb-0*gYQPVq2_luQ|J#0fkXn?5J0K5a zk1JzNCrmRonoOpZc+*|72O(Pm|F+oYwE~`P>>n{+lN!+b`t?!UMj~hmI2mg1#ct=4 zR6N9joGu8gVqTQW_w}8B2ahnCEaAP$PSEw@7}M>DO{PR1O<_rQ(mD5#=M)Q|b_+`d z?9-Wi{`{2Ec|j>fs%xdnG{wniB0y}5rUeHkN%8rtDB}6dYab>9CI}Vc^pwwGoXY_$C30T{SLD;uT}2M?=ZtH`#@wESX1`JbUDA{<@~Omeyzs>z z>=?6~hF3^OesG*U=#n*g8>dU6mOpaOz$ z&42h0y15*dMfDE8vi~3cL$*~Q4Vy7c8#S=s&x{qb@MlJ)}ino~iD``!5#oAbUF2ygRy33ARcj(m4nrTBEk9&X=7w^xnpWr%AQNpCy{Vlu9WLnHr@o|HvKf2c> zKCkBpGNT!N-gm*>zjv!#-C=0oLc@x9VZU){0r* zMdn2?5R_7ANpB({xbA~3m|_)1=;a{6NxdqS%#)k8K93tKXfaYScnfP&X8DDNfy^CM z(2dZu5{EQ;$t%OtG*y=1TX7CK`rJ9X4a|cB2a(=GU+Eyri{KGm)oz5L1GT<9Lx+;y zz{(iPVT6~G7a>R1xOe$zLst-bW#DHu7({=w%LuRF{_L7Orpp}Pf1HnljWx&zhz|xK zGc$QCgMzBsqXVP%Er~Ck8cBDy`BK684P6oTxj0E*a-VK$$(8GJ1Q)}ttUsmf8k>E? zE1pM#_-R3s#%j?zkY-BZy9rhEyAz8WDE1E)(AzCOo}t zUcD<39;x+`y!8_Hv>j^SYAssfjRD6q=5ixkG=65u$!9laI$j~SKy)TyPB5KVw7#hC zGDqduo_S;^N|q3|?Iwm<bdPRZGaulh7k{W1MhI(S!>KDdKASZ_O` zpj%$ar;kVRxxn#hI$_tI!ghGe-~j5~(@*Zm0Ec(D-U)d_TUN6^M=f~UA5(6frW^aU z9~#!)U?x()N6((zm7MO`6Y3fY9U3Zzm(7NuDJ~%XVP0HL5b5n%@^5@9KnE4y!U9jR z*94d(wO7?&Y3xN&y`?})lgxNs!#S|qjF6MWKjKTtQA!E@(14u4UJ(4@+S(05IQrlJ z^Pm4u_P4&8kYOMu;kpQXOlTl^&tzUwm?#v|FHuOl3{Wp!r2t-^Q_`ftEf_U~HxRyj zRx&6;_9%H=OjC!X>O5&d5z7h5S_9i^2s5l4MF^&s)n5Dstzz z@C+PA5#kh#HtC@`vk2(vOlb1V)7Vf>C`Mi5qE0(fG1=Wyq3MQ3m>JD1_`B!qIH8As zTR~&^`m}Ni4q522*vmBN8JMHxSqPtH4d2_L*j~(Ilzq-Ngf@*`5w!Lex`n(8I82x4 ztEFY>DqChQ7<+Xw@eA8Nn=c7pzv*~EAC?MQ9}|%b3`R~_SMYEWdWwg!YdH1|qgh;F zx~77PR@Ntoj&m?MAD4gfvxc^p^jFs#FDP-u)bt(_h6(>Gk29-PU~&#)HG~x=6)a%` z=zEzqxQiPPepZ-Y6y)1wEe(F9Asj)!Vvte%jRUaazYZsQ zg4lu6$z;BUk99Z#s$(dWVQa}p3I~Quq|vw^CPLpxaUj$K&;iE@RGd$XKC3kiGzr2$ z3NGZ7?JJ49Bm4yf!yQQvpPx$bL{{41)visH$c4{A6^zpemw`3X1_&cYD~i505p z5cDU}j)~I~Ws|bCE>ObA;I9ydJu9XY!VmbfvYkairrrTe2_DVJ3R?FHtpC2>?e^T) z4z<+(!+P?2IOS>O)8_54C%WHdCsqiD3R*Mfoa9I}7q`JJQBOwT(5?x(XqG|qtC`yK z7S3*P+AxrgI*m8yPJ@RV`fJ_UV0`WVSPWn?<(ng-`@!$O2E8fx@-=j$-#i0kLp%7- z1z6Mgu;axko7;@vJr);}|9%@+g`-p| z<7ziA#w8^MUa-=@3GBr9FPvhr5-y*2$LG)#8V+U9dYJ%J< zVTZrlJ382Zy}N&cePeP446BdG`eRVG8!Vq$BZvX$I*ETex8S{IXc99t7{LgN3+Vo{ zY2;rNR)9{Hd?+A&zNrZ8ZW6gOPJ=Tm$!DDfukvE&@cVC%;lH3ay+Y8|_dB~sCwnjV zcD7G;gBdV+PUP^T=rI-3!U$f0lvz{;+mSMyUT4rc>eg>cIBu@0+sF3|LJ= z(<$3l9hdn<9pWwwOauX=pbq(l<0J&PI^*V&&~)sgjxdHp%>S2082o$zpw}= zO%MsV4NG`b|B3{%Tam(`MRX#V+lL-{kdI&#QO|H}-y&D51XSQPqXwMdZr9pA%xAe4 z)`)-Gaa0z^#AK1UWcNqJ#vqU|@#D+c@nzJrtPqg{Dq_)G$VY_viGo{&&bW!5G0`}p#}tk?<(QU^a>_!iVvrRmZ>F#uJ)Ln;KrbF~gvP~>HU`W4t`I;5 z!iQ?yk2M>$r}b{6)cr?!Lfv#3%m!*aZAakc^|+i zY;IoVv#~S3vG6d_)J681>w&BQ4`!u#-P|;zxw+XQ`4XC>*smfVD&e(XvK31YP)5)= zpSp<_JH4{5AMTCvSg6WOwMe$29$%FRuajq(;g?#gGT5kFJM<1_^t~^{2PNW*6(BzN zh!wG!*yN!LFk5IQHi85_YH7ypM8Apa7)geLoDp=g;Le^plNB3wC0tn7I@o;)M-S$G73|l!=J6FijVUR&xr6`-t*)f2;bZ}6CMzA-gZ@zTUz*-VL zN$4;Nc5x+u)sdiWO1Z3h935fhos#h-m^h@V zB!+*4Q4pz@5=7+=lH%T<6fzed8I~No#gzlCzD0fly&Pn7eR`1r5BbqS3|uvP%?0~#takFq*O(?#3|ha*4E@`>K^UL~|25)Bj9&{! z!lDCe@Dsnxk=AC8C^^KiB2G$D1^DeHSj_P_gg=GdLjEkeJ8JVL+a3`UB|{)4wGPle z6x`-^wqIPCv|X&WY|xpoJh#W$t^zdSg4)g;7mlC|Re1aFv}vE(q0oq~TkMyFrm*^A zXYBijerkqc3})!0i#w^Iq$|s6r&rIkf?QK z!}i3y%=xIa$1U`=%Wq6z+lxZYB4TryPqql;f08nc0;j?fze3uzKoQGF-1OZ;aOGLe zgjDOY#JpbuU`Ixm#GJix&E-mrk%^hHCocaOjY^eFJPezg$Af%Krf5OVo~oqGIciu@ z#gvli2YVlcQ1^#Q*t8C>=NF()eKpav^NJFs-xjlul6;aM4M%2k^H3~VtWkxIe#t`~ zXvzi3Ws<$@-q$8Mm4TvKm@Bcl`G&TG&%cm%f?%t#qCf7;%YuDyQ>XP|c6A2F7XI>Z zi(x&TDae)U8hPGkKU*)l=iN3@G9SXX=QHB2$B#z^jFSBOyK;_X&GKo#_;LQ*3O{d{ zXCb-=r1@fuK^kPneTyDW_mzCpUD}1OCMb>KmLG3rNBI?o2}YR#>Oph<}*^pd?SolCLUsQ%|$z;QF z6xumH=LU$xg>LA@`K5ZkE(;hie*0lQod<-9;w%P{ zga~SiW8s=09T)2x<)V(`dZM+&t9&cz5pT7Q(0!K7ZO-TH6Q?;RzF2GxLz%GODP;7r zWU$hA%p#BJ!nr1a)&S{|t|n>Rs%=mZa?Pt8y)n$n517w~D4{h5=CAb)lC`>q-TWgC z$3oD=u2FiT3JXg>Wo;5}(##OJNQiSnq}@jJkQjd{0FqQ{j_&qeR^Jl_Pl=JVlv+HkGy~Og z*6|Uq(_{D^jExnl#mlppY*=sgJSGlW=fe(#f9!n@dlYx19$d`BY%O(2yNIJnEv`(_ zxtS)-Llc(^rcE=o9OSW1eU8$pP-~m$(r8#2LXn!GCmrP?(eQSPvooAvy_I-9#9*VK z--6KJ^Et6|G<5?u^?1Ui(4aFL+?!dr!%}OA+~JGMX*Mm#$O|Kbj==iM!tkC@isHANqUaRKxW=pwJJ*_xsYxwG z1dI9LC(0+Rz|;@FUM21E4mTLrP<)e3f~@_h*^a4`Gh9>VfYbNl1I*>Foi9K{jHSR4 zT(iKDMy!#eJxH|gUn{P!NI3F)xHjyr3sOnB^mjdlyUYkBEveo((Q)vQ%F zz<8h=I8R(Dh?850K!lMze|7%rf9roA6#uJ5zthjSCI0vLS%Uxj@w1QdzhC0d@gGt_ z8pr2SStf1Ial_er6cJ3Nq$DYO00eaBb4SiXC&TAHJ&)8%L$~{b1Y-YaRZrR zZ4MnnqjunC3=l%LLitb_1(lOcoiQAa4+BdPC6S9BJ9k$QMNUodEs^U-^eT2yg4yL% z)Tk40Wp%m7+vHk&kk^2G;)70M7Xuf2l%KZuVi$FN+Pz$0P?_igJiWn9;zRK+;*y|c zLz+L>m?k=i62W1>99AE-Lox|Zi;MhSN!9$mO%Wn^3pJ|U;`}mLC{+OzL7FX@B3yPe zH;J;A9G~%Mn9d54Y3E`xsiL~KDox25LG4iC)S?P5>vGwrJeinFcWKJ63_x9$_+Epq zDOUyZ#7UEvp;AlD^E>4(A<>~8NZ*#qDHq5Bn8Rz&=OzQyCd1IX-qJUMfuw&hAd;}l zs~1#Xg-c0HNSzvD%S*UQ((Qy?OX(SlCW}TfFh2a?Pe{%pl?{d!3HzzJCDJ2hfkb?_ z2353xrr%XPpBLlP#={NIe3Y^saxaAg16>X2vsxuC+a8CNTm#gkmM@U%x)u;4(=R zr6aKe-&q$cfpWDdu?u-|g{FuOU|779T3Br@mIs43h)Y{LnaCT$RGw(gi2ni02I|R{ z;*}#+%zkf03NIRZQZn_atTe?;A=*oGtUgnZi$PR(253H`8ic|%l%s9I2GKb9(YFiB z?|9}OwaHQjxE`L}$5h)D=LQaGBEV>8UoNNy*FM~i4iP@pe}{!uwLY3F0E6HXIlTo& zPscGI+c627VcZF8fOjpSp)Yl3>E!};!JsB~R|!JZEA%<)DbLq9nY~0*4^GG1;u;2F z*tP73%bub&>9pkg-5&BJdLJo37(I(C?`4!d+0iP*xnxx?eI(4@4DOs!QZY>GQ~x}! zu^anV^6XC~QaG3jbJaMXQngBEu`CFN1?t^(McE;~SWUXb(1amRSV`Jov%f`e2@$^F zPuqukZ3m9hd};-lF^rM*gG2;{23E??sAHeriMbdJ+Y;5?L4S@@F0^=_lcie6CiEt` z>&vt7AV9XOSuyu?V+kuE(dDVYHBz3!((jVwj0bAzdFu6tbmnF^^j)?kb+&q5^ z0)oS=v9ylNaO+ryk?=J46f1sBOVqhk%z)aIXeNbPN`)`Q2`Uq@G*4DOePcnHu|gHa zX>+#)>242}n`j(icHD&d=(x;8faz=s(UxcF?o?dQ6;E28T?*q$6qoiF0&@{$2ouX7 zTveuO?MYY~(^t7M_YUGOl-_XN*h1W2S6~r`Z`W{ga~?G;@)wRhgAeJ(s)lYEat5~5 zSy={E+GIfo0VOOBTwXY8Y(#Fs7I#&lj#MT5Zqy2vYsg%}d9|e{U`OxFUSIPo5aubm z86_kO`T!Eb{320Mi(a4KhN@e8HW3oWfm8%r#;|6gR5PDPr3-l{n$m?- z7af=06;_kZ-UKBAqiTGa2#5$R_jRIW3u z+w3!%B@w(0Kf$IrM{ggK*pmEtSF%@GGGMvXkR&B{U1{wT(sQXNoR0$Xg;;CfzxtKc zY@d3)Ur74oMD-~-ge$HW^>URT6hhF=b}5~ja>GvxwAmc*hAS3^0v03sC>S{1+_Tn_ z+Cat0?lF(b!!1p~kF+S9DP}LU6=xW&i-Q)_p#dG?1v-aj$W?64J*`LXxmKAX6Aw9< zalid1GsOs_=jMEYUN^#cW5HBc^Q2UT&pdmJ!BbyelO(O7F~guCh)7Cz!k6>tc7a_N z^GxaKTmm8^p(frOl$*^{e0G+%8Lz0tQN93OMw>7t&?DeIk@dC%e@nle@C$LPR4PK+ zbjG32MAz&Msu3?9{*3a0hUL3*h(&AMn^QA~L71^NI9|`jg*cdiYEh1vD~CC&(N3V+ zU(zSZ4wQ!KrRKb5aKX8eCD(?y=NL*4*#?XWOid`(BmRw1Kp;8^t1mYP1JYOK-K-|zSOW|Cr+1>Lj z#K=Wr#oQZspKp=##jMP4N&#!ig&KsqqykY$Nkaa)!@U=*TyDTUC%6MNPC8@xy%jEH z#+_M7{ir6XndI9DYOdsU$R)gts1Ck&F6>rIwc#1XF@mCDNHAV;ftxYChDIVd4Tw#6 zOe@oTq-$m$(#Q!H8-(pYEM`P+A_cQ-kuee7FW}HdVTIn20L+B~M&?v^e1+8nD^4)Z zhSy+6ltlazG&C#NWHf!#erO7dm7G+vXF3^^7Eb+BSR@f&`+O@84yVU)nrkI2aAV{) zgGHp)zuo{2jgPQo<4Z2tf?|^^Z;0H*ggi(oLEMukHo?#UwU?0!t~8B zur$J5@viD-qz80{Gw99OPv%N8))lkKz0%?e8b#4famBant&&zGpl+dDXTrM?d*bEf zv27Xet={qQ(Sx8}@?xueZ0Jb#KmAXfTk#L;vDIWQ3JfTaKR^62>&&wIT~~bl_YeNi zg;wrw_>)4bfE;NR5N2Y74P-=7swBNq^NWa`a#jpn#H+T((T!y|d?419g9ovLwEwN8 z#vqLsQce>#2Gf|Q2uAsFE>ExBeFHld?Cv#GZr3o`BrbeGEAX-ymbCFPIf9v})u&g|wfhU4 zM|J*EsTy=F<3hmQh#@eAb2zQ2)lwxldi^WH3CU~nd$C5(FQ%nmc`3E-#&v%m3AJA! z(xnGa!~(lL^cvAd!C|@IY9_(Ik8i&0UQ3bB>*qwzS>DcCEG3}0!kJuKj63x9xYyQ1 zV6&9vdn(PYPoLGg$mF!91L+lw*4L#L3P7hfdIeo&8RSrw(lYSRfDju$Hg!$voNaL!gNX*zt$F*5GSa{r2Ek1ie-FoaSI%fsnyZkd$B9HH8apR1|Qb*G#py(?%RrO!tume zG&;Ui0gc)#W);q&aPbm_$YMTmN~08IwvgO)OVf=Bn)u>eW>SmHCQwGWpogI2qh}*h z?w)KVxS!Av-x2C3ui0^VtrZZ*h9}yA zlfhsyE#W-t*z$E%;C^%E;)Ik!S1O*Bbl;KR#N%plIh`|=1#v<6wBM@%r>qDD|vG7jMa2`KYRH_TogewdIz~<9Y@fyCf^_)UU zY*21s4!xr8-;TE!mYWtJzE`^Hen+?-q2{hv*cXB(UvQzrxmjGNT;+U=RX=j=hh+fK zG$TYan0d&a))nt4$+%c$y9ehBmH?-Nk(@H}QnX?cw%t?jYD#40H(JBG(Wyk8m|C*g zIVeCbJg@`2QHle`51@Q)#M7aRlhpxle%%~N99|j1*8#GB)w}>rt`TjG#)cJ? zAO5)91Hs;u`(3xIe*Xvfs-iL!>s?xG*%x1AYjQHIZQS?U?l1rGzx=oV`hR5KAVMlw zKop6iqCk66NfL9na^a(^)3-!!cFix5ZZcsEF0A4b{RFt!{4mo5iSv*8@C#RM8!pYi>2J_*) z&;)oI`+I4e2^9VV7Z3cf8k4+GzUCs>u8t3PyjHl{^XN^J!8BoHCYv7Pgbw7oxhX(p zbVK?u*kwM$gtEM9NfmC1E_kl9xEQxNGfi3U?1#yz(^Fc+qQTFT(?i_`hEO!*t(5o- z#j{ydqR}OpK8A`9KV7#54hrZQm?AW0HOSA-*c6jAr21&m@l8xh$K`yX)vtpJ9A+1C z0U*tvqP(0RuPN~xbWP|H6}F|ZVq!5KP@f#}Spb48XM9ctHCcngI{3UjpDhZ{tjz$% z&T8Kge;ch|+!>t`MRj>eK{w%#;tMxyH>qSB222Q}gwiUUTdD!8U-Ssg#NT5Z^XsW7 z@zUCa8BU99;X&oXQa?_4zk~#At^`I{SXGPR1Q896GN4j%TIkx9N7lIg64Na*Dz8M> zjBt)pCK#)&h!n%+I*_AKB1hutQ`?1B%r^OgI$WpXU0Ga_9$2F9UsFx71P}BFw|jUf z?(=RSLkr)5w+%`ayJs#L>RG+5Hf}p7{SxhkeK!ZW4q7d6L5+sPmZp?4N9EGXHA^>V zO_-}DRoU8}*b=zQFRx`U^~|E{)mD3Z4gJiVP}REo>|P?mO_6jlUgVio4aZ?k+`iZH z4DGXOg?n>j`MEeVIzuSKHeFp8i!@Cx14M_D)VR)JIKXO>rZKK?ARV*Q2b3*t6ENz*CwB!#!5teLl=+51M$|^RN+qi4#_KxaMKNbVm)8p; z+cl}y+MyB&94{`B;RnyE*B}G)w*@S_q1HvEYmc#NUd@peJ4mI*&@hAmn9@BrKK3up zigx6|bbhL!9WAH|C;0hfZlhE+rz1r;A64mss^9m+h79%Hhu zOxBPOLI%f_s9$kf zleCOn#fIq`kM|!hN3GAb!qM^Y?H6mW_2|U7Mi;(Xd)~^j&*2{#+TCBQaid**F}wmj zpm?Hxv4%1D?t`_gM>qYCoeq^h?{uEGKJUq6B*AfW(~kyofZ>=Qa~d1HTm3;qgc7~8 z(Uk~F(wU92WFMUl`o-F&G_jU-6%|lcI%8BwODCYBAy>y5YR3NYE_I3uiUfn()}*s> zJO{EoP$wKEHA?Xk3kx(CDdT%E=U(%+1bOiAw9BBBFR`XY!zzLWV_133MT%dve7s~K zGvh#?zL6jnS!wu=j-$7-`H^N^ctQqMi%5O5`c%LVST(RfQuV!xf5+dxz_O~X*1Oy% z`F)YSC^01ojL2fRZOR%j?(IvyY#qj`+W4&1>)~o!b$Qv>o&;}ew^6_8E&68R5xNqV zIr^QlHQz8SI&^q+h`VHMlW3a1*W9tqHM3xwo>E)3ZpqTLpVG9u>C6QW>tg<3c5KDE z%{mhosUl~GL2>*Wvb?ZpLA@|*xU`%s%Ev+OVW#L66T-iB$mX{W8NK^A+6nn$K9EgH9p0+N;Tk*zPu#sRz-MS5Xn%#){yA zo14-HEMrsa1^1F6lewfy#;@jm<0}3$Ct9r~80Z-;mx1V<)gj?ezQb8LnU(Wv0UToK zUM42Xd`*|O5WTRfBPk2SD*{I=`P;P>j3&`VnLu>Vorcuc4Ylo4-i(K6mfot_-7vTJ z-f{5i6yK5R7-`4D!_(}hezG?n;rh_NU|-}sHEPuDk9=gG=+3>HlL6uSOwA0B>vaD4Lp zi@l>S{$U-yCynMG*5Ah%+1S9iz`=|Y-BSzFJ?`b+t6g~BdupCAITxtYbROC#`X$@= zR`02{ndiBb2CX9NtWSkXh*f;TaVmOwo}pt|Knyc~eGOyMYyIWV|LHG({-5FhGWtS? zxfkM_|MI{6-@X2dK+^i8Y(P{03tpOTP-e+p^k=!qLkbgJDP@E7qOKf=!oSvNe7}Eq z_=GpbY4oD{gqKP`xBL;%KvUb%-tL*+GXtzdN~?yZqx*h)-$ zbwSsP`&5m_UYXGaW+ipt5^7jye$}UkzApLA&0>mZ_w?&(Ht5yXUp_4pv%A^~x=OB$ z#{mHmfbFb~4VTeuh!Bqxh^j|a&D#eK!XPY$;(yKtM1Ui*-pa>eD z7z=e-c*xO$%%fus+Ym_ClrJ#hly(%;;7B`_3z^O^$;MSL3wuy~BzOPY|N9XCf4ana z{Ta*s{|_ENdl>ouA3t~qKY#T9{{nyad>HSwo5f{ipe2AXPJj zL2?Sba9PE3#s6Pj*i>D0hcgVAs&XjHB|WjPpf`Ca11}tc#}UCjDvBw4BiN##g}mA4e(vjw0WcN&`bht=0Bi(PG689i#ESIR%cx?hhb_@l-n2Y zn4wE|CkPdt)3!dkVf~Hx)Ct2?42eM9hRkaZR8FqG#OwQPFEi&?NG2UHLo0_g5ob#&Jbw zjX(m!;!IgPA&Ya&(d_i87^QL@!(f=0knyhpDp^7Q z^IU?x_j?8^d7nIe+NA&CUxfd7_~7y5{~>$&)4po@@87uocenZ1s_S=q#&Z4t@h1-- z#Q2{_kDfgGsQ>>Ge;5)$>AaQIX1g_%B5oiRx3aa(%{K=}-@JTv@Ft^Wpt*;PpH&GA zvXfQF0GLNanv$is!^Q2}**A;RVm5}O6V9;hKP_flcW(R5aW{MO_3r*w_D^fWxbc5l zYs1&Vf$F}W&C4^8{T2TzH({k_Y&CO*+Obb^jU&?aors&67qBiUcVOEhg>hv5X|0>> z?SFUh&F-;|8@k%t?#c>cbw zW2op+-#{CZF|otML#X3KETomtwi2DZI|;q6g~W^9wH6p1#khiLe!X|nx-8~7rmk+a zGCU41CNub6^>z48@W(LeBoEdkaHfrWvRz{+GMjYGTtwtH&sLi=B#W!IE6CcGF{Kys zL))TSV+j;Sgq@T8#dlA9x zB*%Eu>z?uQt^U1T$o|_{{`w8a*Azi19%xm!Df>cn^I?ILlZ&_G#5yM zUqHF|Rs#E>9KV~qMH8LL(3z>w-J^vHCRnw1LGno42T5e38uGGlM>Z)^05eoJ!EFCZ zwszel(ek!D@<@9n$%MRJzFL$+-Y(^MP;!DZ27SKu1l+c8gsZh+)-y3Aw8U2@%=5Bm zC5nT%`+CI*T3kF`nCtGVM4Ps$+{z;C%PqAyWThLLmbK({;XW`AO&WH?8XFloVcz@@uyI@}!X;}|)un^ylhp6&NOH<^0X{jd zF3JYwDi_J($5aPRrd7+XfDY4D*eMBJEp<|P#v-(C+be>Enned0C zR*EUFvi5nn*fLH|193cmzP5Tb>y?{XQsbHjxg7Ek!#6U_I=rI6V5!xyXRJe|y=psw z#t>eV+gb~xEk1d_Tg8Xwrvi~|XDE%7xj~AJq$zgSgE*sK2}w}n@>LcPcjzf?i+B^F}E1=VXJhD3dZ6Px7K@Wk`= zK-{@bH6y(SOp=hRIU#8rmuubPk|0(DZMjNazRkV{-$)hc4e16f8iY4=uk*{1#231t z#clRKbGS;Zv^%OeJ+D7hVi*>6i)r6c#6}t=dRDzeN%aV|6g&oIN{_*H-$;2uSu&W5 z`tM5Q$#euGgfu}_F*>7g3Cf|ZfFqcSp0Ir4@q3=sbyKx3Y!yD^b4w_@^z1js^(W_7 z6_@rMvaTFdv#E~=sA|0(R3cwaTuQ5GZrDLd9i#q$u@+P?^5{ZO8_zpz8|)?qdpWhz zSa|v6Dd8@w?RokFS}g>p}~Tx?$nB z4+BTDj-8>! zWug~J;=+ioFw(njg007rN@)^kin$UERk-SL9ZjD-pERpzAIMYSUx$++^tem@c(W+O z=XxzDcs5Zfj@f;JGh^;YYEvqUgc)trMG}E2M-t6q$@ogS%X%>=vXfF7!!EKogbDLx z7m0Do#Z2rdF|Hr0=_sFL5SObXhL(|n;hDG1_Q`6b4IPr(620Lju8PwN8Ew&yRRRk5 zj-K12qE%eywRE^{_eAl2`0g}`m1N%xyeR}-he}ofV{>joG5au`a zb7R?ac#HSkJ~Ss4G$t1Y2LzQSgx}zd!KVhYuR^W_IuqCwSYw%#pQ8k05-5cFQu8!+ zy0=#spmJm5wq$UIq$H&o|iOK;M~8ca_2F`nV(R(>1tQHd@TrrHD3?JTFb`n7Y6)2f7bT%5W%gTs_%SPfz-`c64# z7qLQKrdm>sLavpq3F+RCnUZk+$ydp%*&1usgb~nud<}uMHQhtXC05IIRp)p&%RKOU zt*FjA$2B^#6I~;ygHAy1OLo*QL?Z^r&a18E_-vLt-aS*SlT%})6103v?oQ;MEV(h8 zHtoDi-OWvQ$Q{?~J&stVMq|zAVTzA=u$axr@rsBR!w!*3$!OO=5ab{XD%&B?QhCNj zl_a5ob~9Dk?Q$0hWE;gtWS_;uOH|$D-{Z7NeoA?Et+g|$#xkgMu$TW-^sY))30>oL z@1Jx*6XwC(epe-|ZMklVt58mI;YyKW9`izt6!P*t!in1%<;1;7TsvoVF~Td?O`w$n zT>tMEv<%uA75KJN-bV)K9>nN;O)j)=2s&0B3wlhetO2P9)4zFm8W zCGOBePnVGtLih8LIFqz#N206F5o)B(5O8hKhC3RL{n%JsR36ww$39Zay(_3%_~s@B zV9XH;s#phoQ#MM&iy^DKDSmIsKSGbh#EjO{z~ug_$Dep?-pR#%nxuCpOA5D!7tkA&R%cl z1+JX0%K6ueQ$d|ohU6`5eozjEal`W623CLH+>B!am1hZ15$?PgZt;jumuQWOdEgTB z;s=}ShBr9Ra8$C|279PV{O0C1r8bzB4ewCpWZKcfI^^z$2Y9#J?c2Hxk=sY8)+#T& zw}&gmQQc7)NObkt42K>FjC%x*_)Z{KsDu*zQ!@+)oD9Z3fZ*am4m&J6K*4~;dr4zr z*%JXT(WrG&C*6NkV95=v`6UYh+4ZF!Id*l|U%w9Hx6%KsAsHV8*gK|vwx_-N#0~D# zOt&MvL-YHOixL!Na@o1TchTe6UCR~L%RlkNiFp~kDAjarJExZ``$W)1f&!3}Q_+8I z=c}NU)u4Sn)6l*(;11ly(RG5=P?xr>R6Mw6Aozx#xO~=Qh>I!pdr%V6AQ3DzoC8d?|&L zQ;XLlm_>y8#wDUE#Y<;ctIDG+pNPY8n4x?ym@LNgo;8iy^gu9?w3cIAO66IDsRwGu z;H}H%ZG9qZi)cJ`hlzha5Rj2k5`ZmqdnV~LM4s+&8BM2yEwz1sCH=3*dFUGTuO5IE-aA%J#ivSHKc-Vczkd1Tt&{Ga&9+fr*n<&(8P>j$&ft{aZ{s)3Q!z93KZ8;o$?E^Y6UM0b3EC=lv5t%IhL^3DR+P4icaZL%)O~H#c)ay7ctek9qh)5tX!8$ z+-ixIZAIK!2oy+~#1t1g;no`BLVHFLxoL8QAu{4Ffup8N-Y2pomuWPjcAxF)9fC&Y zGT*fMGH&+$Lq5qn{WFt4r=Nbe;7lLq<^)wzuLz1XeFE+^GP>`1%=Ed~dGlwsQZ9Xh&^WU!rCf^a7O zW;YglC8XQJ%f%VC46(8H0z&~b+fGK*#ub=&ouD*+Scq~Z$UX3J0<;F(!WYleg0I{` z-ZCtE?uHH;d?Qe9CaB~N!`F`QpCSwhxvH*V(tq@*O+NwOe@_XrY!*+04oXR2%yg7d zhNBExs|GY82th~>H75^KDH_b`B-7)%qi0Z`)2oZ98+zUM?mB+K8M!H`UWGS@J#%ZS z3keIUoqYof>N|=T@HmM9eJmeTuo6hAZ=#B5CX1DAMm%Tz9DjvFs&ea+4?=OnNc9U% zU*z>fs8}Yp_hg4|`8rsV9?u+@l6+4Dz-s@+KrqLh>68sf z3gF~d+B%tKQo&pgQ}1-c)%+nI)|$gswo}{mRW^6ic2*Ul^IneEySjI4m?c?0s+#HQ zRZZuTi_41+))PP)>Rxe=m%D47bGZI|aq1WcP!!v+QH|T*u-30ehcLU3Q$3QiDDWn< zV@QE2?`;Phux=stvUc9fsa*F`$IhuhuzrXBFpPbGQHK?ral-cDs^0POQN}19{05y9 zKd4O$EAuM9?vXH7FbYL^xmrNn^JV^O=l;fqBmcKEaMV}L0weHO^jsoG${KeIlASel zOp&dCpW|-2qdD%Y{ee2n?ko4TK?%5Cw7uPcPxa^l-T)~7MDagoD-G>c()S6Rtrp>8 zjAKe=-`97*yy0oa^aUzWA?4^d+9ZORy&_eMg9`cCxFkkNCBs5;cwE&F%n_t|FXbhk z+4&eP?`7FblHe32HIP}!a=D)9M1WT<9^$QBkDG9qE(;fAQ{38^V&krpZz5$9!vs!- znyAEABa?+#!HjW`isA6?G@STR+)iM&o*}bTw(iiE0rKIz4uH6AA;M?!5Wxs+WEC5o zW|9#E?A%tcD@P;5+86J$PE|Q2MhNd4ik9Ek?1G~*G*l1ncu0%L+Q9g7TdT~g3u22`t5X(!k#rMt!H z01TBgPk{Z3;9a=LHt(QNlv0T;5K~JE33^q|g3^}ZkqlzfmKHXM1FqV|ed>@f=5iGv zZY!mY1Vg;SxxwRVXFD6wOT%eo4l})io{Hp^vOs5 zk6+^Nz31O*2e&+9IsXSt_Or*){KLOK`N;qAOZ+*OVGDcpPDSVPG^zUNh_VJi9ZeX7 zARJ5)OL9bcI4-$(GO5@t$H5`avbWK=5fO*a5E8J*{vKrWa@=HTNGT&YmB652IDo-T zIv?XB!fo4Uap7+Z|8wCw2Kf=5E_suOm;Qpl(kBg_aUcsfPyuq(l|pxkW!}S;M&b51 zBf@7g-aMha0CGLwJ7KlZZri!%9E^T^1|}e?zZ#D8h|WZcdk|(unygIa9^Y(8&K@=r z6zL&|#LFi1Qj+7H9u)@Tnf%vxPb>7wFl3)wz(X+lX58yjC5TY(J?GL#(=uS#)al?V z^axo;01SsFQ7f;JC-56kEo$m72J!2o*fx9wetHEZj^PR%957Ry1sEp(8k}HEA>hDz zChTxaZvUW6r@}Mgq!7PV>1(W^@(z7V#nrM4Zzm9Nupn=n-3KY#?jFtnCoKAiXi+6qD8Ze1h?k-{iho2X|+BWejxeN?ybTJ>&PCo^6 z8upe5Qk3S1S?_=xkZxDboVyPE{GQF*Y?wU}wE>X{M|uRVUPsVL0zU_nG=KlPHpgvr z2XWFARaL1p`VMD#E?h;qB)oECiOh8S+#o?b`r)?7v%91rPV9bPC6de8+i`%_)2AzT z-epFoG9DCuqTa##4s}LRkQwqy(g;t+l6p*8B{XL7Lzt0*QUrL&nFfFd*<-3k;*;1( zAQ?U2*HJ{F5ceq?DfnXm<(bj0whBuUG09qzWLMfOga$mYofm2@qi{2XLFoAh>p+PQ~m9^MaJ&Sa2l(uG6~OLBM9D|Q`~aODcv7Z<36 z!WA97qC;coc%G1CA6)&=!9N|s(OxcpT10M69?J5nxzD|=Ca@g#bv4Q zPaN2Wn?Qg8()Nr!Otq-JiA_tqt{CTI6CI-!oNJ^(765t{t1AiMkC_U4l%?vH_YO~f z_nC%)(VGs_UBL-Z!9s_dL8wX~5Z-fEjB*3|bD?D;Ml9in*$!M!PvasK85RXmr!hq1 zaq=)(0CA+*g`t-$iAmOV#QhXU)#_Y@0#luecuJ%K49=BYN0a`=Arzaz%4@@&i@1bK z+r4B?ek5n3>{5Esw@fBiTyg<)AzL2LXA_i|K>#Zcvsrzd$;4-6xa^X3d7l`TW z!^t#a<3!7M%5Z?ri)*c$9hS(5Lra_BcTRZ4>xQ3QU(FJx_5>HzMKMC|f{I+Y^BF8m zwlL&W!q6G&6Qp+21P~a3UCVL|aHgMg&clGj!LuYA3Zn>l%_7)=S>fkb6WaL#lvQvl zo&2?CeI-VPnbe>&AH>E(SbX9t&O@NN*0>bAvgt zV<5J0Ibf^~O{E?`g4ISzXq|ZFjWrue*zFotPRTvwREWWanFGW&Fnhew81cFQf?Q3E zt#%O}cXB3)&Qot5-eRbk#1z{JIpeAjzGE@gT9cMsP1jiA*DBSx-BjkrAw^*TBQ zEuPw%j7mdKn*?1_}aCw5Ia{W9CS^8Rcv< z`GH2QE2DVibCb{=reye0u^Iq9eM%w@<4weh!8@DIG<}Ez=*LP*Npwm-A+9}2Xdx{e zlgz`^mRd54yex@b1Og_!FH(;qwF@;uU&(1_J$9kbhJfEDR7-xw0r_R{JXLp&8+;2$ z&^k%sp&}#9m@iHb(>2d9Ms+1?T7q$2mmCY_KmT-gtk({9UU94fj!T=GnEn<5wv z_?Vra6kV?Yr)_W;Bf$Th&x=WzDN(b&%r53+jSjOkEYBFgBP7ZaKwKcf-#ZG9*8%Y5 zLY2DKuVp-G`3O_%K$8hhudT@ou>1&Uz``>oV;}^~66AparEtwv0L~@WR)>YA3t4pBG(cE$U)mu?IO|eI zA=h?F<<6dMeMbW9(7u7nV*4wiV~txSg38u#v6-M@uy)anfx@;G2y0-SOsG00m!1cz z6RX#R)+DYzT2=Ki7GBx61r|d0D|?F&%!qqV45Y_E$F~XcxrWOQV9$RQ)H5MKdc)!! zoBW|2r?bBm1B)y4Kt`jomaTnGYySCK-)rOk269X0LRV-X%I?YZ@}U*r?2G=Ij+e z5Bfw_78#FY2~|<~73T6V9Aw_ri`O7&wJOF{NmJM0K%gU@b^WWdLKoyq7Y3?hTZK8L zUq{6DBSJ!_VE%6V^(zz`PO%3TCZ9&iw5%%Tjj`fUs1;53Vjui zUx0#6RIzeAXa4DJLO(O!nMAU{oGr0T0Bk^$zk{z_B5@V_8*qTgDof;_lN?3&?j@$c z(lEkn%y12n!rV>z+Fkp(j-qn&(ff0&g`J!p{?DS6oGv@8(C;0h zdLtI9uJk?bZ9DslaouD&R%-Q7Y-hPd_BQPc^;jpK zh+m^V2oiBLnTmzZ?t!*h(Db@IYLUT?2@u0`bwENZrMi4LjL6{pk@}sz|=jd(K0)yOr4dPV;JhyU=s5c z>6k4M2sMZkre8{hNpGhhHc7fz-b1j1q*x8;ltc`9R(6rDY8*%;X&Q?;(L|D?YD#Jghl7&)ei)+*ySV`JT zUIyQ}Vg<$xFTpf)u_xF&XguT`MFC@Qq+h*sU3*@+oMK(52vBUR4;s@><$7M zupEl*VDQP^I!7lj35_}j57&0B4t@3Ul8`KW*s8uaD`l0=Tr7bA3~ zY@=t;Bnj>>eq>KMYcQx@qgIX19FT10Q6Y&2$~yuHW9bD$17CJ#r1i!U4c8m^t|^50hcS-8xEhIw)LpARPnO*spO?C8#rM;Neb+Zk-nYUt4|!K? zSI3l#{f4dmyF7h`7VgpyoU3D!gYVUehTHGbgF_Z5yiDJ#6U{BG-Vf%{6z31tsjl9m zE4MUqx8Cslen?lES}^?}c?P2S7*ksX1kik#*v$NgkAKBSeeDKZ6Tf8?Fp+&BG~QIl za?mb3(@M^492X}2zwUaa0%Bz)p;vO`5KLw)V896%59DsJm&jFzC$eSq3Q-jrE0diO z=R8p4xWY>!J*jk>fqI*r(}&_~?rF9P3uQrIIj=0JJ1$=}gOo9T z2L7|db8$MGTvfu`3d`dRUH_D#W7M`AtEdD|iN3=(5sxoF$bRy)++YNw9|oSzDviE| ziu&+H;&0(74-}yuohPE|lb)!#lo+&kL+?e=%u$2&)R zhbLgC^~sAo6EqvTO$sdLdgi~z_(DEIw?Ty?n-53VE)6@WPzT$^Xx-4$*|mlb4Eqz@ zNc+IHCoA9V2Os1l0+DbJth50%_6qBb7#oz9M6q5$HknWvPi9I9afvtCm(rjU99YvZ>o*Gjb$v`bTVt@xnyklVFQL3EU%q?^&-~r? z(IH`<#aM#b`0?5$&vDGaOp8_x3QY}t%#CyI*LrTw3fJvhI~$40nHPgN{{9A@00rDv z#1MfoaUrCUn1|tOzDYJ4bUIRi%w|VQ8;tUZJu2dYEDRnGapY<*Rkk27nq(T^;78Tf zb6KrlUMIS>QzS=uEcJ*KL3)FM4hk0&JGT2-%`U!-gCsM#l6l*}>Me^79}-XIQa8S=P9&vem`8vMDK^Vpf@mP4idi>%k+Qxy%4d8HN|8=x zyDl}}?HVmN z2Dj>QzXreXUGfi^$sejUu8shIU6ev!&*#%!@ldZ9<9A!2&mJ6|$TLV;h5sh1QrF(h z=(z9&0)^-H{c_~6ab3+mdDl+{MP#9__iNYTU7ngW@3O?aX$UnM2+!nOQO(VI^T2LWREq3mBP zdfK<>#To^<@NK#0FdwdniH>OZSelMUQorHH7>VMBS~Rs^@}{?ElWQ(fJGjtcjN+bHvzfb5 zvbV-s@m}iaxlC5Q?)%UgVV{sT&=3YxIipXN!NB+vf}Ig=4M|GNWn|1@?+V5XG2Rjr z*6q@|xr`Bbl9ke^8q4@12|Lm(KhdeG)Mc_EgLTtY43fx?*au}2YZzR})ElgExKVcN zSIgWcL~+c08)wUgj%Y~@zM8145>n%A6l&`rb&~NxWY?HB=&^Gy+!0TMJrPpbhriG60Oz`lT*5q7aBK<84Vq* zRJb?=`LpO?LT*JScE()}`s&Pc!`(yI1~^-`|M_;m7xg;*{l&q_{_e^5JFoV>KiPe? z`+E1}=y%^A9G)C}d$N^13@Qw$XK-yk3W5w4<9}&PKOU4|N7yRvrmUlThY@gIG1&yyNTx$DQ#ysnS++y z+=t+*XP@McpMLUq@az27pFYbUpZ$99^w&?GKK}LDll=7Q!-vDCpPZgP9u&WJZQ=B7 zxlgbnsGW}Rox+%+eEP9|!55s%rxl+E#XBnj9=KhaQo!zwYYn%yS|#7mt#Bd_-${5-~PLIkK*w*KR$f?>65|ZClB(|N5f(M^ubv% zd~{kAj~_gFID9z#^pgh|5>wXy4(M^Z;y6yBkb;< ze82Z1`ODSw zb8tkj@z*}NKK}g_fA6jTNq*TX&seViJ$~{G{vG0fpFDZ^@T30sOZ?qyu9vX_64)Cm z%6TD%c9E{DfnE%HUK=YH5J^c>l@3?p1ZivvD(&iv@Syr>DzTQANO;)RyqpHd1DdA3 z9Ahh>)MRsQwT5q7PWo0IC}pd@o0iDGfat4K65oa7;N78sbJ@PMeAy zOi*_qPpUd2i36jp@Y)1fmQTLZob{?_GO2N3l5^5?Y#=U!sjVjKI4er>R4%6E3{6l` zdEPefDs?0eraVOond|e^8B`_?g%x1AAHoNE7y3?u?9Rz|bB-8YlzJUq1Sxp0#0OF9 z=#0vWV8{l?I6qyYp-@!bQQ% zVcDy4y!bI=aaJ+Ezk~t|ktSTuzA1-^M;{P8Mj%4`EY@4iB&Xsjo*1WX=`wT&ktdF} z96Zyl*KXEdK9Z7Tj~|Pg-8r!(5wvj`w$@L~<_SL70*{6Io)&#QxDOLtr?D`HL^67w{ALNv4#(-eSsrO)VbZ z)JuJQiYeVWPIx{nmps&gPa#3jO@gng-iJ(Rm1+Q=n9Rb(4wewK zi4VA5H{3DHEhmz5>9n@SxFRVX36gmUmNHG{$q3PCR4zoSip~K)wE0}l1)0O8f=)*u z!IctmP6ugH^EjX7(+lS5%ddTfX3tTvnDfu(Xk@AfK94at+p=c(-JOI;TTpirMlQij zqYah!8peJ^=QfmSywz`LeSxw6FLK5;_|@J$`8#tgIezI{rXCZ+Hulxk5vYHcB%iHa z)ks5&uoa5C%s6Ob7O)GngzREWEfue2JSo^;>G1qXoNt=Ad4`ozP-)2_x>E7&L8n!b_!itj~YqmKT{X_WOjU;c(T z-wkhwGakPme&^rr(IfkAx`h0@ncg(K;dpwP4NCVTiYlU(<2jYYoeh z<}#C+^vPdyB-gYpS4|OV1gKH=B9|g_J(KRs+f=Q3G0YO;l_G7p-oE;J=g3J|1|o7y zO>hnW?%m@+#OjOKy+pUqxM8zxaug&QYe~mcL~}bi_-1$C?Uobf<7F?O8#uGnB9K3q zU?#@9#G6`MHjJ3LfY!=F__MR;Ht5raU?xd^E1BBPvK& z$u9N~GDaD~O(1O5$LZZjsl_XsEQHQF?WL9@onsCuCrN`uLf~rXT1u?nWFY^qDwa|{ zuj#E^rIaLo!P`ZtyeW zuk9z16+{mcf+ZwJU@~<}u%7}$^c|kszd2Ew`~FDfbsx0DjSJ=t=|Knumw4FeWHLw4 z_morOgvg$6ZX6_GvYk=wa~_Tas~+Zp;zmziGnu%YAzVHfo0n9X+O^_T6`DkG6$CwM zJ6b_AQOZr9UuDlzr^8FB%bwg9; zk>5OL2P|nkyl8{# z_?ZxihvQvP7B(t~7$vTSYoiPg*48+7`?KV7@JjgpeNsC9esegQ!^RpNjgj{tzqxjJ zWm@IG(HVSq2FgOQLYl^HQLyyWf2s6}J4U|a?1kgl>50ikWdC7d)kR=uEO9hT#sA*Q%CQ>2}WJg8VqODDn&P{U+GkO zMJ$|^K2c{#5Xe_Kay5pl{ERAeOUkaVD`uW?i?-KKuE>5n5!rMwS24}{;q()9ZF`tc zUDSg{%V?s5$y5p~W~ZZaJfs*v2T9T_M%~$ueN+(o0mV}q4Q49D3zCdTveDSC+O;6M zhlIyS5-fj21p>s1*K!}BiB8jRiXp}|8SAcPjg4?nUJXxIRMMNeOv^y@BcORY(S+0F zD^D@Co(cUc%m;77el?WYLKr>~k5K0b2*O|n5}k<5=*0sfnz9AM$1e=pQ^)u1d0kkF zMWJSy4H1Y@7F4TA01W(2CG7-EYepKl8jE$a*qbM0W7CO8ad$$rVzN4g8&aL|k*`#p zIMM61sBhbBhHyt)Gb-BD+7anQ;?2{2un}#P#}px@XIpu_$^PLXc>}zggJlR@ln?WH z9tA;A$QVir_D;wKZabb)tv;3YzuP<6{k~5gj`BwuY!TNSnGvcdQ6tFN!=xC*y5QJl zq?@qcXL)3&NB#58Q!G8noBz^|Js)_7a1AoqIET8F`IZ_&^7HXOzRK9e?FPy_itv?2 z!g5kfLx1zAN)@I~6`xJ*ehRq4{u@W=VD>-7HE4w=FD@U${+_U6Xsh*Q`sRAV$*8Io zSGQ=!TY-+X(lzS)jA@&y6!^S*K1DGQJ-m{zy&8RJDW5PiF{7)Jcq4^_0 z{3lO?fdkZU*Ft4@jZ5XqxfB3=byb}52MX*%%Wtc`sZl3Eoqm2L=#w*a?lS3%)Vl3Z z!C-t5t{hAb|4Nfk*car_mp8{`U6?9(LVv^Q9mr?{Z^^Urnk#U6A+$w!>`1a`ZBUwt zW(kGw@&;M+EZPI(*Eq+hx7-3q;yJk`21*sS{Q@CGh(DWUl`Y0D1Bl6!`eLcWyKw(I zcybj(mpz0MgY$_(X0Y5Q09D24EWD8_MzaqV-Mr+TBPl?`P?8^ZejV0`mX)<72r0cN z6w=JhW(HO<5}xISi}v~Yi&DK7D@P&}gAoT5cL#ll>Z(>j1@w-Oa5&rojbSLHxOOee zmLax2Tf_*eNET9Dj*~NNst9L^{A^W|ep>#Q&|Rg^xRw9q(I*lA%cF-+Kj#1bB7Z6G zpjU_gJ~&_!!0Pw)A^y#hvIrJDetwJwPxZiD^W@jRe*Cz|pFaBZ(?MPg@=u>Vc{F@j zeEK9mI~_hg`(*g+(?<^;JbKXR>`GzG!|v9R<`!OVTunCJ#6Zpc7Y%^(zpo6OkJK)| zh<7r7uTs-)dOvz5Ka#ur+x&ZP{V$^JUB(1+OZ~^EPaZst_+OqqdHD3B{`X7#-HQRv zcV~U6lTrs>4^G(fWG%*-^-wS}kat`%FFWss}ZveLZQ>a{3lC`%M; zQCD+NaD^Wt&oEBS^|eTB-C9s3d~~Xsitj+Jn9BQf@gN5^C_BJq7R@R`vo05-c{!y7Ue1j-x55S)mQ{XvT7sy+pK_2e(KKp3 zg3pLT?OvN5&Rr%crxxw&%WH3t+n1WFasE?ySfVW>qVXQEW!V_Z~t>1i`y9bLh(sKX=9X)*;Qu6h`H`B%BhK`Q~4L&9s~oH z(k5*a9As}ltfO%uiLUdR1}@DMNg-%XZ9=H{(qr335CS@MDq`|&O8it_{t!wtDW`1N z9M!8`F&?=C8Q@(3n%YIM2J8hC8E~84k*){3Mye$=*U!<13|8sMV5Oc+SLulo`yk_1 z2ep)RsSvF`GhEClK^|kQF^jsVAX5r;*cCs7MM;b^pDPwhXcJ2m7K67o8V8dod0Azp zXxL(?71G9zAf7H(Ml{%MNMkt99(@Wn)&m|AX{L6~Q@lh@E8b!T02c@yDu08IJBb9& ziU$GTNW=^7@PQ47iGQNGpJKh8ON_k;mgb7;HXv4oqLx}Pr=F1(fHF--UsrIam|%lv-lVE<%$Z-4jb`=f975l#Aq9`qQ+Y`kbn%b*{{@oDM0 z3K6gvFjH8|F}PJqxx$7<1v9C6O`Ei0w2hD>kSrxG0i)r%x;m*1(P`=n;%Jt)lugJXMOaZfYjFgg z(@rSHN+CN;pw!gwPLTMWFnAJSe;>7zW+NLea!tZyoFgJZvoR#~-A4~iF)O3}Emi38 zKCdBDQ}_yF)ZLdwAGa9%F>Y>k7>i#f9EwJ;La<`$-TPep2brIwHQx*qB)S{*hagXx zwj^g6!O0*&qV%MQ5yW4qfUhF8oEeB zR^LwR$%%%jG=LM#L4Su_rj(O0D$eGUcbvT^G6)Q*VoqTqP%kGV1XhtpC_=OZRf&c$ zHx;L=-}9slTuJDiX2^sIsqXMdWt*`4p(J^wU$`Lr&?G9?#e4S}rtFf(C+0=b)>+xm zlN*ctke@0&E%0vjZt{|BPY3QDjIzFjgcgSi3 zzVHnyQ1Q#dYYH~bFfL*Zb;r2iNS3-hb338}Q#qX2h!!pakjfn@kuxM^nhu{6GmeA- zt=s%mA89l>H3~PI6b>-P%~+wgtSQ$!8ThD~We$m?gUGe4aHJ8|dpB^lnhI>XuuK`& zT5@NYRDuYbl@($FIJ&}0G(o%3=&&Yd)IzUvxRDWrW29ISvj$@arEX%gxQ!R1;B-js z;Aap4u&gTvOPbZs^T=4*LJC!rHwu>-je&GAK&W=L_4w&!MLPxFgi0t!dnni^&iHKe z+0u5y)uG{X*?k=hPFe=pSSeOWFY7qL)^=9#=NkWe{{7zT?XPx`&t_hZ;x0ONQpAGd zqrY_1r;CLFPtpf`7CHRv1HRnbfARVNbk^@bhr5d(pNF*>kwGg*4U6pt0Z^UcmAvN(u<2>#4<r<(wrU+|*{QQ`PT-w64zyEX?WL zrWPi13>zyaq%#sREikok4KY1-mhsN8#Jm;Thf7`{@V-1ELuI7fdoGN)5<>l%wPFahE8~Q#g%11(*kUSI>;#StE zk+n9;Q>i5)&Ka|D9k;ebgk1^)c?-!=UnF*?ttU>RVNi_oS&2>vOtQcr{Q75?DxKhZX1js(PbO0KpB8zQod4VKSM>>6 z?T*A4zW0r{g1;gNmk`A0i_-E*+~9(y$s26UU^!yUt?1O)?>% z8r7gLDMZN8hABJw=gfIUY}&Z{-i|RgVxK))V2brA+J@ebOGqU($ce^{Dp;I}#N<}^ zR;l#lu2!Id4-*>*Ra-d~m6fbQ#YcCNxM8SY5m;|ED4)9c20ExgWDVkLb zl6Y?{;P!Axlk9dpG-x!ryTI=5($os5Ky40Mkv;x_{kpxOJM_v+pbzZT?G3HaE5zIE z%2xh*K8Il!yv<#Dv|1wxtR$BCcI1H56gb24nFgo~+QsaVl*5iZcK+(1Mq$SvoMjad z+YYmJOhabU!3`#rV7C`1)Nk#$c8*E&>W4gn)V2UVO=1b-qxoNoX^>J)lf{%I4~ZAN z{YZ)V6C?j?-X254yTBDr? zfbQ%G^)^YrD5p;PQZa|-9r$bVssU^%L6b!F5#&QR)0*5gUnJ%*`=?g4H@;ZADlh@> zk@+o{p)b}huSs2=o#Q2m8aVhMnwbu*KYGefov&z@M6ytfefM5?l=SqRaiZB3DmW!% z7!^QXTw+bxKXG6LcGe7w$0Cb7x-jyH*U9@L`kg*Uc>3S>RclPmrr?tT!kk1=phR_^ zWQCwqbsp?!jfsQ+j)e4+;hM!A07DLmFJXG#?Tq6u7 zj7VNBVs1y@e;z577#{6nk7yA;oEoe&tYI}YDPQZ(stW+R*&D6ZVv23D z(X7&tG+lhI&ooNkU^<8|c(P;ffYTjs04aG%2?(Oy+Hx4!l0zoiw>Hh7!~#w2ZDqc5 z(l#g>(R{{zW_KxL&#_t3>~O)~@t`W(GNs=hy{bOr?SwKDhlL)@Ekzr+78u8eU|+st zG;)l1lyxTi3=^FkK9w(MvzBClNnoH-QZK8eSdS6`il{#5PSDL8<_OSjl(Zt?0bg|y z<<@+jldpVx=cop0M}nrped#fE#ZZ+pJ5;YmLuG2|A8?u8U`<1lKrug^! zshZBwoX|LvUTDzyTz`KOwbS+>t$p69A1kY%lCFMLoOaIg0cSHU*GOrvN?^A7sEhuL zx~K(++)V{_OT}oS8cSbYBSi&~vWQU{I(>>&d$?PnbCd(yAJI?in)`3dP=S2)U=rRAavr-|(0=>BmhM zhDx$d%w5!DH<^I|cXZx?k{Co}C%D$;`8nqV#gmwiMmX0#_qu=lwzRgcGRg15Awbb0 zk|T+3Mg=Vg90N7;j)`1Yb$Zy5o?=)de^*j0Xy~}1T^=n@xvF@RS$U2w8EFF)>}yQ; z_4%BU(rXu-PjwiCz$>aott-tSpK?Z`sDM-~x{0Ph$Gj+0|EuhVLeBi+oE ziw^WKm9||{-(fr(?+nthg)}m(14NW`f?RI(NIK0Jhz__PSr#HF*9=%SG;uD~!n!213hnvSKUg z?Zp`PAps*bP8sYuT5nBW<4Su>PT5YM7?~|^wq~E$A;BuBnDrvX;{A5}Oi>{COXqA> z6xI;s=YisW!QNU-`a$MAe6)X}Ry9~=bR#n{=6;|OL5jL$1I;FHX@dpoFl}qw{Ut0m ztI11S>QI7fEJZnX;~pwX>rbIVHyuzb=+hoiw30r(68Q4{lxwi7>soaZMFHCE+v+D# zr|Fq#J$kh#Kl(e-5tmQi9o5FS)%$oZe|{Sv(%5a$gc16s%=-t~rwZ52op+&eq~xGs z6Ft7vHp(STFYNC2!9~G2&KX(E-pN+>3BiUCC2fKFs0{ZF9j`y8ZWFM0PKSe^?JbZa64Q zW0Q_)KY@Msh@Su@d`pX|dbqd@1P0(zXQ;G2Y8&wy4Cn^-s2K31qZ$rOvr#zOUL|zW z4X-djN%1M?-cMA7E09)Mm0Vf^Ot@`5W#94n=+4F({<22RqGSZGF#Xj9bcJiYfqvy~ z#YUEJzG$J7B~O~DLrZTKq`?X_8y=@)QB>%BWc_MMY}yGshVD{76KPmDnH>AJwR2om zFWiWAIEj}(^Ekd90>k*{Du(zGJxRO%9gnPJzB@c5^S5oGt5PtJH>bcL^-68xuJrg*SX<0%C)JX7i=o0OCuKh;E zrN;rffO&*c@ZvdjdHmCmR|_{!8OV0CgKEi-;yPe@9pTjR$(2NcA|Mls3Qh3XG;-Et zsbTdJLlQ)9=dY=~%bnP3c6gJSmO|)lh>!}aBXZWd1*?gaS9U4sw38Lrn0RC4gYzq) zmTUq{6l#4}n7Z-g)r!cMOqm(w(a|CtR!75)5t8tD{vG#rWCi2iN)z!^pQ#VSr1K!Y zheN73Cs-v8M?5Os#6NjDAf`7|`&aE^*{4B?@i5P=whkmpiuyc0At3J!;61KwYg zDn>p<7^_u@IS`b_E{JadBXJn-_M%+K^-`>_4N1FPm+|yEmh2)D1H^6}=xsMrh+Tu7 z^v4D2kZb=;MG|;jmcy+Qx;__>*_Qs}Ul2zxGL$TrmYYXuj12iY+%)|d#SCtFkh;?h z;RTOzRJRa0P|Pr*->v)e;g zjs~U6KB$9e@o(Y(B*wJiz&9eugvop_ttUh_BY>;m-H`9*E1o3C$w3(#FE z-GCqd2B^qo4<9^ewNi0wS3B>gn&^u658{bIrg=Dk$ipW0#7};;1&OU_ld+n zM#B{1S4$p`IwOtN2RzS@a-(aL%M-rO1dqTZ=}ukwX6to`WZdP$;XIDNiY?k0vwu{II43qjo)MNK!qfE-u=T85GdtYI24z zZE4k?Esk;~Q2J~bbJa`4`ZuIOvEPp-hhK^6IKwtCYWZ`1Vz+`^+cUn}%)8TiYud@( zx3zCVHb&n&#8utr--&TZcTWJCpqft{Nm(&oq&YJZst=!zC#q?{@?XipEyB)ZdVeFNpEsnIq z)BN<<7j^x_y%L~>f;FUs@0O4Pi1SE;zSeU<61O~aw2+@k{5OcF)@p*kC+?%OYD7%9 z*#+c_uW<%LcVQEl{p2Q2_I^8*Ky*O6d2tJ=cP@T!bF$)cAaDJwK!W;0sz1x)d-%5z1oj|KkeUp^Zx~*r{&LB&j0uL(X+=7BL2Up&py`w{bl~{S-C^r&f9{L?^7Pm zH`gXP!WIk2jP@CC)s(iC=nt+Y_Cg}8-3r+0Jj2y{6Va{8 zIk&6i#FS~ty2a=^LCEBWgGrwBlk$UU$*qa;K&N>BSZSMi>MUtTb4QdlO~Pm|;1HYV zqk}<_2jGSE7YB$w&2}UvD~Rxj)ejtK?go72G@g;Lwe^mPptIBS{%Idx~Unxb@E(R`P*P9I@&v`5djbREVhA~ zri>z`6d82j5r3DL*A(Zp)H>=d_{qy{nx5Xqzn0ikt1_+`QZgC36eF3r>TXnSD|4PL zyS(|CKM?`lU#sS4T3p|S9%_XaR$!{ay?>Xd)~;XS8K$QnTuby$Z6&y|QmR8rVfaZ3 zjm98dE;`w8QvWoCMj12kE+2!~&trWxp)2XmPs7}8&Y&hnBLvj*^@=CO_~9J3+$)dc z3FDqRuH7?9MTs-qI7lyH>~ z2=Br7bD0+IxsG9oGYa?^Ihf}9n z&ID^Yoy^(g4{uJO%fSUY70HuqhrO%egqXvep@vH}Vlxe)cI=g#yiq}R`Z*@t*lWCt z>&PWw3=Cl96j!^gm@CY@43~qcV_znAp91s7yRSNU+f0k6vU6N>`S{wuwOcigdY~bZ?PpcgiPt`)QO<&;aO% z>wbz<0)AXAm0-1pKGp$92%#m?vI;qZ`sQWbLf3UJDTRdZS3A|bsG?OgZySlsbwInj=*UR>lR=f&x8mv+TYY2OCRr<1J_@mINh&VTCfHYO8 zInd7cS*mzI6F(s$DaZ6j zhB>U0WH|);o2P@hVqG=?|4h%6-)X+CNF7~Fvv+0Avgrj6@0a{KIm|0Gxu)cGT`qnQiy=DswhZKEZs7l_H;Fv;QR4Y@6akUEmw^DpHoYi|=8~8xu z*SkNWi4Lvp-^*mq^0vc0s?__d5kk zlr2E8!S-iFzKUDdI1CwBBWrGPBBx{Uam<@U7uT>hNA~R|9(W$CTE*56vAyYfd+ZX{&ulm($Z$)76s( zuo?x;8O@Ek%b#T-e=(;gtJhiC6J3@v=do(71=90JfoqwUo0R2spgq6x?X*k2olb|MWzQsrxNXYN{a{YuVtfue z;xm|Ecxm>;xrF7RH4{Vo(OQ-SmNPaa!6t=ePb%Jy{QrC;Nr(i@C&%%I0aYgdw8Job%&=WxAfl7MW|jsOSxnV4g7 ztq12Nu&7C^oS5anfNAjMs~#rTY)1BPy#2I_*#&N&^tNjOM}&Y3t|n^ALi0UD5Qf8Pi#5x+FD!X>aKU!3^UF~m zKT7U|`wXW!)(-^j2S56ZlMvDFs*Kp|e_zFba?(jWP|`LPx}n1BgJK$Y{Ooo%5IJdi zJG(A%e4o++hz%kHmoLdvj|n6w6`WZr8e=!hnILRE^g$yUOp(OI^`RsoxnbS#ma!mv zoT&UPfbxjCNyATB$b@uS1vke*j8NF!YHzRf?lljBflxCyZ!h5FC4UmS3Uodz-f1LH z9V;biOr%L%@|fJl1ljVWeZDocI+#GYF}5X-GWqM|^QAmK+AwHUTszi{#n@NbGAzJD zrNrQTbPb)85|7*xr^0$X6n5r0P9(n_=z$<^YboQ{0e5aXN0j$Kh|`k_Qqx0uE4uzg zOb5PzMo4BLhE!Rl$OPV(>0lZDt{rp(HzP(>VW!TpsLEUu6!c{95meqvcjrK8+?FOX z(Q^=gmxtEi|7#>f*qhs2h{WdPy_?#cZGSMe{VpZ=+NlHtT+WzoE>?wWnjo0l{A|}u zBy`CiY|((ES|p-6mb^hw157N))_&A1=sX+}x$SVH18uOBq9Uc8_zjgiIt)A?5!;wP z711ko5OH(Xa`2m%vr<@L6bz{h&Mg#)iOUHBYs-Iu@hb~j*`R2PkdFh5l8i*$Fx5#o6PNY9|`R?d5~AkJO2d8rz~ ztK(MdOGBw;DC#v%rf!z>F>&9Sj?Oe@Vgs&VU4}t6&7@;`f3-s@76>jBqhUNPS>|vx zX_Fj5tLD*hd5xEpGtdxiG}^1H4nfjmHi46DW&|$AC0z)!Ats4br?6kfl2i(BT{+&_ z>T>e7=c(i#k6opKDWNFX|$aM-!Wv)$kqt^{-i3nca& z!2)4*n>@PnS#CEL>zKF@-9Zua9hl|k&qsj+oXIt7YHB=OcW;`0bFn;Al%?hcDdfqI? zACk9758656PL)-KvhmhT4$YS(g22hBun~yhX>?g9AI$pBZ-+7fb}%9soo5x-q+5u7 zO31Y->5v4-5TDkN!Y!cdN;YM`omiRi-g7JUmrI{~@BF{~>h{Dx`CFd<_vpd1Po75k ze@`Aic=R#<@0a+ymurn*sY^$BgxHFv0K^S*LbU@o3xXSgXD(sV~#|)!XKQ6oHj&`?Syxs-z;)9!(6yqJr zdG%G!3eqWEv4MUy8}#^>P1PorK@~4rVz~O49pp->r2Fn9+AWJ9B+$d$Q(OQ&Mgsee}6dA5@Eq?~U(2J+mS&hqoi zTu0f}olDPfOR+zKy~)1FUQqVSDFPe~5p{Qm++|<#W$Um3tBud-Zi^Rsa|~~2fhu?b z{t=ZJCI!wxu>ZaKN5@>SMnLcvI?jRBtdz(6$jsw3=S!m#4%0Ky2~m|YeyQ^UBgAvuJB{a`0qpu1zASBNeN&;_rMP%Nq;PPrE2YM}m9HwN6Se*P72pg5tag6uvxA;KI8@uKQ5F!JGLT zk@XM65*zoV`mwjy^fgwJjqHV{yod%J473jlvm5xCN<^=*2&R7IbYjQK4oAh{qMIH2 zwC~!+4!sbBmSbD94L zjlucyIO|Yc=PbL)D%g0m`iPpAv>ba^Pu_zr!Jx*{i`4RzNQ%4&d6EXip^CM7G%TDn zAwLU35KiCmRV(~Zarn*{VLlNNe#7G5eB`2p80FW{MmNCW@P8X@X^TENNh}`%64)*h zyuh)Lk``NEicQc1yt00yg6Q}oJFX#y?-$aKOgN(!G2DY5`eI?W{DD95Kmqie62(FX%~gibpuep@ zk%;?Kco4H7Bg@{g)2uh++q219;L`_DcopBSamXKYd z@mhm*(qdDfc!jOUmw$SKZh) zu(z&VHPkAq$Qjyb=8a5S_&R(Y>siAxeAEbZ?s5k!^N>$8w4lL!Ca-%H8n0?TzPBw* z-rJp;Q6R;VY8C|P^GW}z;KrT1^Kz;6xy;09E{7RdhmlFxdXY%6h|X{lWa4fNg_wlK zgA2zK}KB{ zzDRvOI(YT!%k7z=G zH~0~}{U#g*y$Z%a-qzP_U;#%>k60#Bch1D4RoP$AI?^AYUHzvHaPXO1H!_lYPoJsA zuYU{r=!OM5d@;F_ZxQF%0pT+-wGI>!!)O?fc8PR(KICP2k?}4b7g8d0x*b9a_amRR z-Rw1$NT3}{t@NTI2|;ayl-Ha!iyGpNmq|O)fW497%#Way6IVNLaUzdXZcK#-mOY}; zG+MldL!BD2ZXaJ0!g*?pvq$eBXNYrgpvXmSQT4Kv=G&2&vJVUe!CgEFtHmiD!ALSi zS}&%1br_YBa1zKXUFJlWPKU>o!`G-4kn6Ugd7M)iE#V%yw8pSxV3ilNWZ7>W7y6#!&Ra;T&gr!)fFzDvZ}4hyckAE3as1D!^MCx!-m6!u0=}ZZ<@m3MpFDW_EW&?1eDLU#kNB@&;;+-`v^YX+ zX1g^kOtqfpomQ4@ZoWA<`sU@UgEtv1h0V>Zlf6V4)}f;kJ>TT(<6_v}?UK`p)Xfe{ z)Tq@(GA^$A(CYg6tSH6=5@sr*T(L?eC=Kf_l8jAkw88G2Osn8LWJVO#Ix#$Yc*mLva55C7sC(V`FpnKjLJO z?$J~0)?Vt>k_|p&dvF`-@pusq9bX12A}TV_r;Rp^o1}pV07Ok_1#oBC-u`z7-|QZ{ z$wWhSooAG$5|kfkZ=>zsDqEL)5b<^CdZMMafo;NoWygE_U%lGR4i0yZwompB_TdQ@ zvcjv?EMKHlKiLI46D-fZ3#@iqBhWO)SVZXc-bo9TqfL${ER)^r>*C(G5sz?bpct*h_i}>xF_}q-i#f=qLVvbQQ9G* zJ(fiUjnjAwpz*pu_bXL*cD^_TavUhHj(bYU==Kqj7+g>dG1`;R=uF{pY>a=pL4*9FUu%;y zm$qT!o{uJ{FbEhTrlz@X^x4URjtbrG&Zq?0DevW=VH?!`3r)JZlp1jXeiGaEa70)I z*eRGXS4*K3@k=@QNZ8{0NY67J&HnN~|Cu{CmcqhVVHpJ4Z}(0x;CYFF%jaCe0!FHL zIJqij#}}BW=EhNSVJhSOir_XL3#9WXA%Hn&YqVP1=%B<^zPX9p19k@^PV46s@&Hi^ zq%L~_oVkTR85iCSzy0b}OJHLh_~rDQBsVSz2&yb{3^o`H&`tI=Mv5oWQTY72$my1J z5s}2Y-Je(-9)0e22Nn8#`__U|aGdVd&v`h z6&J;stog%wt(~n!r!#(KW^yfV7E=i~H^B%-f=E?Nc%l&jA;%R)_R00VxhWEL7M~%T zo863-RsfVdR=-*_=(%XXK!xq zPv)@s^kAf=isugp4^_{($}7n%Cc4FkkGqd?_Z}BT_Is1rwmO>Q?axLOaa z@T8X%xbUzS$Tgnss#cKaY*5VbI3Y|m+HqJ9M_-nf@7;&5i*R9OV$fgOXa`ZYZKs2; zL=?VUhno#J=3l~5thNMqEFiV4ar+H!#$`U|%grctrK8kk39qWaD_S6A{i4?DV=$C%t%oBU*M} z2FosKVe@jcHb7``iCW>e?>d~I$~V_$6^#BBZu53l%m>|#79I=)^z&Amu#)7X$@rWK zE6kZm=%LGb#_|2V)93!Mes8G%-rzdc^jb=zfP-&N;j+?Wm)360Xlrx5Q3`gdo7=EG zoXOTQt7GZnBYmJ&_a(i=kjr+-t)8&6d4)diM$<|DkuB!6K1p~Xw1$#`GwUcY76i5C-ecka2%GdXA2G>#jhp*C>CZ*G3=P1P)XY<>5Tg@7v#&z`FQ zqStn!x-Ih@q~D=|JSVan2KkfkA@Ekg6No5??cKKGq6+2*4>mgO{r49nC@1105Z|wJ zC6XhS`{IP>3XTCXpx%^PtNxv2=nbQ8?Dj4={!Iy~-tR>1&a! zN4V@z5e?(2p6tBz~8i$w=n44+JDE`k;QY>7uyf~|XHia|NA4Uxv}ovOh=3Ja-8`h8y#Y?W6Qn%0JkJcF|ez!^=K)8tzg8ToDpnY8C{zx2;F(i za$nGMOIvq}UYs6$#sy^1QVQ<(PQdtP=I`@f!Nw-MgRmc}`LWZKHZLqI^n&7>)Z3z| zu+~)LD0)i?l}BFtx^EvF`~S%l z!qWhjHHaTx6*@BWxNoh&WXdpm98clWnaz;L2=ktZ9|ZR0H7Ma^!R-+93W%YB+MP9F zabrbBG;1r{vo+}C~G+ooaZ}|0iKA9T5^21hj+&31!FrcwAgod;8!1cZQf+p*0 zrRjDDJQdhA86o&i6!n8%l4ze5Y#Q}d#qTq)`zFI$96F9fVN$Wd zMAj1*`OQrgGSdJ$8|f;_MRO{Jd3Il6RkRPzh`#2AEGW(i)1`bjuPsm2dwGy!n@-o2 zI(*eHd_(?j{nmTS_4i|+A2ciGvuklqMk}LQ3gZ{Krz})@R&;|9SlM(I?UTKYjS%$w&OpFY$LTIv&(r z*lHaGMZ!@5svjiTWE>(})Nj}oilVJnLUnO#sW%vvJ%yzQ%T0Wj#%~}`eKEn4HT(|$ zw*+Gt(;hWcEsRNJ2yLP859XC=sF%oUH5LMgZEJH=!#%O{Y;zM$3{tyOr>E#4d{mR= zW|^B?%bZx1;H~#CVnpEIbFP9jHo=7pgMCnfUGY6}-p~^NQ|NqhJ~MekxcN0a_w3q_ zlk)~(3L^r23BN4)0@Dc?S)?YSjwmpdY`xFHe+|P{#NJAt-C~2_(hJEe93th?WQtsy z1!ia>%}Eh@C-8>~xveVHF$=^L&kL>w#D4S*2P5Y~w!`c|bfh?ck4*jFiw5-v2HW4r z)o2?mW>~~89d{>vH?(T{)-*BpNY|&Uqv4r|Uy!W?FF+NG#S{uS3FK*r6(1KPdRRps zZm?W+4XLm z5W7aqWJ7G~oI7_&#`^+{h!Le>@Gp|GR_I9l2Ksh^{kteeQ@V|k-|>^^|0J@$!nC{> zY;`VQOi(!;6hDGv6lyO~<)MG?4 z@D=ug4W%COhCXu<&?cvT!mC$!j_Juk=Mo8Ali~;`(-d|UO&>>?g4E?Dp$}FQl97d1 zWXhtfe@Ps~H`#a0s-41GHOc;5Hdb%~tnt2G<1=8*FY6xWbp24R^$GrhWY|uJxE{~* zA8|Aml}JWy8c%}0I;rL!?@J2_t6O-l6J`MkRh~=EAtVHd9uP28M(YYwMNd1pdF?!) zM;E7YG3GiRxb%;`baCOWpi@$RiF7a%He4t2fLh#8Hq=YXlVSbgwq|p?(miA?HzEpE zE?mQhvk5GdOY8trQ#g1}hv>AKp#?cu%+vYA9ToEsGLA8%a!^hwax%{LUhMLjr+6W? zY?M8HJnYOTon7K{mdF-{r#Ckbk>HX_qseIk?GnjA4RED+^yj>CZ~T&dzBIbLx#=>zluE-)sb3J(Q4=fvRU#_9L5LXthT(3N zSi-5}-7?bmiS5V||DtrwE&7C<)o^1t(mn`7GbBFIc7V@oa730|4{=%C_>8MN6D<$L zw2x6B()&?lMhMeu%6H{T&%wW3d5cP->eX8x-~kD)Y-X@PE7tPYnU3GW-25UUO!jPu zVttsAe^?j+uPzGCKAR93fY6l_UsnZ3=zv9(h+x5?Im&m5_=lE=oV)>t0BdFC{3R)H zuJCp{dk35FvXFN?@TQcfBszf8=tNabzV16cADA@M^6p20@@F_xdbuo@2{A}63%%FF zrkpjv5h}BkqFuV7+y%67{eTb^I`mWW5jnfQ+Zakb^uey0-75yB?fwdg2nIlp0~=I% ze^7T(Uwl`ylB1Aaqq^aPnJ5x`9ZT2x1~lKYKv*A^bb&UvuFZZ9J3w){h8Shb@#J!} zk-~jGUT7ypRPa_wM-KJZ<*~zM z(L;-@N(K-OAUP!NUK&Dd4jeWG3zI>?2FqCDWy)EZgksT}bvEj(P;%Abher{UVWMOx zYo!2|)V~sl!G{I2R8w?Et>LcAUGUCWdpMTtKfqr(`5nD?NG4Vib`r5DR70UYaN>E$ zoYw&dPJ9eReht$v#cRSSu}DS6X-EK0V#b%3pw_eV<_1p&9vbJ?!m|OVQE-1^U`HRW;{v6plF+GY^VE(Fx!$&ub&1n}ff4HMWJYh|` z1#|VX#tjF8!`tZrKpHa^b|MXOu4Ip0(+RSElJ+j_nmPztD>oU7mg`b^&i%u^3dwmb z$v8+}Sg-}XAk&i(&k?U9+Z$b*#0+-|x-m|cNDHl(4P9H;ptgP&r6z)jh}~>u;rM#0 zEDUzGEySE(Qh4o!<0dU*oM5?yL=IYUP^>kS=S4GLM8XFuZ$;c)z6a*7&=VI;c^ z=l`pOWXS2&pjpaz`{l}aeDL3U#D8q)dZlM9kN-UQ>wnjmwh?P|1_aY&z*brFvQH5LA7ctM_>!g z6OGsun5c!9c{N9#7IX9FravH1D#r;jVegt6RxH11*ql4I5y||y$zE$}ltj=d!84f# zlo+M=X(i|oWk2s-qUMW9rg$1_;(@#u^X|B8pKh2Vl*%_UIYK5qm2hFCnv?;DL81j; z=gA}?gcF4CH;2Y|?lBTGk>X@bb=HY>*45~yP$d39bLHyP1@uZTBzCuW*SIYyGdMT< z1fD;$Ru~Z)arIrkVbab#WDcI?SydgyKUbOW_FSeDQ)z*YnJ?yGy)Dj|5fuY`3eA&+ z+o`a5UJTTu#aCE>gf##SO}nl+fyDQ|YkSsWy!ktcR>p7SZnG}hAQtCDk9@cFuOd39 zjME)@ct;Z-kKdM49w20pm&xWn(Cj;RjK(y=oa~=JkY`Va&avW?-GVpq8oM`xo#4U& zT7@FDC2Fx?SR7yjf$_;SZuD03*`lCKgR^{86&x1i=`s_J22d?HofIKi?I{=uFA|~1JbKO{ovKQ1ee}>tbsa|~yS_gmhL!O!pA9qD+=k?rdq&qO6Y0c#ZhL?5B_S{BBCEepDvwJj8xYE9`jR9LdEUVgx2;&7|4lfF2kCwm*pI}bX>YC zEE`gpLYy}z!`K8*3AYb2KE=Q&b}3h~qdSi%(i(sJ7{0}NG@5rS zO99&s<3>`(mlDS-OT}q7K8_2%iXIh|yhwD7O3tY4{dM6ZJwke!{RrX@dK(X_%NVEQ zM=Hs?)R5-3`EI*$`5(_UF_*8)!{l68v`O5bDkU}! zt13*AR983F|CD_$-qhzoprPPy!JOAUeXK?vP{P!>Lx5E5? z2=ycV-BSPe=>wSa(Es<~!PAfYkH5%YC*prxmHP2yGL_n?gkG6($x@b~4}2TYZp709H!00nr)1lSH8<0@#(5N|^eC&?)_6pr%v8e^iu zVL&@-$Ng$E;W`|~r0t+@uI(5I`NVJgPm`(pp#CZM1?MxHk5mG~eHI=>(|8lrB%VQS ze~~}DkXNS1C1Tz02xc{HD)qR3Y~_0dI^*dCtqWMo{#(iubRQQ}#73AFnFCoqOy&hy z^_<#YbS^02*YGK}$#(p5Hn{@5;T7G=tksBW)aQHBarOMRW7bL-UJiw~v%^t-eZ|ub zW|>Euq=+q1VUkrqURKAKL+q2{`BOf|jkc7KcZCozr8je-YiWpyEAhh4bcRs$V#DBZ zxS}bc&sZen(#0G>3>C^9A)*qD@<@lhLC6* zf;|B*ZkXbNB`;{a{41ko3CLbFnyq~-ivN42L(aFBpFbYH9 z`Y>cY92${_$obO=_3LYp*zlh(-IA@5ZB#Wz6NUhBRDYwPcX#TVp@NF~^%D(SRm|s9 zQ2WMXH~Pkdq|G-z7fu%{*~_o{l}l|nInMMbyh(8^-Q^KP(1$x-0h^8~&~3qpL$Gfb zoxvO-c79kcE)!3|2OtlhKmEpOhEA9hWD6X-uoBM&0w%_?cWETuxv{KdeBoO1-9R0sOsN!$5V>i_;UmvnrifaD0iv%^%d95o-6KE{wLhN~MWuBC zv91;ti^jeQdCiTT4T_!EXI&{(V|v}LsWm1z&Mrlb z)5Fw~K8@1B8wvblSK*3D)S;u9<7a*b;|!0wDl0<&o)w1t-y0pO7+sh*k@Y629N-q z!Lo}E;FrEXUdXm3=pk~YW6&y_PDrtZM!pU4e>?`R1&+gKpUa>;j|wG-rad7JZmAEC zW^kWF-SjHBb7ut^II==qPAipyCU`6*yKu|2yD6XfVqy{-f53RvX)z!ybZPc=x*jX6u)+Q8Guz% zxor(YTMoQTlLAe!qtEioa#ZGicOWKoNrU;cjATWf&GN>=r)iuI_q>V__rbG24&ygj zO7ZGY5YYmT-62@4jW7e*Z%O;J^0Zk3#&_|O2iuPpkG;=%8>sX9ve8yS#|AevfP1|a zp2qkxqY#|z!Zmh+wsMNLFeW49mCODr@(}suEDK!N?|z?hF|5KqOBNpELO2d9uOILT z8k%57PK)8Hjp*s9D=pETJU5~k1wmz%8o$H3UjT$Prh*r$cYWZa-&hA@&&Nc=Tvqr6 z56*yfAy1G~23-LishQ?I>c$K4HL6q5dmFWovFLH(%X)*xzOw%|nqT(cv98j_^0hOn zbMKDGHfYb}=p!8`dX~4B?6ds0(X(D?rBI9m8(V9gTy#2XefGl*xr)WSM8jQJ1Nj&6 zi}NEc0U1yWpH6eWKRYNN>N0Ohwejf)S;MeQ6v>3&XXSco~9?BrYFsC6= zipS8n`g%NOtf5maN}VYOaq*$lz8KMa*?x5?YqF~6kt1hIwE5N#p?MfuMzUg*9{!u3 zo|d>{iHVwX&BL_d^__cdOMieLC$pB9%5WDNLRGWP{HyHX1pVWhupXhJRbN5{yH!O~ zA&XA2rp{|Yo>s~V&F3&snAq6|`L|#EIR9-0SD_*XpT8^T1$}DuBM1EYM!&t3t{KdR zaS4l%d=7o=@vg`6L4%r}Y5V|fpV@fctBS!4mj7ibqHZIY`S1W~z0yd%6z#@mY-yM! zId=!9YY`d;1K z%X+PT90KXP%9N7zgO29K6ov$0b?!vs=n7S?9i3rv%nM5)jf=8nH6~9`TBUZlsfFZ* z+Y9p+5pZ_2R=W3PaqXvk)thy?UVF!#Bt6?3=|Ytmpza%KF#E=yIdodO_ZH1Tf>~jy8pPhiWl9YJ8z5O0a~u*k{F$v&JJmoPMH=WRIa+zxU}eIAAKH;l+B z=Ok%{MS=LyoqHe;;goRxQMU4e7_Cr+VwK%@$VG6TN30rIa}UED@*7HyNV7T1pw)XC zY8T5M7Uy?2JWkxpjSsY1O7AStCh_U5&CU85BxprPS`B&>TE!IS#MOm|lgoIH*|`Z0 z+hUXXZ?X=aDdqMv7Q$<1jw^XJZe^H@-IDx|dI5-{i^-5Z*PMxyYDZ|KVJ3zP&b#*g zIpy&F`tmw=8_GJ3ddcM_S0ShARkG!JnXqn(O;}ds!^fS+om03R9mE~+c*b17w_MBA zz`MjYfT>XkQIM;|@x}A!soKBH(2f8xm)FDj% zwRs@c$NGBbkh87w5hi@V4F>|!CTq`sVW>l}Jvhy`$3r>3?YZt-ODMCOeLV1B5)?=w ziCRb+1g*j_Jfi1PVJN51a+P{?HW9qUS(0=vKGWRKTRtSx zj{NO`=xs+q0^?`$vx$yDEIebVPp%9(>!SA}#LG z!xP{VG+;)P$=e0fj)?j|?HOU+e8G-8%?58YDFGlx{3V$@q8FYn6YDh_B<=YF^S`jr6(I|B%P z1JoJhx&pnBeS*j-LE~9*maZ6B>_?~y@8x%;cf?}EXTw!8ObQHQEAbx}m=4QL5hVfo z0EDs~*)>!HZb0!W%el|gC*v#W4#faa>>mz%~s1L zI+7_K7}$vDRR#TRb5p1}&{KycLnXkneSElgwEO*w?c=Y%JlH;Z@jcx0{qFvY!-Kv3 zlkdMhdexUyNX_fyCt}N%Q`k5SF;<3kM}uBClMt(r>Mxzk!L(z7AM9~j$&{7gX0~6< zQ91O$z71yU!yugOr2b~tHp#DTzw@n14-V#|>8>cc#s>Z_pOv^o_KDvEgJ{KUe(lO4 z<8$#}>iMK!Kg$kIULAV1Sy(~QYoVYhWbr`e2J*#954PV{$8Q%WCDN)CX0npna-6aEbD)f}tNM8B96m zpENhy5H8Zjz*r)OWYASiuJmgdjm&?CE<0&jdbXTjCz=!!8Bbii?%*SQljok@dA0YM zuR-D8cP4}?vJZ_BgeWhV;bF=vg#7*J)$RW~^$!(86&V^Fjc;4$+s%Cv|jnK#EMv(E| zQ9P-8s8cU`=n?2#&5-|LENw}2MxPU65&YWX=>K9Gzy@xzmlT)^Lj9cIPz&*h?h@B` zrI?aT``)5=P&yd$W+>*$=QH|W~;_U54hb10D83sczZH5v`SaJRwei(CRpKHKQW{cjLr;l;`JF$zxi z^sA6~y=2xc?fX>cnG<4*8Ku(7)MVVG&%bcjAUJN%w@%bueW7nJbR#QvnuP%MKSL=b zX_{$Op2tG5;_ezR_0h#jxHfNxxX4j!70bh%@~AbF_D!`qP7AUunQJhv{#CWcAhBg^ z@KhzeOZtfzNlk8ro^5W{?HcZ>)-032F(>jMFWhx2W%O8TAr+}O@6%a{AOL6b@B@%w z4%g0BtJV}V)ws;qjJ)hsbfBfq9##M-cS9@4P^oDy=jL8yGHXE}IA)7ju5z-Vy3fnb z&UnZXbA6V-!<3UVw)ZJ!FKk<^rV*u-nLpBa`5>#%$X{XpF_=ciPB{;%)ikIVHkqci z$F!C0=pF>4qRrw!n~E=sd5UdbnFOJ2O**Tsm|6gBI)80}IWB#>QgJzikj^O~0^U}$ z?zX66ihYTtc?EUmqdsPgSw?45jxa189;;ljvPI<@ZU>)iyl>?qpZ-W=b}Lr&_5w$Y zT+XVClE~>P711F_sFY8LR3WLD1~4=7&+%AJN{%erfeh}J2oZ&^ID!_mIpQEHE^6;& zB3r^c;~wEa62PGKx}@Uig6CRHD@?+<^x<{SX$q>_WyN`+U1HEzVMmoSs5|T*YX$x> zek!tqF+xFnnE5$EljpTWfv(i?5fed>)vmxz3b84LcjOKd$+{Ji_w_w7nJZ|MtzTlb z#gbV)nbMDqd0al;+5TCExJ^!p#Vj*eCC)3j7>_yoFWcGM=yIu^?FA*71dhB#1l8dWpGpNOMTc2=eVA+H|Bv}XELsV(Et_nIt zMpUQ>{m6k2i#iaxvDiYaK(K|Lt8VL4e)yHm#xcTEDlZAGBJI2@)+JbsgvaTGPgyNC zgB`Ky5R!b2hW4;PK96qe*LH5b`PhIt5+hh=*_fmB&db&B^$Pt;9*ji({Z(B_AQ~P% zFam3-^Rn)gbSuikDh(Ha43)a($nWBul&yzWw6f<3H9!#lk@E>FWTA`<9bW2!(z716 z1U(EEWJkST>;!{HS(zL-iJW6c%5X_b%biF6wr~))$A3{T`mkr*o&WEX$Im|U|NSC= ztIhw1Q2;KF|37*B^phI?wo+rf1QZ`4>R>vqyVH`SWnpHQq~d)eX+%g1SHJ~ zeM$RjrEI+{`|0!bpi!d4sAcR~>OrfCw42g`+msco*@->MP3&=IV(;~2d_G}P8Sbug zjJetl6#hEgYbQq-0S$1r{RmID>4Ee~k^}KgYV3Sw3_TlG+x;6&e03B?iV8Ld>Hr4x zWAZ+nueSKu;8)ZcNfY$nky#^f;}nr!Xty#2pEq(h+B`Wu%oyw;e72{&5_qX2FAQm18->b%vd%`MDJ}dcAe! zSiZVgkOW>F%1|2*v5yn{MtmQ8`SlVlqc9n%`tOc1(z?Z_#2}7 zCtoEqTd{lQzyIg|`2WeiM#Jw2cWge|%7~gG+f5Tw;Ees{|M6$Ea&}+BrliccYl^ND zgXKiGIC?U!+u~|zBVEiGwfheL3=3>+epTde*LeR!t5T~i!G$qFw`(1>%mv#F-x)Dt zxiBL145nG~`<`c#1ZXJqG|9uJ0*K+s@HP$?DAQhvSrK!5#9%Zz1#_vusTBJe%#l$mqTJd9b|0uG53?Fnp!P%$vJl(=RuqK)e^Cro52OUA@YAti4)|puPk-so ztDBerEFvdku}Y3tlGe7j1gRlU@f@SASo;@AQqr3biC7tvVs%jmjYAkF=5mI$D4B~- zYABE6r?*ImA(+KgAf_bG(kwX?jTN5t^b4NOYj`+gN7sCAm;TsVBf>27kKl520;Rjg zHN}pN&qfEjN^%fkIQ9~YUJHEhvli>yxIY(T;ZmT{7*EPdFsEO^QG}y>2q)v9#JVKe z%h2=xgp=f{xLf%rmz|~_9em~c6%#|Xwukw29w_*5)fop>n5COYyO3^)LWAh9%4e9{ z-&H;?!m3$6+D5-cRm_oZ%lZA2!B~RmwKaU8kVXzTQfYRsO(u2Hi4S1wVHQ#qV7c@u zF$uGa9+y45Her;70u5Ev*zs5oOG z+!k}>oS+On8h^>*cVAbI15H@@suBecmOzne-_<4JXG}&RK6-*gHmAkhq)`Q8QE5O= zVS;qeZxLznpNiR}Ly#_MO-D*kW_q6fs7OS5m14}*&*JLwEiRqTp(9F9iz?BBetnHp z4Fj39%!aSMn-P?)>MfW~<=~=4F%}Fjg-3w!A(mKr=hDTaIdRWJy}qqhR2C&&*f7H^9h8hYG9pm$Dj| zg~XarMCTx9w_rC}tYcZZ_-a0*m?nS|j-4mL9a0zvf)_+~9#=ttvYOE_=9ny6+Sxl~ zgfBRW1iwHBP1U37Q9|>|mFW~%S0SQDqMdCj_?IPUo3ySrnp&{uhbV)FRO^mcA#<^U zjZ@5!8U8rWk6ZX_Pge}FW%~K9CamhBq7dyHCv0VGnTPA-6?A!sG6&uVPJNyr+*v{X zfG@~EcbKYmJR*MR+^Qz!ogNaN4OE7nEuG`zS3PWwNYgH`{2rB{!)$k!I9Ln76#~FW zvtuxVRV-rl4KKg1F{5b5_Y0S~*oge5zC^iF?RxYzqPJlAB50t+dx-8yQQ`z-IID1` zNIk7}!TE&vV5P%JV80OHAmk#>*^(s4VcNF{Q-c6z!wVg==%BSJSS<$U=pkRSI)ej` zFN#xBmSNBwDZAnV zajHn`#A8kTV=I->K)lQT3-J*cW^xVI2R!L5$1<#6(aZXt~9{T{grGfUmz$d)xj}+=Dr}hQQl#tDe11H9OO?>SSQ9yEO2fcHrE0&y_A{hv z&Tm?6NRf=79t}VCE|qA=CcL*L>mtY^j9Mz^7_^k9+6we>of&%MGLq2v29hTzy@h?g z$!&~}0^*^06%<;wU_Rh)PciN!#ougY4{=6{Q?UdMH{p_V+v_O>uwH+%0DN48lW(Q`5sWFG4 z*?cJZvPS%c^z&QHnpUUOxt&D^Il*G8V*hZ@q~nvDdOo*Bv-lmffZhFI)X^L>HdtA7 zahMiUA_d}FW61tQ?59yrYrk?&!>@g2OIyp7J$?zQ>u{gTCToF8IGO7 z5$I^2J2Ff)nz_cSxN{?v%>K>6hR-ohcu5XbRGv@KiJOxj)L(X4T%Ll7a8XX{fp3?9 z$K4(k=lS58vUL2bQ~!U+-{rdZvHoP#@%qo)Mg?@{D6%tFMTLg`LI#s&`LDU5KIAo=) zWvUJiGe)M23N$g+A2Qo^tMw)yz4Z`U&L2Uq++=o5n)90r$}>j2IVwfqtR$zezyU%I zz;4EcukUZlQF|Z00PDL$EQ|Lyd#B~Nw;2FSK(xPqIXV3SzU(`*$*WMccCEe?H-qNX z_b$dH?P+!I<`%EzExPl`z(Y z-ph4k{8k*F!BE>MUyKJA$MV|ltxNM&fcWQO;i!iy4K#s6DG>AbzBS zVPiq3#alA*yYinua(>)a>rfpd@!`L@8J<|^D8ZNn+Nq3iO7}5|M==RYxwlvAsGA! zu`FZ;=xji<=z`el^~hU;(s=9Xg9i_kWrLCO7SU1**<|Z|o#*aiHtKJf2jX#yCJVaY zd-?FP9QVC{lQ0}V5*a&j@)80h2pFM>TTKiKeMm?L97otvYdl@3U?KlH60nc19skJ$ zwRD;*U%8X11wA!CnGX(NuF_otEV3L>?-HD$PqVy5`3lO^v;sBEX<$CR_#O^4mNmkj zkecTF9uDW^czPIz7CwR6sPZu?TBI%Vi;HfC8^x!|RqMJowkxMjTq+i5g8Z?#_MQ}3g6hYf1X^>}JUd?Kw6l|*h$62t9) zdqQZE7upf3(|UtiNgYaZqbIqQHhPlvvi|<`y!s>YBeNcJ6AG8L>G|457rs%6vc5Jv zUE5$=GD3`ObaQi^h@~5H&koD@xyN&O;O>tcG4ArgB@d?$>uVG#@sHwf@6)%gVL9FO zM)|nDcKJuVasAyVu<%M^-0dN5v{NCw){Mx`Hnd&6>Hg&(wOu+sUwkRe$|qJBBgYux-avu#SoBUv2;Y+k3a=Hm)>X@E%W*0q=5i80ffy|XF*Wq9P@%y^rBbp`zIm_^dyL4x1)1@+Z+LzgByPvd&BmCm}>@V1% z&v*LC&XBLLc9H#@jF0xTJxzw*)|!3Y+}06?Yghrr@uxkcKe@$pnWIIitD{#5`ly%e z&?RWqT_O!bdhA;!r#n`ZM({2Fl0e(OYErXtDuE*OUC`a{!I)G2$-nE3{xAfUdHR2|Oo zTNNDo3TqItWdAgFiwnC!sGsPBEK83pO-EWt3ljNeTsPbWB#B6C{5Cs9I7xi)_7$dl z%+nlyek8(j95Conzcex3L(G)h-sB#yWgHtNKb$P&@v$sPeCQwBcZR7})#Ex`67|3l zsx|NiaT1p8M(-$=I$$CGf=*TNsL?~RY(XlgDCIue#*+*`7FL#_J_X!eMlfRS`3k^l zAelS*0E?&L396UM8S#)!$EF&b(nMEgT zSA5o`VU77z1hQPG#t*IBf7kd$Wg{BZUlNc%-_wE@__Te3thl<_@mfo5bg^lN)6>Sj zCux)=U*1P9rp%^Rvxf$i-NC3yh{Bfcs=EC}-4g=WRBG}swUl?d!4PnOp3lH^^(tXK zJ-An<<3KcZ4;f>R|B?<@fVwc>$!nehuduUH4;qyGHAm37X{082n7T;Y7xp@i{6sXE zM~A!bpBnXVQEn7)$Y4EOY>8Z6HeE`J*hft8jWqtIk+TYvoF?&liZHMW9-8<^1 zNLaD0R(8TS+pnfHio=y7_nwsC8GAK<#}|$hyH9sqwGAj=5Ovc*oM;H!0;|=wqgBy z4S~5C{wFV)AeJD2VTZDDEn=`W8YQg7oEhXZ!1P4cw^<^$y;Fum|7$J>7n^TeB>{#7?qo~=dMjrRDE$nh&#mewF zRgazSs_oEhfC5CYS|aYZWm)BS?tJ&r?N2}dI{r}6V= z3SSua*d{dr-r|x47+)*kL+bPfGA)z*%KY4Y@AB#d!3sLm- z{Ll^AI6-6YMFSo3pvH~+is|T`jrUow&_{b;kGt$!2^cJs_ac8b*5|LYE1OA2#m@RV zN-+ZKhOKHv?=l{~&4pBot>|@E7Yvft>me?q3s>r1+4rx910-9oM~4m*LUI;!9Cw9? zsA?u6|21HZ(nlUT-!-SKSly6zb7}=e#p)tmpt#C{w9?SwIZ_1AUP-EWVDQlR!}R0= zHy*{JYsAi*i_M+S(U3|0c$t6=AoAx(!>yp+Nw!%J#DipISJF)6)|*{ zBWN;1j4A@6}qOPYZ-<1C%t0T#htRBQ8a0W&r#pxhxMgon~7;3$M+99LmyHesM}lwKOjG z0b<|sZ<1f~1ovd#3&ypnhJ(;E*_j;VEjnp zIfSAhz}!?%i08bkE+NF4G#PZ1X8|q*jkOP#{-?nhdpG>2Uq=D<{`_CJKfU#V|Lb@8 zdGGme5CS(n;|Bh(Pww8W&i|)(Z~fr|{@3sE^HC_gUF@;dIx>07$s`Jj<1ZEH6fX95 z3G_+FXU~5erNc||b^tZi*_QCm+F4_6yKr;;W={QyfTk?!ce*;~VY8=S^s?Zs^`*P5 zR`|}lq3i4yd+1wy>++)FGP@?lf(8~@7Weo@4zS1f#^HH=z%O*ldB@iBqVZ#XN;mdj zMVD@I>ruC^>kJb2vlaE{l7G3WOa8%eyA7_p8+>=~?uqjcioMw4d1J0|_d5SNfgMA( z!Hn$xt0m`anJ-pKtsUMIQl=SZ`sR|97K5AEhU64)8$tI{TFjpXp7Eii;k&$Dsl|5> z_a}iEmz*bu>{{C&(Ymis;&oAg2!NzB}w~zmRw24c8#7|0liZzgubY3+Dbtq4KUW z)88@k`RaMOhig!Rn|u|& z-Qlxb^cGw~^LAuUlQojPTDVc9ITWJJ5Uk}w#39CcAHq}Eyd zhM)P)S(0H;T#RSOTay8xcqk9qd)mMsGzt@3xCK41{k;!3m?J**&0?L;lC{*3GIj$> zWZk2$O%uRU-jZk_iRs#rfcdB&MAN&$W41@?ImF6geg9r*>3}*I=AUFDV_5~=jv2cU z4E@{x@PD~p7oby>OrKt_Dc8rfgdRO+2gyGCBOSN-*?|(eP9U%ZaY9GjLh|l~=tvDa zH?dkcfx)YGLh`fD1ju52X^=}&zmHfT-mTSX%1Y`EJ&!N#*I9anWGt8KBqi%9B1@%Z z9S*A+`!5YuZ;M196)QxY|Cx;5-zIQ98YMUU{QC_*Xksr))WQw`6tBc4m~dtt)=nTI zUdgxeDe#(TmjrvJR9vy`l@WW(^D$M_q{5^B{9A#*V!Cr&rwm#I_)tZAFJt$hj+>s zuRm?0B66``kR;koZqpy>8~x+%O{P$=0=EqWy#uxKUiQ@1re2k%=*V#zFiLbDXS2EJ zJ>6Y7S(?Y@)#u2J7PTd)w$u|ac`UmTs*C)p5r0@E+f|q5^An03j$Qy-=YkU8PhrcW z{p;TG(SS3ur(vXa$SbF?Ao2|32^L4lKoG5d*Bl^uV(bC@5%1|d7f*%2lj2<*WdKCjO zceuFuSx&mAsWyFpTjp;7f{0JY<6{n^2KG-gX=~EJeG@LVTDJW=Dc#pk=5Ep+i6P-8 zbr-+V%B=PhVROW5n|jHsxROy0-ELq2opl`#O{%%*l77Q%@zx^CrWr#%(-$)R)LY+B zd%`i)N@b#K1+_9b+`h$zcB5%o**c`|&+8I{AnBX)uqE_8ot9hDxe+e5D5n$kEd2&! zM7z)PGluCv7G%xJsGAskC+Ax|EJ!JnmzmP9DtDf*a#B2bIfQ@Hy5V*H)vP$BJqaAK z(f-3{qDUKHSQ%(s1>cuktO}Ir%U^xw=+3whb>bVJ1f~FUv5&K}4Wdj(*(w)a()OZU zY6thuNH;B!ThNir>W`C&9a?MU=p=$6Pia>i7Y-mcE+luCfJu1K`79SDd{xp}S-k>Me9kwE zGnJX!nY>i0MI&~!;-WvN3&|)Im&W`k99Db#$9?S&BBgtW{jgu6YCdt59FL3QRpU+? zZ%(EB@d0OZu+;6O<<;U$RIi_sQ4hk>jhe2SCn%Oq&me?HuUZ462COkjxk@3&H1`ZhTsvB#~sSDiFA0~Z_v{Tsrv zz?q*GWR@vFBU9y0G&;5 zy2^aWgdGtj*F6r{KgpdVtq?G~LVsF%P@GcwJ1zbLV7xRYFL9h@QzpxEZ5qviBs|U; z`klda0H3|0J}5-`ibW)&L$Nh2nD7F3#pvMibmko3ZMB{Vno@nLh4x#D$cx!bD!yQ~ z!v&%2vlD8~ z7WWj86A7D^@bX+nFx*L*o@X=MU(KX7asjkqb(wzeLlBWc_#evnt_b5P1ATIHybu}B;fl;c z#(dHyP2X~;sZ&EuZKxNiCCk^VNP4|vI$(L;b%zQ{q0j^)`Eo*9=j7l`Hc_1M1%B5B zEF2j6f}SkG18eRZ9G`&K+bYG_`-l$9=OOwht)TClPKm+7W z>M*4Qf-&eNx9_uilQ-YcjvPJX|2`e#N){Si^1=n{9L~q*lac&e;+Ft)guk<%$pbwt zx2P}tPJq^QHFZ8QkTc_+PCF8+2gZi& zqIxm{%9p_~qzce#^PuxDQ+!y8TjVh(Y11TI9da`cQ@&iCjrept&DV5@&@z9aEO8_Gs>9@8 z+KJaCo0QoKYoZ(7FVhTSW8E8c?S|I{O>9>cic+pMKrvkJ@b&~k_ay@x*#%u?Xn>px z)=6EeVcjtFvUG^iY7wAk#5X5si_KbS%J?F_i&I_ZqOg>&0@fbod(wi^Qw%c4ZKmOj z-Q7(L158vYFS1*$ui&BL9U96%VEQ9FJG&^fd}Qfcm}3K<<+KcS0i^-F@MpI^4$(@% zGUfTkpxW*-BVYTF`avgh^0|NZWp?VH-`QK{eF+cslS5{DUz48sfb=GYH$31nefyUd zI^1H#impB5HT*0v%*$>+`7}8TBrK+<1C56?a-K|5q3G7d`6_kwF2jek77#M6e0;!N zd4z~69BtIXk289}-JsM!1Rg!YL{~p~TGD0qr;4J!12=NA5?#e`e?r3;o-5_B$8>7F zLr&ucnbcGqLR?H?fyuMN!<&)P|1H>MOojeD9$*L1rc0*6I`YSMuhr`HR9%^pYJ$^# zmMW?qwHeMds1)tI2Tj?W?1KD1GK0xn`z0%MLApE-7iR)sNV2=leV-M@vaK~DqAPe) zh2Qd1*49K2shjwMdM(LQ9Zrf+bR3Th$R~`Ca_av4E6F9c4L=XoQ@HrF zj%OXcB`b+MFuZ;JzBPbv_f~vDlgtnb4#P^TmT+>F3%|{RF5Mvkn@)}UY_2w)@7|at z%0*F3b@d90TinHRb9R=&!foK&-R<>wYQ}UlMaJG8Uq>%IjC?|dOQv&Ya5rIk3WT}3 z<}0)YXy%UVWyk3S;+S-)y?;xX0D*Z5R1brC%LP+sA^=)#VYh z72*eN*JQerNK=+{`q&0IIVON z*d;DvVm4D@IoH&ZE7?iWHm1`qFJBF)wT;XC!AYrJ5L(UIn=?*NEx6h`$cE$U~+m$t5v_O<2H_0fRSZJT}?fvGxPV95MHaYi@ICHUX!w-9rHUea}G?wCNap|Z^1cixM zpJX^CQn)`Bn{*aTpz>3}1i}Oc?UWfZ($*PrYGN8TBO-n#O0U2#roOIHPM`H|1sepi zHE3VDLT6c`<`Gu$U|{QTI2^GdVAJyJ)&UR2u{#%K8zi@R?8F8V+CIV9o5_|6IxDZL4ZnpP2wfPgtV8ZV@Y%! zKm3{mkyv_>Q&Z5J)!rPG(^sV@NQ)?LB+JONb;IT>oq>n;*&cusz*vg-(ZiEfigcHB zHW9&{&1QpFi{f(8XX24F=_of~RK*X>E*#37FqlI2DyXEAr#?HMNa79eD!6ng-TvnV(jS`O}Ov zup9zykuWxZW^)UEu#}r)pNPB*h_aX*qb;#XJkl+1Er z8^cOuDJ&rM0B^mI)X6~* zEx>uw&>0;HGLs``T8Xjr{xmEqgOu~@D;z=iC_k6dHIfJTm6>OAcCmQ} zjjSl9okfVm74>i`s~pR5%?Si_0?gpaaWnbj`ZckUNxkMZ^B zqsUCRA=Q0**8IXe8R57?{shBr3Wt%+(r#dl%k2Y(@pf8*4H@2UvhA6DA!beHS*n=; zNSo*Vm>G|!2y@Xx6p9cRx|m&nrl5wnj=q`R5#_LCfTP3IuSB^;Wej1=@KO7)?z=}7 zq#<4a;ty@wV=5Z4=$agzOV3E7MW<(MQ>qy86vx_vHawFP-p<~NqhL3g&;iV{CVm+i zH@poGiMWdVTY)%+onf{+R@p^vcrxX1;={1wZY5K>;M#kr)y3>J=TV&tJ2!gCZOuWg zSZ-GGGD0ypEwYkDrg9DYfx2W$bA4G;(2X2~H(0dUmiprLKp#gO7aI1(%?2{UQNPd0 z6!O6it05D8#|4*UNl09f&dw`aPG<(qtGN;`C5fds7tjg8Yu}gwyS#X3K}x%guoL4j zxy69SVuA)Ce^AtRarm2; zbdERUgTuvp>Dg&*K`($iH(Qp8MXDMu09TPS!r^U>dZ#QB zFgTOy);B#GbKrFy>h~$lx>?R-974Iw zQC!A4mZ+FKwrSz!^;L>AY~&V*knKy1GVHR2;KJ}-B6e8H8-;YL{FEcj?rTdhVYw!O zy`2?2l~70e9JdRw!x-1WWk=Pq*_w0ATCE2a$zH6?5npLT=AI!dIE=B_nsDZp9(0Kx zH#u2b>{gQ?r%z--5H~mu?D%Q$G&`ct*O{?a)L@PZr;-OWJev7)xB84=aRwdAOHHn! zl~`;pUW#X;2}f6LwWsM)KMRW-hb%_Zt-}%@atJcPgvi(7bh`OS7lj?ln!qC*alY46 zBM{xKrenP>^^>5K-oA{J60@{qv_^ViSPd5i>0+sdoII0e^@UKgxsXBy7m6~&qu1N| zXoxK4)Si5sod)0Iiy%zjm?vIGG;Wb0HEf&+2Tzd-W@RblJF_dP0@SJIR0376B{?8Q zB#8Uij67byE6B5}kASUX173BMM^o;u&B3NGy3Ey+_!CseNq;ID|~w2 z7y9r0j(ig>sR195Qdl>Nce1D!l9pLOE4Yl@V*#}xM`9m(wZYj@Kcw>p$)yJth?8kK zEHg1&MbB4lZ<;RGtODqI1g<9#1K1deUO+9-+EZ}SAq!T6v|HD%gjeK9<*_);q)yl= z*1D`}xw^8OR+3yrCKlV7>&wEz9K$fDLI_7!7TN9fIDMzuYXQ@~q<8ALFsi-)b2g_l z5rFKbA0!e$2}NH;zm_OUjGLhFmGHqJv0;gdEm|1d;7QOFQXA5hQ`rMNRkqw?DvOni z(;=Uu1k`FNO+G7RLs|-?PJYGU)Ofnw^7sb0J9!Y($Fh^EI*O9)Mn^D*GVfTZY2~0u zwM9}v-K1j3Im>CKJ?%5?i(aYY&B)`cb?j>rT;c7TR;Vo|*Ge55|HB1?-=dGsf91Bi zcalwH6FFdb`)1giNH*3xwB}mjuOV{j=6ODy&N5EIH6&iQFcz#vUkgUBhv-)Mt#9d= z6Dy}%aQbv4#a-N;w3se zSMW(j`8Wn>r2P#CkDjH^andO{1oL@|E6RRXygvsWW~;dgECAf_b{`3n(vK|*ayfd z8*o@}%7%?Na`Iy0IvW_s?Aof1qk32R-aS!V@{*{EKKB$k`{@uB{Zi-X8eqD2P~2KK zsFtmo$W$mGX0lqzH4;l-2Eks1QljrvwpDJqMq}mgLI@^S3RVq{q1e66YbTJ$Y_8+# zGx|%H>(c@Yh7~wyX}KePnhL1Mf&+0|XJAxS*PYP!J{*5^Of)?bVJ;QSb@j}mtt9tc z*l;|3MWVmYDu+}@8y9YHqIFR>fY_I}JGejt7P%Ai>hOj$#H77paW7qo0Po)AD#sdJ zQfD&*N_1K&Oa~EzEYpA-FLH(HR@Q}76avsf14#wBgknVVx;*po51agou9oyUPW0!H4^b! zjdTP>M%{MTuMd#Rv&G35wc=SW!SFg=hf5|=w)9R=%2X2?+1l)Uw9z9`({Ar zl6GHiFXYP7GKXJl{QoT|Fro%#S!!QLt z(X~3R8s8AtbS{RYm_uQ)7Q35HYVJXal36sCBYEjnOa}EFShcDAIa<`v>{EVHO zSI!+gv;^C26(G9ErcF9EOGEoHt6e9_C`RfhQEScDh)2@MyL6lnX|XW zDd1D)MagBLUY4U8DM&Ve_7o4Vfbqk@z(*Q2KMNGsas#XLN~%TGo8U~AU#@uKvwHd=N!1MdZTkyQ#mn(<`G^BO>IEfFgKI~$!ynby}++uuX1h3 zAxeGqilq!$g&vN%`6-#C#ik6~;&I)U?_uYmx+X21A$M;OkEW|B`m~{p1#LmgH+6&8 zP^Lp*#mbzu{sFb&cmH{B{D%fQg`1vnJ^sV3&;D@tlMnds|Dd1kbcs^95ubww)mL$C z7;<0L=V)Hv<+{b5Fv%0xDb4aOcggUMI8R`I_j-}z#yt zUBiUMWm{1ivLQ+ai=^=h4oQ3w;yEY{Tj<4U)@O$!ORn_VYtYbe)cDZmPxW>OU&O$m zhwEzApi*#p=ThY6jTkw%IzL-m?2f;yS3}U#-i<1zpH$W((@PGAL-9AdQ;|M-Cr3zw z%foRzj9%pFBB=z~_>B>q~DR{mIu_^RClsfSj%yy*V9utHmH%I$X@1$k+CsG$G@XHiay33k1! ztg*wT>p=IR-s+;Zu^4W1HMAJ@4~a8w{5tq&K_*XE$JIu;1L2+XH$w(#53^@~Bi!FA zPI6nH-u_MKESl*jR5UT1b!pD55Y`&+Vq$_0#5=ccCC|V1gbK!bYkf&Nj7&Omkmn=D zN=12nZ82hs|1?eve6m!o3TzF9#5RUQ28C`->Mx{XyJb=_Ai#a`;?|e=cXu>$9D2LY z=%?x8(ylJ+mm4fUX59MPXOM4vR0`EHN1EJYOn<=Ry!C9ngh_ ziUSZ&7mD;yo)(aT|K*G~v~*MH1H+Oejs@?B3H5@V+HhE~Th-Nm>lS8NHI8n?T zUfHUI-g>R;fETmwg_s!n%lj2vMtsVTPyv4IZwvGaFrk4&#V=J%Ez?VYeg(^ylk0Z>rPrBD6GU6GMC>b&WcnX74*Gt?a(TQ8-8{^n9@_% zj5b`hq^fYiSc})Nx64uZXjWW$Q~INXK`NkWMY-}?f#zb8{g@*i)LO!N-U?$B$zXs8 z8{z;f?p_vi*i#3RJHzB#E3aq!-g#r(h;|?=l!usMzJx2sNaVXM_iNmIQss{4v*II3 z#F%kmiGJ?Hk~PI~!!Lqo-ftwL_y|sC;A1QUrxM=kE{$Tk$k$f`p%BqL4tXD0MwOLt zL)X#hCyF1UE6)#gu?!5}t7K9ecKVJ{s!PGx)ItGfvTnFISTYuC>Tps3eB&(zRW2Ec z1nC))+*8xP_)lWdxo8+!ZzN-});Eu<`HB^0k_PL4;>>RGl(S9w8p!*?Iu{`B)t z?tb4fHT;6E-kc8UO-6IGS@lR63DNk7@92`MOg+w*9AoC@<#8IYvVMo}8_)Q}`|Flg z6I_)ZVC8~{s4nBA140KBL$`zm#`99SkUwPL55+0XrNk z;tE^Nk(kq~L*W)ME>;GrfHX9990ugS6-G>6JlJv*$>t?Xg+WnDKLoNNQT|QZ)2SGS zN^ul!EyREzoK5t?YzqY0PT4}4k6Ml#S{x?OCkLFBLnC~~RuVrv;S3MKWef2^3M-R$ zV=B7`(I#n`Nj-Z#!%QDA+@a*sBVkk))t&cVRCl`FU1~TUuvKR8yrfUzL0A+S*=vrg zeiDc=$vX`isme^pLvKXw%Ipls%@yr%61nXIIRsVqsH)BxsWNp#)0#`GtXKsd@LD^?_>6?zKO7U75|6AB0tdSbn; z+}Yu{U@1kyJT}OR>sFrO`fgLsJHi2Xh5H!S@S97Lb!}ta<9bHUL(n$*-dsoL5dh%w zV&)rYTnoJ3Kw1H(b8YsonR_%Th04+K9^Paxo4kW`M9V8Qu8GEnC$g32`>e_RCsRX;6R#6 z^^mEq5y{k7SPSGksa~8o$0wIEdGHbE{_Vy|ECuHVa^WM1R&(3%o;P|elC`WjFX(+O zX=)nuMWjNvZU==>n&66@aA4#Bp=IN z_sjz+AK&BbYWz-@%Kb>b_Ghmc0Ml6gCvo~%vHVncHOVfXt7*z5SoDu9kr9S!gt;9C zD-ISh*Mgbm_gs0M7?X4GFk8-wt7JScUS;Ckh*NS&Bna%%R4!a_%22tvxX8?R%m#sp z3Z=0c44^j;0Pa(I4VGJoz6zpv%uK^+lYzvRd*b$?$tSm~_WvL5-uYnv{~kXdRbpR)gLkeO zpHW`qc0LHVSBj-V5EPaY7Pk(qV6|biQNdC#>W@h}j2QSi&*-0E2&=g~)X&e4f zj(`-TGf4A|ko0V&|T}+n^X?x{Wkoh~3b=D~yI!fX51k+?)wYdRhkY5QmXZ*^{VlkcvX z=1%u}+@#wOy815EE9h^-Z1H-?Qek{>{FlT1gBQ>Co*wjDh-tKF9EPkU1Xa!%%GMoJ zk;Mc+vRyg|E6Ey=j9spR8w@m7JsH6!;TiMv39PBgLdT9cy-MT?fq(%X5r_8}qnC>Z z6&X56_!QA|bVXX;qCwm&$(Atb5zYT#nlpK0{9&}tTJd*2w{?)ZHsSWemQh47PF>Zn z&JF&jHfJ)ToJJLupYD{-BcQSaZaFZR1NmsUykfb*S*-?0S(hSol}eE%4@!$yf2@dL zdcBC(ePo*Lwi~1}MK9*HzdDkfg4MOR3}sO5&Ic%G952&_gFtV(+a_X&i5Sw7T^B=a zduj58eQk|m&!A1)G21$12{3xwQ?Pd2^b&ec-2@LMx32S3Ti4wXhJAaNt{-Zt&C{(Hr#k(tD)C}#t5np}XmGG6W^3zdetVWA$J%SsQo5C+% z<|=f7Riq;A62s)uA}~R^;pU=G=I+TeP-89?GvG`>WGU z@Cp4Bs}%j@MY8*45&mTb!HTei3F|UFb2xsvSp&qcxn~~9>|30`oRFvab_R@ZGmRd*Tn=#qIGadr zggkLgT@1O4y(^WOfS7#aJW96;Cmf9~A9{psDB|L2p>KKOrrkDre$mawl- z=Pu-Vy}KtUN1IfZ#MOKz-hk)>_z#BFhv7r1EGfJxG{>>dc+)5&3y{fT{|!ov&qO!C zU3tV1lGCQw2IpWkkFz=lCQ{t?8egyTo*Q>1y#7~`@Cc|c;Y^uP>=q3f9($2`1ZN3IGUW#C6Oj6LH1oM5)p z>vlP*p;%p{jJe^QWb9CbRy`F)^^y|_V*U>dtL&aFFl1_CO| zce&S2zB`*0n`vK~{GPsCDxFHyy!-I>C)NAytA3K5`Tg{&;2ESO#)DV>aOblTK0Om1XiH1D;`;zV+|4E9 z8VM$s!Eil6(Rzr_rFddYOXeXL;x)m0Map`y_}v2Iv?rgw#mG}qh^)1wQm-1rj#s!G zu)+Mhgp=M+{1m#u(s{uvJ2I~Y$F7WyjnkPGRU@RRT9dX1-0-`eiiLxuu4LVMVI$(V z8XzmHSMlHix_3z*qFNl~=ZF*bdX_xwLWBMu$zP(4>2dgdX*GsoQAB(HRd&_)bYwtK zxJXx1#%>S}Q}zQ(=Ws}Tis!4088FMud45(E#>}(;Ve_&;H?=Dh9(2K6w1J=at_znl zL$taBM=@|pU%%}*c{pUgm@3|rudjA6?QzMXi2g$?`vEJEjz$w0f=jM|JQ7q1xUQ#O zhjbT~%rB9w(n|q*v*(gouf|wh(hmRO+7L0^co*|# z*dKXPvG9z8uh&0uhgU?5p(rRVsSU;+#ofNi2-cmIMBx`DtJCJ^B>>!!@@^*IUN? zS-N&Zj^A&5@i|GO6cu__jDET)2buQ&+F_8(A8g8aB;3c%gTaM7ywL>Fh9wDO8l&`i z)4bFD=2pVybEry?QXJi{3JexU2LY-ZLoS#wSS<@<9*NaAe)xa+=`VZ7KTneKiW+<) zK}=KFtr>+snZO+?%6J*5Q~{hE^ns^i+SxD0doPam#^Z0FA3xkp{>xADp?&f*U3T<* zeDc-t!8p3C_MtjCdcD8>Z~y22^MC)($^P--$>ILqlU<`85b=xzJW^bzl7+KX<}&R- zMd0*nvZ5dD-+m+m`qlj@`R0NdEYFREe6PnY1}pid={?(U;+FPT>diB!l=A_p?3I(+ zXiSpR>@3B!*+d#QhD3|u#F+|Zq*yw8wz8P~QOIgRtDL$AT2K6vVT*x|3sJ7?FU1A* zfJa4;OEn3+Mg=2wA(B0XCuI$xEW~cZ3h~$9{*V7D`S$qvv#+?nn&w)WQ|?g^&UeFE za>eEH;>cbiF1)m7ciMI!4Xn{Nim$1w>)iy5&B_L6msQ%a#p0TwyRD(!;PHqQ;+6i} ze@%`Lzj}P47QRbJb1MC%@g@@B1EjL zSu-T@>6Pn8UG}(L18YY6rdQeca{{B7CF~|nEMw6I&WU5apMjp)I#Tsi1vI<~9L`o| z84=lHN*Kwn4G+hvQ{4n&iE1Qi*E^JiqD;sX46IA(s4Q`Vw!|9{ow8J?oR3&86F!Wh5122YC#l6D&TE4*~ zd9^pd%zw-E4P}GUyH)dbA42PR#x4$hN=Iyz7qBvRM>Kh(V!0MYcG86kf%gC(q4by4 zg+$B-WLq|@kXe47on4VBoYWttxK2E34UI22*P$&}we3-?Pb)J7SNs4UE}iGIJY}jT z&N9zA2X;fl)0qrM@-{TI{F{FfT=o=Kb=qNV3J7Td z^PpB99^8n2$gj*dvpb@V7=F;yD~4VV-a2egeEX7XPrAlQzT6c^;dS+B=B@-(n9k2e zjgjb3#Y*zWym{#=7z{=eGDAr%5VP{UkYkd9?L&Ozd1dv>HtiG6yu=cJ=s2Yv^r+YF zCIZt|zCa$aiAV>ZV@0Dbz{g75N47^I4_Ug*#l_=W+|_E}fw(=~Uu+l2@f02K^4#Bs z0hOQF_zge30&x&s?LZ@NogSkVOaWRRSxGa7aN$o{sx7p| zB`Yc5yS}>JmYeorHyMuP8vR`SbJ%vf8Xw@Zj_3Djy40!wHJbbdO`t!TejcsR;0^=W zAh9;IPE-**tlqmLu0#~S>?WVz`uvtHxyQvNDg>YnDD3D$fTdN^Th+VrHi(nRcS5Tk zXbu>55Nfwdsnc+#OyF>U#j51h*sRhGC|~&wuGNfH#6JAhoJ+?n1jbANP!IWJ81+qP zYDvKmrs$Q(Y&*lCGvJk61ZEbjq!0qB0+PgKwgs9*j742PuuJj&!!wdC_4438YiR>0jl3>J3_I)JgGzg0RxKBTBvP}lWf{B9uN;9Km zlAXVZK0?dP@YO&L?mMIEmA}Pd0VzMBazye~xJ^v37iUn?&t-$Z=naw4kGq}m#E$0h zBAk8KQiJn+vg^GFj2TkRVOLj4!@aU%^{9n8V`tfGh#^ZSGIciOLQEUR#8M-bT_APY zft3fiJM96A=_WMfQ9gJOlGMB~mCe}KC_d3JKoGGEW>73sZWf1s$k=>=?YEA)W0?O2 z^q-fSyQpvB2ka$GuWc`rltuU?4x82A2_{vQllxmC6;0O&EO_RPnd`=4iXDOi`Rr`7 zW^X{l97Ul;wb)ZvE-mI5%F=?ny)-#+O?OPWZ{(53TV6*I&j*$)2S;j7vf=J_Msuk- zj^%;Gjt9Wp5vmZ8IK*S+FbMXm0HkUf@Mdkf!=_f;!Uz!ffKwEXmxJe`mjXqOmW1(K^!ay zh0>BR-ZEdGGlftHobtFjKtm5+QXGge8v5+khy-5v&b-3_Y3E!7AdZ=<+8l5(2@)H|-YU2>_Rf3S2jAI}rH46ob=P#) znJE#Sx%E)#qRkzoH1KLV^A-P}&!qhC^XI+uKegt6pdf+2KL3*r_)q^(#s8)YKIDJ? z9zP$200F|DI!zEln2`aqS%#^Q+tNWVR{~AMWxJmy9Y%xQL~Onsg>XiVo$`1DE>syy zPX&=h5G~O{TJbAu|BTX8kz+KdyeD~1u0?9f{CCzp=FTO-sXgG=aI?Dhf83B-aK8=M zfOK)O;UF>VrEh&vZNtOShIAmG;_(P11~_fZrx{G86d;;ih82LRVt>#nQp#{U9jnTy zOXG|&wV{?GL9anNIvN{%Pq{M9j0}0RusDS`j5(~s@G()?P14wC@_2&j{M;-9PE#dO z=blLBAhjU#D^4Pr!}vshDaPeh{A_iHxblw1#I}5~VEXUu zvgW@5#$9qkO8YD5e{R-cQ?O)Q6jBzf%S6(}8$*6z8~dRc0=}a(K$b;6 zEca#S5tlrxSV^bT$*);8H}(9h0s_uPQ~uI5gEun6ZgL?`o8d$NYOsr@Iaxob=V9pS zcY4|}o&0X*Hsv~8oj2W!#ff{lUdnc|CZ}TdPdH?u1(}vm0P)1zP?Ud%_9HLWM9|L8 zDhGZao{Ve~<`)ZgaWUuKX@=uOSa^_<>K%Y8yL36;-A#_^>vJ=az{=~q{awkeiadP% zdfcU7%U|OTVJRzFqO4~Te~`wq6C5&z)!NK$KJzMt;O+NZog# zV*0T-xGf?Pq}JNT3*kG)a?*z9vy3uxJM!_~YC8GF&(iqS1ux#_@1%&`3R>GJ>isI? z3=TVW=yK{PjT7x$Xb%94LUkd`UXIF1OEhPIHtx1+ItTYGOgw4Vuem7V3#c29G#f>g zgZ+<^Q4&C(Kq+Wa7}AOw)!L@Yn2c=DWv;tNg?w~ZfG4s|=^0?G2K325+Pcs0_(b&s zZNE9`+H}YHW}Q|+Z|!;Ec4TJ07_hbBDt)BO6^n0ZP7AYS(os#boT%4aW=y=#@jRfM zB!q0Y7?21Sr%2aTTR;A_2kQV&s@nWkjj~f!rTbPY%Tw6*DpvO$w9lxo)ftGD;D(V` zReBQ126q(Wi9%2ZrIMW}j0+%L7$USLE~I?%8mbyCJtCqS`YPv*2nk!TK;@>02^sy4g zrc7H-0;^_B$JvissgFulP0tA%@_b^vRDGFGu%)JUb7xv4&z_%ng84Gj$mr~<%Zm76 zRQR(Owo%Q5to+q#DBwwKQ2MOh%0}%6xl~EHUKwm@y-+8NeY|T6-grv(s~5@L?owaP zlN^Iy^t~Sy*5=?uSxJWL`?eDn>Ri+5)(NBGC5o_Jac(6Y3BPRAeiGqSImo-ap^ON; z2`z7$Y5HF9fubb^p8)mjhq5K9g0V>&Ugj*twR|@ab@tjzR-~6JB8gz}Gyb1|z}S&Y z%WF+1#3bqX)2OSJ(@ad~FWCQxQPVfeDVHKRElr$+>@f?{;*A|03IiS4{(3&O_d>@? z)9Dfy@_FlLGK%b+3EYP#^}|C?fxs5cfVs;7I0Zhk$>q0Kt-C^iwYR|8xr)@1}sb$;lkI^%DDmcSd z@)(uJu{qy%da5NYcooMg#qU!eIvRdaz?L~b-(A106PMX((1~y9udUo=_grFeV7iSW z`_gCeD|R7|n=sSc^V8bsrA?zJ_sjUz8fX~B>L6a$2T`lo;9Br2OL%+&5a(WR*Y+s_ zC1)8jLo0g#xeSfEvE(U0$K-egn;AmkyxSVoPndESo+|rrET6r2@r5nwXTkwsA>W2e zFG^Uv98S9Q5R);q6wtiqT8mqyzs-H6f8X*CdRI2LPjBD3OAj^C6xV}5IH_=9 zcUJRwxtJ7}0*Gr!6>g58=WJg2ig!0rHs|}H-F|eick<2g!HY*v_P%=Yueb^2 zGFvaF)9l692Y*E=TQSd^>gZRSVUviLnDhkXY5JuQB&6?&K_q`XU{pmXtwxN_J>slZ z!Q-zU1=J=~K3#W#%Rm&+esWFl#d8Ta)ON0@*YYSDYLOiIfeuz7QlhT=hFNWyU%-ZyIkxwNrI7!;HwJ1{iZCdRyu7gDmj~oL9xv zWgOA_<4vPJ8-50-qjN^m$TE^xt|npX5*?w)71oR&;kA-f-cx3G#FW#S1?nJbO&Q?$ zcHw?SW7DjB1?2mAaftWla$}7=A7|PJR_l6c7NTu zQF$}b(jb>A6BC3~nsVZp`$N~}Z`q+G7ruOY>(*`UT76)RLshydh)oOQe&xO_I9yxl zPaN3|`lP>cu6wOx4-H3Z-)DArw)UPnv759J5Nv}Z*A=ljodREd`Grh*HXYy%?%Sa| zx#6L<2%}s?|5hyi6uz03d}#-BCTn%*cvRIZ6!KNOf{vlsFnqtO)5xn8-c=>0Sz)&z zdE36$@pgZ{+gNMf@$#xRqd|PXKArRmy9h?V^HX>h?nCO|I1%Pm73wE-9{Y-gn~!bD zz2{m3m)Z~MM*W4fyL@>Si=w(DvOp?7oUu50tJd{^s^s~`0{?Pp{3cp59RMmh&j3%t zEW1e0uCCoza*57`|D$ntU9)famFy*>9kN|2bJ;uJ=wjR*P`vk(ll`N<>FEp@O{#`@ zw}N#HOjol+w#hO^n)Sl$D}PI@Zfhs)de@^GkFGmInDFXLl6{}vy4UcncAKt{Ha&4} zG&*{~hr?U?p_-?p8K$A5BpNTSD>5Bn26i^C7OWUNI12{n>6y}JDGhfi>K%-EpeqX} zS3x$Vs6)9GC8^h`Hu!&6A@VK)JD9!ZL3AU16nWQRdvk!tW4|Vy10nA2Gmm*cc{EGQ zS6!Eahyb5~&oSz_&|PMT{SO_l#{|8b_@qEsP7k^^cqFc(R@LtGCO6O zKNsL0^u-Qjj6gp@u7bWpQqp9dy>xX{G6I|~#99kf7OldPY{jr@!D%=cODNQ0D0v%6 zGW*#~3d_Y93Z2Wfc^<|x_J?9F@cToXq}$M^*bMw*TM9-ZOZpPhQc2&X=jB-kw?3z0S|LL1_v5Y2I@b+BGjCZKXSH8IWfObYb%Fd2;gjaNGc7vHwx%W9A~d zc=CDLziOKH-{xTa1vA|Um4R8Ppkm0x_#~q0<0QnH`6XlCwPt(^NXTpD$I$!GV%MCt zX)Uo@yER%P#Er7{>M<3cql7|1!z!-gZIGzMqMN0s*{rOD)KJ8R41!i`pS4b48l(I@ zP_$S_a>c%y+|Z~UVTo}PuD_f8&NGJE`7Nz)#rk0$KWMFDjeq{hCwFhU#Rzu8n?A9X zp~xh!hi?0j358sK)I4h88xNp3fd_CNlT6s0P79cDl8M>pyc@3e_-7k7{&xKJ;gcuB z`E)D3&8{mH*Ho=q*|_S@4hKcG9a@1A-IvQd(TJn6=v<1VGeDi5nAbH)npL2SNzH6!UpY*RlV04E* znk8TX&fg~^9+Vlfej_R{%mwB3<(K>TX7t>+tL z!3-TV6~i%wEZz;!9D-Dp8BUeDJ8P@N<5p~&b9Y5B^H1%wW3Cx{k5$n6d#SS^S%H2S1f?M>9o^1Yf#dRpv9KlS>O!eFIL#31vMkcQ)kQhcGS@|!GiZ*)mjZZD z2^%pTcRf%PB@a?Nwg#mST&rLY0<;Y+tD{<{;1E}Hnxz-48)a&wMkipN7B=U;d!u}T zLr~K$i<#zaNU<$;1G3XWDsWM1FeNWNAm5BsA(#=XynJ;%bY}hh3;J0b-C%(hs~gs5 zVVFl7noeDxMYJ$^$P9a^<{B{q8(f74&~h3&?yYKESuJi}P|h{dq`31Whm+SBd;I2K!GA z-9%d0JcYr$nriuv-*QrT+U;8wFar-ar2$K^m+%rJQ!qcjveb3%oY}pBqrPrX#?XF_ z;vU{uZc?+3G_YSLuat7CGz_EQ3=Jr_a(0iN{%F$uJXC zj5D+jtTxmZy6|W}0&Y^SY~*J-E!3=We4+UQy`F=HSi5FPR9Gl4Xi`bl=}lmYb9!3J zRrEC8bnGYzwVs{Hbi*?o1ZeA?&Fl3JzfPZMzdCLN?33Y<>%C{P%sIX7H48XC(Pro` zkAil(QpMnsTix3#fx3HTEiAC>;Y{JREWx?Pv>o;U#rFEUC-Qhp(7k)xPm}}Bb&jc| zVHPVB!kR4WGd4H15c)7UddBM%6+*sQ%j<^WAtB=w@Dk@~K11|oOFCd9dP={!zR+W- z7`lo}ZrzfSp71FQ`cRrF95*2ZwA%zL?v&xA`AOc<`TW^c8TGz(H9$6qd@ai zotF+zCV8rvVSF5fXMiB5TnQF)xLC1nf=Q^D1ECn@^j+;pMQSKay$Wg75j&FOBY$;U zt#L8;uwiBxa2gg?wA|xpA+6!$DFPx4yCujj;Unr&G&|i~AaS{(54eiNwt|tcJ}|#p zWf$Pay&AcX-&@qm^#^Y_v*QsRp-D%3S-E`ot#17faa}=clW~>`s!<8uyT< z_A7jc0&?Dv{-(`h$`0J6v?eATqsalzY-^}ND1V`~p+)@K7tej0#pNAv$H6H5lkxyoH zvr(Lr@IB2*b6R=Kf--Qq%S;|;;>AJHe-lEg(&SWe!=$+m$hzGwaG$pIrU7@R-Tk$$ zkcOV>6SU$Q)#uF^7TFKtKodj&m%=7>~kIY~CFm6iZX)!i^a zEjJe=9kT%jS)(7(&Jas}I=xaf*1p|Zn(pZjAQR2hx9)rClmSi}eKQbHZ!yh6gm90@ z&jh3-fD|EVNp zct8t$fa<(=V*tG#|LOK8pWV4##ebqdKHxw79zP!?V^}x;1V#7ks@l8>* zO8|6mk^3+iFu%c0PtmY<=!0UN43;UWk~_W8X}%cssvj1oKhTGQ>Tyym2QyfH43h0V z*TEC!>6>Y`q+36+r)iZQa}rb=M|R_jc`@D0XkXYN+ezQ^aTrdn_N*2H{_fV`pTGaO(%!nPeekB%uWBDkef|OR!Yc*K9x33hD_G>p ze*JucGuwl;-%BPx{Fn@*Kufrq=@4Ne?zWB`XY>3`f;92%aR`YQK6H3G|b zV5HnBPpzFtmVe|EFDLb)N>ZsA89`x+1<$aWce9I}R!wN$tY+9^&&n|+P z*r(+)D=q{a25X6Z&LZITS#ObU74y;3Dvv*?Q+3t&xwd}5%9c5hD$BefiG!E9a9B;! z+KxyYcLa5i_Yv>~R#1r385h27#{Jrk_=|Vw;%mC%RKEdx zGNwjiFFWZ9y$Ink+ViU$o-Ehx%&)F_sN1CvA5^wuo3~@bBZ3E1c5dw{{uwMUBH7Vq z+2I?jN))+|cT2*ehpm;c9saJlvEZw)MMHP2?)AE`c%w{GdT)vasd zxGJ67^B;prugL7Ti4)rWoMkm1D_~`KX;NdsNdk>x*oa~LizIE%iftR|&rc6428jt97 z5V%jF^n43sD8K}XUeqALV^tn{6j-S`nbq^t0Ddxr@MTuB#ST~*dOnUoEy+wE<-Q}2TZZ4Q*o3!x!ZqmMq~PjoAG zls=;58o6__>NPCKa$zymmkw^!WqSl(=yS&NwFj~RVed*0{7#OqzaXXVd+6L}qNQ0% zg5Jl3QRVPC*^_&R!>^X>Pi{ZW7rEQ{NDbVqqk^&~yWkR4jlbPLlA65BIP=NA7kE!2~2zC`?2 zi;k8;X=RB5>KmRWq~tGOhN%fC{I_%iE?<+_WNW;?_bfR+7=QERWDHVyb^PuoC^PPe z1E$+g#K+=3pex-8wF?l1q@xD}zA`5ddr5b_nb6VUqxM8obRW=74IWWn)sio`L!iGX z%?tN1NF#zLq18G%etz_PeDDzO^Zc9R{e$GI}4wWZvWI;E~@OfCz@vw98n zCVUkC$q_%nrg%aEr`7uNHwR;6RgRMf2alc~A0&s#!NbE7YW?=m*`~o@`Z=nnOtJ~P zt%Br_$#;JpExxCQ?`RSUP18=9j3oWR6^Er|qRAW1x;6KoZ{qGyBB2bF&p2li2sP^J zv$|*$#A-`ir}Z`~&?oRmjYyb{NRWIlfz5C2B6Zb?5vhWOv7pM6@3|K0xN1OLnK^7By@xFSgGr1^CfSl7_M2&j*5W)w8x!e9<{ z$f6A{(O42QLzr>BA}&!;b{TY$fBmeh8Nx4%qO{XTP_TnHipV`A6YDza3B%-66^iEoTX>$(O)*1S{$MYQTt4C3bP~hej5WPXRbFfZ@4G+lb_JU*eUp2;ZCOi z@t&{^>v_>9y>wA(l}0)sw~Go_7i*FgKi(PYBB+ESP(`z|s7>MO{23UjP^8utsJ zWcgxHnK;Ac_UEfT$(fdU+4N){A6zr=BnfgRgE$9yrHY~=wF~Ly!m&8?x#E!tB<&cN z-lzZxq=jE%D(|Xe+cg>`vXN(o8_}?vgb}({4$xH1ZnF$iir>;p!d+SV!OSRovoUGI z^rKI=qg}dc^s24x!Ipv*6@gziuBW8&hu(r;xP3)NdEzfmIziWiwuPd{{~aOFR6#T( zKaFLZRaEb~iEQ&@bkMQc$fWEG$QwY4(H|WgXtXz$>O0pjfCrO0Zhi5ZeJh8TX9syEM2;O?U92aKgPu@WbC9jRwWzMwU$yYwAf_oQ8<*7IYU zp#JOVSV`!4gg=R22}6!PSwG+76>-uj-C{7hAXya7d~QHeLw5~CAqI_S z8VTfMF_a`9FhXh30Hmnw2LZ|?V~@D%3aO0Yeay%uBw?f?EY(~FV;G9O242BKK-+;t zYHxAuam9$#6mhN1m|mL&9!u?0u^9&isd{vN3VaWXVO}yM+y%CWhTbU{UAYyvfOW~r-j-P)!J~+k(2P~l;j#@P(%O_t{@i^JUQ56mK%+zy1RgIPpdN(_P|jR807=O$tVx@{;0l;%xZ+3h9Ib)vj@1Kt!_1sqJmk~r_=4d$ z(bwuLBZxq~O%9F@j-T!(z{nC=hU`N6W1iDS^VLhd;SlZ!z{0Fb&#Hn=i_1zr+=khk zjI`&M#f(8bgeAUUwEUd6v=mNP-_2eNLPTCIN6F6}@f%Jzi)reojiIg&7hKmVWX6Dp zKrOUej(vH;BvFzMwg>F?(8*$;yMAd8lVg3wN+;<_gAnFn<@&|tue0J(>JuSrQQIZ7 zJ6t6ZttZCfp5|ocp?1mdAh%E$c~;6_ftym+P!rNsX5C^QJvg+2_teE{#g~mpMy~uD z1JEEKEVHY!V{{4mdF6aTUM)@+TsCFQJ52|1de(GIU|gs2u!iS$M>rjLVSYiHb$AJR zMPq1n!MCmlVy|nZt~)oVr(w@ZRHjbDVT!tle)$bxvZhv$e%%akuC1NDe#%qEiCX^# zLw@h?Hc-uaycMQpGhbh}oAlZqydXY+yh)GYINSj~_N#n(?Uo88ZjQZC+dC>RgFOQc zg>(|$YKr^OAV73uS@90P)@0c3$+pi+t-@a}XC|c^rd86S8YK3_Ah&}!Gb=J?$Mc;S zYI&V?<@T8V`Is8fk%*pB7hxoGbR^bceW4zItbAPORJ*DG zy?4~BCNaR!sAY%)41=;Z26NFjOB)>pNfAykcY1VL7aPs9m7wUAnR5mymJDD?{Q;_K zBUuh2EcxL8p=d*Fwn?X!L2mc)nKnfq3r+>wf^>~>&1@EY9eu$Y316+tGyCikHMRO# zG8o98^wrO;pB=2jLOocObWs_GzL7qq$vj`=^K`~zeVhFI zojb$Zcjfo{U*7p78p>YpaLz!8i#5|@=o--FF5wvf^!naTM)B@;Xa#!!RSPBNsi6!bl#Xf;WistA#_ zpRJw?$EV+}Yfjd{hCLYHxpn7`?RtIf-Ne6g^iA+S4cBDlQ@#pG!KD5N0t(^?*|O{S zvtqM;n6J>(a0IWpH3SK3UwEOT^F*J17fvKppDok=y-6D^ri1Yl+W)QAbJhg>QZo0j zs&dBuAbfyglr7U$=|*w5wCWK-n`rn-Q%WYzMhJskHtkqi7O@sM*qAs_`dc}Xh3fZg zg{Aa&iwnPZ{MTDB1K>*(@NwS|1in80d*{|?pWdp(e?Pl(_e1>mcll{l68CIk!9FiX zqM&j}l0sDe1>b-imiV^eu+hMfXnc0=wDQs&Qd5?LaQsPvbtzCNryw#ZPERu?T{%GG ztk_2n2jDa_h>`)iS)8Zye3sL6?j0TOZlg3d7T{(!=?OK+r(P55aq>kzy|4AHfBWD6 zSFTb_jmN3!q;pNG zpQ#y1U44v~#QT;8q14cMC=|V(TcE5DfvEbDbQ(skHkI{?bb%|Fw6F8m8TFJMc^g4l zr8!Y>kwT&aoUlw?IEjXV;dyhQlQj0k>$(YCP3P&^x^Ma}k}Rf~s#v;GofTrR;pHnq zBC*9NKDn*$-e)g3TrN%t^qGC9*{rzK%)UdiyqVn^?P7tmhrQZfgLFtD>0ryk$*Go& zi^&a{ENoCeA;E~sA_$nwG5^7zHuVD3!zmq-pGITCK@#!WvT$PWh+B=rdCB6a;A7T~2>D!B?OJ3E35+9yg;b?aW7hNj8&A4t%SKQWh)5Hlw?v%T zA@?dS`9k-{ebJEv;Hj41hfQk?5{RBSRPs-BKGcFc@S&}Q+73)vyHSxgJ2b*6V% z4co~ceO8>e#P5K$;bN>S*#`qil7UIHA)>Bu$O?$~TXqiaJ^6O;uj6e6Y8*ywJoFlb za$?tpvaD8#M$2G099qD!x$#&~Z*N!hQxsa|)b0+o>kATv=>`G|Bg^{|8>#lJ9yiB_ z6PF7B6m68yp}CrBItg-w&Ek$b!4(FZ1xug{$e9bI-#4iUlE)7{S2h3_DQJ;Nl{cF~ zUYpmnlqrs1WwUGz8xsEZvX!&%fwq_E+7Faj`DZy=cK$^>qkQQ*&J}AE%;t`bZtr-P z?eZc3#Cf;MLgOcuOyB74xG3!=zi@qyfwhGH8%RbYpXu_V@ne3deWSR+(WP7ISXy(Jq5EA=U z7)14VK%8*6e*or$9nFIuDw}mcmVsl~GQqbrq*SaseI3*-xlU-wP3o4W9zNIsh%mxa z*ZxpbwPbf=KkM~y7Cn5PJbQkUd^0}i^$bRHzzgzaHUmPK*D|GIgH{W8mZrPZM0MTU zctH8DeC5_m!*^BK0b1$}Z3jD{_9f5~pR-Kg@+A%TUy#owVB(~S-KR(3{6!AlX3I9# zBv0O?h@}`(@g8lQlxwgQ3mKz&zj^dXGr6SZE|fl4LER)jIAWIN@|O(2%>GTup!3AD zN_{p#F+IH`Q!6N_tK=C;kQ`JLq?e}x>dgvv<{PZ?U0J5n)%=g4mW&Dp|T!+Yq#-uQs74xbE!f$MI0{Cs>;y#)h!-95;zs@{Wv4&=Xk z@o$z>=(P{Bm+9-AL~Bczl8piu5`@PoEu#%zTMFM)1&Jpi6+{VQVO-Fe#7Rw_C2UK5 z$wc6-9aU^pm{PJ9MvCerJ`%fL*8jR=-@hk6n64**f1i$oY`Cq%v{Z0T-1DS%ah`0% z+V0A*pg=$7jio8@h@x&s_lcAQ*~{uuMX{=&FId?y628KKtfINxOMXcK@FcC~(^sF% z`Cc8LhUV{!6U@bBJBw^x((+rQD~tTBn5D1Mk^8XPtEhxkgCw1f=VsL%(q&oXh$f7a9C2?l zI+22@vM46mbg#jtso8Y2kdX=(pd56A4e>BfFBY)!&JsmFc)tJj!SSQRCkGF{IehZ) z;P}?9+s0$+S}>1ZeQ10K%qZNMUy+2-8;7i>1;{E ztu06^bnZ@bYJJ3zDgKMIVtLgughEBx!!j9#7r`jnLG1M`uirc5>!Z)-)6RF?8GPkR z@^`-P21EbXz2l?gm{vW92!30Y+r!7U|NY@A#YdarNG8CXLYMYP{UPxRu5S>PT4`fU`6tAjGHes6O< z`3_y&thv3Ha?nsIPF=D$-rL_Z9BL&1izAe|>Ah0{A*g6982;@KF+GGklMUrL4-3I) z_%iwKr$iXd@E$`Af6%g>LsRpNvb+38^7HriTA^?V&=S~iHo&S){=O!FkSFEaD4O1oYUFQ7nh#XtNR=f>u+lBI~GoP$M65p)=-E&Ykh* zl-8)jn|TI`y*C&JTN|Q6+4{j^ctkSM-SCz7K4**96Ac$gwR8g<1sXe?bE)R0eCb@~ zZxdnpOAPS8pRCi1zFc&w`oFkVP1T)mlu5@}Wzou0X3q&p0+|#c^md{VJ9n1(GGh<5 zd%pXGjMAkR`|ucG{MDP4NTvLigV!DYmL;lt+5NfQJ?2hGf8ZTHLBIRD4plCbeW#!vz)Bt!-q6jYxQgdt^H~5 zFMH$tmLcX(GAMIA2RWcvX=so)&h`ORyI z(d}v{GRK1U8V>YH^sp*U(l$7zNPA$FL~&3#S`3eOi#_A)Q~&nmm7fETpK^0d-EW!c zJq>`thPW|g&adi&xfB6KOcTR{CxQN@bls4 b!_SAG4?iD%KK%UW|M`CbRI7({0H6f`v6bx@ literal 0 HcmV?d00001 diff --git a/src/Aspire.Cli/Agents/AspireSkills/Embedded/aspire-skills.metadata.json b/src/Aspire.Cli/Agents/AspireSkills/Embedded/aspire-skills.metadata.json new file mode 100644 index 00000000000..4b76e028392 --- /dev/null +++ b/src/Aspire.Cli/Agents/AspireSkills/Embedded/aspire-skills.metadata.json @@ -0,0 +1,7 @@ +{ + "version": "0.0.1", + "repository": "microsoft/aspire-skills", + "tag": "v0.0.1", + "assetName": "aspire-skills-v0.0.1.tgz", + "sha256": "8f0aa535917bb6d2589acbf8f986c7b0d622ee7744e39c526bb7166c0664b53c" +} diff --git a/src/Aspire.Cli/Agents/AspireSkills/EmbeddedAspireSkillsBundleProvider.cs b/src/Aspire.Cli/Agents/AspireSkills/EmbeddedAspireSkillsBundleProvider.cs new file mode 100644 index 00000000000..3b7d70cadd1 --- /dev/null +++ b/src/Aspire.Cli/Agents/AspireSkills/EmbeddedAspireSkillsBundleProvider.cs @@ -0,0 +1,80 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace Aspire.Cli.Agents.AspireSkills; + +/// +/// Provides access to the Aspire skills bundle snapshot embedded in the CLI assembly. +/// +internal interface IEmbeddedAspireSkillsBundleProvider +{ + /// + /// Gets metadata for the embedded Aspire skills bundle snapshot. + /// + EmbeddedAspireSkillsBundleMetadata? Metadata { get; } + + /// + /// Opens the embedded Aspire skills bundle archive. + /// + Stream? OpenArchive(); +} + +internal sealed class EmbeddedAspireSkillsBundleProvider : IEmbeddedAspireSkillsBundleProvider +{ + private const string ArchiveResourceName = "aspire-skills.bundle.tgz"; + private const string MetadataResourceName = "aspire-skills.metadata.json"; + + private readonly ILogger _logger; + private readonly Lazy _metadata; + + public EmbeddedAspireSkillsBundleProvider(ILogger logger) + { + _logger = logger; + _metadata = new Lazy(LoadMetadata); + } + + public EmbeddedAspireSkillsBundleMetadata? Metadata => _metadata.Value; + + public Stream? OpenArchive() + { + var stream = typeof(EmbeddedAspireSkillsBundleProvider).Assembly.GetManifestResourceStream(ArchiveResourceName); + if (stream is null) + { + _logger.LogDebug("Embedded Aspire skills archive resource {ResourceName} was not found.", ArchiveResourceName); + } + + return stream; + } + + private EmbeddedAspireSkillsBundleMetadata? LoadMetadata() + { + using var stream = typeof(EmbeddedAspireSkillsBundleProvider).Assembly.GetManifestResourceStream(MetadataResourceName); + if (stream is null) + { + _logger.LogDebug("Embedded Aspire skills metadata resource {ResourceName} was not found.", MetadataResourceName); + return null; + } + + try + { + var metadata = JsonSerializer.Deserialize( + stream, + AspireSkillsJsonSerializerContext.Default.EmbeddedAspireSkillsBundleMetadata); + + if (metadata is null) + { + _logger.LogDebug("Embedded Aspire skills metadata resource {ResourceName} was empty.", MetadataResourceName); + } + + return metadata; + } + catch (JsonException ex) + { + _logger.LogWarning(ex, "Embedded Aspire skills metadata resource {ResourceName} could not be parsed.", MetadataResourceName); + return null; + } + } +} diff --git a/src/Aspire.Cli/Agents/AspireSkills/SkillBundleManifest.cs b/src/Aspire.Cli/Agents/AspireSkills/SkillBundleManifest.cs index a41777944c1..5e48ed3f573 100644 --- a/src/Aspire.Cli/Agents/AspireSkills/SkillBundleManifest.cs +++ b/src/Aspire.Cli/Agents/AspireSkills/SkillBundleManifest.cs @@ -56,6 +56,22 @@ internal sealed class SkillBundleFile public string? Sha256 { get; init; } } +/// +/// Describes the Aspire skills bundle archive embedded in the CLI. +/// +internal sealed class EmbeddedAspireSkillsBundleMetadata +{ + public string? Version { get; init; } + + public string? Repository { get; init; } + + public string? Tag { get; init; } + + public string? AssetName { get; init; } + + public string? Sha256 { get; init; } +} + /// /// Source-generation context for Aspire skills bundle JSON. /// @@ -65,6 +81,7 @@ internal sealed class SkillBundleFile PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true)] [JsonSerializable(typeof(SkillBundleManifest))] +[JsonSerializable(typeof(EmbeddedAspireSkillsBundleMetadata))] internal sealed partial class AspireSkillsJsonSerializerContext : JsonSerializerContext { } diff --git a/src/Aspire.Cli/Aspire.Cli.csproj b/src/Aspire.Cli/Aspire.Cli.csproj index 096c95408cf..afd4bd73f5e 100644 --- a/src/Aspire.Cli/Aspire.Cli.csproj +++ b/src/Aspire.Cli/Aspire.Cli.csproj @@ -297,6 +297,12 @@ + + false + + + false + diff --git a/src/Aspire.Cli/Commands/AgentInitCommand.cs b/src/Aspire.Cli/Commands/AgentInitCommand.cs index bfb45024e53..5788f880841 100644 --- a/src/Aspire.Cli/Commands/AgentInitCommand.cs +++ b/src/Aspire.Cli/Commands/AgentInitCommand.cs @@ -311,6 +311,8 @@ private async Task ExecuteAgentInitAsync(DirectoryInfo } } + var installedSkills = new List(); + foreach (var location in selectedLocations) { context.AddSkillBaseDirectory(location.RelativeSkillDirectory); @@ -328,27 +330,39 @@ private async Task ExecuteAgentInitAsync(DirectoryInfo continue; } - hasErrors |= !await InstallSkillAsync( + var installResult = await InstallSkillAsync( workspaceRoot, location.RelativeSkillDirectory, skill, aspireSkillsBundle, isUserLevel: false, cancellationToken); + hasErrors |= !installResult.Succeeded; + if (installResult.UpdatedSkill is not null) + { + installedSkills.Add(installResult.UpdatedSkill); + } if (location.IncludeUserLevel) { - hasErrors |= !await InstallSkillAsync( + installResult = await InstallSkillAsync( ExecutionContext.HomeDirectory, location.RelativeSkillDirectory, skill, aspireSkillsBundle, isUserLevel: true, cancellationToken); + hasErrors |= !installResult.Succeeded; + if (installResult.UpdatedSkill is not null) + { + installedSkills.Add(installResult.UpdatedSkill); + } } } } + DisplayInstalledSkillsSummary(installedSkills); + // --- Phase 4: Handle Playwright CLI (installs binary + mirrors skill files to registered directories) --- var selectedSkillDirs = selectedLocations.Select(l => l.RelativeSkillDirectory).ToHashSet(StringComparer.OrdinalIgnoreCase); if (selectedSkills.Contains(SkillDefinition.PlaywrightCli) && selectedLocations.Count > 0) @@ -424,8 +438,8 @@ private async Task ExecuteAgentInitAsync(DirectoryInfo /// /// Installs the files for a skill at the specified location, creating or updating them as needed. /// - /// true if successful, false if an error occurred. - private async Task InstallSkillAsync( + /// The install result, including the skill/location pair when files were updated. + private async Task InstallSkillAsync( DirectoryInfo rootDirectory, string relativeSkillDirectory, SkillDefinition skill, @@ -465,23 +479,60 @@ private async Task InstallSkillAsync( if (!anyFileUpdated) { - return true; + return new(Succeeded: true, UpdatedSkill: null); } - var displayRelativeSkillPath = relativeSkillPath - .Replace(Path.DirectorySeparatorChar, '/') - .Replace(Path.AltDirectorySeparatorChar, '/'); - var displayPath = isUserLevel ? $"~/{displayRelativeSkillPath}" : displayRelativeSkillPath; - _interactionService.DisplayMessage(KnownEmojis.Robot, - string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.InitCommand_InstalledSkill, skill.Name, displayPath)); - return true; + var displayLocation = GetDisplaySkillDirectory(relativeSkillDirectory, isUserLevel); + return new(Succeeded: true, new InstalledSkillSummaryItem(skill.Name, displayLocation)); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException) { _interactionService.DisplayError( string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.InitCommand_FailedToInstallSkill, skill.Name, fullSkillDirectoryPath, ex.Message)); - return false; + return new(Succeeded: false, UpdatedSkill: null); + } + } + + private void DisplayInstalledSkillsSummary(IReadOnlyList installedSkills) + { + if (installedSkills.Count == 0) + { + return; + } + + var skillNames = string.Join(", ", GetUniqueValues(installedSkills.Select(static installedSkill => installedSkill.SkillName))); + var locations = string.Join(", ", GetUniqueValues(installedSkills.Select(static installedSkill => installedSkill.DisplayLocation))); + var message = string.Join(Environment.NewLine, + AgentCommandStrings.InitCommand_InstalledSkillsSummary, + $" {string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.InitCommand_InstalledSkillsSummarySkills, skillNames)}", + $" {string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.InitCommand_InstalledSkillsSummaryLocations, locations)}"); + + _interactionService.DisplayMessage(KnownEmojis.Robot, message); + } + + private static IReadOnlyList GetUniqueValues(IEnumerable values) + { + var uniqueValues = new List(); + var seenValues = new HashSet(StringComparer.Ordinal); + + foreach (var value in values) + { + if (seenValues.Add(value)) + { + uniqueValues.Add(value); + } } + + return uniqueValues; + } + + private static string GetDisplaySkillDirectory(string relativeSkillDirectory, bool isUserLevel) + { + var displayRelativeSkillDirectory = relativeSkillDirectory + .Replace(Path.DirectorySeparatorChar, '/') + .Replace(Path.AltDirectorySeparatorChar, '/'); + + return isUserLevel ? $"~/{displayRelativeSkillDirectory}" : displayRelativeSkillDirectory; } private static async Task> GetSkillFilesAsync(SkillDefinition skill, AspireSkillsBundle? aspireSkillsBundle, CancellationToken cancellationToken) @@ -509,6 +560,10 @@ private enum AgentInitErrorMode Strict, BestEffort } + + private sealed record InstalledSkillSummaryItem(string SkillName, string DisplayLocation); + + private readonly record struct SkillInstallResult(bool Succeeded, InstalledSkillSummaryItem? UpdatedSkill); } internal readonly record struct AgentInitExecutionResult( diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index dc8df361491..8d6cc53c443 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -464,6 +464,7 @@ internal static async Task BuildApplicationAsync(string[] args, CliStartu builder.Services.AddSingleton(); builder.Services.AddHttpClient(); builder.Services.AddHttpClient(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/Aspire.Cli/Resources/AgentCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/AgentCommandStrings.Designer.cs index f1e80b58211..6133c22ac5e 100644 --- a/src/Aspire.Cli/Resources/AgentCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/AgentCommandStrings.Designer.cs @@ -205,11 +205,29 @@ internal static string InitCommand_PlaywrightCliSkipped { } /// - /// Looks up a localized string similar to Installed {0} skill ({1}).. + /// Looks up a localized string similar to Installed Aspire agent skills:. /// - internal static string InitCommand_InstalledSkill { + internal static string InitCommand_InstalledSkillsSummary { get { - return ResourceManager.GetString("InitCommand_InstalledSkill", resourceCulture); + return ResourceManager.GetString("InitCommand_InstalledSkillsSummary", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Skills: {0}. + /// + internal static string InitCommand_InstalledSkillsSummarySkills { + get { + return ResourceManager.GetString("InitCommand_InstalledSkillsSummarySkills", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Locations: {0}. + /// + internal static string InitCommand_InstalledSkillsSummaryLocations { + get { + return ResourceManager.GetString("InitCommand_InstalledSkillsSummaryLocations", resourceCulture); } } @@ -358,7 +376,7 @@ internal static string AspireSkillsInstaller_InstallingStatus { } /// - /// Looks up a localized string similar to Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available.. + /// Looks up a localized string similar to Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available.. /// internal static string AspireSkillsInstaller_GitHubUnavailable { get { diff --git a/src/Aspire.Cli/Resources/AgentCommandStrings.resx b/src/Aspire.Cli/Resources/AgentCommandStrings.resx index 275a332b141..2364ff91d8d 100644 --- a/src/Aspire.Cli/Resources/AgentCommandStrings.resx +++ b/src/Aspire.Cli/Resources/AgentCommandStrings.resx @@ -108,8 +108,14 @@ Playwright CLI requires npm, which was not found on PATH. Skipping installation. - - Installed {0} skill ({1}). + + Installed Aspire agent skills: + + + Skills: {0} + + + Locations: {0} Failed to install {0} skill at {1}: {2} @@ -160,7 +166,7 @@ Installing Aspire skills... - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. The Aspire skills bundle is invalid: {0} diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.cs.xlf index 4c3fa5cbf55..7139b9c497a 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.cs.xlf @@ -3,8 +3,8 @@ - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. @@ -57,9 +57,19 @@ Installed Playwright CLI. - - Installed {0} skill ({1}). - Installed {0} skill ({1}). + + Installed Aspire agent skills: + Installed Aspire agent skills: + + + + Locations: {0} + Locations: {0} + + + + Skills: {0} + Skills: {0} diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.de.xlf index 9083254de4e..a09a22f0b5d 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.de.xlf @@ -3,8 +3,8 @@ - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. @@ -57,9 +57,19 @@ Installed Playwright CLI. - - Installed {0} skill ({1}). - Installed {0} skill ({1}). + + Installed Aspire agent skills: + Installed Aspire agent skills: + + + + Locations: {0} + Locations: {0} + + + + Skills: {0} + Skills: {0} diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.es.xlf index d3657b160c0..416a73644e6 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.es.xlf @@ -3,8 +3,8 @@ - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. @@ -57,9 +57,19 @@ Installed Playwright CLI. - - Installed {0} skill ({1}). - Installed {0} skill ({1}). + + Installed Aspire agent skills: + Installed Aspire agent skills: + + + + Locations: {0} + Locations: {0} + + + + Skills: {0} + Skills: {0} diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.fr.xlf index 297ceb6f134..38117b59c88 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.fr.xlf @@ -3,8 +3,8 @@ - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. @@ -57,9 +57,19 @@ Installed Playwright CLI. - - Installed {0} skill ({1}). - Installed {0} skill ({1}). + + Installed Aspire agent skills: + Installed Aspire agent skills: + + + + Locations: {0} + Locations: {0} + + + + Skills: {0} + Skills: {0} diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.it.xlf index 0735f52225e..670db6c9e77 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.it.xlf @@ -3,8 +3,8 @@ - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. @@ -57,9 +57,19 @@ Installed Playwright CLI. - - Installed {0} skill ({1}). - Installed {0} skill ({1}). + + Installed Aspire agent skills: + Installed Aspire agent skills: + + + + Locations: {0} + Locations: {0} + + + + Skills: {0} + Skills: {0} diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ja.xlf index 922fd845ae4..297b0f9eb54 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ja.xlf @@ -3,8 +3,8 @@ - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. @@ -57,9 +57,19 @@ Installed Playwright CLI. - - Installed {0} skill ({1}). - Installed {0} skill ({1}). + + Installed Aspire agent skills: + Installed Aspire agent skills: + + + + Locations: {0} + Locations: {0} + + + + Skills: {0} + Skills: {0} diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ko.xlf index 87a7da1cb2e..05a8359420d 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ko.xlf @@ -3,8 +3,8 @@ - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. @@ -57,9 +57,19 @@ Installed Playwright CLI. - - Installed {0} skill ({1}). - Installed {0} skill ({1}). + + Installed Aspire agent skills: + Installed Aspire agent skills: + + + + Locations: {0} + Locations: {0} + + + + Skills: {0} + Skills: {0} diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pl.xlf index 09c9a59599b..d87eccc448a 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pl.xlf @@ -3,8 +3,8 @@ - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. @@ -57,9 +57,19 @@ Installed Playwright CLI. - - Installed {0} skill ({1}). - Installed {0} skill ({1}). + + Installed Aspire agent skills: + Installed Aspire agent skills: + + + + Locations: {0} + Locations: {0} + + + + Skills: {0} + Skills: {0} diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pt-BR.xlf index cddc1e8fedd..66b4e8820f1 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pt-BR.xlf @@ -3,8 +3,8 @@ - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. @@ -57,9 +57,19 @@ Installed Playwright CLI. - - Installed {0} skill ({1}). - Installed {0} skill ({1}). + + Installed Aspire agent skills: + Installed Aspire agent skills: + + + + Locations: {0} + Locations: {0} + + + + Skills: {0} + Skills: {0} diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ru.xlf index f42eb379f17..76dc2665fa2 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ru.xlf @@ -3,8 +3,8 @@ - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. @@ -57,9 +57,19 @@ Installed Playwright CLI. - - Installed {0} skill ({1}). - Installed {0} skill ({1}). + + Installed Aspire agent skills: + Installed Aspire agent skills: + + + + Locations: {0} + Locations: {0} + + + + Skills: {0} + Skills: {0} diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.tr.xlf index 8559bf134d6..65b85538393 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.tr.xlf @@ -3,8 +3,8 @@ - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. @@ -57,9 +57,19 @@ Installed Playwright CLI. - - Installed {0} skill ({1}). - Installed {0} skill ({1}). + + Installed Aspire agent skills: + Installed Aspire agent skills: + + + + Locations: {0} + Locations: {0} + + + + Skills: {0} + Skills: {0} diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hans.xlf index 2e2531cc0ad..f4c08201732 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hans.xlf @@ -3,8 +3,8 @@ - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. @@ -57,9 +57,19 @@ Installed Playwright CLI. - - Installed {0} skill ({1}). - Installed {0} skill ({1}). + + Installed Aspire agent skills: + Installed Aspire agent skills: + + + + Locations: {0} + Locations: {0} + + + + Skills: {0} + Skills: {0} diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hant.xlf index 177fbc55ac9..62de972aa38 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hant.xlf @@ -3,8 +3,8 @@ - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. - Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. + Aspire skills could not be downloaded from the verified GitHub release asset, and no valid cached or embedded bundle is available. @@ -57,9 +57,19 @@ Installed Playwright CLI. - - Installed {0} skill ({1}). - Installed {0} skill ({1}). + + Installed Aspire agent skills: + Installed Aspire agent skills: + + + + Locations: {0} + Locations: {0} + + + + Skills: {0} + Skills: {0} diff --git a/tests/Aspire.Cli.Tests/Agents/AspireSkillsInstallerTests.cs b/tests/Aspire.Cli.Tests/Agents/AspireSkillsInstallerTests.cs index f17750521e0..3b418f7cc99 100644 --- a/tests/Aspire.Cli.Tests/Agents/AspireSkillsInstallerTests.cs +++ b/tests/Aspire.Cli.Tests/Agents/AspireSkillsInstallerTests.cs @@ -31,12 +31,14 @@ public async Task InstallAsync_WhenValidBundleIsCached_UsesCacheWithoutNetwork() var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory)); var cachedBundleDirectory = Path.Combine(executionContext.CacheDirectory.FullName, "aspire-skills", AspireSkillsInstaller.Version); await CreateCachedBundleAsync(cachedBundleDirectory); - var installer = CreateInstaller(executionContext); + var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync(); + var installer = CreateInstaller(executionContext, embeddedBundleProvider: embeddedBundleProvider); var result = await installer.InstallAsync(CancellationToken.None); Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status); Assert.NotNull(result.Bundle); + Assert.False(embeddedBundleProvider.OpenArchiveCalled); } finally { @@ -89,6 +91,47 @@ public async Task InstallAsync_WhenGitHubReleaseIsUnavailableAndNoCache_ReturnsF } } + [Fact] + public async Task InstallAsync_WhenGitHubReleaseIsUnavailableAndEmbeddedBundleMatches_UsesEmbeddedBundle() + { + var rootDirectory = CreateTempDirectory(); + + try + { + var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory)); + var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync(); + var installer = CreateInstaller(executionContext, embeddedBundleProvider: embeddedBundleProvider); + + var result = await installer.InstallAsync(CancellationToken.None); + + Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status); + Assert.NotNull(result.Bundle); + Assert.True(embeddedBundleProvider.OpenArchiveCalled); + } + finally + { + Directory.Delete(rootDirectory, recursive: true); + } + } + + [Fact] + public void EmbeddedAspireSkillsBundleProvider_OpensSnapshotResource() + { + var provider = new EmbeddedAspireSkillsBundleProvider(NullLogger.Instance); + + var metadata = Assert.IsType(provider.Metadata); + using var archiveStream = Assert.IsAssignableFrom(provider.OpenArchive()); + + Assert.Equal(AspireSkillsInstaller.Version, metadata.Version); + Assert.Equal(AspireSkillsInstaller.GitHubRepository, metadata.Repository); + Assert.Equal(metadata.Sha256, ComputeSha256(archiveStream)); + } + + private static string ComputeSha256(Stream stream) + { + return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant(); + } + [Fact] public async Task InstallAsync_WhenGitHubReleaseAssetIsAvailable_UsesGitHub() { @@ -115,7 +158,12 @@ public async Task InstallAsync_WhenGitHubReleaseAssetIsAvailable_UsesGitHub() }); var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory)); var attestationVerifier = new TestGitHubArtifactAttestationVerifier(); - var installer = CreateInstaller(executionContext, httpMessageHandler: handler, githubArtifactAttestationVerifier: attestationVerifier); + var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync(); + var installer = CreateInstaller( + executionContext, + httpMessageHandler: handler, + githubArtifactAttestationVerifier: attestationVerifier, + embeddedBundleProvider: embeddedBundleProvider); var result = await installer.InstallAsync(CancellationToken.None); @@ -127,6 +175,7 @@ public async Task InstallAsync_WhenGitHubReleaseAssetIsAvailable_UsesGitHub() Assert.Equal(AspireSkillsInstaller.ExpectedWorkflowPath, attestationVerifier.ExpectedWorkflowPath); Assert.Equal(GitHubReleaseAssetBuildType, attestationVerifier.ExpectedBuildType); Assert.Equal(AspireSkillsInstaller.Version, attestationVerifier.ExpectedVersion); + Assert.False(embeddedBundleProvider.OpenArchiveCalled); Assert.NotNull(releaseRequestUri); Assert.NotNull(assetRequestUri); Assert.Contains("/microsoft/aspire-skills/releases/tags/v0.0.1", releaseRequestUri.AbsolutePath); @@ -139,7 +188,7 @@ public async Task InstallAsync_WhenGitHubReleaseAssetIsAvailable_UsesGitHub() } [Fact] - public async Task InstallAsync_WhenGitHubAttestationFails_ReturnsFailure() + public async Task InstallAsync_WhenGitHubAttestationFails_FallsBackToEmbeddedBundle() { var rootDirectory = CreateTempDirectory(); @@ -163,13 +212,85 @@ public async Task InstallAsync_WhenGitHubAttestationFails_ReturnsFailure() { Result = new ProvenanceVerificationResult { Outcome = ProvenanceVerificationOutcome.WorkflowMismatch } }; - var installer = CreateInstaller(executionContext, httpMessageHandler: handler, githubArtifactAttestationVerifier: attestationVerifier); + var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync(); + var installer = CreateInstaller( + executionContext, + httpMessageHandler: handler, + githubArtifactAttestationVerifier: attestationVerifier, + embeddedBundleProvider: embeddedBundleProvider); + + var result = await installer.InstallAsync(CancellationToken.None); + + Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status); + Assert.NotNull(result.Bundle); + Assert.True(attestationVerifier.VerifyCalled); + Assert.True(embeddedBundleProvider.OpenArchiveCalled); + } + finally + { + Directory.Delete(rootDirectory, recursive: true); + } + } + + [Fact] + public async Task InstallAsync_WhenVersionOverrideDoesNotMatchEmbeddedBundle_DoesNotUseEmbeddedBundle() + { + var rootDirectory = CreateTempDirectory(); + + try + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [AspireSkillsInstaller.VersionOverrideKey] = "9.9.9" + }) + .Build(); + var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory)); + var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync(); + var installer = CreateInstaller( + executionContext, + configuration: configuration, + embeddedBundleProvider: embeddedBundleProvider); + + var result = await installer.InstallAsync(CancellationToken.None); + + Assert.Equal(AspireSkillsInstallStatus.Failed, result.Status); + Assert.False(embeddedBundleProvider.OpenArchiveCalled); + } + finally + { + Directory.Delete(rootDirectory, recursive: true); + } + } + + [Fact] + public async Task InstallAsync_WhenEmbeddedArchiveHashDoesNotMatch_ReturnsFailure() + { + var rootDirectory = CreateTempDirectory(); + + try + { + var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync(); + embeddedBundleProvider.Metadata = new EmbeddedAspireSkillsBundleMetadata + { + Version = AspireSkillsInstaller.Version, + Repository = AspireSkillsInstaller.GitHubRepository, + Tag = $"v{AspireSkillsInstaller.Version}", + AssetName = $"aspire-skills-v{AspireSkillsInstaller.Version}.tgz", + Sha256 = "0000000000000000000000000000000000000000000000000000000000000000" + }; + var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory)); + var installer = CreateInstaller( + executionContext, + embeddedBundleProvider: embeddedBundleProvider); var result = await installer.InstallAsync(CancellationToken.None); Assert.Equal(AspireSkillsInstallStatus.Failed, result.Status); Assert.NotNull(result.Message); - Assert.Contains("Provenance", result.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("SHA-256", result.Message, StringComparison.Ordinal); + Assert.Contains("0000000000000000000000000000000000000000000000000000000000000000", result.Message, StringComparison.Ordinal); + Assert.True(embeddedBundleProvider.OpenArchiveCalled); } finally { @@ -180,14 +301,17 @@ public async Task InstallAsync_WhenGitHubAttestationFails_ReturnsFailure() private static AspireSkillsInstaller CreateInstaller( CliExecutionContext executionContext, HttpMessageHandler? httpMessageHandler = null, - TestGitHubArtifactAttestationVerifier? githubArtifactAttestationVerifier = null) + TestGitHubArtifactAttestationVerifier? githubArtifactAttestationVerifier = null, + IConfiguration? configuration = null, + IEmbeddedAspireSkillsBundleProvider? embeddedBundleProvider = null) { return new AspireSkillsInstaller( githubArtifactAttestationVerifier ?? new TestGitHubArtifactAttestationVerifier(), new MockHttpClientFactory(httpMessageHandler ?? new MockHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound))), + embeddedBundleProvider ?? new TestEmbeddedAspireSkillsBundleProvider(), new TestInteractionService(), executionContext, - new ConfigurationBuilder().Build(), + configuration ?? new ConfigurationBuilder().Build(), TestTelemetryHelper.CreateInitializedTelemetry(), NullLogger.Instance); } @@ -273,6 +397,28 @@ private static string ComputeSha256(string path) return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant(); } + private static string ComputeSha256(byte[] bytes) + { + return Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); + } + + private static async Task CreateEmbeddedBundleProviderAsync() + { + var archiveBytes = await CreateBundleArchiveBytesAsync(); + return new TestEmbeddedAspireSkillsBundleProvider + { + Metadata = new EmbeddedAspireSkillsBundleMetadata + { + Version = AspireSkillsInstaller.Version, + Repository = AspireSkillsInstaller.GitHubRepository, + Tag = $"v{AspireSkillsInstaller.Version}", + AssetName = $"aspire-skills-v{AspireSkillsInstaller.Version}.tgz", + Sha256 = ComputeSha256(archiveBytes) + }, + ArchiveBytes = archiveBytes + }; + } + private static HttpResponseMessage CreateJsonResponse(string json) { return new HttpResponseMessage(HttpStatusCode.OK) @@ -341,4 +487,18 @@ public Task VerifyAsync( } } + private sealed class TestEmbeddedAspireSkillsBundleProvider : IEmbeddedAspireSkillsBundleProvider + { + public EmbeddedAspireSkillsBundleMetadata? Metadata { get; set; } + + public byte[]? ArchiveBytes { get; init; } + + public bool OpenArchiveCalled { get; private set; } + + public Stream? OpenArchive() + { + OpenArchiveCalled = true; + return ArchiveBytes is null ? null : new MemoryStream(ArchiveBytes, writable: false); + } + } } diff --git a/tests/Aspire.Cli.Tests/Commands/AgentInitCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/AgentInitCommandTests.cs index 6c7d792f38d..ace28bcd5c4 100644 --- a/tests/Aspire.Cli.Tests/Commands/AgentInitCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/AgentInitCommandTests.cs @@ -17,7 +17,7 @@ namespace Aspire.Cli.Tests.Commands; public class AgentInitCommandTests(ITestOutputHelper outputHelper) { [Fact] - public async Task AgentInitCommand_UsesNormalizedDisplayPath_WhenInstallingUserLevelSkill() + public async Task AgentInitCommand_SummarizesNormalizedDisplayPath_WhenInstallingUserLevelSkill() { using var workspace = TemporaryWorkspace.Create(outputHelper); var homeDirectory = workspace.CreateDirectory("fake-home"); @@ -44,13 +44,60 @@ public async Task AgentInitCommand_UsesNormalizedDisplayPath_WhenInstallingUserL var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(0, exitCode); + var expectedSummary = string.Join(Environment.NewLine, + AgentCommandStrings.InitCommand_InstalledSkillsSummary, + $" {string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.InitCommand_InstalledSkillsSummarySkills, SkillDefinition.Aspire.Name)}", + $" {string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.InitCommand_InstalledSkillsSummaryLocations, ".agents/skills, ~/.agents/skills")}"); + Assert.Contains( interactionService.DisplayedMessages, - displayedMessage => displayedMessage.Message == string.Format( + displayedMessage => displayedMessage.Emoji.Equals(KnownEmojis.Robot) && displayedMessage.Message == expectedSummary); + Assert.DoesNotContain( + interactionService.DisplayedMessages, + displayedMessage => displayedMessage.Message.Contains("Installed aspire skill", StringComparison.Ordinal)); + } + + [Fact] + public async Task AgentInitCommand_SummarizesDefaultSkillsOnce() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var homeDirectory = workspace.CreateDirectory("fake-home"); + var interactionService = new TestInteractionService(); + interactionService.SetupStringPromptResponse(workspace.WorkspaceRoot.FullName); + interactionService.PromptForSelectionsCallback = (_, choices, _, _) => choices.Cast() + .Where(choice => choice switch + { + SkillLocation location => location == SkillLocation.Standard, + SkillDefinition skill => skill.IsDefault, + _ => false + }) + .ToList(); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.InteractionServiceFactory = _ => interactionService; + options.CliExecutionContextFactory = _ => CreateExecutionContext(workspace.WorkspaceRoot, homeDirectory); + }); + + using var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse("agent init"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(0, exitCode); + + var expectedSummary = string.Join(Environment.NewLine, + AgentCommandStrings.InitCommand_InstalledSkillsSummary, + $" {string.Format( CultureInfo.CurrentCulture, - AgentCommandStrings.InitCommand_InstalledSkill, - SkillDefinition.Aspire.Name, - "~/.agents/skills/aspire")); + AgentCommandStrings.InitCommand_InstalledSkillsSummarySkills, + string.Join(", ", SkillDefinition.All.Where(static skill => skill.IsDefault).Select(static skill => skill.Name)))}", + $" {string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.InitCommand_InstalledSkillsSummaryLocations, ".agents/skills, ~/.agents/skills")}"); + var message = Assert.Single(interactionService.DisplayedMessages, displayedMessage => displayedMessage.Emoji.Equals(KnownEmojis.Robot)); + Assert.Equal(expectedSummary, message.Message); + Assert.DoesNotContain( + interactionService.DisplayedMessages, + displayedMessage => displayedMessage.Message.Contains("Installed aspire skill", StringComparison.Ordinal)); } [Fact] diff --git a/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs b/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs index 7ac8fdec09f..17ef7854130 100644 --- a/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs +++ b/tests/Aspire.Hosting.Docker.Tests/DockerComposePublisherTests.cs @@ -2,11 +2,13 @@ // The .NET Foundation licenses this file to you under the MIT license. #pragma warning disable ASPIREPIPELINES003 +#pragma warning disable ASPIRECONTAINERRUNTIME001 using System.Text.RegularExpressions; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Docker.Resources.ComposeNodes; using Aspire.Hosting.Publishing; +using Aspire.Hosting.Tests.Publishing; using Aspire.Hosting.Utils; using Microsoft.Extensions.DependencyInjection; @@ -812,6 +814,8 @@ public async Task PrepareStep_ResolvesContainerImageReferenceViaIValueProvider() var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, tempDir.Path, step: "prepare-docker-compose"); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(sp => (IContainerRuntimeResolver)sp.GetRequiredService()); builder.AddDockerComposeEnvironment("docker-compose"); From 6a8235582edcc92b676480612f82284843cb96e9 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Thu, 28 May 2026 04:19:52 +1000 Subject: [PATCH 10/43] 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 --- 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 9184a94e07a..78e8be0a050 100644 --- a/eng/pipelines/azure-pipelines.yml +++ b/eng/pipelines/azure-pipelines.yml @@ -8,6 +8,24 @@ parameters: displayName: 'Publish 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 @@ -167,6 +185,7 @@ extends: - osx-x64 codeSign: true teamName: $(_TeamName) + aspireCliChannelOverride: ${{ parameters.aspireCliChannelOverride }} extraBuildArgs: >- /p:Configuration=$(_BuildConfig) $(_SignArgs) @@ -182,6 +201,7 @@ extends: # no need to sign ELF binaries on linux codeSign: false teamName: $(_TeamName) + aspireCliChannelOverride: ${{ parameters.aspireCliChannelOverride }} extraBuildArgs: >- /p:Configuration=$(_BuildConfig) $(_OfficialBuildIdArgs) @@ -194,6 +214,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 35b52fe7828..ac72bbde30d 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 628238e9f0e..c540b425adc 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 f89b2b7eeeca32b376f52f299fc34426c91cd86a Mon Sep 17 00:00:00 2001 From: "aspire-repo-bot[bot]" <268009190+aspire-repo-bot[bot]@users.noreply.github.com> Date: Wed, 27 May 2026 12:36:14 -0700 Subject: [PATCH 11/43] [create-pull-request] automated change (#17555) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/Aspire.Hosting.Foundry/api/Aspire.Hosting.Foundry.ats.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Hosting.Foundry/api/Aspire.Hosting.Foundry.ats.txt b/src/Aspire.Hosting.Foundry/api/Aspire.Hosting.Foundry.ats.txt index 2129008fb18..2176f9296d9 100644 --- a/src/Aspire.Hosting.Foundry/api/Aspire.Hosting.Foundry.ats.txt +++ b/src/Aspire.Hosting.Foundry/api/Aspire.Hosting.Foundry.ats.txt @@ -257,7 +257,7 @@ Aspire.Hosting.Foundry/addImageGenerationTool(name: string) -> Aspire.Hosting.Fo Aspire.Hosting.Foundry/addKeyVaultConnection(keyVault: Aspire.Hosting.Azure.KeyVault/Aspire.Hosting.Azure.AzureKeyVaultResource) -> Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.AzureCognitiveServicesProjectConnectionResource Aspire.Hosting.Foundry/addModelDeployment(name: string, model: Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.FoundryModel|string, modelVersion?: string, format?: string) -> Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.FoundryDeploymentResource Aspire.Hosting.Foundry/addProject(name: string) -> Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.AzureCognitiveServicesProjectResource -Aspire.Hosting.Foundry/addPromptAgent(model: Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.FoundryDeploymentResource, name: string, instructions?: string) -> Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.AzurePromptAgentResource +Aspire.Hosting.Foundry/addPromptAgent(name: string, model: Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.FoundryDeploymentResource, instructions?: string) -> Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.AzurePromptAgentResource Aspire.Hosting.Foundry/addSearchConnection(search: Aspire.Hosting.Azure.Search/Aspire.Hosting.Azure.AzureSearchResource) -> Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.AzureCognitiveServicesProjectConnectionResource Aspire.Hosting.Foundry/addSharePointTool(name: string, projectConnectionIds: string[]) -> Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.SharePointToolResource Aspire.Hosting.Foundry/addStorageConnection(storage: Aspire.Hosting.Azure.Storage/Aspire.Hosting.Azure.AzureStorageResource) -> Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.AzureCognitiveServicesProjectConnectionResource @@ -293,11 +293,11 @@ Aspire.Hosting.Foundry/HostedAgentConfiguration.metadata(context: Aspire.Hosting Aspire.Hosting.Foundry/HostedAgentConfiguration.setCpu(context: Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.HostedAgentConfiguration, value: number) -> Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.HostedAgentConfiguration Aspire.Hosting.Foundry/HostedAgentConfiguration.setDescription(context: Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.HostedAgentConfiguration, value: string) -> Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.HostedAgentConfiguration Aspire.Hosting.Foundry/HostedAgentConfiguration.setMemory(context: Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.HostedAgentConfiguration, value: number) -> Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.HostedAgentConfiguration -Aspire.Hosting.Foundry/withComputeEnvironmentExecutable(project?: Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.AzureCognitiveServicesProjectResource, configure?: callback) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithEndpoints Aspire.Hosting.Foundry/runAsFoundryLocal() -> Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.FoundryResource Aspire.Hosting.Foundry/withAppInsights(appInsights: Aspire.Hosting.Azure.ApplicationInsights/Aspire.Hosting.Azure.AzureApplicationInsightsResource) -> Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.AzureCognitiveServicesProjectResource Aspire.Hosting.Foundry/withBingReference(bingReference: Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.BingGroundingConnectionResource|string|Aspire.Hosting/Aspire.Hosting.ApplicationModel.ParameterResource) -> Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.BingGroundingToolResource Aspire.Hosting.Foundry/withCapabilityHost(resource: Aspire.Hosting.Azure.CosmosDB/Aspire.Hosting.AzureCosmosDBResource|Aspire.Hosting.Azure.Storage/Aspire.Hosting.Azure.AzureStorageResource|Aspire.Hosting.Azure.Search/Aspire.Hosting.Azure.AzureSearchResource|Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.FoundryResource) -> Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.AzureCognitiveServicesProjectResource +Aspire.Hosting.Foundry/withComputeEnvironmentExecutable(project?: Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.AzureCognitiveServicesProjectResource, configure?: callback) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithEndpoints Aspire.Hosting.Foundry/withFoundryDeploymentProperties(configure: callback) -> Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.FoundryDeploymentResource Aspire.Hosting.Foundry/withFoundryRoleAssignments(target: Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.FoundryResource, roles: enum:Aspire.Hosting.FoundryRole[]) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResource Aspire.Hosting.Foundry/withKeyVault(keyVault: Aspire.Hosting.Azure.KeyVault/Aspire.Hosting.Azure.AzureKeyVaultResource) -> Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.AzureCognitiveServicesProjectResource From e59b9ba6adffa7a374c98e3b876bda6d4fa0c7e9 Mon Sep 17 00:00:00 2001 From: "aspire-repo-bot[bot]" <268009190+aspire-repo-bot[bot]@users.noreply.github.com> Date: Wed, 27 May 2026 13:52:30 -0700 Subject: [PATCH 12/43] [release/13.4] Enrich AppHost codegen TypeLoadException diagnostics (#17556) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Enrich AppHost codegen TypeLoadException diagnostics (#16709) When the installed Aspire CLI ships an spire-managed server whose bundled Aspire.Hosting.dll is on a different build than the user-restored Aspire.Hosting.CodeGeneration.TypeScript / Aspire.TypeSystem DLLs, reflection-based codegen can throw an empty TypeLoadException that travels back to the CLI with no message and triggers a 60-second backchannel timeout. This change adds three coordinated improvements: 1. Server-side: wrap reflection-load exceptions (TypeLoadException, MissingMethodException, MissingFieldException, BadImageFormatException, FileLoadException, ReflectionTypeLoadException) in a LocalRpcException with a safe, language-agnostic Message and a structured ErrorData payload (TypeName, MemberName, loaded ATS assemblies + informational versions, runtime Aspire.Hosting version, original exception type) carried via JSON-RPC error code -32050. 2. CLI-side: tiered output — emit a yellow pre-flight warning on detected CLI/SDK skew; render only the safe summary + remediation hint by default; reveal the full .NET diagnostic payload under --debug; always log the full payload via LogDebug. Also fault the BackchannelCompletionSource immediately on codegen failure so users no longer wait through the 60s timeout. 3. CLI-side: prune leftover cli.sock.* files older than 24 hours from ~/.aspire/cli/runtime/sockets/ on startup so stale entries don't accumulate from previous crashed runs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR review feedback for #16709 - Drop [JsonPropertyName(camelCase)] from CLI diagnostic DTO so the source-generated context deserializes the server's default PascalCase payload. Add a wire-contract test that round-trips the on-the-wire shape and the BackchannelJsonSerializerContext options. - Use SemVersion.ComparePrecedence in IsKnownIncompatibleSkew so SemVer prerelease identifiers are compared (the #16709 case: 13.4.0-preview.1.26218.1 vs 13.4.0-preview.1.26227.1). Update skew tests to cover prerelease and build-metadata cases. - Resolve the runtime Aspire.Hosting version by walking AppDomain.CurrentDomain.GetAssemblies(); never fall back to Aspire.Hosting.RemoteHost (which is what typeof(AssemblyLoader) returned). Add a regression test. - Only the diagnostic-section header keeps the microscope emoji; the continuation lines (Exception, Type, Member, runtime version, loaded assemblies) render as plain text indented under the header. - Tests: use Directory.CreateTempSubdirectory() instead of manually combining Path.GetTempPath() + Guid for CliPathHelper janitor tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix stale CLI socket cleanup matching Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR feedback on codegen diagnostics - Catch AppHostCodeGenerationException in sdk dump per-integration path so one failing integration does not abort the full Task.WhenAll batch. - Log the full serialized AppHostCodeGenerationDiagnostic payload in RenderCodeGenerationFailure so debug logs match the XML doc contract. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: IEvangelist <7679720+IEvangelist@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Sebastien Ros --- .../BackchannelJsonSerializerContext.cs | 4 + src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs | 2 +- .../AppHostCodeGenerationDiagnostic.cs | 93 ++++++ .../AppHostCodeGenerationException.cs | 29 ++ src/Aspire.Cli/Projects/AppHostRpcClient.cs | 60 +++- .../Projects/GuestAppHostProject.cs | 165 +++++++++- .../Resources/ErrorStrings.Designer.cs | 36 +++ src/Aspire.Cli/Resources/ErrorStrings.resx | 14 + .../Resources/xlf/ErrorStrings.cs.xlf | 20 ++ .../Resources/xlf/ErrorStrings.de.xlf | 20 ++ .../Resources/xlf/ErrorStrings.es.xlf | 20 ++ .../Resources/xlf/ErrorStrings.fr.xlf | 20 ++ .../Resources/xlf/ErrorStrings.it.xlf | 20 ++ .../Resources/xlf/ErrorStrings.ja.xlf | 20 ++ .../Resources/xlf/ErrorStrings.ko.xlf | 20 ++ .../Resources/xlf/ErrorStrings.pl.xlf | 20 ++ .../Resources/xlf/ErrorStrings.pt-BR.xlf | 20 ++ .../Resources/xlf/ErrorStrings.ru.xlf | 20 ++ .../Resources/xlf/ErrorStrings.tr.xlf | 20 ++ .../Resources/xlf/ErrorStrings.zh-Hans.xlf | 20 ++ .../Resources/xlf/ErrorStrings.zh-Hant.xlf | 20 ++ src/Aspire.Cli/Utils/CliPathHelper.cs | 61 +++- .../AssemblyLoader.cs | 49 +++ .../CodeGenerationDiagnostic.cs | 297 ++++++++++++++++++ .../CodeGeneration/CodeGenerationService.cs | 13 + src/Shared/BackchannelConstants.cs | 11 + ...deGenerationDiagnosticWireContractTests.cs | 99 ++++++ .../Projects/GuestAppHostProjectSkewTests.cs | 46 +++ .../Utils/CliPathHelperTests.cs | 89 ++++++ .../CodeGenerationDiagnosticBuilderTests.cs | 131 ++++++++ .../ServiceErrorMessageTests.cs | 4 +- 31 files changed, 1453 insertions(+), 10 deletions(-) create mode 100644 src/Aspire.Cli/Projects/AppHostCodeGenerationDiagnostic.cs create mode 100644 src/Aspire.Cli/Projects/AppHostCodeGenerationException.cs create mode 100644 src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGenerationDiagnostic.cs create mode 100644 tests/Aspire.Cli.Tests/Projects/AppHostCodeGenerationDiagnosticWireContractTests.cs create mode 100644 tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectSkewTests.cs create mode 100644 tests/Aspire.Hosting.RemoteHost.Tests/CodeGenerationDiagnosticBuilderTests.cs diff --git a/src/Aspire.Cli/Backchannel/BackchannelJsonSerializerContext.cs b/src/Aspire.Cli/Backchannel/BackchannelJsonSerializerContext.cs index dd1c9472bab..a79a52c00dd 100644 --- a/src/Aspire.Cli/Backchannel/BackchannelJsonSerializerContext.cs +++ b/src/Aspire.Cli/Backchannel/BackchannelJsonSerializerContext.cs @@ -8,6 +8,7 @@ using System.Text.Json.Serialization.Metadata; using Aspire.Cli.Commands; using Aspire.Cli.Commands.Sdk; +using Aspire.Cli.Projects; using Aspire.TypeSystem; using Spectre.Console; using StreamJsonRpc; @@ -62,6 +63,9 @@ namespace Aspire.Cli.Backchannel; [JsonSerializable(typeof(JsonNode))] [JsonSerializable(typeof(CapabilitiesInfo))] [JsonSerializable(typeof(CommonErrorData))] +[JsonSerializable(typeof(AppHostCodeGenerationDiagnostic))] +[JsonSerializable(typeof(AppHostLoadedAssemblyInfo))] +[JsonSerializable(typeof(List))] // V2 API request/response types [JsonSerializable(typeof(GetCapabilitiesRequest))] [JsonSerializable(typeof(BackchannelTraceContext))] diff --git a/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs b/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs index 86b554a262e..ef3fcb185d3 100644 --- a/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs +++ b/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs @@ -366,7 +366,7 @@ private async Task DumpIntegrationCapabilitiesAsync( return new IntegrationDumpResult(integration.Name, Success: true, HasErrors: capabilities.Diagnostics.Exists(d => d.Severity == "Error")); } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or InvalidOperationException or RemoteInvocationException) + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or InvalidOperationException or RemoteInvocationException or AppHostCodeGenerationException) { _logger.LogWarning(ex, "Failed to dump capabilities for integration {IntegrationName}", integration.Name); return new IntegrationDumpResult(integration.Name, Success: false, HasErrors: false, ex.Message); diff --git a/src/Aspire.Cli/Projects/AppHostCodeGenerationDiagnostic.cs b/src/Aspire.Cli/Projects/AppHostCodeGenerationDiagnostic.cs new file mode 100644 index 00000000000..7c88ed4c560 --- /dev/null +++ b/src/Aspire.Cli/Projects/AppHostCodeGenerationDiagnostic.cs @@ -0,0 +1,93 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Cli.Projects; + +/// +/// JSON-RPC error codes returned by the AppHost server for code-generation failures. +/// +/// +/// Values mirror those defined server-side in Aspire.Hosting.RemoteHost. +/// +internal static class AppHostCodeGenerationErrorCodes +{ + /// + /// The AppHost server failed to load reflection-based code generation metadata. + /// Typically caused by an assembly-version mismatch between the bundled + /// Aspire.Hosting runtime and the user-restored integration assemblies. + /// + public const int IncompatibleAspireSdk = -32050; +} + +/// +/// CLI-side representation of the structured diagnostic payload the AppHost server attaches to +/// code-generation failures (mirrors CodeGenerationDiagnostic in +/// Aspire.Hosting.RemoteHost). The JSON shape is contractual; updating one side without +/// the other will break the round-trip. +/// +internal sealed class AppHostCodeGenerationDiagnostic +{ + /// + /// Gets the CLR type name of the original exception thrown by the AppHost server + /// (e.g. System.TypeLoadException). + /// + public string OriginalExceptionType { get; init; } = ""; + + /// + /// Gets the name of the type that failed to load, if known. + /// + public string? TypeName { get; init; } + + /// + /// Gets the name of the missing member, if the failure was a missing-method or + /// missing-field error. + /// + public string? MemberName { get; init; } + + /// + /// Gets the informational version of the bundled Aspire.Hosting assembly on the + /// server side, if it could be discovered. + /// + public string? RuntimeAspireHostingVersion { get; init; } + + /// + /// Gets the on-disk location of the bundled Aspire.Hosting assembly, if it could be + /// discovered. + /// + public string? RuntimeAspireHostingPath { get; init; } + + /// + /// Gets the loaded integration assemblies probed by the AppHost server at the time of the + /// failure. + /// + public List LoadedAssemblies { get; init; } = []; + + /// + /// Gets a short, language-agnostic remediation hint suitable for surfacing to AppHost + /// authors. + /// + public string? RemediationHint { get; init; } +} + +/// +/// Identity information for a single loaded assembly captured at the time of a code-generation +/// failure. +/// +internal sealed class AppHostLoadedAssemblyInfo +{ + /// + /// Gets the simple name of the assembly (e.g. Aspire.Hosting.JavaScript). + /// + public string Name { get; init; } = ""; + + /// + /// Gets the informational version of the assembly, when present, otherwise the assembly + /// version. + /// + public string? InformationalVersion { get; init; } + + /// + /// Gets the on-disk location of the assembly when available. + /// + public string? Location { get; init; } +} diff --git a/src/Aspire.Cli/Projects/AppHostCodeGenerationException.cs b/src/Aspire.Cli/Projects/AppHostCodeGenerationException.cs new file mode 100644 index 00000000000..76d9ea71a38 --- /dev/null +++ b/src/Aspire.Cli/Projects/AppHostCodeGenerationException.cs @@ -0,0 +1,29 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Cli.Projects; + +/// +/// Thrown by when the AppHost server reports a reflection-based +/// code-generation failure. Carries a structured +/// payload supplied by the server so the CLI can render an actionable, tiered diagnostic. +/// +/// +/// The always contains the short, language-agnostic message the +/// server produced; the full structured payload — including .NET-specific identifiers such as +/// type names and assembly identities — is exposed via and is only +/// rendered to the user when --debug is supplied. +/// +internal sealed class AppHostCodeGenerationException : Exception +{ + public AppHostCodeGenerationException(string message, AppHostCodeGenerationDiagnostic diagnostic, Exception? innerException = null) + : base(message, innerException) + { + Diagnostic = diagnostic; + } + + /// + /// Gets the structured diagnostic payload that accompanied the RPC failure. + /// + public AppHostCodeGenerationDiagnostic Diagnostic { get; } +} diff --git a/src/Aspire.Cli/Projects/AppHostRpcClient.cs b/src/Aspire.Cli/Projects/AppHostRpcClient.cs index 14e306703f2..5d7e0a81ea9 100644 --- a/src/Aspire.Cli/Projects/AppHostRpcClient.cs +++ b/src/Aspire.Cli/Projects/AppHostRpcClient.cs @@ -3,6 +3,7 @@ using System.IO.Pipes; using System.Net.Sockets; +using System.Text.Json; using Aspire.Cli.Backchannel; using Aspire.Cli.Telemetry; using Aspire.TypeSystem; @@ -94,19 +95,19 @@ public Task> ScaffoldAppHostAsync( /// public Task> GenerateCodeAsync(string languageId, CancellationToken cancellationToken) - => InvokeAsync>("generateCode", [languageId, null], cancellationToken); + => InvokeCodeGenerationAsync>("generateCode", [languageId, null], cancellationToken); /// public Task> GenerateCodeForAssemblyAsync(string languageId, string assemblyName, CancellationToken cancellationToken) - => InvokeAsync>("generateCode", [languageId, assemblyName], cancellationToken); + => InvokeCodeGenerationAsync>("generateCode", [languageId, assemblyName], cancellationToken); /// public Task GetCapabilitiesAsync(CancellationToken cancellationToken) - => InvokeAsync("getCapabilities", [null], cancellationToken); + => InvokeCodeGenerationAsync("getCapabilities", [null], cancellationToken); /// public Task GetCapabilitiesForAssembliesAsync(IReadOnlyList assemblyNames, CancellationToken cancellationToken) - => InvokeAsync("getCapabilities", [assemblyNames], cancellationToken); + => InvokeCodeGenerationAsync("getCapabilities", [assemblyNames], cancellationToken); /// public Task InvokeAsync(string methodName, object?[] parameters, CancellationToken cancellationToken) @@ -116,6 +117,57 @@ public Task InvokeAsync(string methodName, object?[] parameters, Cancellat public Task InvokeAsync(string methodName, object?[] parameters, CancellationToken cancellationToken) => _jsonRpc.InvokeWithProfilingAsync(_profilingTelemetry, ConnectionName, methodName, parameters, cancellationToken); + /// + /// Invokes a code-generation RPC method and rethrows structured load/type failures as + /// so the CLI can render an actionable + /// diagnostic instead of an empty or .NET-specific error message. + /// + private async Task InvokeCodeGenerationAsync(string methodName, object?[] parameters, CancellationToken cancellationToken) + { + try + { + return await _jsonRpc.InvokeWithProfilingAsync(_profilingTelemetry, ConnectionName, methodName, parameters, cancellationToken).ConfigureAwait(false); + } + catch (RemoteInvocationException ex) when (ex.ErrorCode == AppHostCodeGenerationErrorCodes.IncompatibleAspireSdk) + { + var diagnostic = TryReadDiagnostic(ex); + if (diagnostic is null) + { + throw; + } + + throw new AppHostCodeGenerationException(ex.Message, diagnostic, ex); + } + } + + /// + /// Extracts a from a 's + /// structured error data, returning if the payload is missing or + /// can't be deserialized. + /// + private static AppHostCodeGenerationDiagnostic? TryReadDiagnostic(RemoteInvocationException exception) + { + if (exception.DeserializedErrorData is AppHostCodeGenerationDiagnostic typed) + { + return typed; + } + + var payload = exception.DeserializedErrorData ?? exception.ErrorData; + if (payload is JsonElement element) + { + try + { + return element.Deserialize(BackchannelJsonSerializerContext.Default.AppHostCodeGenerationDiagnostic); + } + catch (JsonException) + { + return null; + } + } + + return null; + } + /// public async ValueTask DisposeAsync() { diff --git a/src/Aspire.Cli/Projects/GuestAppHostProject.cs b/src/Aspire.Cli/Projects/GuestAppHostProject.cs index 80b633fa0c0..08c79b2bdb6 100644 --- a/src/Aspire.Cli/Projects/GuestAppHostProject.cs +++ b/src/Aspire.Cli/Projects/GuestAppHostProject.cs @@ -700,6 +700,14 @@ Task StartBackchannelConnectionAfterGuestAppHostLaunchesAsync() context.BuildCompletionSource?.TrySetResult(false); return CliExitCodes.Cancelled; } + catch (AppHostCodeGenerationException ex) + { + // We already rendered an actionable, tiered diagnostic in GenerateCodeViaRpcAsync. + // Avoid double-printing here — just log and return the standard failure exit code. + context.BuildCompletionSource?.TrySetResult(false); + _logger.LogError(ex, "Code generation failed for {Language} AppHost", DisplayName); + return CliExitCodes.FailedToDotnetRunAppHost; + } catch (Exception ex) { // Signal that build/preparation failed so RunCommand doesn't hang waiting @@ -1041,8 +1049,13 @@ await GenerateCodeViaRpcAsync( await EnsureRuntimeCreatedAsync(directory, rpcClient, cancellationToken); } - catch + catch (Exception ex) { + // The backchannel connection task was started before code generation + // (see StartBackchannelConnectionAsync above); fault it eagerly so the + // caller doesn't wait out the connection timeout when generateCode fails. + context.BackchannelCompletionSource?.TrySetException(ex); + // Once Start() succeeds we own the server process, so dispose it here when // post-start work fails - the `await using` below isn't in scope yet. await serverSession.DisposeAsync(); @@ -1503,10 +1516,22 @@ private async Task GenerateCodeViaRpcAsync( // The code generator is registered by its Language property, not the runtime ID var codeGenerator = _resolvedLanguage.CodeGenerator; + WarnIfCliSdkVersionSkew(appPath); + _logger.LogDebug("Generating {CodeGenerator} code via RPC for {Count} packages", codeGenerator, integrationsList.Count); // Use the typed RPC method - var files = await rpcClient.GenerateCodeAsync(codeGenerator, cancellationToken); + Dictionary files; + try + { + files = await rpcClient.GenerateCodeAsync(codeGenerator, cancellationToken); + } + catch (AppHostCodeGenerationException ex) + { + RenderCodeGenerationFailure(ex); + throw; + } + var outputPath = Path.Combine(appPath, LanguageInfo.GeneratedFolderName); // Legacy TypeScript AppHosts (`apphost.ts`) still import generated files from // `./.modules/aspire.js`. When that scaffold shape is detected, convert the @@ -1578,6 +1603,142 @@ private bool ShouldEmitLegacyTypeScriptGeneratedFiles(string appPath, FileInfo? !File.Exists(Path.Combine(appPath, TypeScriptMtsAppHostFileName)); } + /// + /// Emits a single pre-flight warning when the installed CLI version doesn't match the SDK + /// version pinned in aspire.config.json. This is a best-effort heuristic — we keep it + /// purely informational and let code-generation try first so that benign skew (e.g. a + /// daily-build CLI against a stable SDK) doesn't block valid scenarios. + /// + private void WarnIfCliSdkVersionSkew(string appPath) + { + try + { + var configDir = ConfigurationHelper.GetConfigRootDirectory(new DirectoryInfo(appPath)); + var config = AspireConfigFile.Load(configDir.FullName); + var configuredSdkVersion = config?.SdkVersion; + if (string.IsNullOrWhiteSpace(configuredSdkVersion)) + { + return; + } + + var cliVersion = VersionHelper.GetDefaultSdkVersion(); + if (!IsKnownIncompatibleSkew(cliVersion, configuredSdkVersion)) + { + return; + } + + var message = string.Format( + System.Globalization.CultureInfo.CurrentCulture, + ErrorStrings.CodegenVersionSkewWarning, + cliVersion, + configuredSdkVersion); + _interactionService.DisplayMessage(KnownEmojis.Warning, $"[yellow]{Markup.Escape(message)}[/]", allowMarkup: true); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to evaluate CLI/SDK version skew prior to code generation."); + } + } + + /// + /// Returns when the supplied CLI and SDK versions look mismatched in a + /// way that is worth warning about. We deliberately tolerate metadata-only differences + /// (build suffixes, +commit hashes) and only flag a skew when the parsed major/minor/patch + /// numbers disagree. + /// + /// + /// Returns when the supplied CLI and SDK versions differ in a way that + /// is known to produce ABI incompatibilities — specifically when they differ in + /// , , , + /// or in their prerelease identifiers (e.g. 13.4.0-preview.1.26218.1 vs + /// 13.4.0-preview.1.26227.1, which was the exact reproduction case in + /// ). Build metadata + /// (everything after +) is ignored per the SemVer spec. + /// + internal static bool IsKnownIncompatibleSkew(string cliVersion, string sdkVersion) + { + if (!SemVersion.TryParse(NormalizeVersion(cliVersion), SemVersionStyles.Any, out var cli) || + !SemVersion.TryParse(NormalizeVersion(sdkVersion), SemVersionStyles.Any, out var sdk)) + { + return !string.Equals(cliVersion, sdkVersion, StringComparison.OrdinalIgnoreCase); + } + + // Compare full precedence, which covers Major/Minor/Patch *and* prerelease identifiers + // but (per the SemVer spec) ignores build metadata. NormalizeVersion already strips '+' + // suffixes defensively for parsers that include them in precedence. + return SemVersion.ComparePrecedence(cli, sdk) != 0; + } + + internal static string NormalizeVersion(string version) + { + var plusIndex = version.IndexOf('+'); + return plusIndex > 0 ? version[..plusIndex] : version; + } + + /// + /// Renders a to the user with .NET-specific + /// details tiered behind --debug so that polyglot AppHost authors aren't confronted + /// with C#/CLR jargon by default. The full structured payload is always written to the debug + /// log file via the logger's LogDebug call regardless of mode. + /// + private void RenderCodeGenerationFailure(AppHostCodeGenerationException exception) + { + var summary = string.Format( + System.Globalization.CultureInfo.CurrentCulture, + ErrorStrings.CodegenIncompatibleSdkSummary, + DisplayName); + _interactionService.DisplayError(summary); + + var hint = exception.Diagnostic.RemediationHint; + if (!string.IsNullOrWhiteSpace(hint)) + { + _interactionService.DisplayMessage(KnownEmojis.Information, $"[grey]{Markup.Escape(hint!)}[/]", allowMarkup: true); + } + + _logger.LogDebug( + "Code generation failed. OriginalExceptionType={OriginalExceptionType}, TypeName={TypeName}, MemberName={MemberName}, RuntimeAspireHostingVersion={RuntimeVersion}, LoadedAssemblies={LoadedCount}", + exception.Diagnostic.OriginalExceptionType, + exception.Diagnostic.TypeName ?? "", + exception.Diagnostic.MemberName ?? "", + exception.Diagnostic.RuntimeAspireHostingVersion ?? "", + exception.Diagnostic.LoadedAssemblies.Count); + _logger.LogDebug( + "Code generation diagnostic payload: {DiagnosticPayload}", + JsonSerializer.Serialize( + exception.Diagnostic, + BackchannelJsonSerializerContext.Default.AppHostCodeGenerationDiagnostic)); + + if (!_executionContext.DebugMode) + { + _interactionService.DisplayMessage(KnownEmojis.Information, $"[grey]{Markup.Escape(ErrorStrings.CodegenDebugHint)}[/]", allowMarkup: true); + return; + } + + _interactionService.DisplayMessage(KnownEmojis.Microscope, $"[grey]{Markup.Escape(ErrorStrings.CodegenDebugHeader)}[/]", allowMarkup: true); + var diagnostic = exception.Diagnostic; + if (!string.IsNullOrWhiteSpace(diagnostic.OriginalExceptionType)) + { + _interactionService.DisplayPlainText($" Exception: {diagnostic.OriginalExceptionType}"); + } + if (!string.IsNullOrWhiteSpace(diagnostic.TypeName)) + { + _interactionService.DisplayPlainText($" Type: {diagnostic.TypeName}"); + } + if (!string.IsNullOrWhiteSpace(diagnostic.MemberName)) + { + _interactionService.DisplayPlainText($" Member: {diagnostic.MemberName}"); + } + if (!string.IsNullOrWhiteSpace(diagnostic.RuntimeAspireHostingVersion)) + { + _interactionService.DisplayPlainText($" Runtime Aspire.Hosting: {diagnostic.RuntimeAspireHostingVersion}"); + } + foreach (var assembly in diagnostic.LoadedAssemblies) + { + var version = assembly.InformationalVersion ?? ""; + _interactionService.DisplayPlainText($" • {assembly.Name} {version}"); + } + } + /// /// Saves a hash of the integrations to avoid regenerating code unnecessarily. /// When project references are present, the hash is always unique to force regeneration diff --git a/src/Aspire.Cli/Resources/ErrorStrings.Designer.cs b/src/Aspire.Cli/Resources/ErrorStrings.Designer.cs index cd7bbbd50b5..0a1e6964118 100644 --- a/src/Aspire.Cli/Resources/ErrorStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/ErrorStrings.Designer.cs @@ -416,5 +416,41 @@ public static string InvalidJsonInConfigFile { return ResourceManager.GetString("InvalidJsonInConfigFile", resourceCulture); } } + + /// + /// Looks up a localized string similar to The installed Aspire CLI version ({0}) differs from the configured Aspire SDK version ({1}). If you run into errors, run 'aspire update' to align them.. + /// + public static string CodegenVersionSkewWarning { + get { + return ResourceManager.GetString("CodegenVersionSkewWarning", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0} SDK code generation failed because the installed Aspire CLI appears to be incompatible with the configured Aspire SDK. Run 'aspire update' to align the CLI and SDK and try again.. + /// + public static string CodegenIncompatibleSdkSummary { + get { + return ResourceManager.GetString("CodegenIncompatibleSdkSummary", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Run with '--debug' for full diagnostic details.. + /// + public static string CodegenDebugHint { + get { + return ResourceManager.GetString("CodegenDebugHint", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Diagnostic details:. + /// + public static string CodegenDebugHeader { + get { + return ResourceManager.GetString("CodegenDebugHeader", resourceCulture); + } + } } } diff --git a/src/Aspire.Cli/Resources/ErrorStrings.resx b/src/Aspire.Cli/Resources/ErrorStrings.resx index 408361118bb..aba3b239f19 100644 --- a/src/Aspire.Cli/Resources/ErrorStrings.resx +++ b/src/Aspire.Cli/Resources/ErrorStrings.resx @@ -262,4 +262,18 @@ The configuration file '{0}' contains invalid JSON: {1} {0} is the file path, {1} is the exception message + + The installed Aspire CLI version ({0}) differs from the configured Aspire SDK version ({1}). If you run into errors, run 'aspire update' to align them. + {0} is the CLI version, {1} is the SDK version configured in aspire.config.json. + + + {0} SDK code generation failed because the installed Aspire CLI appears to be incompatible with the configured Aspire SDK. Run 'aspire update' to align the CLI and SDK and try again. + {0} is the AppHost language display name, e.g. 'TypeScript (Node.js)'. + + + Run with '--debug' for full diagnostic details. + + + Diagnostic details: + diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.cs.xlf index 0e9f7aa0537..7c2ddc27688 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.cs.xlf @@ -57,6 +57,26 @@ Developer certificates are only partially trusted. Trust them interactively by running 'aspire certs trust'. + + Diagnostic details: + Diagnostic details: + + + + Run with '--debug' for full diagnostic details. + Run with '--debug' for full diagnostic details. + + + + {0} SDK code generation failed because the installed Aspire CLI appears to be incompatible with the configured Aspire SDK. Run 'aspire update' to align the CLI and SDK and try again. + {0} SDK code generation failed because the installed Aspire CLI appears to be incompatible with the configured Aspire SDK. Run 'aspire update' to align the CLI and SDK and try again. + {0} is the AppHost language display name, e.g. 'TypeScript (Node.js)'. + + + The installed Aspire CLI version ({0}) differs from the configured Aspire SDK version ({1}). If you run into errors, run 'aspire update' to align them. + The installed Aspire CLI version ({0}) differs from the configured Aspire SDK version ({1}). If you run into errors, run 'aspire update' to align them. + {0} is the CLI version, {1} is the SDK version configured in aspire.config.json. + This command is not yet supported with single file AppHosts. Tento příkaz zatím není pro funkci AppHost pro jednosouborové scénáře podporován. diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.de.xlf index 1a1760c3d4d..7ee217e468a 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.de.xlf @@ -57,6 +57,26 @@ Developer certificates are only partially trusted. Trust them interactively by running 'aspire certs trust'. + + Diagnostic details: + Diagnostic details: + + + + Run with '--debug' for full diagnostic details. + Run with '--debug' for full diagnostic details. + + + + {0} SDK code generation failed because the installed Aspire CLI appears to be incompatible with the configured Aspire SDK. Run 'aspire update' to align the CLI and SDK and try again. + {0} SDK code generation failed because the installed Aspire CLI appears to be incompatible with the configured Aspire SDK. Run 'aspire update' to align the CLI and SDK and try again. + {0} is the AppHost language display name, e.g. 'TypeScript (Node.js)'. + + + The installed Aspire CLI version ({0}) differs from the configured Aspire SDK version ({1}). If you run into errors, run 'aspire update' to align them. + The installed Aspire CLI version ({0}) differs from the configured Aspire SDK version ({1}). If you run into errors, run 'aspire update' to align them. + {0} is the CLI version, {1} is the SDK version configured in aspire.config.json. + This command is not yet supported with single file AppHosts. Dieser Befehl wird für AppHosts mit einer einzelnen Datei noch nicht unterstützt. diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.es.xlf index a5dc8ef08bc..00257b66305 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.es.xlf @@ -57,6 +57,26 @@ Developer certificates are only partially trusted. Trust them interactively by running 'aspire certs trust'. + + Diagnostic details: + Diagnostic details: + + + + Run with '--debug' for full diagnostic details. + Run with '--debug' for full diagnostic details. + + + + {0} SDK code generation failed because the installed Aspire CLI appears to be incompatible with the configured Aspire SDK. Run 'aspire update' to align the CLI and SDK and try again. + {0} SDK code generation failed because the installed Aspire CLI appears to be incompatible with the configured Aspire SDK. Run 'aspire update' to align the CLI and SDK and try again. + {0} is the AppHost language display name, e.g. 'TypeScript (Node.js)'. + + + The installed Aspire CLI version ({0}) differs from the configured Aspire SDK version ({1}). If you run into errors, run 'aspire update' to align them. + The installed Aspire CLI version ({0}) differs from the configured Aspire SDK version ({1}). If you run into errors, run 'aspire update' to align them. + {0} is the CLI version, {1} is the SDK version configured in aspire.config.json. + This command is not yet supported with single file AppHosts. Este comando aún no es compatible con AppHosts de un solo archivo. diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.fr.xlf index 7090a201033..280e4d050b7 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.fr.xlf @@ -57,6 +57,26 @@ Developer certificates are only partially trusted. Trust them interactively by running 'aspire certs trust'. + + Diagnostic details: + Diagnostic details: + + + + Run with '--debug' for full diagnostic details. + Run with '--debug' for full diagnostic details. + + + + {0} SDK code generation failed because the installed Aspire CLI appears to be incompatible with the configured Aspire SDK. Run 'aspire update' to align the CLI and SDK and try again. + {0} SDK code generation failed because the installed Aspire CLI appears to be incompatible with the configured Aspire SDK. Run 'aspire update' to align the CLI and SDK and try again. + {0} is the AppHost language display name, e.g. 'TypeScript (Node.js)'. + + + The installed Aspire CLI version ({0}) differs from the configured Aspire SDK version ({1}). If you run into errors, run 'aspire update' to align them. + The installed Aspire CLI version ({0}) differs from the configured Aspire SDK version ({1}). If you run into errors, run 'aspire update' to align them. + {0} is the CLI version, {1} is the SDK version configured in aspire.config.json. + This command is not yet supported with single file AppHosts. Cette commande n’est pas encore prise en charge avec les hôtes d’application à fichier unique. diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.it.xlf index e04f31c5d83..3f9864622c6 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.it.xlf @@ -57,6 +57,26 @@ Developer certificates are only partially trusted. Trust them interactively by running 'aspire certs trust'. + + Diagnostic details: + Diagnostic details: + + + + Run with '--debug' for full diagnostic details. + Run with '--debug' for full diagnostic details. + + + + {0} SDK code generation failed because the installed Aspire CLI appears to be incompatible with the configured Aspire SDK. Run 'aspire update' to align the CLI and SDK and try again. + {0} SDK code generation failed because the installed Aspire CLI appears to be incompatible with the configured Aspire SDK. Run 'aspire update' to align the CLI and SDK and try again. + {0} is the AppHost language display name, e.g. 'TypeScript (Node.js)'. + + + The installed Aspire CLI version ({0}) differs from the configured Aspire SDK version ({1}). If you run into errors, run 'aspire update' to align them. + The installed Aspire CLI version ({0}) differs from the configured Aspire SDK version ({1}). If you run into errors, run 'aspire update' to align them. + {0} is the CLI version, {1} is the SDK version configured in aspire.config.json. + This command is not yet supported with single file AppHosts. Questo comando non è ancora supportato con AppHost a file singolo. diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.ja.xlf index 3df3a1c272c..84cb684a628 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.ja.xlf @@ -57,6 +57,26 @@ Developer certificates are only partially trusted. Trust them interactively by running 'aspire certs trust'. + + Diagnostic details: + Diagnostic details: + + + + Run with '--debug' for full diagnostic details. + Run with '--debug' for full diagnostic details. + + + + {0} SDK code generation failed because the installed Aspire CLI appears to be incompatible with the configured Aspire SDK. Run 'aspire update' to align the CLI and SDK and try again. + {0} SDK code generation failed because the installed Aspire CLI appears to be incompatible with the configured Aspire SDK. Run 'aspire update' to align the CLI and SDK and try again. + {0} is the AppHost language display name, e.g. 'TypeScript (Node.js)'. + + + The installed Aspire CLI version ({0}) differs from the configured Aspire SDK version ({1}). If you run into errors, run 'aspire update' to align them. + The installed Aspire CLI version ({0}) differs from the configured Aspire SDK version ({1}). If you run into errors, run 'aspire update' to align them. + {0} is the CLI version, {1} is the SDK version configured in aspire.config.json. + This command is not yet supported with single file AppHosts. このコマンドは、単一ファイルの AppHost ではまだサポートされていません。 diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.ko.xlf index e4f6ee1e19f..1cdf0d2b9d3 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.ko.xlf @@ -57,6 +57,26 @@ Developer certificates are only partially trusted. Trust them interactively by running 'aspire certs trust'. + + Diagnostic details: + Diagnostic details: + + + + Run with '--debug' for full diagnostic details. + Run with '--debug' for full diagnostic details. + + + + {0} SDK code generation failed because the installed Aspire CLI appears to be incompatible with the configured Aspire SDK. Run 'aspire update' to align the CLI and SDK and try again. + {0} SDK code generation failed because the installed Aspire CLI appears to be incompatible with the configured Aspire SDK. Run 'aspire update' to align the CLI and SDK and try again. + {0} is the AppHost language display name, e.g. 'TypeScript (Node.js)'. + + + The installed Aspire CLI version ({0}) differs from the configured Aspire SDK version ({1}). If you run into errors, run 'aspire update' to align them. + The installed Aspire CLI version ({0}) differs from the configured Aspire SDK version ({1}). If you run into errors, run 'aspire update' to align them. + {0} is the CLI version, {1} is the SDK version configured in aspire.config.json. + This command is not yet supported with single file AppHosts. 이 명령은 단일 파일 AppHosts에서 아직 지원되지 않습니다. diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.pl.xlf index a4eab89ac77..791f4f7644c 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.pl.xlf @@ -57,6 +57,26 @@ Developer certificates are only partially trusted. Trust them interactively by running 'aspire certs trust'. + + Diagnostic details: + Diagnostic details: + + + + Run with '--debug' for full diagnostic details. + Run with '--debug' for full diagnostic details. + + + + {0} SDK code generation failed because the installed Aspire CLI appears to be incompatible with the configured Aspire SDK. Run 'aspire update' to align the CLI and SDK and try again. + {0} SDK code generation failed because the installed Aspire CLI appears to be incompatible with the configured Aspire SDK. Run 'aspire update' to align the CLI and SDK and try again. + {0} is the AppHost language display name, e.g. 'TypeScript (Node.js)'. + + + The installed Aspire CLI version ({0}) differs from the configured Aspire SDK version ({1}). If you run into errors, run 'aspire update' to align them. + The installed Aspire CLI version ({0}) differs from the configured Aspire SDK version ({1}). If you run into errors, run 'aspire update' to align them. + {0} is the CLI version, {1} is the SDK version configured in aspire.config.json. + This command is not yet supported with single file AppHosts. To polecenie nie jest jeszcze obsługiwane w przypadku hostów AppHost z jednym plikiem. diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.pt-BR.xlf index 77bda0f40af..8c867c5648d 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.pt-BR.xlf @@ -57,6 +57,26 @@ Developer certificates are only partially trusted. Trust them interactively by running 'aspire certs trust'. + + Diagnostic details: + Diagnostic details: + + + + Run with '--debug' for full diagnostic details. + Run with '--debug' for full diagnostic details. + + + + {0} SDK code generation failed because the installed Aspire CLI appears to be incompatible with the configured Aspire SDK. Run 'aspire update' to align the CLI and SDK and try again. + {0} SDK code generation failed because the installed Aspire CLI appears to be incompatible with the configured Aspire SDK. Run 'aspire update' to align the CLI and SDK and try again. + {0} is the AppHost language display name, e.g. 'TypeScript (Node.js)'. + + + The installed Aspire CLI version ({0}) differs from the configured Aspire SDK version ({1}). If you run into errors, run 'aspire update' to align them. + The installed Aspire CLI version ({0}) differs from the configured Aspire SDK version ({1}). If you run into errors, run 'aspire update' to align them. + {0} is the CLI version, {1} is the SDK version configured in aspire.config.json. + This command is not yet supported with single file AppHosts. Este comando ainda não tem suporte para AppHosts de arquivo único. diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.ru.xlf index 96d40225090..77cc5494af3 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.ru.xlf @@ -57,6 +57,26 @@ Developer certificates are only partially trusted. Trust them interactively by running 'aspire certs trust'. + + Diagnostic details: + Diagnostic details: + + + + Run with '--debug' for full diagnostic details. + Run with '--debug' for full diagnostic details. + + + + {0} SDK code generation failed because the installed Aspire CLI appears to be incompatible with the configured Aspire SDK. Run 'aspire update' to align the CLI and SDK and try again. + {0} SDK code generation failed because the installed Aspire CLI appears to be incompatible with the configured Aspire SDK. Run 'aspire update' to align the CLI and SDK and try again. + {0} is the AppHost language display name, e.g. 'TypeScript (Node.js)'. + + + The installed Aspire CLI version ({0}) differs from the configured Aspire SDK version ({1}). If you run into errors, run 'aspire update' to align them. + The installed Aspire CLI version ({0}) differs from the configured Aspire SDK version ({1}). If you run into errors, run 'aspire update' to align them. + {0} is the CLI version, {1} is the SDK version configured in aspire.config.json. + This command is not yet supported with single file AppHosts. Эта команда пока не поддерживается для одиночных файлов AppHost. diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.tr.xlf index 85492f3c874..2fb689d9b5a 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.tr.xlf @@ -57,6 +57,26 @@ Developer certificates are only partially trusted. Trust them interactively by running 'aspire certs trust'. + + Diagnostic details: + Diagnostic details: + + + + Run with '--debug' for full diagnostic details. + Run with '--debug' for full diagnostic details. + + + + {0} SDK code generation failed because the installed Aspire CLI appears to be incompatible with the configured Aspire SDK. Run 'aspire update' to align the CLI and SDK and try again. + {0} SDK code generation failed because the installed Aspire CLI appears to be incompatible with the configured Aspire SDK. Run 'aspire update' to align the CLI and SDK and try again. + {0} is the AppHost language display name, e.g. 'TypeScript (Node.js)'. + + + The installed Aspire CLI version ({0}) differs from the configured Aspire SDK version ({1}). If you run into errors, run 'aspire update' to align them. + The installed Aspire CLI version ({0}) differs from the configured Aspire SDK version ({1}). If you run into errors, run 'aspire update' to align them. + {0} is the CLI version, {1} is the SDK version configured in aspire.config.json. + This command is not yet supported with single file AppHosts. Bu komut, tek dosya Uygulama Ana İşlemlerinde henüz desteklenmemektedir. diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.zh-Hans.xlf index 2bd6ff0b22a..f46af90c416 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.zh-Hans.xlf @@ -57,6 +57,26 @@ Developer certificates are only partially trusted. Trust them interactively by running 'aspire certs trust'. + + Diagnostic details: + Diagnostic details: + + + + Run with '--debug' for full diagnostic details. + Run with '--debug' for full diagnostic details. + + + + {0} SDK code generation failed because the installed Aspire CLI appears to be incompatible with the configured Aspire SDK. Run 'aspire update' to align the CLI and SDK and try again. + {0} SDK code generation failed because the installed Aspire CLI appears to be incompatible with the configured Aspire SDK. Run 'aspire update' to align the CLI and SDK and try again. + {0} is the AppHost language display name, e.g. 'TypeScript (Node.js)'. + + + The installed Aspire CLI version ({0}) differs from the configured Aspire SDK version ({1}). If you run into errors, run 'aspire update' to align them. + The installed Aspire CLI version ({0}) differs from the configured Aspire SDK version ({1}). If you run into errors, run 'aspire update' to align them. + {0} is the CLI version, {1} is the SDK version configured in aspire.config.json. + This command is not yet supported with single file AppHosts. 单文件应用主机尚不支持此命令。 diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.zh-Hant.xlf index 682a813559d..be6279bf20b 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.zh-Hant.xlf @@ -57,6 +57,26 @@ Developer certificates are only partially trusted. Trust them interactively by running 'aspire certs trust'. + + Diagnostic details: + Diagnostic details: + + + + Run with '--debug' for full diagnostic details. + Run with '--debug' for full diagnostic details. + + + + {0} SDK code generation failed because the installed Aspire CLI appears to be incompatible with the configured Aspire SDK. Run 'aspire update' to align the CLI and SDK and try again. + {0} SDK code generation failed because the installed Aspire CLI appears to be incompatible with the configured Aspire SDK. Run 'aspire update' to align the CLI and SDK and try again. + {0} is the AppHost language display name, e.g. 'TypeScript (Node.js)'. + + + The installed Aspire CLI version ({0}) differs from the configured Aspire SDK version ({1}). If you run into errors, run 'aspire update' to align them. + The installed Aspire CLI version ({0}) differs from the configured Aspire SDK version ({1}). If you run into errors, run 'aspire update' to align them. + {0} is the CLI version, {1} is the SDK version configured in aspire.config.json. + This command is not yet supported with single file AppHosts. 單一檔案 AppHost 尚不支援此命令。 diff --git a/src/Aspire.Cli/Utils/CliPathHelper.cs b/src/Aspire.Cli/Utils/CliPathHelper.cs index 1548d5b39ce..fdb2114f17a 100644 --- a/src/Aspire.Cli/Utils/CliPathHelper.cs +++ b/src/Aspire.Cli/Utils/CliPathHelper.cs @@ -11,6 +11,13 @@ internal static class CliPathHelper { internal const string AspireHomeEnvironmentVariable = "ASPIRE_HOME"; + // 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). + internal static readonly TimeSpan s_staleSocketThreshold = TimeSpan.FromHours(24); + + private static int s_socketDirectorySwept; + internal static string GetAspireHomeDirectory(string? processPath = null, ILogger? logger = null) { var effectiveProcessPath = processPath ?? Environment.ProcessPath; @@ -205,11 +212,63 @@ internal static string CreateGuestAppHostSocketPath(string socketPrefix) ? CreateSocketName(socketPrefix) : CreateSocketPath(socketPrefix); + /// + /// Prunes leftover CLI socket files from ~/.aspire/cli/runtime/sockets/ whose last + /// modified timestamp is older than . Returns the number of files + /// that were deleted. Exceptions from individual file deletions are swallowed so a single + /// permission-denied or locked file can't break startup. Exposed for tests via + /// . + /// + /// + /// Unlike , + /// CLI sockets don't encode the process ID in their filename — they're created with a random + /// GUID-style suffix — so the only reliable signal we have for "this is stale" is the file's + /// mtime. We pick a generous default threshold so an in-flight long-running run never has its + /// socket pruned out from under it. + /// + internal static int CleanupStaleCliSockets(string socketDirectory, TimeSpan maxAge, TimeProvider? timeProvider = null) + { + if (!Directory.Exists(socketDirectory)) + { + return 0; + } + + var now = (timeProvider ?? TimeProvider.System).GetUtcNow(); + var deleted = 0; + + var socketFileSearchPattern = BackchannelConstants.ComputeSocketFileSearchPattern("cli.sock"); + foreach (var path in Directory.EnumerateFiles(socketDirectory, socketFileSearchPattern)) + { + try + { + var lastWrite = File.GetLastWriteTimeUtc(path); + if (now - lastWrite >= maxAge) + { + File.Delete(path); + deleted++; + } + } + catch + { + // Best-effort cleanup; one bad file should not block CLI startup. + } + } + + return deleted; + } + private static string CreateSocketPath(string socketPrefix) { var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); var socketPath = BackchannelConstants.ComputeCliSocketPath(homeDirectory, socketPrefix); - Directory.CreateDirectory(Path.GetDirectoryName(socketPath)!); + var socketDirectory = Path.GetDirectoryName(socketPath)!; + Directory.CreateDirectory(socketDirectory); + + if (Interlocked.CompareExchange(ref s_socketDirectorySwept, 1, 0) == 0) + { + CleanupStaleCliSockets(socketDirectory, s_staleSocketThreshold); + } + return socketPath; } diff --git a/src/Aspire.Hosting.RemoteHost/AssemblyLoader.cs b/src/Aspire.Hosting.RemoteHost/AssemblyLoader.cs index ad80280e668..2150836b087 100644 --- a/src/Aspire.Hosting.RemoteHost/AssemblyLoader.cs +++ b/src/Aspire.Hosting.RemoteHost/AssemblyLoader.cs @@ -3,6 +3,7 @@ using System.Reflection; using System.Runtime.Loader; +using Aspire.Hosting.RemoteHost.CodeGeneration; using Aspire.Hosting.RemoteHost.Diagnostics; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; @@ -68,6 +69,54 @@ public IReadOnlyList GetAssemblies() } } + /// + /// Snapshots the currently loaded ATS integration assemblies as + /// records suitable for inclusion in a + /// diagnostic payload. Returns an empty list when no assemblies have been loaded yet so the + /// caller can include the result unconditionally. + /// + /// + /// This intentionally avoids forcing to run if it hasn't already, + /// because we want to capture the actual state at the moment a failure occurred rather than + /// triggering the load (which may itself throw). + /// + public IReadOnlyList GetLoadedAssemblyDiagnostics() + { + var infos = new List(); + if (!_assemblies.IsValueCreated) + { + return infos; + } + + foreach (var assembly in _assemblies.Value) + { + infos.Add(CreateAssemblyInfo(assembly)); + } + + return infos; + } + + private static CodeGenerationLoadedAssemblyInfo CreateAssemblyInfo(Assembly assembly) + { + var name = assembly.GetName(); + string? location; + try + { + location = string.IsNullOrEmpty(assembly.Location) ? null : assembly.Location; + } + catch + { + location = null; + } + + return new CodeGenerationLoadedAssemblyInfo + { + Name = name.Name ?? assembly.FullName ?? "", + InformationalVersion = CodeGenerationDiagnosticBuilder.GetInformationalVersion(assembly), + Location = location + }; + } + internal static IReadOnlyList GetAssemblyNamesToLoad( IConfiguration configuration, string? integrationLibsPath, diff --git a/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGenerationDiagnostic.cs b/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGenerationDiagnostic.cs new file mode 100644 index 00000000000..fde1a9c7e98 --- /dev/null +++ b/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGenerationDiagnostic.cs @@ -0,0 +1,297 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Reflection; +using Microsoft.Extensions.Logging; +using StreamJsonRpc; + +namespace Aspire.Hosting.RemoteHost.CodeGeneration; + +/// +/// JSON-RPC error codes used by the AppHost server for code-generation failures. +/// +/// +/// Values are within the JSON-RPC reserved server-error range (-32000 to -32099). +/// +internal static class CodeGenerationErrorCodes +{ + /// + /// The AppHost server failed to load or JIT-compile reflection-based code generation + /// metadata. Typically caused by an assembly-version mismatch between the bundled + /// Aspire.Hosting runtime and the user-restored integration assemblies. + /// + public const int IncompatibleAspireSdk = -32050; +} + +/// +/// Structured payload describing a reflection-load failure encountered while servicing a +/// code-generation RPC method. +/// +/// +/// Carried as from the server to the CLI so that the +/// CLI can render an actionable diagnostic. The shape is intentionally flat and JSON-serializable +/// so it survives the StreamJsonRpc SystemTextJsonFormatter round-trip without requiring +/// shared types between the server and the CLI. +/// +internal sealed class CodeGenerationDiagnostic +{ + /// + /// Gets the CLR type name of the original exception (e.g. System.TypeLoadException). + /// + public string OriginalExceptionType { get; init; } = ""; + + /// + /// Gets the name of the type that failed to load, if known. Populated from + /// when available. + /// + public string? TypeName { get; init; } + + /// + /// Gets the name of the missing member, if the failure was a + /// or . + /// + public string? MemberName { get; init; } + + /// + /// Gets the value of the bundled + /// Aspire.Hosting assembly on the server side, if it could be discovered. + /// + public string? RuntimeAspireHostingVersion { get; init; } + + /// + /// Gets the on-disk location of the bundled Aspire.Hosting assembly, if it could be + /// discovered. + /// + public string? RuntimeAspireHostingPath { get; init; } + + /// + /// Gets the loaded Aspire.Hosting* integration assemblies that were probed by the + /// AppHost server at the time of the failure. + /// + public List LoadedAssemblies { get; init; } = []; + + /// + /// Gets a short, language-agnostic remediation hint suitable for surfacing to AppHost + /// authors (e.g. instructing them to run aspire update). + /// + public string? RemediationHint { get; init; } +} + +/// +/// Identity information for a single loaded assembly captured at the time of a code-generation +/// failure. +/// +internal sealed class CodeGenerationLoadedAssemblyInfo +{ + /// + /// Gets the simple name of the assembly (e.g. Aspire.Hosting.JavaScript). + /// + public string Name { get; init; } = ""; + + /// + /// Gets the value of the assembly's + /// when present, otherwise the assembly version. + /// + public string? InformationalVersion { get; init; } + + /// + /// Gets the on-disk location of the assembly when available. + /// + public string? Location { get; init; } +} + +/// +/// Builds payloads from caught reflection-load +/// exceptions and converts them into instances that StreamJsonRpc +/// will propagate to the CLI with structured error data. +/// +internal static class CodeGenerationDiagnosticBuilder +{ + private const string SafeMessage = + "Aspire SDK code generation failed because the installed Aspire CLI appears to be incompatible with the configured SDK version. Run 'aspire update' to align the CLI and SDK and try again."; + + private const string RemediationHint = + "Run 'aspire update' to align the installed Aspire CLI with the configured SDK version, then retry."; + + /// + /// Inspects the supplied exception (and any inner exceptions) and, if it looks like a + /// reflection/load failure, returns a carrying a + /// in its . + /// Returns for exceptions that are not reflection-load failures. + /// + public static LocalRpcException? TryCreateRpcException(Exception exception, AssemblyLoader? assemblyLoader, ILogger? logger = null) + { + var loadException = FindReflectionLoadException(exception); + if (loadException is null) + { + return null; + } + + var diagnostic = BuildDiagnostic(loadException, assemblyLoader, logger); + + return new LocalRpcException(SafeMessage) + { + ErrorCode = CodeGenerationErrorCodes.IncompatibleAspireSdk, + ErrorData = diagnostic + }; + } + + /// + /// Builds a from the supplied exception without + /// wrapping it in a . Exposed for testing. + /// + internal static CodeGenerationDiagnostic BuildDiagnostic(Exception exception, AssemblyLoader? assemblyLoader, ILogger? logger = null) + { + string? typeName = null; + string? memberName = null; + + switch (exception) + { + case TypeLoadException tle: + typeName = tle.TypeName; + break; + case MissingMethodException mme: + memberName = SanitizeMemberMessage(mme.Message); + break; + case MissingFieldException mfe: + memberName = SanitizeMemberMessage(mfe.Message); + break; + case FileLoadException fle: + typeName = fle.FileName; + break; + case BadImageFormatException bife: + typeName = bife.FileName; + break; + } + + var (runtimeVersion, runtimePath, loadedAssemblies) = CaptureLoadedAssemblies(assemblyLoader, logger); + + return new CodeGenerationDiagnostic + { + OriginalExceptionType = exception.GetType().FullName ?? exception.GetType().Name, + TypeName = typeName, + MemberName = memberName, + RuntimeAspireHostingVersion = runtimeVersion, + RuntimeAspireHostingPath = runtimePath, + LoadedAssemblies = loadedAssemblies, + RemediationHint = RemediationHint + }; + } + + /// + /// Walks the exception chain and returns the first inner exception that looks like a + /// reflection-load failure, or if none is found. + /// + internal static Exception? FindReflectionLoadException(Exception? exception) + { + for (var current = exception; current is not null; current = current.InnerException) + { + if (current is ReflectionTypeLoadException rtle) + { + foreach (var loaderException in rtle.LoaderExceptions) + { + if (loaderException is not null && IsReflectionLoadException(loaderException)) + { + return loaderException; + } + } + + // No specific loader exception matched, but the RTLE itself is a reflection-load + // failure — fall through and return it from the IsReflectionLoadException check below. + } + + if (IsReflectionLoadException(current)) + { + return current; + } + } + + return null; + } + + private static bool IsReflectionLoadException(Exception exception) => exception + is TypeLoadException + or MissingMethodException + or MissingFieldException + or FileLoadException + or BadImageFormatException + or ReflectionTypeLoadException; + + private static (string? Version, string? Path, List Assemblies) CaptureLoadedAssemblies( + AssemblyLoader? assemblyLoader, + ILogger? logger) + { + string? runtimeVersion = null; + string? runtimePath = null; + var loaded = new List(); + + // Locate the actually-loaded Aspire.Hosting assembly (the runtime that backed the failing + // codegen). We avoid `typeof(Aspire.Hosting.X).Assembly` because Aspire.Hosting.RemoteHost + // does not reference Aspire.Hosting; if for any reason it isn't in AppDomain.Assemblies we + // leave the version null rather than substituting a sibling like Aspire.Hosting.RemoteHost. + var aspireHostingAssembly = AppDomain.CurrentDomain + .GetAssemblies() + .FirstOrDefault(a => string.Equals(a.GetName().Name, "Aspire.Hosting", StringComparison.OrdinalIgnoreCase)); + + if (aspireHostingAssembly is not null) + { + runtimeVersion = GetInformationalVersion(aspireHostingAssembly); + runtimePath = TryGetLocation(aspireHostingAssembly); + } + + if (assemblyLoader is null) + { + return (runtimeVersion, runtimePath, loaded); + } + + try + { + foreach (var info in assemblyLoader.GetLoadedAssemblyDiagnostics()) + { + loaded.Add(info); + } + } + catch (Exception ex) + { + logger?.LogDebug(ex, "Failed to capture loaded assembly diagnostics while building code-generation diagnostic."); + } + + return (runtimeVersion, runtimePath, loaded); + } + + internal static string? GetInformationalVersion(Assembly assembly) + { + var informational = assembly.GetCustomAttribute()?.InformationalVersion; + if (!string.IsNullOrWhiteSpace(informational)) + { + return informational; + } + + return assembly.GetName().Version?.ToString(); + } + + private static string? TryGetLocation(Assembly assembly) + { + try + { + return string.IsNullOrEmpty(assembly.Location) ? null : assembly.Location; + } + catch + { + return null; + } + } + + private static string? SanitizeMemberMessage(string? message) + { + if (string.IsNullOrWhiteSpace(message)) + { + return null; + } + + // The CLR's Missing*Exception messages are typically of the form + // "Method not found: 'System.Void Aspire.Hosting.X.Y.Z(Aspire.Hosting.Foo)'." + // We keep them verbatim; the CLI controls whether they are surfaced to the user. + return message; + } +} diff --git a/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGenerationService.cs b/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGenerationService.cs index dad2198a3f5..fd9ff421ad3 100644 --- a/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGenerationService.cs +++ b/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGenerationService.cs @@ -19,6 +19,7 @@ internal sealed class CodeGenerationService private readonly JsonRpcAuthenticationState _authenticationState; private readonly AtsContextFactory _atsContextFactory; private readonly CodeGeneratorResolver _resolver; + private readonly AssemblyLoader _assemblyLoader; private readonly ILogger _logger; private readonly RemoteHostProfilingTelemetry _profilingTelemetry; @@ -26,12 +27,14 @@ public CodeGenerationService( JsonRpcAuthenticationState authenticationState, AtsContextFactory atsContextFactory, CodeGeneratorResolver resolver, + AssemblyLoader assemblyLoader, ILogger logger, RemoteHostProfilingTelemetry profilingTelemetry) { _authenticationState = authenticationState; _atsContextFactory = atsContextFactory; _resolver = resolver; + _assemblyLoader = assemblyLoader; _logger = logger; _profilingTelemetry = profilingTelemetry; } @@ -85,6 +88,11 @@ public CapabilitiesResponse GetCapabilities(string[]? assemblyNames = null) { activity.SetError(ex); _logger.LogError(ex, "<< getCapabilities() failed"); + var wrapped = CodeGenerationDiagnosticBuilder.TryCreateRpcException(ex, _assemblyLoader, _logger); + if (wrapped is not null) + { + throw wrapped; + } throw; } } @@ -251,6 +259,11 @@ public Dictionary GenerateCode(string language, string? assembly { activity.SetError(ex); _logger.LogError(ex, "<< generateCode({Language}) failed", language); + var wrapped = CodeGenerationDiagnosticBuilder.TryCreateRpcException(ex, _assemblyLoader, _logger); + if (wrapped is not null) + { + throw wrapped; + } throw; } } diff --git a/src/Shared/BackchannelConstants.cs b/src/Shared/BackchannelConstants.cs index 031cc750296..8ec6bcfbe4c 100644 --- a/src/Shared/BackchannelConstants.cs +++ b/src/Shared/BackchannelConstants.cs @@ -285,6 +285,17 @@ public static string ComputeSocketFileName(string socketPrefix) return $"{GetCompactCliSocketPrefix(socketPrefix)}{CreateRandomBase64UrlIdentifier()}"; } + /// + /// Computes the search pattern for randomized socket files created with the specified logical + /// socket prefix. + /// + public static string ComputeSocketFileSearchPattern(string socketPrefix) + { + ArgumentException.ThrowIfNullOrEmpty(socketPrefix); + + return $"{GetCompactCliSocketPrefix(socketPrefix)}{new string('?', CompactInstanceIdLength)}"; + } + /// /// Computes the socket path prefix for finding compact sockets. /// diff --git a/tests/Aspire.Cli.Tests/Projects/AppHostCodeGenerationDiagnosticWireContractTests.cs b/tests/Aspire.Cli.Tests/Projects/AppHostCodeGenerationDiagnosticWireContractTests.cs new file mode 100644 index 00000000000..2ef2f3f1b3a --- /dev/null +++ b/tests/Aspire.Cli.Tests/Projects/AppHostCodeGenerationDiagnosticWireContractTests.cs @@ -0,0 +1,99 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using Aspire.Cli.Backchannel; +using Aspire.Cli.Projects; + +namespace Aspire.Cli.Tests.Projects; + +/// +/// Guards the wire contract between the AppHost server's reflection-based +/// SystemTextJsonFormatter (default , no naming policy, +/// PascalCase on the wire) and the CLI source-generated +/// . If either side adopts a naming policy or +/// renames a property, this test must catch it before the diagnostic silently degrades to +/// "all null" on the CLI as in issue #16709. +/// +public class AppHostCodeGenerationDiagnosticWireContractTests +{ + [Fact] + public void Deserialize_PascalCaseServerPayload_PopulatesAllFields() + { + const string serverJson = + """ + { + "OriginalExceptionType": "System.TypeLoadException", + "TypeName": "Aspire.Hosting.SomeType", + "MemberName": "Void Foo()", + "RuntimeAspireHostingVersion": "13.4.0-preview.1.26218.1", + "RuntimeAspireHostingPath": "C:\\aspire\\Aspire.Hosting.dll", + "LoadedAssemblies": [ + { + "Name": "Aspire.Hosting.CodeGeneration.TypeScript", + "InformationalVersion": "13.4.0-preview.1.26227.1", + "Location": "C:\\aspire\\Aspire.Hosting.CodeGeneration.TypeScript.dll" + } + ], + "RemediationHint": "Run 'aspire update'." + } + """; + + var diagnostic = JsonSerializer.Deserialize( + serverJson, + BackchannelJsonSerializerContext.Default.AppHostCodeGenerationDiagnostic); + + Assert.NotNull(diagnostic); + Assert.Equal("System.TypeLoadException", diagnostic.OriginalExceptionType); + Assert.Equal("Aspire.Hosting.SomeType", diagnostic.TypeName); + Assert.Equal("Void Foo()", diagnostic.MemberName); + Assert.Equal("13.4.0-preview.1.26218.1", diagnostic.RuntimeAspireHostingVersion); + Assert.Equal("C:\\aspire\\Aspire.Hosting.dll", diagnostic.RuntimeAspireHostingPath); + Assert.Equal("Run 'aspire update'.", diagnostic.RemediationHint); + var loaded = Assert.Single(diagnostic.LoadedAssemblies); + Assert.Equal("Aspire.Hosting.CodeGeneration.TypeScript", loaded.Name); + Assert.Equal("13.4.0-preview.1.26227.1", loaded.InformationalVersion); + Assert.Equal("C:\\aspire\\Aspire.Hosting.CodeGeneration.TypeScript.dll", loaded.Location); + } + + [Fact] + public void Roundtrip_UsingRpcFormatterOptions_PreservesAllFields() + { + // Use the exact same JsonSerializerOptions the StreamJsonRpc formatter uses on the CLI + // side. This catches the case where the wire-level deserializer differs from the typed + // JsonTypeInfo deserializer used by TryReadDiagnostic. + var options = BackchannelJsonSerializerContext.CreateJsonSerializerOptions(); + + var source = new AppHostCodeGenerationDiagnostic + { + OriginalExceptionType = "System.TypeLoadException", + TypeName = "Aspire.Hosting.SomeType", + MemberName = "Void Foo()", + RuntimeAspireHostingVersion = "13.4.0-preview.1.26218.1", + RuntimeAspireHostingPath = "/aspire/Aspire.Hosting.dll", + LoadedAssemblies = + [ + new AppHostLoadedAssemblyInfo + { + Name = "Aspire.Hosting.CodeGeneration.TypeScript", + InformationalVersion = "13.4.0-preview.1.26227.1", + Location = "/aspire/Aspire.Hosting.CodeGeneration.TypeScript.dll" + } + ], + RemediationHint = "Run 'aspire update'." + }; + + var json = JsonSerializer.Serialize(source, typeof(AppHostCodeGenerationDiagnostic), options); + var roundtripped = (AppHostCodeGenerationDiagnostic?)JsonSerializer.Deserialize(json, typeof(AppHostCodeGenerationDiagnostic), options); + + Assert.NotNull(roundtripped); + Assert.Equal(source.OriginalExceptionType, roundtripped.OriginalExceptionType); + Assert.Equal(source.TypeName, roundtripped.TypeName); + Assert.Equal(source.MemberName, roundtripped.MemberName); + Assert.Equal(source.RuntimeAspireHostingVersion, roundtripped.RuntimeAspireHostingVersion); + Assert.Equal(source.RemediationHint, roundtripped.RemediationHint); + var loaded = Assert.Single(roundtripped.LoadedAssemblies); + Assert.Equal(source.LoadedAssemblies[0].Name, loaded.Name); + Assert.Equal(source.LoadedAssemblies[0].InformationalVersion, loaded.InformationalVersion); + } +} diff --git a/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectSkewTests.cs b/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectSkewTests.cs new file mode 100644 index 00000000000..8e9af6d88e2 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectSkewTests.cs @@ -0,0 +1,46 @@ +// 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.Projects; + +namespace Aspire.Cli.Tests.Projects; + +public class GuestAppHostProjectSkewTests +{ + [Theory] + [InlineData("13.1.0", "13.1.0", false)] + [InlineData("13.1.0-preview.1.26218.1", "13.1.0-preview.1.26218.1", false)] + // Build metadata (everything after '+') is SemVer-spec ignored for precedence. + [InlineData("13.1.0-preview.1.26218.1+abc", "13.1.0-preview.1.26218.1+def", false)] + // Issue #16709 reproduction: same M.M.P prerelease tag with different daily build numbers + // is detected as skew (this was the exact failure case). + [InlineData("13.1.0-preview.1.26218.1", "13.1.0-preview.1.26227.1", true)] + // Release vs prerelease of the same M.M.P is skew. + [InlineData("13.1.0", "13.1.0-preview.1", true)] + [InlineData("13.1.0-preview.1", "13.1.0", true)] + [InlineData("13.1.0", "13.2.0", true)] + [InlineData("13.1.0", "14.0.0", true)] + [InlineData("13.1.0", "13.1.1", true)] + public void IsKnownIncompatibleSkew_DetectsMajorMinorPatchAndPrereleaseChanges(string cli, string sdk, bool expected) + { + var result = GuestAppHostProject.IsKnownIncompatibleSkew(cli, sdk); + + Assert.Equal(expected, result); + } + + [Fact] + public void IsKnownIncompatibleSkew_FallsBackToStringCompareForUnparseable() + { + Assert.True(GuestAppHostProject.IsKnownIncompatibleSkew("not-a-version", "also-not-a-version-but-different")); + Assert.False(GuestAppHostProject.IsKnownIncompatibleSkew("identical", "identical")); + } + + [Theory] + [InlineData("13.1.0+build.5", "13.1.0")] + [InlineData("13.1.0-preview.1+sha.abc123", "13.1.0-preview.1")] + [InlineData("13.1.0", "13.1.0")] + public void NormalizeVersion_StripsBuildSuffix(string input, string expected) + { + Assert.Equal(expected, GuestAppHostProject.NormalizeVersion(input)); + } +} diff --git a/tests/Aspire.Cli.Tests/Utils/CliPathHelperTests.cs b/tests/Aspire.Cli.Tests/Utils/CliPathHelperTests.cs index f6b3cd19ab3..b25d247fc27 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliPathHelperTests.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliPathHelperTests.cs @@ -3,6 +3,8 @@ using Aspire.Cli.Acquisition; using Aspire.Cli.Utils; +using Aspire.Hosting.Backchannel; +using Microsoft.Extensions.Time.Testing; namespace Aspire.Cli.Tests.Utils; @@ -296,4 +298,91 @@ private static string WriteBinaryWithSidecar(string binaryDir, string source) return binaryPath; } + + [Fact] + public void CleanupStaleCliSockets_DeletesFilesOlderThanThreshold() + { + var tempRoot = Directory.CreateTempSubdirectory("aspire-cli-sockets-"); + try + { + var staleFile = BackchannelConstants.ComputeCliSocketPath(tempRoot.FullName, "cli.sock"); + Directory.CreateDirectory(Path.GetDirectoryName(staleFile)!); + File.WriteAllText(staleFile, string.Empty); + var freshFile = BackchannelConstants.ComputeCliSocketPath(tempRoot.FullName, "cli.sock"); + File.WriteAllText(freshFile, string.Empty); + + var fakeTime = new FakeTimeProvider(DateTimeOffset.UtcNow); + File.SetLastWriteTimeUtc(staleFile, fakeTime.GetUtcNow().UtcDateTime - TimeSpan.FromHours(48)); + File.SetLastWriteTimeUtc(freshFile, fakeTime.GetUtcNow().UtcDateTime - TimeSpan.FromMinutes(5)); + + var socketDirectory = Path.GetDirectoryName(staleFile)!; + var deleted = CliPathHelper.CleanupStaleCliSockets(socketDirectory, TimeSpan.FromHours(24), fakeTime); + + Assert.Equal(1, deleted); + Assert.False(File.Exists(staleFile)); + Assert.True(File.Exists(freshFile)); + } + finally + { + tempRoot.Delete(recursive: true); + } + } + + [Fact] + public void CleanupStaleCliSockets_OnlyMatchesCliSockPrefix() + { + var tempRoot = Directory.CreateTempSubdirectory("aspire-cli-sockets-"); + try + { + var matching = BackchannelConstants.ComputeCliSocketPath(tempRoot.FullName, "cli.sock"); + Directory.CreateDirectory(Path.GetDirectoryName(matching)!); + File.WriteAllText(matching, string.Empty); + var unrelated = BackchannelConstants.ComputeCliSocketPath(tempRoot.FullName, "apphost.sock"); + File.WriteAllText(unrelated, string.Empty); + + var fakeTime = new FakeTimeProvider(DateTimeOffset.UtcNow); + File.SetLastWriteTimeUtc(matching, fakeTime.GetUtcNow().UtcDateTime - TimeSpan.FromHours(48)); + File.SetLastWriteTimeUtc(unrelated, fakeTime.GetUtcNow().UtcDateTime - TimeSpan.FromHours(48)); + + var socketDirectory = Path.GetDirectoryName(matching)!; + var deleted = CliPathHelper.CleanupStaleCliSockets(socketDirectory, TimeSpan.FromHours(24), fakeTime); + + Assert.Equal(1, deleted); + Assert.False(File.Exists(matching)); + Assert.True(File.Exists(unrelated)); + } + finally + { + tempRoot.Delete(recursive: true); + } + } + + [Fact] + public void CleanupStaleCliSockets_MissingDirectoryIsNoOp() + { + // Create-then-delete to guarantee a unique path we know doesn't exist on disk. + var probe = Directory.CreateTempSubdirectory("aspire-cli-sockets-missing-"); + var missingDir = probe.FullName; + probe.Delete(); + + var deleted = CliPathHelper.CleanupStaleCliSockets(missingDir, TimeSpan.FromHours(24)); + + Assert.Equal(0, deleted); + } + + [Fact] + public void CleanupStaleCliSockets_EmptyDirectoryReturnsZero() + { + var tempDir = Directory.CreateTempSubdirectory("aspire-cli-sockets-"); + try + { + var deleted = CliPathHelper.CleanupStaleCliSockets(tempDir.FullName, TimeSpan.FromHours(24)); + + Assert.Equal(0, deleted); + } + finally + { + tempDir.Delete(recursive: true); + } + } } diff --git a/tests/Aspire.Hosting.RemoteHost.Tests/CodeGenerationDiagnosticBuilderTests.cs b/tests/Aspire.Hosting.RemoteHost.Tests/CodeGenerationDiagnosticBuilderTests.cs new file mode 100644 index 00000000000..0e7630ef106 --- /dev/null +++ b/tests/Aspire.Hosting.RemoteHost.Tests/CodeGenerationDiagnosticBuilderTests.cs @@ -0,0 +1,131 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Reflection; +using Aspire.Hosting.RemoteHost.CodeGeneration; +using StreamJsonRpc; +using Xunit; + +namespace Aspire.Hosting.RemoteHost.Tests; + +public class CodeGenerationDiagnosticBuilderTests +{ + [Fact] + public void TryCreateRpcException_NonReflectionFailure_ReturnsNull() + { + var result = CodeGenerationDiagnosticBuilder.TryCreateRpcException( + new InvalidOperationException("plain failure"), + assemblyLoader: null); + + Assert.Null(result); + } + + [Fact] + public void TryCreateRpcException_TypeLoadException_ReturnsLocalRpcExceptionWithDiagnostic() + { + var typeLoad = new TypeLoadException("type not found") + { + // TypeName/Message can be empty when the JIT throws — exercise the empty path here + // separately; for this test we want to confirm the wrapping path itself works. + }; + + var result = CodeGenerationDiagnosticBuilder.TryCreateRpcException(typeLoad, assemblyLoader: null); + + var localRpc = Assert.IsType(result); + Assert.Equal(CodeGenerationErrorCodes.IncompatibleAspireSdk, localRpc.ErrorCode); + var diagnostic = Assert.IsType(localRpc.ErrorData); + Assert.Equal(typeof(TypeLoadException).FullName, diagnostic.OriginalExceptionType); + Assert.False(string.IsNullOrWhiteSpace(diagnostic.RemediationHint)); + Assert.False(string.IsNullOrWhiteSpace(localRpc.Message)); + // The default message must NOT leak the .NET-specific type name. + Assert.DoesNotContain("TypeLoadException", localRpc.Message, StringComparison.Ordinal); + } + + [Fact] + public void TryCreateRpcException_TypeLoadExceptionWithEmptyMessage_ReturnsStructuredDiagnostic() + { + // Repro of issue #16709: JIT-thrown TypeLoadException with no Message — we must still + // produce a non-empty, actionable Message on the wire. + var typeLoad = new TypeLoadException(); + + var result = CodeGenerationDiagnosticBuilder.TryCreateRpcException(typeLoad, assemblyLoader: null); + + var localRpc = Assert.IsType(result); + Assert.False(string.IsNullOrWhiteSpace(localRpc.Message)); + var diagnostic = Assert.IsType(localRpc.ErrorData); + Assert.Equal(typeof(TypeLoadException).FullName, diagnostic.OriginalExceptionType); + } + + [Fact] + public void TryCreateRpcException_MissingMethodException_PopulatesMemberName() + { + var missing = new MissingMethodException("System.Void Aspire.Hosting.Foo.Bar()"); + + var result = CodeGenerationDiagnosticBuilder.TryCreateRpcException(missing, assemblyLoader: null); + + var localRpc = Assert.IsType(result); + var diagnostic = Assert.IsType(localRpc.ErrorData); + Assert.Equal(typeof(MissingMethodException).FullName, diagnostic.OriginalExceptionType); + Assert.False(string.IsNullOrWhiteSpace(diagnostic.MemberName)); + } + + [Fact] + public void TryCreateRpcException_WrappedTypeLoadException_FindsInnerCause() + { + var inner = new TypeLoadException("nested"); + var outer = new InvalidOperationException("wrapper", inner); + + var result = CodeGenerationDiagnosticBuilder.TryCreateRpcException(outer, assemblyLoader: null); + + var localRpc = Assert.IsType(result); + var diagnostic = Assert.IsType(localRpc.ErrorData); + Assert.Equal(typeof(TypeLoadException).FullName, diagnostic.OriginalExceptionType); + } + + [Fact] + public void TryCreateRpcException_ReflectionTypeLoadException_FindsLoaderException() + { + var loader = new TypeLoadException("missing type"); + var rtle = new ReflectionTypeLoadException([null], [loader]); + + var result = CodeGenerationDiagnosticBuilder.TryCreateRpcException(rtle, assemblyLoader: null); + + var localRpc = Assert.IsType(result); + var diagnostic = Assert.IsType(localRpc.ErrorData); + Assert.Equal(typeof(TypeLoadException).FullName, diagnostic.OriginalExceptionType); + } + + [Fact] + public void BuildDiagnostic_CapturesRuntimeAspireHostingVersion() + { + // BuildDiagnostic looks for the loaded Aspire.Hosting assembly via AppDomain. Calling + // any Aspire.Hosting type forces its assembly to be loaded so the search succeeds. + _ = typeof(global::Aspire.Hosting.DistributedApplication); + + var diagnostic = CodeGenerationDiagnosticBuilder.BuildDiagnostic( + new TypeLoadException(), + assemblyLoader: null); + + Assert.False(string.IsNullOrWhiteSpace(diagnostic.RuntimeAspireHostingVersion)); + } + + [Fact] + public void BuildDiagnostic_RuntimeAspireHostingVersion_DoesNotFallBackToRemoteHostAssembly() + { + _ = typeof(global::Aspire.Hosting.DistributedApplication); + + var diagnostic = CodeGenerationDiagnosticBuilder.BuildDiagnostic( + new TypeLoadException(), + assemblyLoader: null); + + var aspireHosting = AppDomain.CurrentDomain.GetAssemblies() + .First(a => string.Equals(a.GetName().Name, "Aspire.Hosting", StringComparison.OrdinalIgnoreCase)); + var aspireHostingVersion = aspireHosting + .GetCustomAttribute()?.InformationalVersion + ?? aspireHosting.GetName().Version?.ToString(); + + // Guards #16709 finding #3: prior code fell back to typeof(AssemblyLoader).Assembly which is + // Aspire.Hosting.RemoteHost - a sibling, not the runtime that backed the failing codegen. + Assert.Equal(aspireHostingVersion, diagnostic.RuntimeAspireHostingVersion); + } +} diff --git a/tests/Aspire.Hosting.RemoteHost.Tests/ServiceErrorMessageTests.cs b/tests/Aspire.Hosting.RemoteHost.Tests/ServiceErrorMessageTests.cs index 133b3ab28e7..9b26bc03ae4 100644 --- a/tests/Aspire.Hosting.RemoteHost.Tests/ServiceErrorMessageTests.cs +++ b/tests/Aspire.Hosting.RemoteHost.Tests/ServiceErrorMessageTests.cs @@ -95,7 +95,7 @@ private static (LanguageService Lang, CodeGenerationService Code) CreateServices var atsContextFactory = new AtsContextFactory(loader, NullLogger.Instance, telemetry); var lang = new LanguageService(auth, langResolver, NullLogger.Instance, telemetry); - var code = new CodeGenerationService(auth, atsContextFactory, codeResolver, NullLogger.Instance, telemetry); + var code = new CodeGenerationService(auth, atsContextFactory, codeResolver, loader, NullLogger.Instance, telemetry); return (lang, code); } @@ -130,7 +130,7 @@ private static CodeGenerationService CreateCodeGenerationServiceWithEmptyResolve var auth = CreateAuthenticatedState(); var atsContextFactory = new AtsContextFactory(loader, NullLogger.Instance, telemetry); - return new CodeGenerationService(auth, atsContextFactory, codeResolver, NullLogger.Instance, telemetry); + return new CodeGenerationService(auth, atsContextFactory, codeResolver, loader, NullLogger.Instance, telemetry); } // The default state is "authenticated" when no JsonRpcAuthToken is present in configuration. From 3f0998a95833d5fef96b3159d56b667889b71166 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 13/43] 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> --- 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 f7bbfeeb0cc..d5050c5b3b4 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 e70b0272969..03632c295c2 100644 --- a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs @@ -1609,7 +1609,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); } @@ -1810,7 +1810,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 5d0da8b9c7745d54103b9b38d087dbaa7f6c3487 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 14/43] [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> --- 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 d5050c5b3b4..be7de6bab00 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 03632c295c2..8baa0175b71 100644 --- a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs @@ -1609,7 +1609,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 e18fdb8e989c2d97b41fab57a7857e223a4a3379 Mon Sep 17 00:00:00 2001 From: "aspire-repo-bot[bot]" <268009190+aspire-repo-bot[bot]@users.noreply.github.com> Date: Wed, 27 May 2026 23:11:04 -0700 Subject: [PATCH 15/43] [release/13.4] Use KnownConfigNames for resource service endpoint URL with legacy fallback (#17571) * Use KnownConfigNames for resource service endpoint URL with legacy fallback Remove hardcoded DOTNET_RESOURCE_SERVICE_ENDPOINT_URL constant from DashboardServiceHost and use KnownConfigNames.ResourceServiceEndpointUrl (ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL) with fallback to KnownConfigNames.Legacy.ResourceServiceEndpointUrl. * Add test for configured resource service endpoint URL --------- Co-authored-by: James Newton-King --- .../ServiceClient/DashboardClient.cs | 2 +- .../Dashboard/DashboardServiceHost.cs | 18 ++++-------- .../DistributedApplicationTests.cs | 28 +++++++++++++++++++ 3 files changed, 34 insertions(+), 14 deletions(-) diff --git a/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs b/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs index d21f37f9d11..eb06704c436 100644 --- a/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs +++ b/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs @@ -31,7 +31,7 @@ namespace Aspire.Dashboard.ServiceClient; /// lives until the stream is closed. /// /// -/// If the DOTNET_RESOURCE_SERVICE_ENDPOINT_URL environment variable is not specified, then there's +/// If the ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL environment variable is not specified, then there's /// no known endpoint to connect to, and this dashboard client will be disabled. Calls to /// and /// will throw if is . Callers should diff --git a/src/Aspire.Hosting/Dashboard/DashboardServiceHost.cs b/src/Aspire.Hosting/Dashboard/DashboardServiceHost.cs index b69a48f98fe..3d66f7e2b7c 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardServiceHost.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardServiceHost.cs @@ -24,16 +24,6 @@ namespace Aspire.Hosting.Dashboard; /// internal sealed class DashboardServiceHost : IHostedService { - /// - /// Name of the environment variable that optionally specifies the resource service URL, - /// which the dashboard will connect to over gRPC. - /// - /// - /// This is primarily intended for cases outside of the local developer environment. - /// If no value exists for this variable, a port is assigned dynamically. - /// - private const string ResourceServiceUrlVariableName = "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL"; - /// /// Provides access to the URI at which the resource service endpoint is hosted. /// @@ -135,7 +125,9 @@ public DashboardServiceHost( void ConfigureKestrel(KestrelServerOptions kestrelOptions) { // Inspect environment for the address to listen on. - var uri = configuration.GetUri(ResourceServiceUrlVariableName); + // Prefer the new config name, falling back to the legacy name. + var uri = configuration.GetUri(KnownConfigNames.ResourceServiceEndpointUrl) + ?? configuration.GetUri(KnownConfigNames.Legacy.ResourceServiceEndpointUrl); var allowUnsecuredTransport = configuration.GetBool(KnownConfigNames.AllowUnsecuredTransport) ?? false; var scheme = ResolveScheme(uri, allowUnsecuredTransport); @@ -154,7 +146,7 @@ void ConfigureKestrel(KestrelServerOptions kestrelOptions) } else { - throw new ArgumentException($"{ResourceServiceUrlVariableName} must contain a local loopback address."); + throw new ArgumentException($"{KnownConfigNames.ResourceServiceEndpointUrl} must contain a local loopback address."); } void ConfigureListen(ListenOptions options) @@ -191,7 +183,7 @@ internal static string ResolveScheme(Uri? configuredUri, bool allowUnsecuredTran /// /// /// Intended to be used by the app model when launching the dashboard process, populating its - /// DOTNET_RESOURCE_SERVICE_ENDPOINT_URL environment variable with a single URI. + /// ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL environment variable with a single URI. /// public async Task GetResourceServiceUriAsync(CancellationToken cancellationToken = default) { diff --git a/tests/Aspire.Hosting.Tests/DistributedApplicationTests.cs b/tests/Aspire.Hosting.Tests/DistributedApplicationTests.cs index 459674e7649..bb08e172538 100644 --- a/tests/Aspire.Hosting.Tests/DistributedApplicationTests.cs +++ b/tests/Aspire.Hosting.Tests/DistributedApplicationTests.cs @@ -11,6 +11,7 @@ using System.Threading.Channels; using Aspire.Dashboard.Model; using Aspire.Hosting.Diagnostics; +using Aspire.Hosting.Dashboard; using Aspire.TestUtilities; using Aspire.Hosting.Dcp; using Aspire.Hosting.Dcp.Model; @@ -1433,6 +1434,33 @@ public async Task StartAsync_UnsecuredAllowAnonymous_PassedToDashboardProcess() } } + [Fact] + public async Task StartAsync_ResourceServiceEndpointUrl_PassedToDashboardServiceHost() + { + const string testName = "dashboard-resource-service-endpoint-url"; + var resourceServicePort = await Network.GetAvailablePortAsync(); + var configuredResourceServiceUrl = $"http://localhost:{resourceServicePort}"; + var args = new string[] { + $"{KnownConfigNames.ResourceServiceEndpointUrl}={configuredResourceServiceUrl}" + }; + using var testProgram = CreateTestProgram(testName, args: args, disableDashboard: false); + + await using var app = testProgram.Build(); + + var dashboardServiceHost = app.Services.GetRequiredService(); + await ((IHostedService)dashboardServiceHost).StartAsync(CancellationToken.None); + + try + { + var resourceServiceUri = await dashboardServiceHost.GetResourceServiceUriAsync(); + Assert.Equal(configuredResourceServiceUrl, resourceServiceUri.TrimEnd('/')); + } + finally + { + await ((IHostedService)dashboardServiceHost).StopAsync(CancellationToken.None); + } + } + [Fact] [RequiresFeature(TestFeature.Docker)] public async Task VerifyDockerWithEntrypointWorks() From 03caf53d32d9301983321896d68555e8b0fd3991 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Thu, 28 May 2026 02:23:19 -0400 Subject: [PATCH 16/43] Fix VS Code AppHost launch path resolution (#17408) (#17560) * Fix VS Code AppHost launch path resolution * Use aspire ls for extension AppHost discovery * Refresh AppHost discovery consumers on changes * Use CLI language ids for AppHost discovery * Handle AppHost discovery failures in editor commands * Add TypeScript AppHost launch discovery coverage * Harden AppHost discovery process handling * Honor configured AppHosts in discovery * Consolidate extension AppHost discovery * Fix workspace test path separators * Fix AppHost configured path selection Ensure workspace AppHost selection checks all configured paths before falling back to the single discovered candidate, and add a regression test for multiple config files.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Keep extension-launched AppHost CLI alive Keep the Aspire CLI process alive after VS Code launches an AppHost so the CLI-owned backchannel remains available for the lifetime of the extension-managed debug session. Propagate extension backchannel startup failures instead of returning success. Also ignore generated .modules directories for polyglot AppHosts. * Update extension build and CLI debug logging * Address AppHost launch review feedback Keep extension-managed CLI runs alive only until the AppHost backchannel disconnects, align configured AppHost discovery UX with scanned candidates, restore dynamic debug fallbacks, and return no disposable for async workspace prompts. Restore Yarn 1 frozen install compatibility for extension CI and avoid the npx package-name/bin-name mismatch in localization export. * Address extension discovery review feedback * Stabilize pipeline unit tests without Docker Use the existing fake container runtime resolver in the pipeline unit test context so the built-in container runtime check doesn't depend on Docker availability on CI runners. * Use ordinal comparison in pipeline test provider Keep the fake container runtime service-provider lookup null-safe and explicit. * Revert pipeline test isolation changes --------- (cherry picked from commit 00eff9d115ab6d65513df8eafadb279d59b79441) Co-authored-by: David Fowler Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitignore | 1 + extension/gulpfile.js | 5 +- extension/loc/xlf/aspire-vscode.xlf | 9 + extension/package.json | 7 + extension/package.nls.json | 1 + .../AspireDebugConfigurationProvider.ts | 67 +- .../src/editor/AspireEditorCommandProvider.ts | 225 ++----- extension/src/extension.ts | 12 +- .../src/test/appHostDataRepository.test.ts | 21 +- extension/src/test/appHostDiscovery.test.ts | 473 +++++++++++++ .../aspireDebugConfigurationProvider.test.ts | 195 ++++++ .../test/aspireEditorCommandProvider.test.ts | 165 +++++ extension/src/test/workspace.test.ts | 76 +-- extension/src/utils/appHostDiscovery.ts | 626 ++++++++++++++++++ extension/src/utils/cliTypes.ts | 2 +- extension/src/utils/settings.ts | 5 + extension/src/utils/workspace.ts | 224 +------ extension/src/views/AppHostDataRepository.ts | 48 +- playground/TypeScriptAppHost/.gitignore | 1 + .../Backchannel/AppHostCliBackchannel.cs | 28 + src/Aspire.Cli/DotNet/DotNetCliRunner.cs | 9 +- src/Aspire.Cli/Program.cs | 10 +- src/Aspire.Cli/Projects/ProjectLocator.cs | 69 ++ .../Templates/java-starter/.gitignore | 1 + .../Templates/py-starter/.gitignore | 1 + .../Templates/ts-starter/.gitignore | 1 + .../Commands/LsCommandTests.cs | 40 ++ ...PublishCommandPromptingIntegrationTests.cs | 1 + .../DotNet/DotNetCliRunnerTests.cs | 60 +- .../Projects/ProjectLocatorTests.cs | 42 +- .../TestServices/TestAppHostCliBackchannel.cs | 6 + 31 files changed, 1931 insertions(+), 500 deletions(-) create mode 100644 extension/src/test/appHostDiscovery.test.ts create mode 100644 extension/src/test/aspireDebugConfigurationProvider.test.ts create mode 100644 extension/src/test/aspireEditorCommandProvider.test.ts create mode 100644 extension/src/utils/appHostDiscovery.ts diff --git a/.gitignore b/.gitignore index a82ffd06875..60371d10272 100644 --- a/.gitignore +++ b/.gitignore @@ -186,6 +186,7 @@ tests/PolyglotAppHosts/**/Go/apphost.exe #Aspire CLI .aspire/ +.modules/ # Release notes automation output tools/ReleaseNotes/analysis-output/ diff --git a/extension/gulpfile.js b/extension/gulpfile.js index 77d2a45dc1c..4e504a85808 100644 --- a/extension/gulpfile.js +++ b/extension/gulpfile.js @@ -1,4 +1,4 @@ -const { execSync } = require('child_process'); +const { execFileSync } = require('child_process'); const gulp = require('gulp'); const path = require('path'); const fs = require('fs'); @@ -40,7 +40,8 @@ const exportL10n = (done) => { // Step 1: Export strings from source files to bundle.l10n.json console.log('Exporting l10n strings from source files...'); - execSync(`npx @vscode/l10n-dev export --outDir ${l10nDir} ./src`, { + const l10nDevCli = path.join(path.dirname(require.resolve('@vscode/l10n-dev/package.json')), 'dist', 'cli.js'); + execFileSync(process.execPath, [l10nDevCli, 'export', '--outDir', l10nDir, './src'], { cwd: rootDir, stdio: 'inherit' }); diff --git a/extension/loc/xlf/aspire-vscode.xlf b/extension/loc/xlf/aspire-vscode.xlf index 24c1e3d3d62..d8c04afb042 100644 --- a/extension/loc/xlf/aspire-vscode.xlf +++ b/extension/loc/xlf/aspire-vscode.xlf @@ -268,6 +268,12 @@ Get started with Aspire + + Go + + + Go: {0} + Include environment variables when logging debug session configurations. This can help diagnose environment-related issues but may expose sensitive information in logs. @@ -541,6 +547,9 @@ This field is required. + + Timeout in milliseconds for Aspire CLI commands that discover AppHost projects. Minimum: 1000. + Unable to add folder to workspace: {0} diff --git a/extension/package.json b/extension/package.json index a3181cad200..e3106820e0d 100644 --- a/extension/package.json +++ b/extension/package.json @@ -784,6 +784,13 @@ "description": "%configuration.aspire.globalAppHostsPollingInterval%", "scope": "window" }, + "aspire.appHostDiscoveryTimeoutMs": { + "type": "number", + "default": 30000, + "minimum": 1000, + "description": "%configuration.aspire.appHostDiscoveryTimeoutMs%", + "scope": "window" + }, "aspire.enableCodeLens": { "type": "boolean", "default": true, diff --git a/extension/package.nls.json b/extension/package.nls.json index b889ec3b2f4..32ae40fa38b 100644 --- a/extension/package.nls.json +++ b/extension/package.nls.json @@ -42,6 +42,7 @@ "configuration.aspire.enableDebugConfigEnvironmentLogging": "Include environment variables when logging debug session configurations. This can help diagnose environment-related issues but may expose sensitive information in logs.", "configuration.aspire.registerMcpServerInWorkspace": "Whether to register the Aspire MCP server when a workspace is open.", "configuration.aspire.globalAppHostsPollingInterval": "Polling interval in milliseconds for fetching all running AppHosts (used in global view). Minimum: 1000.", + "configuration.aspire.appHostDiscoveryTimeoutMs": "Timeout in milliseconds for Aspire CLI commands that discover AppHost projects. Minimum: 1000.", "configuration.aspire.enableCodeLens": "Show CodeLens actions (state, restart, stop, logs) inline above resource declarations in AppHost files.", "configuration.aspire.enableGutterDecorations": "Show colored status dots in the editor gutter next to resource declarations in AppHost files.", "configuration.aspire.enableAutoRestore": "Automatically run 'aspire restore' when the workspace opens and whenever aspire.config.json changes (e.g. after switching git branches). Keeps integration packages in sync and prevents editor errors.", diff --git a/extension/src/debugger/AspireDebugConfigurationProvider.ts b/extension/src/debugger/AspireDebugConfigurationProvider.ts index 643db6ed958..24794d8e256 100644 --- a/extension/src/debugger/AspireDebugConfigurationProvider.ts +++ b/extension/src/debugger/AspireDebugConfigurationProvider.ts @@ -1,22 +1,40 @@ import * as vscode from 'vscode'; import { defaultConfigurationName } from '../loc/strings'; +import { AppHostDiscoveryService, getDebugTargetForCandidate } from '../utils/appHostDiscovery'; +import type { CandidateAppHostDisplayInfo } from '../utils/appHostDiscovery'; import { checkCliAvailableOrRedirect } from '../utils/workspace'; +import { extensionLogOutputChannel } from '../utils/logging'; export class AspireDebugConfigurationProvider implements vscode.DebugConfigurationProvider { + constructor(private readonly _appHostDiscoveryService: AppHostDiscoveryService) { + } + async provideDebugConfigurations(folder: vscode.WorkspaceFolder | undefined, token?: vscode.CancellationToken): Promise { if (folder === undefined) { return []; } - const configurations: vscode.DebugConfiguration[] = []; - configurations.push({ + const activeEditor = vscode.window.activeTextEditor; + if (!activeEditor) { + return [this.createDefaultConfiguration(folder)]; + } + + const activeEditorFolder = vscode.workspace.getWorkspaceFolder(activeEditor.document.uri); + if (activeEditorFolder?.uri.toString() !== folder.uri.toString()) { + return [this.createDefaultConfiguration(folder)]; + } + + const candidate = await this.tryFindCandidateForEditorFile(activeEditor.document.uri.fsPath, folder); + if (!candidate) { + return [this.createDefaultConfiguration(folder)]; + } + + return [{ type: 'aspire', request: 'launch', name: defaultConfigurationName, - program: '${workspaceFolder}' - }); - - return configurations; + program: getDebugTargetForCandidate(candidate) + }]; } async resolveDebugConfiguration(folder: vscode.WorkspaceFolder | undefined, config: vscode.DebugConfiguration, token?: vscode.CancellationToken): Promise { @@ -44,4 +62,41 @@ export class AspireDebugConfigurationProvider implements vscode.DebugConfigurati return config; } + + async resolveDebugConfigurationWithSubstitutedVariables(folder: vscode.WorkspaceFolder | undefined, config: vscode.DebugConfiguration, token?: vscode.CancellationToken): Promise { + if (typeof config.program === 'string') { + config.program = await this.resolveDebugTarget(config.program, folder); + } + + return config; + } + + private async tryFindCandidateForEditorFile(filePath: string, folder: vscode.WorkspaceFolder): Promise { + try { + return await this._appHostDiscoveryService.tryFindCandidateForEditorFile(filePath, folder); + } + catch (error) { + extensionLogOutputChannel.warn(`Failed to discover AppHost for debug configuration file ${filePath}: ${error}`); + return undefined; + } + } + + private async resolveDebugTarget(filePath: string, folder: vscode.WorkspaceFolder | undefined): Promise { + try { + return await this._appHostDiscoveryService.resolveDebugTarget(filePath, folder); + } + catch (error) { + extensionLogOutputChannel.warn(`Failed to resolve AppHost debug target ${filePath}: ${error}`); + return filePath; + } + } + + private createDefaultConfiguration(folder: vscode.WorkspaceFolder): vscode.DebugConfiguration { + return { + type: 'aspire', + request: 'launch', + name: defaultConfigurationName, + program: folder.uri.fsPath + }; + } } diff --git a/extension/src/editor/AspireEditorCommandProvider.ts b/extension/src/editor/AspireEditorCommandProvider.ts index 1970d4f172d..5c041cdfade 100644 --- a/extension/src/editor/AspireEditorCommandProvider.ts +++ b/extension/src/editor/AspireEditorCommandProvider.ts @@ -3,37 +3,20 @@ import * as path from 'path'; import { noAppHostInWorkspace } from '../loc/strings'; import { getResourceDebuggerExtensions } from '../debugger/debuggerExtensions'; import { AspireCommandType } from '../dcp/types'; -import { aspireConfigFileName, getAppHostPathFromConfig, readJsonFile } from '../utils/cliTypes'; +import { AppHostDiscoveryService, getDebugTargetForCandidate, selectWorkspaceAppHostPath } from '../utils/appHostDiscovery'; +import type { CandidateAppHostDisplayInfo } from '../utils/appHostDiscovery'; +import { extensionLogOutputChannel } from '../utils/logging'; export class AspireEditorCommandProvider implements vscode.Disposable { - private _workspaceAppHostPath: string | null = null; - private _workspaceSettingsJsonWatchers: Map = new Map(); private _disposables: vscode.Disposable[] = []; - constructor() { - // Watch for both aspire.config.json and .aspire/settings.json changes - const workspaceFolder = vscode.workspace.getWorkspaceFolder(vscode.Uri.file('/')); - if (workspaceFolder) { - this._workspaceSettingsJsonWatchers.set(workspaceFolder, this.watchWorkspaceForAppHostPathChanges(workspaceFolder, this.onChangeAppHostPath.bind(this))); - } - else { - vscode.workspace.workspaceFolders?.forEach(folder => { - this._workspaceSettingsJsonWatchers.set(folder, this.watchWorkspaceForAppHostPathChanges(folder, this.onChangeAppHostPath.bind(this))); - }); - } - - // As additional workspace folders are added/removed, we need to watch/unwatch them too + constructor(private readonly _appHostDiscoveryService: AppHostDiscoveryService) { this._disposables.push(vscode.workspace.onDidChangeWorkspaceFolders(event => { - event.added.forEach(folder => { - this._workspaceSettingsJsonWatchers.set(folder, this.watchWorkspaceForAppHostPathChanges(folder, this.onChangeAppHostPath.bind(this))); - }); - event.removed.forEach(folder => { - const disposable = this._workspaceSettingsJsonWatchers.get(folder); - if (disposable) { - disposable.dispose(); - this._workspaceSettingsJsonWatchers.delete(folder); - } - }); + void this.updateWorkspaceAppHostContext(); + })); + + this._disposables.push(this._appHostDiscoveryService.onDidChangeCandidates(workspaceFolder => { + void this.processActiveDocumentForWorkspace(workspaceFolder); })); this._disposables.push(vscode.window.onDidChangeActiveTextEditor(async (editor) => { @@ -54,160 +37,81 @@ export class AspireEditorCommandProvider implements vscode.Disposable { } } + private async processActiveDocumentForWorkspace(workspaceFolder: vscode.WorkspaceFolder): Promise { + const activeDocument = vscode.window.activeTextEditor?.document; + if (!activeDocument) { + await this.updateWorkspaceAppHostContext(); + return; + } + + const activeWorkspaceFolder = vscode.workspace.getWorkspaceFolder(activeDocument.uri); + if (activeWorkspaceFolder?.uri.toString() === workspaceFolder.uri.toString()) { + await this.processDocument(activeDocument); + } + } + public async processDocument(document: vscode.TextDocument): Promise { const fileExtension = path.extname(document.uri.fsPath).toLowerCase(); const isSupportedFile = getResourceDebuggerExtensions().some(extension => extension.getSupportedFileTypes().includes(fileExtension)); vscode.commands.executeCommand('setContext', 'aspire.editorSupportsRunDebug', isSupportedFile); - - if (await this.isAppHostFile(document.uri.fsPath)) { - vscode.commands.executeCommand('setContext', 'aspire.fileIsAppHost', true); - } - else { - vscode.commands.executeCommand('setContext', 'aspire.fileIsAppHost', false); - } + vscode.commands.executeCommand('setContext', 'aspire.fileIsAppHost', await this.tryFindCandidateForEditorFile(document.uri.fsPath) !== undefined); + await this.updateWorkspaceAppHostContext(); } - private async isAppHostFile(filePath: string): Promise { - const fileText = await vscode.workspace.fs.readFile(vscode.Uri.file(filePath)).then(buffer => buffer.toString()); - const lines = fileText.split(/\r?\n/); - - // C# apphost detection - if (lines.some(line => line.startsWith('#:sdk Aspire.AppHost.Sdk'))) { - return true; - } - - if (lines.some(line => line === 'var builder = DistributedApplication.CreateBuilder(args);')) { - return true; + private async updateWorkspaceAppHostContext(): Promise { + const workspaceFolder = vscode.window.activeTextEditor + ? vscode.workspace.getWorkspaceFolder(vscode.window.activeTextEditor.document.uri) + : vscode.workspace.workspaceFolders?.[0]; + if (!workspaceFolder) { + vscode.commands.executeCommand('setContext', 'aspire.workspaceHasAppHost', false); + return; } - // TypeScript/JavaScript apphost detection - const ext = path.extname(filePath).toLowerCase(); - if (['.ts', '.js', '.mts', '.mjs'].includes(ext)) { - // Match both the new `.aspire/modules/aspire` import path and the legacy - // `.modules/aspire` path so legacy stable-channel TypeScript AppHosts that - // still import from `./.modules/aspire.js` continue to expose Run/Debug - // commands via the `aspire.fileIsAppHost` context. - if (lines.some(line => /import\s+.*createBuilder.*from\s+['"].*(\.modules|\.aspire\/modules)\/aspire/.test(line))) { - return true; - } + const appHostPath = await this.trySelectWorkspaceAppHostPath(workspaceFolder); + vscode.commands.executeCommand('setContext', 'aspire.workspaceHasAppHost', appHostPath !== undefined); + } - if (lines.some(line => /require\s*\(['"].*(\.modules|\.aspire\/modules)\/aspire/.test(line))) { - return true; + /** + * Returns the resolved AppHost path from the active editor or workspace settings, or null if none is available. + */ + public async getAppHostPath(): Promise { + if (vscode.window.activeTextEditor) { + const candidate = await this.tryFindCandidateForEditorFile(vscode.window.activeTextEditor.document.uri.fsPath); + if (candidate) { + return getDebugTargetForCandidate(candidate); } } - return false; - } + const workspaceFolder = vscode.window.activeTextEditor + ? vscode.workspace.getWorkspaceFolder(vscode.window.activeTextEditor.document.uri) + : vscode.workspace.workspaceFolders?.[0]; + if (!workspaceFolder) { + return null; + } - private onChangeAppHostPath(newPath: string | null) { - vscode.commands.executeCommand('setContext', 'aspire.workspaceHasAppHost', !!newPath); - this._workspaceAppHostPath = newPath; + return await this.trySelectWorkspaceAppHostPath(workspaceFolder) ?? null; } - private watchWorkspaceForAppHostPathChanges(workspaceFolder: vscode.WorkspaceFolder, onChangeAppHostPath: (newPath: string | null) => void): vscode.Disposable { - const disposables: vscode.Disposable[] = []; - - // Watch new format: aspire.config.json in workspace root - const newFormatWatcher = vscode.workspace.createFileSystemWatcher( - new vscode.RelativePattern(workspaceFolder, aspireConfigFileName) - ); - newFormatWatcher.onDidCreate(async uri => readConfigFileAndInvokeCallback(uri)); - newFormatWatcher.onDidChange(uri => readConfigFileAndInvokeCallback(uri)); - newFormatWatcher.onDidDelete(() => { - // When new format is deleted, try to fall back to legacy format - const legacyUri = vscode.Uri.joinPath(workspaceFolder.uri, '.aspire', 'settings.json'); - vscode.workspace.fs.stat(legacyUri).then( - () => readConfigFileAndInvokeCallback(legacyUri), - () => onChangeAppHostPath(null) - ); - }); - disposables.push(newFormatWatcher); - - // Watch legacy format: .aspire/settings.json - const legacyWatcher = vscode.workspace.createFileSystemWatcher( - new vscode.RelativePattern(workspaceFolder, '.aspire/settings.json') - ); - legacyWatcher.onDidCreate(async uri => { - // Only use legacy if new format doesn't exist - const newFormatUri = vscode.Uri.joinPath(workspaceFolder.uri, aspireConfigFileName); - try { - await vscode.workspace.fs.stat(newFormatUri); - // New format exists, ignore legacy change - } catch { - readConfigFileAndInvokeCallback(uri); - } - }); - legacyWatcher.onDidChange(async uri => { - const newFormatUri = vscode.Uri.joinPath(workspaceFolder.uri, aspireConfigFileName); - try { - await vscode.workspace.fs.stat(newFormatUri); - // New format exists, ignore legacy change - } catch { - readConfigFileAndInvokeCallback(uri); - } - }); - legacyWatcher.onDidDelete(() => { - // Legacy deleted; check if new format exists - const newFormatUri = vscode.Uri.joinPath(workspaceFolder.uri, aspireConfigFileName); - vscode.workspace.fs.stat(newFormatUri).then( - () => readConfigFileAndInvokeCallback(newFormatUri), - () => onChangeAppHostPath(null) - ); - }); - disposables.push(legacyWatcher); - - // Read the initial value, preferring new format over legacy - const newFormatUri = vscode.Uri.joinPath(workspaceFolder.uri, aspireConfigFileName); - const legacyUri = vscode.Uri.joinPath(workspaceFolder.uri, '.aspire', 'settings.json'); - vscode.workspace.fs.stat(newFormatUri).then( - () => readConfigFileAndInvokeCallback(newFormatUri), - () => { - // New format doesn't exist, try legacy - vscode.workspace.fs.stat(legacyUri).then( - () => readConfigFileAndInvokeCallback(legacyUri), - () => onChangeAppHostPath(null) - ); - } - ); - - return { - dispose() { - disposables.forEach(d => d.dispose()); - } - }; - - async function readConfigFileAndInvokeCallback(uri: vscode.Uri) { - try { - const json = await readJsonFile(uri); - const appHostRelativePath = getAppHostPathFromConfig(json); - if (!appHostRelativePath) { - onChangeAppHostPath(null); - return; - } - - // Resolve relative path based on the config file's directory - const configDir = path.dirname(uri.fsPath); - const appHostPath = path.isAbsolute(appHostRelativePath) - ? appHostRelativePath - : path.join(configDir, appHostRelativePath); - onChangeAppHostPath(appHostPath); - } - catch { - onChangeAppHostPath(null); - } + private async tryFindCandidateForEditorFile(filePath: string): Promise { + try { + return await this._appHostDiscoveryService.tryFindCandidateForEditorFile(filePath); + } + catch (error) { + extensionLogOutputChannel.warn(`Failed to discover AppHost for editor file ${filePath}: ${error}`); + return undefined; } } - /** - * Returns the resolved AppHost path from the active editor or workspace settings, or null if none is available. - */ - public async getAppHostPath(): Promise { - if (vscode.window.activeTextEditor && await this.isAppHostFile(vscode.window.activeTextEditor.document.uri.fsPath)) { - return vscode.window.activeTextEditor.document.uri.fsPath; + private async trySelectWorkspaceAppHostPath(workspaceFolder: vscode.WorkspaceFolder): Promise { + try { + const appHosts = await this._appHostDiscoveryService.discover(workspaceFolder); + return await selectWorkspaceAppHostPath(workspaceFolder, appHosts); + } + catch (error) { + extensionLogOutputChannel.warn(`Failed to discover AppHost candidates for workspace ${workspaceFolder.uri.fsPath}: ${error}`); + return undefined; } - - return this._workspaceAppHostPath; } public async tryExecuteRunAppHost(noDebug: boolean): Promise { @@ -251,6 +155,5 @@ export class AspireEditorCommandProvider implements vscode.Disposable { dispose() { this._disposables.forEach(disposable => disposable.dispose()); - this._workspaceSettingsJsonWatchers.forEach(disposable => disposable.dispose()); } } diff --git a/extension/src/extension.ts b/extension/src/extension.ts index b4620ee821f..eb7ab99ee8b 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -38,6 +38,7 @@ import { readGitCommitSha } from './utils/versionInfo'; import { collectResourceCommandArguments } from './views/ResourceCommandArguments'; import { createResourceCommandArgumentLoader } from './views/ResourceCommandArgumentsLoader'; import { ResourceCommandJson } from './views/AppHostDataRepository'; +import { AppHostDiscoveryService } from './utils/appHostDiscovery'; let aspireExtensionContext = new AspireExtensionContext(); @@ -61,7 +62,10 @@ export async function activate(context: vscode.ExtensionContext) { terminalProvider.dcpServerConnectionInfo = dcpServer.connectionInfo; terminalProvider.closeAllOpenAspireTerminals(); - const editorCommandProvider = new AspireEditorCommandProvider(); + const appHostDiscoveryService = new AppHostDiscoveryService(terminalProvider); + context.subscriptions.push(appHostDiscoveryService); + + const editorCommandProvider = new AspireEditorCommandProvider(appHostDiscoveryService); const cliAddCommandRegistration = vscode.commands.registerCommand('aspire-vscode.add', () => tryExecuteCommand('aspire-vscode.add', terminalProvider, (tp) => addCommand(tp, editorCommandProvider))); const cliNewCommandRegistration = vscode.commands.registerCommand('aspire-vscode.new', () => tryExecuteCommand('aspire-vscode.new', terminalProvider, newCommand)); @@ -85,7 +89,7 @@ export async function activate(context: vscode.ExtensionContext) { const verifyCliInstalledRegistration = vscode.commands.registerCommand('aspire-vscode.verifyCliInstalled', verifyCliInstalledCommand); // Aspire panel - running app hosts tree view - const dataRepository = new AppHostDataRepository(terminalProvider); + const dataRepository = new AppHostDataRepository(terminalProvider, appHostDiscoveryService); const appHostTreeProvider = new AspireAppHostTreeProvider(dataRepository, terminalProvider, context.globalState); const appHostTreeView = vscode.window.createTreeView('aspire-vscode.runningAppHosts', { treeDataProvider: appHostTreeProvider, @@ -195,7 +199,7 @@ export async function activate(context: vscode.ExtensionContext) { context.subscriptions.push(cliUpdateCommandRegistration, cliUpdateSelfCommandRegistration, settingsCommandRegistration, openLocalSettingsCommandRegistration, openGlobalSettingsCommandRegistration, runAppHostCommandRegistration, debugAppHostCommandRegistration); context.subscriptions.push(installCliStableRegistration, installCliDailyRegistration, verifyCliInstalledRegistration); - const debugConfigProvider = new AspireDebugConfigurationProvider(); + const debugConfigProvider = new AspireDebugConfigurationProvider(appHostDiscoveryService); context.subscriptions.push( vscode.debug.registerDebugConfigurationProvider('aspire', debugConfigProvider, vscode.DebugConfigurationProviderTriggerKind.Dynamic) ); @@ -219,7 +223,7 @@ export async function activate(context: vscode.ExtensionContext) { const getEnableSettingsFileCreationPromptOnStartup = () => vscode.workspace.getConfiguration('aspire').get('enableSettingsFileCreationPromptOnStartup', true); const setEnableSettingsFileCreationPromptOnStartup = async (value: boolean) => await vscode.workspace.getConfiguration('aspire').update('enableSettingsFileCreationPromptOnStartup', value, vscode.ConfigurationTarget.Workspace); const appHostDisposablePromise = checkForExistingAppHostPathInWorkspace( - terminalProvider, + appHostDiscoveryService, getEnableSettingsFileCreationPromptOnStartup, setEnableSettingsFileCreationPromptOnStartup ); diff --git a/extension/src/test/appHostDataRepository.test.ts b/extension/src/test/appHostDataRepository.test.ts index 7513438ea57..dd1b6031a6d 100644 --- a/extension/src/test/appHostDataRepository.test.ts +++ b/extension/src/test/appHostDataRepository.test.ts @@ -50,6 +50,7 @@ suite('AppHostDataRepository', () => { let getCliPathStub: sinon.SinonStub; let spawnStub: sinon.SinonStub; let defaultWorkspaceFoldersStub: sinon.SinonStub; + let findFilesStub: sinon.SinonStub; setup(() => { subscriptions = []; @@ -58,11 +59,13 @@ suite('AppHostDataRepository', () => { spawnStub = sinon.stub(cliModule, 'spawnCliProcess'); spawnStub.callsFake(() => new TestChildProcess()); defaultWorkspaceFoldersStub = sinon.stub(vscode.workspace, 'workspaceFolders').value(undefined); + findFilesStub = sinon.stub(vscode.workspace, 'findFiles').resolves([]); }); teardown(() => { spawnStub.restore(); getCliPathStub.restore(); + findFilesStub.restore(); if (defaultWorkspaceFoldersStub.restore) { defaultWorkspaceFoldersStub.restore(); } @@ -410,7 +413,7 @@ suite('AppHostDataRepository', () => { '/workspace/samples/Store/AppHost.csproj', ], })); - await waitForMicrotasks(); + await waitForAppHostDiscovery(); assert.strictEqual(repository.viewMode, 'workspace'); assert.strictEqual(repository.workspaceAppHostPath, '/workspace/apps/Store/AppHost.csproj'); @@ -439,6 +442,12 @@ suite('AppHostDataRepository', () => { path: configuredAppHostPath, }, })); + findFilesStub.callsFake(async (include: vscode.GlobPattern) => { + const pattern = typeof include === 'string' ? include : include.pattern; + return pattern.endsWith('aspire.config.json') + ? [vscode.Uri.file(path.join(workspaceRoot, 'aspire.config.json'))] + : []; + }); let getAppHostsLineCallback: ((line: string) => void) | undefined; let psOptions: any; @@ -677,7 +686,7 @@ suite('AppHostDataRepository', () => { selected_project_file: '/workspace/apphost/apphost.cs', all_project_file_candidates: ['/workspace/apphost/apphost.cs'], })); - await waitForMicrotasks(); + await waitForAppHostDiscovery(); assert.ok(psOptions); assert.deepStrictEqual(psArgs, ['ps', '--follow', '--format', 'json', '--resources']); @@ -751,7 +760,7 @@ suite('AppHostDataRepository', () => { }, ], })); - await waitForMicrotasks(); + await waitForAppHostDiscovery(); assert.ok(psOptions); psOptions.lineCallback(JSON.stringify([ @@ -822,7 +831,7 @@ suite('AppHostDataRepository', () => { }, ], })); - await waitForMicrotasks(); + await waitForAppHostDiscovery(); assert.ok(psOptions); psOptions.lineCallback(JSON.stringify([])); @@ -877,7 +886,7 @@ suite('AppHostDataRepository', () => { selected_project_file: '/workspace/labs/ops/apphost.cs', all_project_file_candidates: ['/workspace/labs/ops/apphost.cs'], })); - await waitForMicrotasks(); + await waitForAppHostDiscovery(); assert.ok(describeOptions); assert.ok(psOptions); @@ -1310,7 +1319,7 @@ async function waitForAppHostDiscovery(): Promise { } async function waitForCondition(condition: () => boolean, message: string): Promise { - for (let i = 0; i < 20; i++) { + for (let i = 0; i < 100; i++) { if (condition()) { return; } diff --git a/extension/src/test/appHostDiscovery.test.ts b/extension/src/test/appHostDiscovery.test.ts new file mode 100644 index 00000000000..dfeadd44878 --- /dev/null +++ b/extension/src/test/appHostDiscovery.test.ts @@ -0,0 +1,473 @@ +/// + +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import * as vscode from 'vscode'; +import * as cliModule from '../debugger/languages/cli'; +import { AppHostDiscoveryService, findCandidateForEditorFile, getDebugTargetForCandidate, selectWorkspaceAppHostPath } from '../utils/appHostDiscovery'; +import type { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; + +suite('AppHost discovery', () => { + test('resolves SDK-style C# AppHost source file to discovered project candidate', () => { + const appHostProjectPath = buildPath('workspace', 'AppHost', 'AppHost.csproj'); + const programPath = buildPath('workspace', 'AppHost', 'Program.cs'); + + const candidate = findCandidateForEditorFile(programPath, [{ + path: appHostProjectPath, + language: 'csharp', + status: 'buildable', + }]); + + assert.strictEqual(candidate?.path, appHostProjectPath); + assert.strictEqual(candidate ? getDebugTargetForCandidate(candidate) : undefined, appHostProjectPath); + }); + + test('keeps file-based C# AppHost candidate as source file', () => { + const appHostPath = buildPath('workspace', 'AppHost', 'apphost.cs'); + + const candidate = findCandidateForEditorFile(appHostPath, [{ + path: appHostPath, + language: 'csharp', + status: 'buildable', + }]); + + assert.strictEqual(candidate?.path, appHostPath); + assert.strictEqual(candidate ? getDebugTargetForCandidate(candidate) : undefined, appHostPath); + }); + + test('keeps TypeScript AppHost candidate as source file', () => { + const appHostPath = buildPath('workspace', 'AppHost', 'apphost.ts'); + + const candidate = findCandidateForEditorFile(appHostPath, [{ + path: appHostPath, + language: 'typescript/nodejs', + status: 'buildable', + }]); + + assert.strictEqual(candidate?.path, appHostPath); + assert.strictEqual(candidate ? getDebugTargetForCandidate(candidate) : undefined, appHostPath); + }); + + test('returns undefined when no discovered candidate contains C# source file', () => { + const programPath = buildPath('workspace', 'Web', 'Program.cs'); + + const candidate = findCandidateForEditorFile(programPath, [{ + path: buildPath('workspace', 'AppHost', 'AppHost.csproj'), + language: 'csharp', + status: 'buildable', + }]); + + assert.strictEqual(candidate, undefined); + }); + + test('does not map source file to non-C# project candidate', () => { + const programPath = buildPath('workspace', 'AppHost', 'Program.cs'); + + const candidate = findCandidateForEditorFile(programPath, [{ + path: buildPath('workspace', 'AppHost', 'apphost.ts'), + language: 'typescript/nodejs', + status: 'buildable', + }]); + + assert.strictEqual(candidate, undefined); + }); + + test('maps C# file in AppHost project directory to discovered project candidate', () => { + const helperPath = buildPath('workspace', 'AppHost', 'Helper.cs'); + + const candidate = findCandidateForEditorFile(helperPath, [{ + path: buildPath('workspace', 'AppHost', 'AppHost.csproj'), + language: 'csharp', + status: 'buildable', + }]); + + assert.strictEqual(candidate?.path, buildPath('workspace', 'AppHost', 'AppHost.csproj')); + }); + + test('does not map C# file under bin directory to discovered project candidate', () => { + const generatedPath = buildPath('workspace', 'AppHost', 'bin', 'Debug', 'net10.0', 'Generated.cs'); + + const candidate = findCandidateForEditorFile(generatedPath, [{ + path: buildPath('workspace', 'AppHost', 'AppHost.csproj'), + language: 'csharp', + status: 'buildable', + }]); + + assert.strictEqual(candidate, undefined); + }); + + suite('service', () => { + let sandbox: sinon.SinonSandbox; + let findFilesStub: sinon.SinonStub; + + setup(() => { + sandbox = sinon.createSandbox(); + findFilesStub = sandbox.stub(vscode.workspace, 'findFiles').resolves([]); + }); + + teardown(() => { + sandbox.restore(); + }); + + test('does not force refresh discovery after cached negative editor lookup', async () => { + stubFileSystemWatchers(sandbox); + const spawnStub = sandbox.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, _args, options) => { + options?.stdoutCallback?.(JSON.stringify([{ + path: buildPath('workspace', 'AppHost', 'AppHost.csproj'), + language: 'csharp', + status: 'buildable', + }])); + options?.exitCallback?.(0); + return { kill: () => { } } as any; + }); + const service = new AppHostDiscoveryService(makeTerminalProvider()); + + try { + const workspaceFolder = makeWorkspaceFolder(buildPath('workspace')); + const firstResult = await service.tryFindCandidateForEditorFile(buildPath('workspace', 'Web', 'Program.cs'), workspaceFolder); + const secondResult = await service.tryFindCandidateForEditorFile(buildPath('workspace', 'Web', 'Program.cs'), workspaceFolder); + + assert.strictEqual(firstResult, undefined); + assert.strictEqual(secondResult, undefined); + assert.strictEqual(spawnStub.callCount, 1); + } + finally { + service.dispose(); + } + }); + + test('fires change event and invalidates cache when watched files change', async () => { + const watcherCallbacks = stubFileSystemWatchers(sandbox); + const spawnStub = sandbox.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, _args, options) => { + options?.stdoutCallback?.('[]'); + options?.exitCallback?.(0); + return { kill: () => { } } as any; + }); + const service = new AppHostDiscoveryService(makeTerminalProvider()); + const workspaceFolder = makeWorkspaceFolder(buildPath('workspace')); + let changedWorkspaceFolder: vscode.WorkspaceFolder | undefined; + const subscription = service.onDidChangeCandidates(folder => { + changedWorkspaceFolder = folder; + }); + + try { + await service.discover(workspaceFolder); + assert.strictEqual(spawnStub.callCount, 1); + + watcherCallbacks[0](); + assert.strictEqual(changedWorkspaceFolder, workspaceFolder); + + await service.discover(workspaceFolder); + assert.strictEqual(spawnStub.callCount, 2); + } + finally { + subscription.dispose(); + service.dispose(); + } + }); + + test('ignores watched files in excluded directories', async () => { + const watcherCallbacks = stubFileSystemWatchers(sandbox); + const spawnStub = sandbox.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, _args, options) => { + options?.stdoutCallback?.('[]'); + options?.exitCallback?.(0); + return { kill: () => { } } as any; + }); + const service = new AppHostDiscoveryService(makeTerminalProvider()); + const workspaceFolder = makeWorkspaceFolder(buildPath('workspace')); + let changeCount = 0; + const subscription = service.onDidChangeCandidates(() => { + changeCount++; + }); + + try { + await service.discover(workspaceFolder); + assert.strictEqual(spawnStub.callCount, 1); + + watcherCallbacks[0](vscode.Uri.file(buildPath('workspace', 'AppHost', 'bin', 'Debug', 'Generated.csproj'))); + assert.strictEqual(changeCount, 0); + + await service.discover(workspaceFolder); + assert.strictEqual(spawnStub.callCount, 1); + } + finally { + subscription.dispose(); + service.dispose(); + } + }); + + test('kills in-flight CLI process when disposed', async () => { + stubFileSystemWatchers(sandbox); + const childProcess = { + killed: false, + kill: sandbox.stub().callsFake(() => { + childProcess.killed = true; + return true; + }), + }; + const spawnStub = sandbox.stub(cliModule, 'spawnCliProcess').returns(childProcess as any); + const service = new AppHostDiscoveryService(makeTerminalProvider()); + const workspaceFolder = makeWorkspaceFolder(buildPath('workspace')); + + const discovery = service.discover(workspaceFolder); + await waitForMicrotasks(); + + service.dispose(); + + await assert.rejects(discovery, /disposed/); + assert.strictEqual(spawnStub.callCount, 1); + assert.strictEqual(childProcess.kill.callCount, 1); + assert.strictEqual(childProcess.killed, true); + }); + + test('times out hung CLI process and allows retry', async () => { + stubFileSystemWatchers(sandbox); + sandbox.stub(vscode.workspace, 'getConfiguration').returns({ + get: (key: string, defaultValue: T) => key === 'appHostDiscoveryTimeoutMs' ? 5000 as T : defaultValue, + } as vscode.WorkspaceConfiguration); + const clock = sandbox.useFakeTimers(); + const killedArgs: string[][] = []; + let hangCli = true; + const spawnStub = sandbox.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, args = [], options) => { + const childProcess = { + killed: false, + kill: sandbox.stub().callsFake(() => { + childProcess.killed = true; + killedArgs.push(args); + return true; + }), + }; + if (!hangCli) { + options?.stdoutCallback?.('[]'); + options?.exitCallback?.(0); + } + return childProcess as any; + }); + const service = new AppHostDiscoveryService(makeTerminalProvider()); + const workspaceFolder = makeWorkspaceFolder(buildPath('workspace')); + + try { + const discovery = service.discover(workspaceFolder); + await waitForMicrotasks(); + + await clock.tickAsync(5_000); + await waitForMicrotasks(); + await clock.tickAsync(5_000); + + await assert.rejects(discovery, /timed out after 5 seconds/); + assert.deepStrictEqual(killedArgs, [ + ['ls', '--format', 'json'], + ['extension', 'get-apphosts'], + ]); + + hangCli = false; + const retryResult = await service.discover(workspaceFolder); + assert.deepStrictEqual(retryResult, []); + assert.strictEqual(spawnStub.callCount, 3); + } + finally { + service.dispose(); + clock.restore(); + } + }); + + test('keeps valid aspire ls candidates when future entries have unexpected shape', async () => { + stubFileSystemWatchers(sandbox); + sandbox.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, _args, options) => { + options?.stdoutCallback?.(JSON.stringify([ + { + path: buildPath('workspace', 'AppHost', 'AppHost.csproj'), + language: 'csharp', + status: 'buildable', + }, + { + path: buildPath('workspace', 'Future', 'AppHost.csproj'), + language: 'csharp', + status: 42, + extraMetadata: true, + }, + ])); + options?.exitCallback?.(0); + return { kill: () => { } } as any; + }); + const service = new AppHostDiscoveryService(makeTerminalProvider()); + + try { + const result = await service.discover(makeWorkspaceFolder(buildPath('workspace'))); + + assert.deepStrictEqual(result, [{ + path: buildPath('workspace', 'AppHost', 'AppHost.csproj'), + language: 'csharp', + status: 'buildable', + }]); + } + finally { + service.dispose(); + } + }); + + test('reports both aspire ls and legacy fallback errors when discovery fails', async () => { + stubFileSystemWatchers(sandbox); + sandbox.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, args = [], options) => { + options?.stderrCallback?.(`${args.join(' ')} failed`); + options?.exitCallback?.(1); + return { kill: () => { } } as any; + }); + const service = new AppHostDiscoveryService(makeTerminalProvider()); + + try { + await assert.rejects( + service.discover(makeWorkspaceFolder(buildPath('workspace'))), + /aspire ls discovery failed: ls --format json failed\naspire extension get-apphosts fallback failed: extension get-apphosts failed/); + } + finally { + service.dispose(); + } + }); + + test('selects configured path from recursive config during service discovery', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'aspire-apphost-discovery-')); + try { + stubFileSystemWatchers(sandbox); + const firstConfigPath = path.join(tempDir, 'First', 'aspire.config.json'); + const secondConfigPath = path.join(tempDir, 'Second', 'aspire.config.json'); + const matchingAppHostPath = path.join(tempDir, 'Second', 'AppHost', 'AppHost.csproj'); + const otherAppHostPath = path.join(tempDir, 'Other', 'AppHost', 'AppHost.csproj'); + + fs.mkdirSync(path.dirname(firstConfigPath), { recursive: true }); + fs.mkdirSync(path.dirname(secondConfigPath), { recursive: true }); + fs.writeFileSync(firstConfigPath, JSON.stringify({ appHost: { path: 'Missing/AppHost.csproj' } })); + fs.writeFileSync(secondConfigPath, JSON.stringify({ appHost: { path: 'AppHost/AppHost.csproj' } })); + findFilesStub.callsFake(async (include: vscode.GlobPattern) => { + const pattern = typeof include === 'string' ? include : include.pattern; + return pattern.endsWith('aspire.config.json') + ? [vscode.Uri.file(firstConfigPath), vscode.Uri.file(secondConfigPath)] + : []; + }); + sandbox.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, _args, options) => { + options?.stdoutCallback?.(JSON.stringify([ + { + path: otherAppHostPath, + language: 'csharp', + status: 'buildable', + }, + { + path: matchingAppHostPath, + language: 'csharp', + status: 'buildable', + }, + ])); + options?.exitCallback?.(0); + return { kill: () => { } } as any; + }); + const service = new AppHostDiscoveryService(makeTerminalProvider()); + + try { + const result = await service.discover(makeWorkspaceFolder(tempDir)); + + assert.deepStrictEqual(result, [ + { + path: otherAppHostPath, + language: 'csharp', + status: 'buildable', + selected: false, + }, + { + path: matchingAppHostPath, + language: 'csharp', + status: 'buildable', + selected: true, + }, + ]); + } + finally { + service.dispose(); + } + } + finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test('selects configured path that matches a later discovered candidate', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'aspire-apphost-discovery-')); + try { + const workspaceFolder = makeWorkspaceFolder(tempDir); + const firstConfigPath = path.join(tempDir, 'First', 'aspire.config.json'); + const secondConfigPath = path.join(tempDir, 'Second', 'aspire.config.json'); + const matchingAppHostPath = path.join(tempDir, 'Second', 'AppHost', 'AppHost.csproj'); + + fs.mkdirSync(path.dirname(firstConfigPath), { recursive: true }); + fs.mkdirSync(path.dirname(secondConfigPath), { recursive: true }); + fs.writeFileSync(firstConfigPath, JSON.stringify({ appHost: { path: 'Missing/AppHost.csproj' } })); + fs.writeFileSync(secondConfigPath, JSON.stringify({ appHost: { path: 'AppHost/AppHost.csproj' } })); + + findFilesStub.callsFake(async (include: vscode.GlobPattern) => { + const pattern = typeof include === 'string' ? include : include.pattern; + return pattern.endsWith('aspire.config.json') + ? [vscode.Uri.file(firstConfigPath), vscode.Uri.file(secondConfigPath)] + : []; + }); + + const selectedPath = await selectWorkspaceAppHostPath(workspaceFolder, [{ + path: matchingAppHostPath, + language: 'csharp', + status: 'buildable', + }]); + + assert.strictEqual(selectedPath, matchingAppHostPath); + } + finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + }); +}); + +function buildPath(...segments: string[]): string { + return path.join(path.sep, ...segments); +} + +function makeWorkspaceFolder(folderPath: string): vscode.WorkspaceFolder { + return { + uri: vscode.Uri.file(folderPath), + name: path.basename(folderPath), + index: 0, + }; +} + +function makeTerminalProvider(): AspireTerminalProvider { + return { + getAspireCliExecutablePath: async () => 'aspire', + createEnvironment: () => ({}), + } as unknown as AspireTerminalProvider; +} + +async function waitForMicrotasks(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +function stubFileSystemWatchers(sandbox: sinon.SinonSandbox): Array<(uri?: vscode.Uri) => void> { + const callbacks: Array<(uri?: vscode.Uri) => void> = []; + sandbox.stub(vscode.workspace, 'createFileSystemWatcher').callsFake(() => ({ + onDidCreate: callback => { + callbacks.push(uri => callback(uri ?? vscode.Uri.file(buildPath('workspace', 'AppHost', 'AppHost.csproj')))); + return { dispose: () => { } }; + }, + onDidChange: callback => { + callbacks.push(uri => callback(uri ?? vscode.Uri.file(buildPath('workspace', 'AppHost', 'AppHost.csproj')))); + return { dispose: () => { } }; + }, + onDidDelete: callback => { + callbacks.push(uri => callback(uri ?? vscode.Uri.file(buildPath('workspace', 'AppHost', 'AppHost.csproj')))); + return { dispose: () => { } }; + }, + dispose: () => { }, + } as vscode.FileSystemWatcher)); + + return callbacks; +} diff --git a/extension/src/test/aspireDebugConfigurationProvider.test.ts b/extension/src/test/aspireDebugConfigurationProvider.test.ts new file mode 100644 index 00000000000..899ec4b9475 --- /dev/null +++ b/extension/src/test/aspireDebugConfigurationProvider.test.ts @@ -0,0 +1,195 @@ +/// + +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import * as vscode from 'vscode'; +import { AspireDebugConfigurationProvider } from '../debugger/AspireDebugConfigurationProvider'; +import { AppHostDiscoveryService } from '../utils/appHostDiscovery'; + +suite('AspireDebugConfigurationProvider', () => { + let tempDir: string; + let sandbox: sinon.SinonSandbox; + + setup(() => { + sandbox = sinon.createSandbox(); + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'aspire-debug-configuration-provider-')); + }); + + teardown(() => { + sandbox.restore(); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + test('resolves launch config SDK-style AppHost Program.cs to containing project file', async () => { + const appHostDirectory = path.join(tempDir, 'AppHost'); + fs.mkdirSync(appHostDirectory); + + const programPath = path.join(appHostDirectory, 'Program.cs'); + const projectPath = path.join(appHostDirectory, 'AppHost.csproj'); + fs.writeFileSync(programPath, 'var builder = DistributedApplication.CreateBuilder(args);\nbuilder.Build().Run();'); + fs.writeFileSync(projectPath, ''); + + const provider = new AspireDebugConfigurationProvider(createAppHostDiscoveryService(projectPath)); + const config = await provider.resolveDebugConfigurationWithSubstitutedVariables(undefined, { + name: 'Debug AppHost', + type: 'aspire', + request: 'launch', + program: programPath + }); + + assert.strictEqual(config?.program, projectPath); + }); + + test('leaves launch config single-file apphost.cs unchanged', async () => { + const appHostPath = path.join(tempDir, 'apphost.cs'); + fs.writeFileSync(appHostPath, '#:sdk Aspire.AppHost.Sdk\nvar builder = DistributedApplication.CreateBuilder(args);'); + + const provider = new AspireDebugConfigurationProvider(createAppHostDiscoveryService(appHostPath)); + const config = await provider.resolveDebugConfigurationWithSubstitutedVariables(undefined, { + name: 'Debug AppHost', + type: 'aspire', + request: 'launch', + program: appHostPath + }); + + assert.strictEqual(config?.program, appHostPath); + }); + + test('leaves launch config TypeScript apphost.ts unchanged', async () => { + const appHostPath = path.join(tempDir, 'apphost.ts'); + fs.writeFileSync(appHostPath, 'import { createBuilder } from "./.aspire/modules/aspire";'); + + const provider = new AspireDebugConfigurationProvider(createAppHostDiscoveryService(appHostPath, appHostPath, 'typescript/nodejs')); + const config = await provider.resolveDebugConfigurationWithSubstitutedVariables(undefined, { + name: 'Debug AppHost', + type: 'aspire', + request: 'launch', + program: appHostPath + }); + + assert.strictEqual(config?.program, appHostPath); + }); + + test('leaves launch config non-AppHost C# source file unchanged', async () => { + const appDirectory = path.join(tempDir, 'App'); + fs.mkdirSync(appDirectory); + + const programPath = path.join(appDirectory, 'Program.cs'); + fs.writeFileSync(programPath, 'Console.WriteLine("Hello");'); + fs.writeFileSync(path.join(appDirectory, 'App.csproj'), ''); + + const provider = new AspireDebugConfigurationProvider(createAppHostDiscoveryService(programPath)); + const config = await provider.resolveDebugConfigurationWithSubstitutedVariables(undefined, { + name: 'Debug AppHost', + type: 'aspire', + request: 'launch', + program: programPath + }); + + assert.strictEqual(config?.program, programPath); + }); + + test('provides dynamic launch config when active file resolves to AppHost candidate', async () => { + const folder = createWorkspaceFolder(tempDir); + const programPath = path.join(tempDir, 'AppHost', 'Program.cs'); + const projectPath = path.join(tempDir, 'AppHost', 'AppHost.csproj'); + const provider = new AspireDebugConfigurationProvider(createAppHostDiscoveryService(projectPath)); + setActiveEditor(programPath, folder); + + const configs = await provider.provideDebugConfigurations(folder); + + assert.strictEqual(configs.length, 1); + assert.strictEqual(configs[0].program, projectPath); + }); + + test('provides default dynamic launch config when active file is not an AppHost candidate', async () => { + const folder = createWorkspaceFolder(tempDir); + const programPath = path.join(tempDir, 'Web', 'Program.cs'); + const provider = new AspireDebugConfigurationProvider(createAppHostDiscoveryService(programPath, null)); + setActiveEditor(programPath, folder); + + const configs = await provider.provideDebugConfigurations(folder); + + assert.strictEqual(configs.length, 1); + assert.strictEqual(configs[0].program, folder.uri.fsPath); + }); + + test('provides default dynamic launch config when discovery fails', async () => { + const folder = createWorkspaceFolder(tempDir); + const programPath = path.join(tempDir, 'AppHost', 'Program.cs'); + const provider = new AspireDebugConfigurationProvider(createFailingAppHostDiscoveryService()); + setActiveEditor(programPath, folder); + + const configs = await provider.provideDebugConfigurations(folder); + + assert.strictEqual(configs.length, 1); + assert.strictEqual(configs[0].program, folder.uri.fsPath); + }); + + test('provides default dynamic launch config when there is no active editor', async () => { + const folder = createWorkspaceFolder(tempDir); + const provider = new AspireDebugConfigurationProvider(createAppHostDiscoveryService(folder.uri.fsPath, null)); + sandbox.stub(vscode.window, 'activeTextEditor').value(undefined); + + const configs = await provider.provideDebugConfigurations(folder); + + assert.strictEqual(configs.length, 1); + assert.strictEqual(configs[0].program, folder.uri.fsPath); + }); + + test('leaves launch config program unchanged when debug target resolution fails', async () => { + const programPath = path.join(tempDir, 'AppHost', 'Program.cs'); + const provider = new AspireDebugConfigurationProvider(createFailingAppHostDiscoveryService()); + + const config = await provider.resolveDebugConfigurationWithSubstitutedVariables(undefined, { + name: 'Debug AppHost', + type: 'aspire', + request: 'launch', + program: programPath + }); + + assert.strictEqual(config?.program, programPath); + }); + + function setActiveEditor(filePath: string, folder: vscode.WorkspaceFolder): void { + sandbox.stub(vscode.window, 'activeTextEditor').value({ + document: { + uri: vscode.Uri.file(filePath), + }, + }); + sandbox.stub(vscode.workspace, 'getWorkspaceFolder').returns(folder); + } +}); + +function createWorkspaceFolder(folderPath: string): vscode.WorkspaceFolder { + return { + uri: vscode.Uri.file(folderPath), + name: 'workspace', + index: 0, + }; +} + +function createAppHostDiscoveryService(resolvedPath: string, candidatePath: string | null = resolvedPath, language = 'csharp'): AppHostDiscoveryService { + return { + resolveDebugTarget: async () => resolvedPath, + tryFindCandidateForEditorFile: async () => candidatePath ? { + path: candidatePath, + language: language, + status: 'buildable', + } : undefined, + } as unknown as AppHostDiscoveryService; +} + +function createFailingAppHostDiscoveryService(): AppHostDiscoveryService { + return { + resolveDebugTarget: async () => { + throw new Error('discovery failed'); + }, + tryFindCandidateForEditorFile: async () => { + throw new Error('discovery failed'); + }, + } as unknown as AppHostDiscoveryService; +} diff --git a/extension/src/test/aspireEditorCommandProvider.test.ts b/extension/src/test/aspireEditorCommandProvider.test.ts new file mode 100644 index 00000000000..bede4044c7b --- /dev/null +++ b/extension/src/test/aspireEditorCommandProvider.test.ts @@ -0,0 +1,165 @@ +/// + +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import * as vscode from 'vscode'; +import { AspireEditorCommandProvider } from '../editor/AspireEditorCommandProvider'; +import { AppHostDiscoveryService } from '../utils/appHostDiscovery'; + +function createEditor(filePath: string): vscode.TextEditor { + return { + document: { + uri: vscode.Uri.file(filePath), + fileName: filePath, + languageId: filePath.endsWith('.ts') ? 'typescript' : 'csharp' + } as vscode.TextDocument + } as vscode.TextEditor; +} + +suite('AspireEditorCommandProvider', () => { + let tempDir: string; + let activeEditor: vscode.TextEditor | undefined; + let activeEditorStub: sinon.SinonStub; + let workspaceFoldersStub: sinon.SinonStub; + let getWorkspaceFolderStub: sinon.SinonStub; + let onDidChangeWorkspaceFoldersStub: sinon.SinonStub; + let onDidChangeActiveTextEditorStub: sinon.SinonStub; + let executeCommandStub: sinon.SinonStub; + + setup(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'aspire-editor-command-provider-')); + activeEditor = undefined; + + activeEditorStub = sinon.stub(vscode.window, 'activeTextEditor').get(() => activeEditor); + workspaceFoldersStub = sinon.stub(vscode.workspace, 'workspaceFolders').value(undefined); + getWorkspaceFolderStub = sinon.stub(vscode.workspace, 'getWorkspaceFolder').callsFake((uri: vscode.Uri) => { + if (uri.fsPath.startsWith(tempDir)) { + return { uri: vscode.Uri.file(tempDir), name: 'test', index: 0 }; + } + + return undefined; + }); + onDidChangeWorkspaceFoldersStub = sinon.stub(vscode.workspace, 'onDidChangeWorkspaceFolders').returns({ dispose: () => { } } as vscode.Disposable); + onDidChangeActiveTextEditorStub = sinon.stub(vscode.window, 'onDidChangeActiveTextEditor').returns({ dispose: () => { } } as vscode.Disposable); + executeCommandStub = sinon.stub(vscode.commands, 'executeCommand').resolves(undefined); + }); + + teardown(() => { + executeCommandStub.restore(); + onDidChangeActiveTextEditorStub.restore(); + onDidChangeWorkspaceFoldersStub.restore(); + getWorkspaceFolderStub.restore(); + workspaceFoldersStub.restore(); + activeEditorStub.restore(); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + test('returns containing project file when active editor is SDK-style AppHost Program.cs', async () => { + const appHostDirectory = path.join(tempDir, 'AppHost'); + fs.mkdirSync(appHostDirectory); + + const programPath = path.join(appHostDirectory, 'Program.cs'); + const projectPath = path.join(appHostDirectory, 'AppHost.csproj'); + fs.writeFileSync(programPath, 'var builder = DistributedApplication.CreateBuilder(args);\nbuilder.Build().Run();'); + fs.writeFileSync(projectPath, ''); + activeEditor = createEditor(programPath); + + const provider = new AspireEditorCommandProvider(createAppHostDiscoveryService(projectPath)); + try { + assert.strictEqual(await provider.getAppHostPath(), projectPath); + } + finally { + provider.dispose(); + } + }); + + test('returns source file when active editor is single-file apphost.cs', async () => { + const appHostPath = path.join(tempDir, 'apphost.cs'); + fs.writeFileSync(appHostPath, '#:sdk Aspire.AppHost.Sdk\nvar builder = DistributedApplication.CreateBuilder(args);'); + activeEditor = createEditor(appHostPath); + + const provider = new AspireEditorCommandProvider(createAppHostDiscoveryService(appHostPath)); + try { + assert.strictEqual(await provider.getAppHostPath(), appHostPath); + } + finally { + provider.dispose(); + } + }); + + test('returns source file when active editor is TypeScript apphost.ts', async () => { + const appHostPath = path.join(tempDir, 'apphost.ts'); + fs.writeFileSync(appHostPath, 'import { createBuilder } from "./.aspire/modules/aspire";'); + activeEditor = createEditor(appHostPath); + + const provider = new AspireEditorCommandProvider(createAppHostDiscoveryService(appHostPath, 'typescript/nodejs')); + try { + assert.strictEqual(await provider.getAppHostPath(), appHostPath); + } + finally { + provider.dispose(); + } + }); + + test('clears AppHost contexts when discovery fails while processing document', async () => { + const programPath = path.join(tempDir, 'Program.cs'); + fs.writeFileSync(programPath, 'var builder = DistributedApplication.CreateBuilder(args);'); + activeEditor = createEditor(programPath); + + const provider = new AspireEditorCommandProvider(createFailingAppHostDiscoveryService()); + try { + await provider.processDocument(activeEditor.document); + + assert.ok(executeCommandStub.calledWith('setContext', 'aspire.fileIsAppHost', false)); + assert.ok(executeCommandStub.calledWith('setContext', 'aspire.workspaceHasAppHost', false)); + } + finally { + provider.dispose(); + } + }); + + test('returns null when discovery fails while resolving AppHost path', async () => { + const programPath = path.join(tempDir, 'Program.cs'); + fs.writeFileSync(programPath, 'var builder = DistributedApplication.CreateBuilder(args);'); + activeEditor = createEditor(programPath); + + const provider = new AspireEditorCommandProvider(createFailingAppHostDiscoveryService()); + try { + assert.strictEqual(await provider.getAppHostPath(), null); + } + finally { + provider.dispose(); + } + }); +}); + +function createAppHostDiscoveryService(resolvedPath: string, language = 'csharp'): AppHostDiscoveryService { + return { + onDidChangeCandidates: () => ({ dispose: () => { } }), + tryFindCandidateForEditorFile: async () => ({ + path: resolvedPath, + language: language, + status: 'buildable', + }), + discover: async () => [{ + path: resolvedPath, + language: language, + status: 'buildable', + }], + } as unknown as AppHostDiscoveryService; +} + +function createFailingAppHostDiscoveryService(): AppHostDiscoveryService { + return { + onDidChangeCandidates: () => ({ dispose: () => { } }), + tryFindCandidateForEditorFile: async () => { + throw new Error('discovery failed'); + }, + discover: async () => { + throw new Error('discovery failed'); + }, + } as unknown as AppHostDiscoveryService; +} diff --git a/extension/src/test/workspace.test.ts b/extension/src/test/workspace.test.ts index 691e13239c6..3c4bdbeb821 100644 --- a/extension/src/test/workspace.test.ts +++ b/extension/src/test/workspace.test.ts @@ -1,15 +1,12 @@ import * as assert from 'assert'; -import type { ChildProcessWithoutNullStreams } from 'child_process'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import * as sinon from 'sinon'; import * as vscode from 'vscode'; -import * as cliModule from '../debugger/languages/cli'; -import type { SpawnProcessOptions } from '../debugger/languages/cli'; -import type { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; import { yesLabel } from '../loc/strings'; -import { checkForExistingAppHostPathInWorkspace, findAppHostsWithAspireLs, getCommonExcludeGlob, findAspireSettingsFiles } from '../utils/workspace'; +import { checkForExistingAppHostPathInWorkspace, getCommonExcludeGlob, findAspireSettingsFiles } from '../utils/workspace'; +import { AppHostDiscoveryService, getWorkspaceAppHostProjectSearchResult } from '../utils/appHostDiscovery'; suite('utils/workspace tests', () => { let sandbox: sinon.SinonSandbox; @@ -79,11 +76,6 @@ suite('utils/workspace tests', () => { }); test('AppHost selection quick pick shows aspire ls language and status metadata', async () => { - const terminalProvider = { - getAspireCliExecutablePath: async () => 'aspire', - createEnvironment: () => ({}), - } as unknown as AspireTerminalProvider; - let spawnOptions: SpawnProcessOptions | undefined; sandbox.stub(vscode.workspace, 'workspaceFolders').value([{ uri: vscode.Uri.file('/workspace'), name: 'workspace', @@ -92,30 +84,20 @@ suite('utils/workspace tests', () => { sandbox.stub(vscode.workspace, 'findFiles').resolves([]); sandbox.stub(vscode.window, 'showInformationMessage').resolves(yesLabel as never); const showQuickPickStub = sandbox.stub(vscode.window, 'showQuickPick').resolves(undefined); - sandbox.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, _args, options) => { - spawnOptions = options; - return { kill: () => true } as ChildProcessWithoutNullStreams; - }); - - const disposable = await checkForExistingAppHostPathInWorkspace(terminalProvider, () => true, async () => { }); - assert.ok(spawnOptions); - assert.ok(spawnOptions.stdoutCallback); - assert.ok(spawnOptions.exitCallback); - spawnOptions.stdoutCallback(JSON.stringify([ + const appHostDiscoveryService = createAppHostDiscoveryService([ { - relativePath: 'apps/Store/AppHost.csproj', path: '/workspace/apps/Store/AppHost.csproj', language: 'csharp', status: 'buildable', }, { - relativePath: 'samples/Store/AppHost.csproj', path: '/workspace/samples/Store/AppHost.csproj', language: 'typescript/nodejs', status: 'possibly-unbuildable', }, - ])); - spawnOptions.exitCallback(0); + ]); + + const disposable = await checkForExistingAppHostPathInWorkspace(appHostDiscoveryService, () => true, async () => { }); await waitForStubCall(showQuickPickStub); const items = showQuickPickStub.getCall(0).args[0] as readonly vscode.QuickPickItem[]; @@ -125,12 +107,12 @@ suite('utils/workspace tests', () => { detail: item.detail, })), [ { - label: 'apps/Store/AppHost.csproj', + label: path.join('apps', 'Store', 'AppHost.csproj'), description: 'C# · buildable', detail: '/workspace/apps/Store/AppHost.csproj', }, { - label: 'samples/Store/AppHost.csproj', + label: path.join('samples', 'Store', 'AppHost.csproj'), description: 'TypeScript · possibly-unbuildable', detail: '/workspace/samples/Store/AppHost.csproj', }, @@ -146,20 +128,15 @@ suite('utils/workspace tests', () => { const secondDiscoveredAppHostPath = path.join(workspaceRoot, 'samples', 'Store', 'AppHost.csproj'); try { - fs.writeFileSync(path.join(workspaceRoot, 'aspire.config.json'), JSON.stringify({ + const configPath = path.join(workspaceRoot, 'aspire.config.json'); + fs.writeFileSync(configPath, JSON.stringify({ appHost: { path: configuredAppHostPath, }, })); - - const terminalProvider = { - getAspireCliExecutablePath: async () => 'aspire', - createEnvironment: () => ({}), - } as unknown as AspireTerminalProvider; - let spawnOptions: SpawnProcessOptions | undefined; - sandbox.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, _args, options) => { - spawnOptions = options; - return { kill: () => true } as ChildProcessWithoutNullStreams; + sandbox.stub(vscode.workspace, 'findFiles').callsFake(async (include) => { + const pattern = typeof include === 'string' ? include : include.pattern; + return pattern.includes('aspire.config.json') ? [vscode.Uri.file(configPath)] : []; }); const rootFolder = { @@ -167,28 +144,24 @@ suite('utils/workspace tests', () => { name: 'workspace', index: 0, }; - const discovery = findAppHostsWithAspireLs(terminalProvider, 'aspire', rootFolder); - - assert.ok(spawnOptions); - assert.ok(spawnOptions.stdoutCallback); - assert.ok(spawnOptions.exitCallback); - spawnOptions.stdoutCallback(JSON.stringify([ + const result = await getWorkspaceAppHostProjectSearchResult(rootFolder, [ { - relativePath: 'apps/Store/AppHost.csproj', path: discoveredAppHostPath, language: 'csharp', status: 'buildable', }, { - relativePath: 'samples/Store/AppHost.csproj', path: secondDiscoveredAppHostPath, language: 'csharp', status: 'buildable', }, - ])); - spawnOptions.exitCallback(0); - - const result = await discovery.result; + { + path: configuredAppHostPath, + language: null, + status: 'buildable', + selected: true, + }, + ]); assert.strictEqual(result.selected_project_file, configuredAppHostPath); assert.deepStrictEqual(result.all_project_file_candidates, [ @@ -230,3 +203,10 @@ async function waitForStubCall(stub: sinon.SinonStub): Promise { assert.ok(stub.called); } + +function createAppHostDiscoveryService(candidates: Awaited>): AppHostDiscoveryService { + return { + onDidChangeCandidates: () => ({ dispose: () => { } }), + discover: async () => candidates, + } as unknown as AppHostDiscoveryService; +} diff --git a/extension/src/utils/appHostDiscovery.ts b/extension/src/utils/appHostDiscovery.ts new file mode 100644 index 00000000000..750d006d2d8 --- /dev/null +++ b/extension/src/utils/appHostDiscovery.ts @@ -0,0 +1,626 @@ +import * as path from 'path'; +import * as vscode from 'vscode'; +import type { ChildProcessWithoutNullStreams } from 'child_process'; +import { spawnCliProcess } from '../debugger/languages/cli'; +import { AspireTerminalProvider } from './AspireTerminalProvider'; +import { aspireConfigFileName, getAppHostPathFromConfig, readJsonFile } from './cliTypes'; +import { EnvironmentVariables } from './environment'; +import { extensionLogOutputChannel } from './logging'; +import { getAppHostDiscoveryTimeoutMs } from './settings'; + +// Mirrors the `aspire ls --format json` candidate shape documented in +// docs/specs/cli-output-formats.md. Older CLI fallback results are adapted into +// this shape so extension code can keep using the modern discovery contract. +export interface CandidateAppHostDisplayInfo { + path: string; + language: string | null; + status: string | null; + selected?: boolean; +} + +export interface AppHostCandidate { + relativePath: string; + path: string; + language: string; + status: string; +} + +export interface AppHostProjectSearchResult { + selected_project_file: string | null; + all_project_file_candidates: string[]; + app_host_candidates: AppHostCandidate[]; +} + +interface LegacyAppHostProjectSearchResult { + selected_project_file: string | null; + all_project_file_candidates: string[]; +} + +const discoveryExcludePattern = '{**/artifacts/**,**/[Bb]in/**,**/[Oo]bj/**,**/node_modules/**,**/.git/**,**/.vs/**,**/.vscode-test/**,**/.idea/**,**/.aspire/modules/**}'; + +export class AppHostDiscoveryService implements vscode.Disposable { + private readonly _onDidChangeCandidates = new vscode.EventEmitter(); + private readonly _cache = new Map>(); + private readonly _watchers = new Map(); + private readonly _activeCliProcesses = new Set(); + private readonly _cancelActiveCliProcesses = new Set<(error: Error) => void>(); + private _disposed = false; + readonly onDidChangeCandidates = this._onDidChangeCandidates.event; + + constructor(private readonly _terminalProvider: AspireTerminalProvider) { + } + + async discover(workspaceFolder: vscode.WorkspaceFolder, forceRefresh = false): Promise { + this._throwIfDisposed(); + + const key = path.resolve(workspaceFolder.uri.fsPath); + if (forceRefresh) { + this._cache.delete(key); + } + + this._ensureWatchers(workspaceFolder, key); + + let resultPromise = this._cache.get(key); + if (!resultPromise) { + resultPromise = this._discoverCore(workspaceFolder) + .then(candidates => this._includeConfiguredAppHostCandidate(workspaceFolder, candidates)) + .catch(error => { + this._cache.delete(key); + throw error; + }); + this._cache.set(key, resultPromise); + } + + return resultPromise; + } + + async resolveDebugTarget(filePath: string, workspaceFolder?: vscode.WorkspaceFolder): Promise { + return await this.tryResolveDebugTarget(filePath, workspaceFolder) ?? filePath; + } + + async tryResolveDebugTarget(filePath: string, workspaceFolder?: vscode.WorkspaceFolder): Promise { + const candidate = await this.tryFindCandidateForEditorFile(filePath, workspaceFolder); + return candidate ? getDebugTargetForCandidate(candidate) : undefined; + } + + async tryFindCandidateForEditorFile(filePath: string, workspaceFolder?: vscode.WorkspaceFolder): Promise { + const folder = workspaceFolder ?? vscode.workspace.getWorkspaceFolder(vscode.Uri.file(filePath)); + if (!folder) { + return undefined; + } + + const result = await this.discover(folder); + return findCandidateForEditorFile(filePath, result); + } + + dispose(): void { + if (this._disposed) { + return; + } + + this._disposed = true; + for (const disposables of this._watchers.values()) { + disposables.forEach(disposable => disposable.dispose()); + } + this._watchers.clear(); + this._cache.clear(); + for (const cancel of [...this._cancelActiveCliProcesses]) { + cancel(new Error('AppHost discovery service was disposed.')); + } + this._cancelActiveCliProcesses.clear(); + this._activeCliProcesses.clear(); + this._onDidChangeCandidates.dispose(); + } + + private async _discoverCore(workspaceFolder: vscode.WorkspaceFolder): Promise { + try { + const appHosts = await this._discoverWithLs(workspaceFolder); + extensionLogOutputChannel.info(`Discovered ${appHosts.length} AppHost candidate(s) via aspire ls`); + return appHosts; + } + catch (error) { + this._throwIfDisposed(); + extensionLogOutputChannel.warn(`aspire ls discovery failed, falling back to aspire extension get-apphosts: ${formatErrorMessage(error)}`); + try { + const appHosts = await this._discoverWithLegacyGetAppHosts(workspaceFolder); + extensionLogOutputChannel.info(`Discovered ${appHosts.length} AppHost candidate(s) via aspire extension get-apphosts`); + return appHosts; + } + catch (fallbackError) { + this._throwIfDisposed(); + throw new Error(`aspire ls discovery failed: ${formatErrorMessage(error)}\naspire extension get-apphosts fallback failed: ${formatErrorMessage(fallbackError)}`); + } + } + } + + private async _discoverWithLs(workspaceFolder: vscode.WorkspaceFolder): Promise { + this._throwIfDisposed(); + + const cliPath = await this._terminalProvider.getAspireCliExecutablePath(); + const args = ['ls', '--format', 'json']; + if (process.env[EnvironmentVariables.ASPIRE_CLI_STOP_ON_ENTRY] === 'true') { + args.push('--cli-wait-for-debugger'); + } + + const output = await this._runCliForStdout(cliPath, args, workspaceFolder.uri.fsPath); + return parseCandidateOutput(output, 'aspire ls'); + } + + private async _discoverWithLegacyGetAppHosts(workspaceFolder: vscode.WorkspaceFolder): Promise { + this._throwIfDisposed(); + + const cliPath = await this._terminalProvider.getAspireCliExecutablePath(); + const args = ['extension', 'get-apphosts']; + if (process.env[EnvironmentVariables.ASPIRE_CLI_STOP_ON_ENTRY] === 'true') { + args.push('--cli-wait-for-debugger'); + } + + const output = await this._runCliForStdout(cliPath, args, workspaceFolder.uri.fsPath); + const parsed = parseLegacyGetAppHostsOutput(output); + return toCandidatesFromLegacySearchResult(parsed); + } + + private _ensureWatchers(workspaceFolder: vscode.WorkspaceFolder, key: string): void { + if (this._watchers.has(key)) { + return; + } + + const invalidate = (uri: vscode.Uri) => { + if (isExcludedDiscoveryUri(workspaceFolder, uri)) { + return; + } + + this._cache.delete(key); + this._onDidChangeCandidates.fire(workspaceFolder); + }; + const patterns = [ + '**/*.csproj', + '**/*.fsproj', + '**/*.vbproj', + '**/apphost.cs', + '**/apphost.ts', + '**/apphost.js', + '**/apphost.mts', + '**/apphost.mjs', + `**/${aspireConfigFileName}`, + '**/.aspire/settings.json', + ]; + + const watchers = patterns.map(pattern => { + const watcher = vscode.workspace.createFileSystemWatcher(new vscode.RelativePattern(workspaceFolder, pattern)); + watcher.onDidCreate(uri => invalidate(uri)); + watcher.onDidChange(uri => invalidate(uri)); + watcher.onDidDelete(uri => invalidate(uri)); + return watcher; + }); + this._watchers.set(key, watchers); + } + + private _throwIfDisposed(): void { + if (this._disposed) { + throw new Error('AppHost discovery service has been disposed.'); + } + } + + private async _includeConfiguredAppHostCandidate(workspaceFolder: vscode.WorkspaceFolder, candidates: CandidateAppHostDisplayInfo[]): Promise { + if (candidates.some(candidate => candidate.selected)) { + return candidates; + } + + const configuredPaths = await findConfiguredAppHostPaths(workspaceFolder); + const configuredPath = configuredPaths.find(configuredPath => candidates.some(candidate => isSamePath(candidate.path, configuredPath))) + ?? configuredPaths[0]; + if (!configuredPath) { + return candidates; + } + + const matchingCandidate = candidates.find(candidate => isSamePath(candidate.path, configuredPath)); + if (matchingCandidate) { + return candidates.map(candidate => ({ + ...candidate, + selected: isSamePath(candidate.path, configuredPath), + })); + } + + return [ + ...candidates, + { + path: configuredPath, + language: null, + status: 'buildable', + selected: true, + }, + ]; + } + + private _runCliForStdout(cliPath: string, args: string[], workingDirectory: string): Promise { + return new Promise((resolve, reject) => { + this._throwIfDisposed(); + + let stdout = ''; + let stderr = ''; + let settled = false; + let childProcess: ChildProcessWithoutNullStreams | undefined; + let timeout: ReturnType | undefined; + const cancel = (error: Error) => { + if (childProcess && !childProcess.killed) { + try { + if (!childProcess.kill()) { + extensionLogOutputChannel.warn(`Failed to stop AppHost discovery command: aspire ${args.join(' ')}`); + } + } + catch (killError) { + extensionLogOutputChannel.warn(`Failed to stop AppHost discovery command: ${killError}`); + } + } + + settle(() => reject(error)); + }; + const cleanup = () => { + if (timeout) { + clearTimeout(timeout); + timeout = undefined; + } + if (childProcess) { + this._activeCliProcesses.delete(childProcess); + } + this._cancelActiveCliProcesses.delete(cancel); + }; + const settle = (complete: () => void) => { + if (settled) { + return; + } + + settled = true; + cleanup(); + complete(); + }; + + this._cancelActiveCliProcesses.add(cancel); + try { + childProcess = spawnCliProcess(this._terminalProvider, cliPath, args, { + noExtensionVariables: true, + workingDirectory, + stdoutCallback: data => { stdout += data; }, + stderrCallback: data => { stderr += data; }, + exitCallback: code => { + settle(() => { + if (code === 0) { + resolve(stdout); + } + else { + reject(new Error(stderr || `exit code ${code ?? 1}`)); + } + }); + }, + errorCallback: error => { + settle(() => reject(error)); + }, + }); + } + catch (error) { + settle(() => reject(error instanceof Error ? error : new Error(String(error)))); + return; + } + + if (settled) { + return; + } + + this._activeCliProcesses.add(childProcess); + const timeoutMs = getAppHostDiscoveryTimeoutMs(); + timeout = setTimeout(() => { + cancel(new Error(`aspire ${args.join(' ')} timed out after ${timeoutMs / 1000} seconds.`)); + }, timeoutMs); + }); + } +} + +export function findCandidateForEditorFile(filePath: string, candidates: readonly CandidateAppHostDisplayInfo[]): CandidateAppHostDisplayInfo | undefined { + const matchingCandidate = candidates.find(candidate => isSamePath(candidate.path, filePath)); + if (matchingCandidate) { + return matchingCandidate; + } + + if (path.extname(filePath).toLowerCase() !== '.cs') { + return undefined; + } + + // IMPORTANT: `aspire ls` is still the source of truth for what is a valid AppHost. + // This block does not discover AppHosts by reading C# source files or by deciding + // that a project "looks like" an AppHost. It only handles the editor affordance gap + // in the current CLI shape: + // + // aspire ls --format json + // [ + // { "path": "/repo/AppHost/AppHost.csproj", "language": "csharp", "status": "buildable" } + // ] + // + // For SDK-style .NET AppHosts the launch target is the `.csproj`, but users usually + // have `Program.cs` or another C# source file open when they invoke Run/Debug from + // the editor or debug picker. Until the CLI returns source identity/project membership + // in the candidate payload, treat C# files under a candidate `.csproj` directory as + // editor aliases for that candidate. Pick the deepest candidate directory so nested + // AppHost candidates prefer their own project over an outer candidate. Keep this + // heuristic bounded to C# project candidates from `aspire ls` and remove it when the + // CLI can report the canonical source file or owning project for each candidate. + const projectCandidate = candidates + .filter(candidate => isCSharpProjectCandidate(candidate) && isCSharpSourceFileForProjectCandidate(filePath, candidate.path)) + .sort((left, right) => path.dirname(right.path).length - path.dirname(left.path).length)[0]; + return projectCandidate; +} + +export function getDebugTargetForCandidate(candidate: CandidateAppHostDisplayInfo): string { + return candidate.path; +} + +export function getWorkspaceAppHostProjectSearchResult(workspaceFolder: vscode.WorkspaceFolder, candidates: readonly CandidateAppHostDisplayInfo[]): AppHostProjectSearchResult { + const appHostCandidates = candidates.map(candidate => toAppHostCandidate(workspaceFolder, candidate)); + const selectedAppHostPath = candidates.find(candidate => candidate.selected)?.path + ?? (candidates.length === 1 ? candidates[0].path : null); + const effectiveAppHostCandidates = selectedAppHostPath && !appHostCandidates.some(candidate => isSamePath(candidate.path, selectedAppHostPath)) + ? [...appHostCandidates, toConfiguredAppHostCandidate(workspaceFolder, selectedAppHostPath)] + : appHostCandidates; + const buildableCandidates = effectiveAppHostCandidates.filter(isBuildableAppHostCandidate); + + return { + selected_project_file: selectedAppHostPath && buildableCandidates.some(candidate => isSamePath(candidate.path, selectedAppHostPath)) + ? selectedAppHostPath + : null, + all_project_file_candidates: buildableCandidates.map(candidate => candidate.path), + app_host_candidates: effectiveAppHostCandidates, + }; +} + +export function isBuildableAppHostCandidate(candidate: AppHostCandidate): boolean { + return candidate.status === 'buildable'; +} + +export function formatAppHostLanguage(language: string): string | undefined { + if (!language) { + return undefined; + } + + switch (language.toLowerCase()) { + case 'csharp': + return 'C#'; + case 'typescript': + case 'typescript/nodejs': + return 'TypeScript'; + default: + return language.charAt(0).toUpperCase() + language.slice(1); + } +} + +export async function selectWorkspaceAppHostPath(workspaceFolder: vscode.WorkspaceFolder, candidates: readonly CandidateAppHostDisplayInfo[]): Promise { + const selectedCandidate = candidates.find(candidate => candidate.selected); + if (selectedCandidate) { + return selectedCandidate.path; + } + + const configuredPaths = await findConfiguredAppHostPaths(workspaceFolder); + for (const configuredPath of configuredPaths) { + const candidate = candidates.find(candidate => isSamePath(candidate.path, configuredPath)); + if (candidate) { + return candidate.path; + } + } + + return candidates.length === 1 ? candidates[0].path : undefined; +} + +export async function findConfiguredAppHostPaths(workspaceFolder: vscode.WorkspaceFolder): Promise { + let newConfigFiles: vscode.Uri[]; + let legacySettingsFiles: vscode.Uri[]; + try { + [newConfigFiles, legacySettingsFiles] = await Promise.all([ + vscode.workspace.findFiles(new vscode.RelativePattern(workspaceFolder, `**/${aspireConfigFileName}`), discoveryExcludePattern), + vscode.workspace.findFiles(new vscode.RelativePattern(workspaceFolder, '**/.aspire/settings.json'), discoveryExcludePattern), + ]); + } + catch (error) { + extensionLogOutputChannel.warn(`Failed to find AppHost configuration files: ${formatErrorMessage(error)}`); + return []; + } + + const newConfigDirs = new Set(newConfigFiles.map(uri => path.dirname(uri.fsPath))); + const filteredLegacyFiles = legacySettingsFiles.filter(legacyUri => { + const projectRoot = path.dirname(path.dirname(legacyUri.fsPath)); + return !newConfigDirs.has(projectRoot); + }); + + const configuredPaths: string[] = []; + for (const uri of [...newConfigFiles, ...filteredLegacyFiles]) { + try { + const json = await readJsonFile(uri); + const appHostPath = getAppHostPathFromConfig(json); + if (appHostPath) { + configuredPaths.push(path.isAbsolute(appHostPath) ? appHostPath : path.join(path.dirname(uri.fsPath), appHostPath)); + } + } + catch { + } + } + + return configuredPaths; +} + +function isExcludedDiscoveryUri(workspaceFolder: vscode.WorkspaceFolder, uri: vscode.Uri): boolean { + const relativePath = path.relative(workspaceFolder.uri.fsPath, uri.fsPath); + if (relativePath === '' || relativePath.startsWith('..') || path.isAbsolute(relativePath)) { + return true; + } + + const segments = relativePath.split(/[\\/]+/); + return segments.some((segment, index) => { + const lowerSegment = segment.toLowerCase(); + return lowerSegment === 'artifacts' + || lowerSegment === 'bin' + || lowerSegment === 'obj' + || lowerSegment === 'node_modules' + || lowerSegment === '.git' + || lowerSegment === '.vs' + || lowerSegment === '.vscode-test' + || lowerSegment === '.idea' + || (lowerSegment === '.aspire' && segments[index + 1]?.toLowerCase() === 'modules'); + }); +} + +function toAppHostCandidate(workspaceFolder: vscode.WorkspaceFolder, candidate: CandidateAppHostDisplayInfo): AppHostCandidate { + return { + relativePath: path.relative(workspaceFolder.uri.fsPath, candidate.path), + path: candidate.path, + language: candidate.language ?? '', + status: candidate.status ?? 'buildable', + }; +} + +function toConfiguredAppHostCandidate(workspaceFolder: vscode.WorkspaceFolder, appHostPath: string): AppHostCandidate { + return { + relativePath: path.relative(workspaceFolder.uri.fsPath, appHostPath), + path: appHostPath, + language: '', + status: 'buildable', + }; +} + +function parseCandidateOutput(output: string, commandName: string): CandidateAppHostDisplayInfo[] { + const trimmed = output.trim(); + if (!trimmed) { + return []; + } + + const parsed = JSON.parse(trimmed); + if (Array.isArray(parsed)) { + const appHosts = parsed + .filter(isLsCandidate) + .map(candidate => ({ + path: candidate.path, + language: candidate.language, + status: candidate.status, + })); + + const unexpectedCandidateCount = parsed.length - appHosts.length; + if (unexpectedCandidateCount > 0) { + extensionLogOutputChannel.warn(`${commandName} returned ${unexpectedCandidateCount} candidate(s) with an unexpected shape; ignoring those entries.`); + } + + return appHosts; + } + + if (isAppHostProjectSearchResult(parsed)) { + return parsed.app_host_candidates.map(candidate => ({ + path: candidate.path, + language: candidate.language, + status: candidate.status, + selected: typeof parsed.selected_project_file === 'string' && isSamePath(parsed.selected_project_file, candidate.path), + })); + } + + if (isLegacyAppHostProjectSearchResult(parsed)) { + return toCandidatesFromLegacySearchResult(parsed); + } + + throw new Error(`${commandName} returned an unexpected output shape.`); +} + +function parseLegacyGetAppHostsOutput(output: string): LegacyAppHostProjectSearchResult { + // `aspire extension get-apphosts` prints a single JSON object: + // {"selected_project_file":"/repo/AppHost/AppHost.csproj","all_project_file_candidates":["/repo/AppHost/AppHost.csproj"]} + // Older builds can include log lines, so scan for the first line with the expected shape. + for (const line of output.split(/\r?\n/)) { + try { + const parsed = JSON.parse(line); + if (isLegacyAppHostProjectSearchResult(parsed)) { + return parsed; + } + } + catch { + } + } + + const parsed = JSON.parse(output.trim()); + if (isLegacyAppHostProjectSearchResult(parsed)) { + return parsed; + } + + throw new Error('aspire extension get-apphosts returned an unexpected output shape.'); +} + +function isLsCandidate(obj: unknown): obj is CandidateAppHostDisplayInfo { + return !!obj + && typeof obj === 'object' + && typeof (obj as CandidateAppHostDisplayInfo).path === 'string' + && typeof (obj as CandidateAppHostDisplayInfo).language === 'string' + && typeof (obj as CandidateAppHostDisplayInfo).status === 'string'; +} + +function formatErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function isLegacyAppHostProjectSearchResult(obj: unknown): obj is LegacyAppHostProjectSearchResult { + return !!obj + && typeof obj === 'object' + && (typeof (obj as LegacyAppHostProjectSearchResult).selected_project_file === 'string' || (obj as LegacyAppHostProjectSearchResult).selected_project_file === null) + && Array.isArray((obj as LegacyAppHostProjectSearchResult).all_project_file_candidates); +} + +function isAppHostProjectSearchResult(obj: unknown): obj is AppHostProjectSearchResult { + return !!obj + && typeof obj === 'object' + && (typeof (obj as AppHostProjectSearchResult).selected_project_file === 'string' || (obj as AppHostProjectSearchResult).selected_project_file === null) + && Array.isArray((obj as AppHostProjectSearchResult).app_host_candidates) + && (obj as AppHostProjectSearchResult).app_host_candidates.every(candidate => + candidate + && typeof candidate.relativePath === 'string' + && typeof candidate.path === 'string' + && typeof candidate.language === 'string' + && typeof candidate.status === 'string'); +} + +function toCandidatesFromLegacySearchResult(parsed: LegacyAppHostProjectSearchResult): CandidateAppHostDisplayInfo[] { + return parsed.all_project_file_candidates.filter(candidate => typeof candidate === 'string').map(candidatePath => ({ + path: candidatePath, + language: null, + status: null, + selected: typeof parsed.selected_project_file === 'string' && isSamePath(parsed.selected_project_file, candidatePath), + })); +} + +function isCSharpProjectCandidate(candidate: CandidateAppHostDisplayInfo): boolean { + // Only `.csproj` candidates can own nearby C# source files for the editor alias + // heuristic above. Modern `aspire ls` candidates include the CLI language id + // (`language: "csharp"`); legacy `aspire extension get-apphosts` fallback + // candidates do not have a language, so `null` is treated as C# here to + // preserve old CLI support while keeping the compatibility gap local to + // candidate adaptation/matching. + return path.extname(candidate.path).toLowerCase() === '.csproj' + && (candidate.language === null || candidate.language.toLowerCase() === 'csharp'); +} + +function isCSharpSourceFileForProjectCandidate(filePath: string, projectPath: string): boolean { + const projectDirectory = path.dirname(path.resolve(projectPath)); + const sourcePath = path.resolve(filePath); + const comparison = process.platform === 'win32' || process.platform === 'darwin' + ? 'case-insensitive' + : 'case-sensitive'; + const normalizedProjectDirectory = comparison === 'case-insensitive' ? projectDirectory.toLowerCase() : projectDirectory; + const normalizedSourcePath = comparison === 'case-insensitive' ? sourcePath.toLowerCase() : sourcePath; + const relativePath = path.relative(normalizedProjectDirectory, normalizedSourcePath); + return relativePath !== '' + && !relativePath.startsWith('..') + && !path.isAbsolute(relativePath) + && !relativePath.split(path.sep).some(segment => segment.toLowerCase() === 'bin' || segment.toLowerCase() === 'obj'); +} + +function isSamePath(left: string, right: string): boolean { + const comparison = process.platform === 'win32' || process.platform === 'darwin' + ? 'case-insensitive' + : 'case-sensitive'; + const resolvedLeft = path.resolve(left); + const resolvedRight = path.resolve(right); + return comparison === 'case-insensitive' + ? resolvedLeft.toLowerCase() === resolvedRight.toLowerCase() + : resolvedLeft === resolvedRight; +} diff --git a/extension/src/utils/cliTypes.ts b/extension/src/utils/cliTypes.ts index 861a788cb47..ff2185b91e3 100644 --- a/extension/src/utils/cliTypes.ts +++ b/extension/src/utils/cliTypes.ts @@ -7,7 +7,7 @@ import { stripComments } from 'jsonc-parser'; */ export async function readJsonFile(uri: vscode.Uri): Promise { const buffer = await vscode.workspace.fs.readFile(uri); - const raw = buffer.toString(); + const raw = Buffer.from(buffer).toString('utf8'); return JSON.parse(stripComments(raw)); } diff --git a/extension/src/utils/settings.ts b/extension/src/utils/settings.ts index 93008fad1e9..7582e58e998 100644 --- a/extension/src/utils/settings.ts +++ b/extension/src/utils/settings.ts @@ -19,3 +19,8 @@ export function getRegisterMcpServerInWorkspace(): boolean { export function getEnableAutoRestore(): boolean { return getAspireConfig().get('enableAutoRestore', false); } + +export function getAppHostDiscoveryTimeoutMs(): number { + const timeoutMs = getAspireConfig().get('appHostDiscoveryTimeoutMs', 30000); + return Number.isFinite(timeoutMs) ? Math.max(timeoutMs, 1000) : 30000; +} diff --git a/extension/src/utils/workspace.ts b/extension/src/utils/workspace.ts index 87e7edc6216..771006d7cf4 100644 --- a/extension/src/utils/workspace.ts +++ b/extension/src/utils/workspace.ts @@ -1,13 +1,11 @@ import * as vscode from 'vscode'; import { appHostCandidateDescription, cliNotAvailable, cliFoundAtDefaultPath, dismissLabel, dontShowAgainLabel, doYouWantToSetDefaultApphost, noLabel, noWorkspaceOpen, openCliInstallInstructions, selectDefaultLaunchApphost, yesLabel } from '../loc/strings'; import path from 'path'; -import { spawnCliProcess } from '../debugger/languages/cli'; -import { AspireTerminalProvider } from './AspireTerminalProvider'; -import { ChildProcessWithoutNullStreams } from 'child_process'; import { AspireConfigFile, aspireConfigFileName, getAppHostPathFromConfig, readJsonFile } from './cliTypes'; import { extensionLogOutputChannel } from './logging'; -import { EnvironmentVariables } from './environment'; import { resolveCliPath } from './cliPath'; +import { AppHostDiscoveryService, AppHostProjectSearchResult, formatAppHostLanguage, getWorkspaceAppHostProjectSearchResult } from './appHostDiscovery'; +import type { AppHostCandidate } from './appHostDiscovery'; /** * Common file patterns to exclude from workspace file searches. @@ -110,19 +108,6 @@ export function getRelativePathToWorkspace(filePath: string): string { return filePath; } -export interface AppHostCandidate { - relativePath: string; - path: string; - language: string; - status: string; -} - -export interface AppHostProjectSearchResult { - selected_project_file: string | null; - all_project_file_candidates: string[]; - app_host_candidates: AppHostCandidate[]; -} - interface AppHostQuickPickItem extends vscode.QuickPickItem { appHostPath: string; } @@ -131,132 +116,6 @@ export function isBuildableAppHostCandidate(candidate: AppHostCandidate): boolea return candidate.status === 'buildable'; } -function isAppHostCandidate(obj: any): obj is AppHostCandidate { - return obj - && typeof obj.relativePath === 'string' - && typeof obj.path === 'string' - && typeof obj.language === 'string' - && typeof obj.status === 'string'; -} - -interface ParsedAppHostCandidates { - candidates: AppHostCandidate[]; - selectedProjectFile: string | null; - isAspireLsOutput: boolean; -} - -function parseAppHostCandidates(stdout: string): ParsedAppHostCandidates { - const parsed = JSON.parse(stdout); - if (Array.isArray(parsed)) { - return { - candidates: parsed.filter(isAppHostCandidate), - selectedProjectFile: null, - isAspireLsOutput: true, - }; - } - - if (parsed - && (typeof parsed.selected_project_file === 'string' || parsed.selected_project_file === null) - && Array.isArray(parsed.all_project_file_candidates)) { - const candidates = parsed.all_project_file_candidates - .filter((appHostPath: unknown): appHostPath is string => typeof appHostPath === 'string') - .map((appHostPath: string) => ({ - relativePath: path.basename(appHostPath), - path: appHostPath, - language: '', - status: 'buildable', - })); - - return { - candidates, - selectedProjectFile: parsed.selected_project_file, - isAspireLsOutput: false, - }; - } - - return { - candidates: [], - selectedProjectFile: null, - isAspireLsOutput: true, - }; -} - -async function getConfiguredAppHostPathFromWorkspaceRoot(rootFolder: vscode.WorkspaceFolder): Promise { - const configUris = [ - vscode.Uri.joinPath(rootFolder.uri, aspireConfigFileName), - vscode.Uri.joinPath(rootFolder.uri, '.aspire', 'settings.json'), - ]; - - for (const uri of configUris) { - try { - const json = await readJsonFile(uri); - const appHostPath = getAppHostPathFromConfig(json); - if (!appHostPath) { - continue; - } - - const configDir = path.dirname(uri.fsPath); - return path.isAbsolute(appHostPath) - ? appHostPath - : path.join(configDir, appHostPath); - } catch { - // Missing or invalid settings files do not block AppHost discovery. - } - } - - return null; -} - -function createAppHostProjectSearchResult(appHostCandidates: AppHostCandidate[], selectedProjectFile: string | null, rootFolder: vscode.WorkspaceFolder): AppHostProjectSearchResult { - const effectiveAppHostCandidates = selectedProjectFile && !appHostCandidates.some(candidate => isSamePath(candidate.path, selectedProjectFile)) - ? [...appHostCandidates, createConfiguredAppHostCandidate(selectedProjectFile, rootFolder)] - : appHostCandidates; - const buildableCandidates = effectiveAppHostCandidates.filter(isBuildableAppHostCandidate); - const allProjectFileCandidates = buildableCandidates.map(candidate => candidate.path); - const selectedCandidate = selectedProjectFile && buildableCandidates.some(candidate => isSamePath(candidate.path, selectedProjectFile)) - ? selectedProjectFile - : null; - - return { - selected_project_file: selectedCandidate, - all_project_file_candidates: allProjectFileCandidates, - app_host_candidates: effectiveAppHostCandidates, - }; -} - -function createConfiguredAppHostCandidate(appHostPath: string, rootFolder: vscode.WorkspaceFolder): AppHostCandidate { - return { - relativePath: path.relative(rootFolder.uri.fsPath, appHostPath), - path: appHostPath, - language: '', - status: 'buildable', - }; -} - -function isSamePath(left: string, right: string): boolean { - const normalizedLeft = path.normalize(left); - const normalizedRight = path.normalize(right); - return process.platform === 'win32' - ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase() - : normalizedLeft === normalizedRight; -} - -export function formatAppHostLanguage(language: string): string | undefined { - if (!language) { - return undefined; - } - - switch (language.toLowerCase()) { - case 'csharp': - return 'C#'; - case 'typescript': - case 'typescript/nodejs': - return 'TypeScript'; - default: - return language.charAt(0).toUpperCase() + language.slice(1); - } -} - function createAppHostQuickPickItems(result: AppHostProjectSearchResult, rootFolder: vscode.WorkspaceFolder): AppHostQuickPickItem[] { const candidates = result.app_host_candidates.length > 0 ? result.app_host_candidates @@ -279,66 +138,7 @@ function createAppHostQuickPickItems(result: AppHostProjectSearchResult, rootFol }); } -export function findAppHostsWithAspireLs(terminalProvider: AspireTerminalProvider, cliPath: string, rootFolder: vscode.WorkspaceFolder): { process: ChildProcessWithoutNullStreams; result: Promise } { - let stdout = ''; - let stderr = ''; - const configuredAppHostPathPromise = getConfiguredAppHostPathFromWorkspaceRoot(rootFolder); - - const args = ['ls', '--format', 'json']; - if (process.env[EnvironmentVariables.ASPIRE_CLI_STOP_ON_ENTRY] === 'true') { - args.push('--cli-wait-for-debugger'); - } - - let proc: ChildProcessWithoutNullStreams; - const result = new Promise((resolve, reject) => { - let settled = false; - proc = spawnCliProcess(terminalProvider, cliPath, args, { - errorCallback: error => { - settled = true; - extensionLogOutputChannel.error(`Error executing aspire ls command: ${error}`); - reject(error); - }, - exitCallback: async code => { - if (settled) { - return; - } - - if (code !== 0) { - settled = true; - extensionLogOutputChannel.warn(`aspire ls command exited with code: ${code}`); - reject(new Error(stderr || `aspire ls exited with code ${code}`)); - return; - } - - try { - const parsed = parseAppHostCandidates(stdout); - const selectedProjectFile = parsed.isAspireLsOutput - ? await configuredAppHostPathPromise - : null; - const effectiveSelectedProjectFile = selectedProjectFile ?? parsed.selectedProjectFile; - extensionLogOutputChannel.info(`Found ${parsed.candidates.length} AppHost candidates with aspire ls`); - settled = true; - resolve(createAppHostProjectSearchResult(parsed.candidates, effectiveSelectedProjectFile, rootFolder)); - } catch (error) { - settled = true; - reject(error); - } - }, - stdoutCallback: data => { - stdout += data; - }, - stderrCallback: data => { - stderr += data; - }, - noExtensionVariables: true, - workingDirectory: rootFolder.uri.fsPath - }); - }); - - return { process: proc!, result }; -} - -export async function checkForExistingAppHostPathInWorkspace(terminalProvider: AspireTerminalProvider, getEnableSettingsFileCreationPromptOnStartup: () => boolean, setEnableSettingsFileCreationPromptOnStartup: (value: boolean) => Promise): Promise { +export async function checkForExistingAppHostPathInWorkspace(appHostDiscoveryService: AppHostDiscoveryService, getEnableSettingsFileCreationPromptOnStartup: () => boolean, setEnableSettingsFileCreationPromptOnStartup: (value: boolean) => Promise): Promise { extensionLogOutputChannel.info('Checking for existing AppHost path in workspace'); const enabled = getEnableSettingsFileCreationPromptOnStartup(); @@ -392,24 +192,16 @@ export async function checkForExistingAppHostPathInWorkspace(terminalProvider: A } const settingsFile = settingsFiles[0]; - extensionLogOutputChannel.info('Searching for AppHost projects using CLI command: aspire ls'); + extensionLogOutputChannel.info('Searching for AppHost projects using shared AppHost discovery'); - let proc: ChildProcessWithoutNullStreams | undefined; - const cliPath = await terminalProvider.getAspireCliExecutablePath(); - const discovery = findAppHostsWithAspireLs(terminalProvider, cliPath, rootFolder); - proc = discovery.process; - discovery.result + appHostDiscoveryService.discover(rootFolder, true) + .then(appHosts => getWorkspaceAppHostProjectSearchResult(rootFolder, appHosts)) .then(result => promptToAddAppHostPathToSettingsFile(result, settingsFileExists, settingsFile, rootFolder, setEnableSettingsFileCreationPromptOnStartup)) .catch(error => { extensionLogOutputChannel.error(`Failed to retrieve AppHost projects: ${error}`); - }) - .finally(() => proc = undefined); + }); - return { - dispose() { - proc?.kill(); - } - }; + return null; } async function promptToAddAppHostPathToSettingsFile(result: AppHostProjectSearchResult, settingsFileExists: boolean, settingsFileLocation: vscode.Uri, rootFolder: vscode.WorkspaceFolder, setEnableSettingsFileCreationPromptOnStartup: (value: boolean) => Promise): Promise { diff --git a/extension/src/views/AppHostDataRepository.ts b/extension/src/views/AppHostDataRepository.ts index 16c2592433b..42cd419fe54 100644 --- a/extension/src/views/AppHostDataRepository.ts +++ b/extension/src/views/AppHostDataRepository.ts @@ -5,7 +5,7 @@ import { spawnCliProcess } from '../debugger/languages/cli'; import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; import { extensionLogOutputChannel } from '../utils/logging'; import { appHostDescribeMayNotBeSupported, aspireCliDescribeNotSupported, aspireDescribeMinimumVersion, errorFetchingAppHosts, workspaceViewSelectedMultipleAppHosts, workspaceViewSelectedSingleAppHost } from '../loc/strings'; -import { AppHostCandidate, findAppHostsWithAspireLs, formatAppHostLanguage, isBuildableAppHostCandidate } from '../utils/workspace'; +import { AppHostCandidate, AppHostDiscoveryService, formatAppHostLanguage, getWorkspaceAppHostProjectSearchResult, isBuildableAppHostCandidate } from '../utils/appHostDiscovery'; export interface ResourceUrlJson { name: string | null; @@ -141,7 +141,9 @@ export class AppHostDataRepository { private _workspaceAppHostDescription: string | undefined; private _workspaceAppHostDiscoveryComplete = false; private _workspaceAppHostDiscoveryUsesWorkspaceRoot = false; - private _getAppHostsProcess: ChildProcessWithoutNullStreams | undefined; + private readonly _appHostDiscoveryChangeDisposable: vscode.Disposable; + private readonly _appHostDiscoveryService: AppHostDiscoveryService; + private readonly _ownsAppHostDiscoveryService: boolean; // ── Error state ── private _describeErrorMessage: string | undefined; @@ -155,7 +157,15 @@ export class AppHostDataRepository { private readonly _configChangeDisposable: vscode.Disposable; private _disposed = false; - constructor(private readonly _terminalProvider: AspireTerminalProvider) { + constructor(private readonly _terminalProvider: AspireTerminalProvider, appHostDiscoveryService?: AppHostDiscoveryService) { + this._appHostDiscoveryService = appHostDiscoveryService ?? new AppHostDiscoveryService(_terminalProvider); + this._ownsAppHostDiscoveryService = appHostDiscoveryService === undefined; + this._appHostDiscoveryChangeDisposable = this._appHostDiscoveryService.onDidChangeCandidates(workspaceFolder => { + const rootFolder = vscode.workspace.workspaceFolders?.[0]; + if (rootFolder?.uri.toString() === workspaceFolder.uri.toString()) { + this._fetchWorkspaceAppHost(); + } + }); this._fetchWorkspaceAppHost(); this._configChangeDisposable = vscode.workspace.onDidChangeConfiguration(e => { if (e.affectsConfiguration('aspire.globalAppHostsPollingInterval') && this._shouldPoll) { @@ -266,13 +276,12 @@ export class AppHostDataRepository { this._disposed = true; this._stopPolling(); this._stopDescribeWatch(); - if (this._getAppHostsProcess) { - const getAppHostsProcess = this._getAppHostsProcess; - this._getAppHostsProcess = undefined; - this._terminateProcess(getAppHostsProcess, 'aspire ls'); - } this._configChangeDisposable.dispose(); + this._appHostDiscoveryChangeDisposable.dispose(); this._onDidChangeData.dispose(); + if (this._ownsAppHostDiscoveryService) { + this._appHostDiscoveryService.dispose(); + } } // ── PS polling lifecycle ── @@ -333,29 +342,16 @@ export class AppHostDataRepository { const rootFolder = workspaceFolders[0]; this._workspaceAppHostDiscoveryUsesWorkspaceRoot = true; - extensionLogOutputChannel.info('Fetching workspace apphost via: aspire ls'); + extensionLogOutputChannel.info('Fetching workspace apphost via shared AppHost discovery'); - this._terminalProvider.getAspireCliExecutablePath().then(cliPath => { + this._appHostDiscoveryService.discover(rootFolder).then(appHosts => { if (this._disposed) { return; } - const discovery = findAppHostsWithAspireLs(this._terminalProvider, cliPath, rootFolder); - this._getAppHostsProcess = discovery.process; - discovery.result.then(result => { - if (this._disposed) { - return; - } - - this._getAppHostsProcess = undefined; - this._workspaceAppHostDiscoveryComplete = true; - this._handleWorkspaceAppHostCandidates(result.app_host_candidates, result.selected_project_file); - }).catch(error => { - this._getAppHostsProcess = undefined; - this._workspaceAppHostDiscoveryComplete = true; - extensionLogOutputChannel.warn(`aspire ls error: ${error}`); - this._syncPolling(); - }); + const result = getWorkspaceAppHostProjectSearchResult(rootFolder, appHosts); + this._workspaceAppHostDiscoveryComplete = true; + this._handleWorkspaceAppHostCandidates(result.app_host_candidates, result.selected_project_file); }).catch(error => { this._workspaceAppHostDiscoveryComplete = true; extensionLogOutputChannel.warn(`Failed to fetch workspace apphost: ${error}`); diff --git a/playground/TypeScriptAppHost/.gitignore b/playground/TypeScriptAppHost/.gitignore index 9d70b9f5312..3f818ff5441 100644 --- a/playground/TypeScriptAppHost/.gitignore +++ b/playground/TypeScriptAppHost/.gitignore @@ -1,3 +1,4 @@ !.aspire/ .aspire/dcp/ .aspire/modules/ +.modules/ diff --git a/src/Aspire.Cli/Backchannel/AppHostCliBackchannel.cs b/src/Aspire.Cli/Backchannel/AppHostCliBackchannel.cs index 0bcfdb3864f..326b876cf77 100644 --- a/src/Aspire.Cli/Backchannel/AppHostCliBackchannel.cs +++ b/src/Aspire.Cli/Backchannel/AppHostCliBackchannel.cs @@ -18,6 +18,7 @@ internal interface IAppHostCliBackchannel Task GetDashboardUrlsAsync(CancellationToken cancellationToken); IAsyncEnumerable GetAppHostLogEntriesAsync(CancellationToken cancellationToken); IAsyncEnumerable GetResourceStatesAsync(CancellationToken cancellationToken); + Task WaitForDisconnectAsync(CancellationToken cancellationToken); Task ConnectAsync(string socketPath, int retryCount, CancellationToken cancellationToken); Task ConnectAsync(string socketPath, bool autoReconnect, int retryCount, CancellationToken cancellationToken); IAsyncEnumerable GetPublishingActivitiesAsync(CancellationToken cancellationToken); @@ -34,6 +35,7 @@ internal sealed class AppHostCliBackchannel( { private const string BaselineCapability = "baseline.v2"; private TaskCompletionSource _rpcTaskCompletionSource = new(); + private TaskCompletionSource _disconnectTaskCompletionSource = new(TaskCreationOptions.RunContinuationsAsynchronously); private string? _socketPath; private bool _autoReconnect; private CancellationToken _cancellationToken; @@ -51,6 +53,17 @@ private Task GetRpcTaskAsync() } } + public async Task WaitForDisconnectAsync(CancellationToken cancellationToken) + { + Task disconnectTask; + lock (_lock) + { + disconnectTask = _disconnectTaskCompletionSource.Task; + } + + await disconnectTask.WaitAsync(cancellationToken).ConfigureAwait(false); + } + public async Task RequestStopAsync(CancellationToken cancellationToken) { // This RPC call is required to allow the CLI to trigger a clean shutdown @@ -301,6 +314,10 @@ public async Task ConnectAsync(string socketPath, bool autoReconnect, int retryC _socketPath = socketPath; _autoReconnect = autoReconnect; _cancellationToken = cancellationToken; + lock (_lock) + { + _disconnectTaskCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } var connectingLogLevel = retryCount % 10 == 0 ? LogLevel.Debug : LogLevel.Trace; logger.Log(connectingLogLevel, "Connecting to AppHost backchannel at {SocketPath} (autoReconnect={AutoReconnect}, retryCount={RetryCount})", socketPath, autoReconnect, retryCount); @@ -340,6 +357,8 @@ public async Task ConnectAsync(string socketPath, bool autoReconnect, int retryC ); } + rpc.Disconnected += OnRpcDisconnected; + // Set up auto-reconnect if enabled if (autoReconnect) { @@ -401,6 +420,15 @@ private void OnDisconnected(object? sender, JsonRpcDisconnectedEventArgs args) }); } + private void OnRpcDisconnected(object? sender, JsonRpcDisconnectedEventArgs args) + { + logger.LogDebug("Backchannel disconnected: {Reason}", args.Reason); + lock (_lock) + { + _disconnectTaskCompletionSource.TrySetResult(); + } + } + private void ResetForReconnection() { lock (_lock) diff --git a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs index 475728cdab0..56ffdb74fcb 100644 --- a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs +++ b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs @@ -160,7 +160,14 @@ await extensionInteractionService.LaunchAppHostAsync( execution.EnvironmentVariables.Select(kvp => new EnvVar { Name = kvp.Key, Value = kvp.Value }).ToList(), options.StartDebugSession); - _ = StartBackchannelAsync(null, socketPath!, backchannelCompletionSource, backchannelParentContext, cancellationToken); + await StartBackchannelAsync(null, socketPath!, backchannelCompletionSource, backchannelParentContext, cancellationToken).ConfigureAwait(false); + var backchannel = await backchannelCompletionSource.Task.ConfigureAwait(false); + + // The extension launched the AppHost process, so there is no local Process to await. + // Keep this CLI process alive because it owns the AppHost backchannel; the extension + // will stop the CLI through the RPC endpoint when the managed debug session ends, + // or the AppHost backchannel will disconnect if the AppHost exits first. + await backchannel.WaitForDisconnectAsync(cancellationToken).ConfigureAwait(false); return CliExitCodes.Success; } diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index 8d6cc53c443..3a78e399b7b 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -219,8 +219,6 @@ internal static (ILoggerFactory LoggerFactory, FileLoggerProvider FileLoggerProv var isMcpStartCommand = args?.Length >= 2 && ((args[0] == "mcp" && args[1] == "start") || (args[0] == "agent" && args[1] == "mcp")); - var extensionEndpoint = Environment.GetEnvironmentVariable(KnownConfigNames.ExtensionEndpoint); - // Create file logger provider from pre-computed path info var fileLoggerProvider = new FileLoggerProvider(loggingOptions.LogFilePath, errorWriter); @@ -253,8 +251,12 @@ internal static (ILoggerFactory LoggerFactory, FileLoggerProvider FileLoggerProv builder.AddFilter("Aspire.Cli.Certificates.NativeCertificateToolRunner", LogLevel.Information); } - // Configure console logging based on --verbosity or --debug - if (consoleLogLevel is not null && !isMcpStartCommand && extensionEndpoint is null) + // Configure console logging based on --verbosity or --debug. + // When the CLI is hosted by the VS Code extension, stderr is captured line-by-line + // and surfaced in the Debug Console, so debug logs still need to flow to stderr there; + // suppressing them only because an extension endpoint is present meant that + // `--debug` produced no visible output under F5, even though it works in a terminal. + if (consoleLogLevel is not null && !isMcpStartCommand) { // Use custom Spectre Console logger for clean debug output to stderr builder.AddProvider(new SpectreConsoleLoggerProvider(Console.Error, logBufferContext)); diff --git a/src/Aspire.Cli/Projects/ProjectLocator.cs b/src/Aspire.Cli/Projects/ProjectLocator.cs index b026298b55b..095a1f6363d 100644 --- a/src/Aspire.Cli/Projects/ProjectLocator.cs +++ b/src/Aspire.Cli/Projects/ProjectLocator.cs @@ -434,11 +434,80 @@ await Parallel.ForEachAsync(candidatesWithHandlers, parallelOptions, async (cand logger.LogDebug("Stopping AppHost discovery early after finding multiple valid AppHost projects."); } + await AddSettingsAppHostCandidateAsync().ConfigureAwait(false); + // This sort is done here to make results deterministic since we get all the app // host information in parallel and the order may vary. appHostProjects.Sort((x, y) => string.Compare(x.AppHostFile.FullName, y.AppHostFile.FullName, StringComparison.Ordinal)); return (appHostProjects, unbuildableSuspectedAppHostProjects, hasUnsupportedProjects); + + async Task AddSettingsAppHostCandidateAsync() + { + var settingsAppHost = await GetAppHostProjectFileFromSettingsAsync(searchDirectory, searchParentDirectories: true, silent: true, cancellationToken).ConfigureAwait(false); + if (settingsAppHost is null) + { + return; + } + + var pathComparison = OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + if (appHostProjects.Any(candidate => string.Equals(candidate.AppHostFile.FullName, settingsAppHost.FullName, pathComparison)) + || unbuildableSuspectedAppHostProjects.Any(candidate => string.Equals(candidate.AppHostFile.FullName, settingsAppHost.FullName, pathComparison))) + { + return; + } + + var handler = projectFactory.TryGetProject(settingsAppHost); + if (handler is null) + { + var relativePath = Path.GetRelativePath(executionContext.WorkingDirectory.FullName, settingsAppHost.FullName); + if (displayProgress) + { + interactionService.DisplayMessage(KnownEmojis.Warning, string.Format(CultureInfo.CurrentCulture, ErrorStrings.ProjectFileUnsupportedInCurrentEnvironment, relativePath)); + } + + logger.LogDebug("Skipping configured AppHost project {SettingsAppHost} because no project handler was found.", settingsAppHost.FullName); + hasUnsupportedProjects = true; + return; + } + + var validationResult = await handler.ValidateAppHostAsync(settingsAppHost, cancellationToken).ConfigureAwait(false); + var settingsAppHostRelativePath = Path.GetRelativePath(executionContext.WorkingDirectory.FullName, settingsAppHost.FullName); + if (validationResult.IsValid) + { + if (displayProgress) + { + interactionService.DisplaySubtleMessage(settingsAppHostRelativePath); + } + + var appHostProject = new AppHostProjectCandidate(settingsAppHost, handler.LanguageId); + appHostProjects.Add(appHostProject); + await ReportCandidateFoundAsync(appHostProject, cancellationToken).ConfigureAwait(false); + } + else if (validationResult.IsPossiblyUnbuildable) + { + if (displayProgress) + { + interactionService.DisplayMessage(KnownEmojis.Warning, string.Format(CultureInfo.CurrentCulture, ErrorStrings.ProjectFileMayBeUnbuildableAppHost, settingsAppHostRelativePath)); + } + + var appHostProject = new AppHostProjectCandidate(settingsAppHost, handler.LanguageId, AppHostProjectCandidateStatus.PossiblyUnbuildable); + unbuildableSuspectedAppHostProjects.Add(appHostProject); + await ReportCandidateFoundAsync(appHostProject, cancellationToken).ConfigureAwait(false); + } + else if (validationResult.IsUnsupported) + { + if (displayProgress) + { + interactionService.DisplayMessage(KnownEmojis.Warning, string.Format(CultureInfo.CurrentCulture, ErrorStrings.ProjectFileUnsupportedInCurrentEnvironment, settingsAppHostRelativePath)); + } + + logger.LogDebug("Skipping unsupported configured AppHost project {SettingsAppHost}", settingsAppHost.FullName); + hasUnsupportedProjects = true; + } + } } if (displayProgress) diff --git a/src/Aspire.Cli/Templating/Templates/java-starter/.gitignore b/src/Aspire.Cli/Templating/Templates/java-starter/.gitignore index d0324a42d14..855df1ccf12 100644 --- a/src/Aspire.Cli/Templating/Templates/java-starter/.gitignore +++ b/src/Aspire.Cli/Templating/Templates/java-starter/.gitignore @@ -1,2 +1,3 @@ .java-build/ .aspire/ +.modules/ diff --git a/src/Aspire.Cli/Templating/Templates/py-starter/.gitignore b/src/Aspire.Cli/Templating/Templates/py-starter/.gitignore index 615cf938939..050768f6060 100644 --- a/src/Aspire.Cli/Templating/Templates/py-starter/.gitignore +++ b/src/Aspire.Cli/Templating/Templates/py-starter/.gitignore @@ -2,3 +2,4 @@ node_modules/ dist/ .venv/ .aspire/ +.modules/ diff --git a/src/Aspire.Cli/Templating/Templates/ts-starter/.gitignore b/src/Aspire.Cli/Templating/Templates/ts-starter/.gitignore index b9485af2bd7..c420eff27d7 100644 --- a/src/Aspire.Cli/Templating/Templates/ts-starter/.gitignore +++ b/src/Aspire.Cli/Templating/Templates/ts-starter/.gitignore @@ -1,3 +1,4 @@ node_modules/ dist/ .aspire/ +.modules/ diff --git a/tests/Aspire.Cli.Tests/Commands/LsCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/LsCommandTests.cs index 26a0394f69c..59a2655e32b 100644 --- a/tests/Aspire.Cli.Tests/Commands/LsCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/LsCommandTests.cs @@ -164,6 +164,46 @@ public async Task LsCommand_JsonFormat_WhenNoCandidates_ReturnsEmptyArray() Assert.Equal("", stderrText); } + [Fact] + public async Task LsCommand_JsonFormat_IncludesConfiguredAppHostOutsideWorkingDirectory() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var textWriter = new TestOutputTextWriter(outputHelper); + var workingDirectory = workspace.WorkspaceRoot.CreateSubdirectory("WorkingDir"); + var configuredAppHost = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "ConfiguredAppHost.csproj")); + await File.WriteAllTextAsync(configuredAppHost.FullName, "Not a real apphost"); + await File.WriteAllTextAsync(Path.Combine(workingDirectory.FullName, "aspire.config.json"), JsonSerializer.Serialize(new + { + appHost = new + { + path = "../ConfiguredAppHost.csproj" + } + })); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.WorkingDirectory = workingDirectory; + options.OutputTextWriter = textWriter; + options.AppHostProjectFactory = _ => new TestAppHostProjectFactory(); + }); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("ls --format json"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + + var jsonOutput = string.Join(string.Empty, textWriter.Logs); + var candidateAppHosts = JsonSerializer.Deserialize(jsonOutput, JsonSourceGenerationContext.RelaxedEscaping.ListCandidateAppHostDisplayInfo); + Assert.NotNull(candidateAppHosts); + var candidate = Assert.Single(candidateAppHosts); + Assert.Equal(configuredAppHost.FullName, candidate.Path); + Assert.Equal(KnownLanguageId.CSharp, candidate.Language); + Assert.Equal("buildable", candidate.Status); + } + [Fact] public async Task LsCommand_JsonFormat_OnlyJsonOnStdout_StatusMessagesOnStderr() { diff --git a/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs b/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs index d226eac99fc..3e554a7f97b 100644 --- a/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs @@ -831,6 +831,7 @@ public async IAsyncEnumerable GetResourceStatesAsync([Enumerat } public Task ConnectAsync(string socketPath, int retryCount, CancellationToken cancellationToken) => Task.CompletedTask; public Task ConnectAsync(string socketPath, bool autoReconnect, int retryCount, CancellationToken cancellationToken) => Task.CompletedTask; + public Task WaitForDisconnectAsync(CancellationToken cancellationToken) => Task.CompletedTask; public Task GetCapabilitiesAsync(CancellationToken cancellationToken) => Task.FromResult(new[] { "baseline.v2" }); public Task GetPipelineStepsAsync(string? step, CancellationToken cancellationToken) => diff --git a/tests/Aspire.Cli.Tests/DotNet/DotNetCliRunnerTests.cs b/tests/Aspire.Cli.Tests/DotNet/DotNetCliRunnerTests.cs index b047cd2537f..6a1a7a8266c 100644 --- a/tests/Aspire.Cli.Tests/DotNet/DotNetCliRunnerTests.cs +++ b/tests/Aspire.Cli.Tests/DotNet/DotNetCliRunnerTests.cs @@ -800,13 +800,17 @@ public async Task RunAsyncDoesNotOverrideUserProvidedVersionCheckDisabledValue() } [Fact] - public async Task RunAsyncLaunchesAppHostInExtensionHostIfConnected() + public async Task RunAsyncKeepsExtensionLaunchedAppHostAliveUntilBackchannelDisconnects() { using var workspace = TemporaryWorkspace.Create(outputHelper); var projectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj")); await File.WriteAllTextAsync(projectFile.FullName, "Not a real project file."); - var launchAppHostCalledTcs = new TaskCompletionSource(); + var launchAppHostCalledTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var backchannel = new TestAppHostBackchannel + { + ConnectAsyncCalled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) + }; TestExtensionInteractionService? testExtensionInteractionService = null; var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => { @@ -826,25 +830,36 @@ public async Task RunAsyncLaunchesAppHostInExtensionHostIfConnected() { HasCapabilityAsyncCallback = (c, _) => Task.FromResult(c is "devkit" or "project"), }; - options.AppHostBackchannelFactory = _ => new TestAppHostBackchannel(); + options.AppHostBackchannelFactory = _ => backchannel; }); using var provider = services.BuildServiceProvider(); var runner = provider.GetRequiredService(); + var backchannelCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var exitCode = await runner.RunAsync( + var runTask = runner.RunAsync( projectFile: projectFile, watch: false, noBuild: false, noRestore: false, args: [], - env: null, - backchannelCompletionSource: new TaskCompletionSource(), + env: new Dictionary + { + [KnownConfigNames.UnixSocketPath] = Path.Combine(workspace.WorkspaceRoot.FullName, "cli.sock") + }, + backchannelCompletionSource, options: new ProcessInvocationOptions(), - cancellationToken: CancellationToken.None).DefaultTimeout(); + cancellationToken: CancellationToken.None); await launchAppHostCalledTcs.Task.DefaultTimeout(); - Assert.Equal(CliExitCodes.Success, exitCode); + await backchannel.ConnectAsyncCalled.Task.DefaultTimeout(); + Assert.Same(backchannel, await backchannelCompletionSource.Task.DefaultTimeout()); + + var completedTask = await Task.WhenAny(runTask, Task.Delay(TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken)); + Assert.NotSame(runTask, completedTask); + + backchannel.DisconnectCompletionSource.SetResult(); + Assert.Equal(CliExitCodes.Success, await runTask.DefaultTimeout()); } [Fact] @@ -876,23 +891,20 @@ public async Task RunAsyncFailsBackchannelWhenExtensionLaunchedAppHostDoesNotCon var runner = provider.GetRequiredService(); var backchannelCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var exitCode = await runner.RunAsync( - projectFile: projectFile, - watch: false, - noBuild: false, - noRestore: false, - args: [], - env: new Dictionary - { - [KnownConfigNames.UnixSocketPath] = Path.Combine(workspace.WorkspaceRoot.FullName, "cli.sock") - }, - backchannelCompletionSource, - new ProcessInvocationOptions(), - CancellationToken.None).DefaultTimeout(); - - Assert.Equal(CliExitCodes.Success, exitCode); var exception = await Assert.ThrowsAsync( - () => backchannelCompletionSource.Task.WaitAsync(TimeSpan.FromSeconds(3))); + () => runner.RunAsync( + projectFile: projectFile, + watch: false, + noBuild: false, + noRestore: false, + args: [], + env: new Dictionary + { + [KnownConfigNames.UnixSocketPath] = Path.Combine(workspace.WorkspaceRoot.FullName, "cli.sock") + }, + backchannelCompletionSource, + new ProcessInvocationOptions(), + CancellationToken.None).DefaultTimeout()); Assert.Contains("Timed out waiting for AppHost backchannel", exception.Message); } diff --git a/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs b/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs index fadc6f70402..8fc9e2bdb46 100644 --- a/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs @@ -442,7 +442,12 @@ await File.WriteAllTextAsync(configPath, JsonSerializer.Serialize(new })); var executionContext = CreateExecutionContext(workingDirectory); - var projectLocator = CreateProjectLocator(executionContext); + var displayedSubtleMessages = new List(); + var interactionService = new TestInteractionService + { + DisplaySubtleMessageCallback = displayedSubtleMessages.Add + }; + var projectLocator = CreateProjectLocator(executionContext, interactionService: interactionService); var result = await projectLocator.UseOrFindAppHostProjectFileAsync( projectFile: null, @@ -452,6 +457,41 @@ await File.WriteAllTextAsync(configPath, JsonSerializer.Serialize(new Assert.Equal(settingsAppHostFile.FullName, result.SelectedProjectFile?.FullName); Assert.Contains(result.AllProjectFileCandidates, file => file.FullName == settingsAppHostFile.FullName); + Assert.Contains(Path.Join("..", "SettingsAppHost.csproj"), displayedSubtleMessages); + } + + [Fact] + public async Task UseOrFindAppHostProjectFileTreatsSettingsAppHostWithoutProjectHandlerAsUnsupported() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var settingsAppHostFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.custom")); + await File.WriteAllTextAsync(settingsAppHostFile.FullName, "Not a supported apphost type"); + + var configPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName); + await File.WriteAllTextAsync(configPath, JsonSerializer.Serialize(new + { + appHost = new + { + path = "apphost.custom" + } + })); + + var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); + var interactionService = new TestInteractionService(); + var projectLocator = CreateProjectLocator(executionContext, interactionService: interactionService); + + var exception = await Assert.ThrowsAsync(() => + projectLocator.UseOrFindAppHostProjectFileAsync( + projectFile: null, + multipleAppHostProjectsFoundBehavior: MultipleAppHostProjectsFoundBehavior.None, + createSettingsFile: false, + CancellationToken.None)).DefaultTimeout(); + + Assert.Equal(ProjectLocatorFailureReason.UnsupportedProjects, exception.FailureReason); + var warning = Assert.Single(interactionService.DisplayedMessages); + Assert.Equal(KnownEmojis.Warning, warning.Emoji); + Assert.Contains("apphost.custom", warning.Message); } [Fact] diff --git a/tests/Aspire.Cli.Tests/TestServices/TestAppHostCliBackchannel.cs b/tests/Aspire.Cli.Tests/TestServices/TestAppHostCliBackchannel.cs index 565c289dea8..f80db942b48 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestAppHostCliBackchannel.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestAppHostCliBackchannel.cs @@ -23,6 +23,7 @@ internal sealed class TestAppHostBackchannel : IAppHostCliBackchannel public TaskCompletionSource? ConnectAsyncCalled { get; set; } public Func? ConnectAsyncCallback { get; set; } + public TaskCompletionSource DisconnectCompletionSource { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); public TaskCompletionSource? GetPublishingActivitiesAsyncCalled { get; set; } public Func>? GetPublishingActivitiesAsyncCallback { get; set; } @@ -115,6 +116,11 @@ public async Task ConnectAsync(string socketPath, bool autoReconnect, int retryC } } + public async Task WaitForDisconnectAsync(CancellationToken cancellationToken) + { + await DisconnectCompletionSource.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + public async IAsyncEnumerable GetPublishingActivitiesAsync([EnumeratorCancellation]CancellationToken cancellationToken) { GetPublishingActivitiesAsyncCalled?.SetResult(); From ce6bac8da87c1ff525ceead8dfb3755642f6304e Mon Sep 17 00:00:00 2001 From: "aspire-repo-bot[bot]" <268009190+aspire-repo-bot[bot]@users.noreply.github.com> Date: Wed, 27 May 2026 23:39:30 -0700 Subject: [PATCH 17/43] [release/13.4] Update --search option description with aka.ms link and field filter details (#17568) * Update --search option description to include aka.ms link and field filter details * Regenerate xlf files to match current resx values --------- Co-authored-by: James Newton-King --- src/Aspire.Cli/Resources/LogsCommandStrings.resx | 2 +- src/Aspire.Cli/Resources/TelemetryCommandStrings.resx | 2 +- src/Aspire.Cli/Resources/xlf/LogsCommandStrings.cs.xlf | 4 ++-- src/Aspire.Cli/Resources/xlf/LogsCommandStrings.de.xlf | 4 ++-- src/Aspire.Cli/Resources/xlf/LogsCommandStrings.es.xlf | 4 ++-- src/Aspire.Cli/Resources/xlf/LogsCommandStrings.fr.xlf | 4 ++-- src/Aspire.Cli/Resources/xlf/LogsCommandStrings.it.xlf | 4 ++-- src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ja.xlf | 4 ++-- src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ko.xlf | 4 ++-- src/Aspire.Cli/Resources/xlf/LogsCommandStrings.pl.xlf | 4 ++-- src/Aspire.Cli/Resources/xlf/LogsCommandStrings.pt-BR.xlf | 4 ++-- src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ru.xlf | 4 ++-- src/Aspire.Cli/Resources/xlf/LogsCommandStrings.tr.xlf | 4 ++-- src/Aspire.Cli/Resources/xlf/LogsCommandStrings.zh-Hans.xlf | 4 ++-- src/Aspire.Cli/Resources/xlf/LogsCommandStrings.zh-Hant.xlf | 4 ++-- src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.cs.xlf | 4 ++-- src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.de.xlf | 4 ++-- src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.es.xlf | 4 ++-- src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.fr.xlf | 4 ++-- src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.it.xlf | 4 ++-- src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ja.xlf | 4 ++-- src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ko.xlf | 4 ++-- src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pl.xlf | 4 ++-- .../Resources/xlf/TelemetryCommandStrings.pt-BR.xlf | 4 ++-- src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ru.xlf | 4 ++-- src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.tr.xlf | 4 ++-- .../Resources/xlf/TelemetryCommandStrings.zh-Hans.xlf | 4 ++-- .../Resources/xlf/TelemetryCommandStrings.zh-Hant.xlf | 4 ++-- 28 files changed, 54 insertions(+), 54 deletions(-) diff --git a/src/Aspire.Cli/Resources/LogsCommandStrings.resx b/src/Aspire.Cli/Resources/LogsCommandStrings.resx index 99490e2f50f..d8672150792 100644 --- a/src/Aspire.Cli/Resources/LogsCommandStrings.resx +++ b/src/Aspire.Cli/Resources/LogsCommandStrings.resx @@ -163,6 +163,6 @@ No logs found. - Full-text search to filter log content + Full-text search to filter log content. See https://aka.ms/aspire/cli-search for more details diff --git a/src/Aspire.Cli/Resources/TelemetryCommandStrings.resx b/src/Aspire.Cli/Resources/TelemetryCommandStrings.resx index 769ad3d5811..95e65028377 100644 --- a/src/Aspire.Cli/Resources/TelemetryCommandStrings.resx +++ b/src/Aspire.Cli/Resources/TelemetryCommandStrings.resx @@ -154,7 +154,7 @@ Filter by error status (true to show only errors, false to exclude errors) - Full-text search across telemetry text fields, such as log messages, attribute values, names, source, and IDs + Search and filter telemetry fields such as log messages, attribute values, names, source, and IDs. Supports full-text search and field filters. See https://aka.ms/aspire/cli-search for more details The --limit value must be a positive number. diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.cs.xlf index 58c98a9e6ee..f8408a93422 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.cs.xlf @@ -48,8 +48,8 @@ - Full-text search to filter log content - Full-text search to filter log content + Full-text search to filter log content. See https://aka.ms/aspire/cli-search for more details + Full-text search to filter log content. See https://aka.ms/aspire/cli-search for more details diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.de.xlf index 499761e8a5f..6989ae08437 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.de.xlf @@ -48,8 +48,8 @@ - Full-text search to filter log content - Full-text search to filter log content + Full-text search to filter log content. See https://aka.ms/aspire/cli-search for more details + Full-text search to filter log content. See https://aka.ms/aspire/cli-search for more details diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.es.xlf index 86db07047d4..c904dcd19e0 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.es.xlf @@ -48,8 +48,8 @@ - Full-text search to filter log content - Full-text search to filter log content + Full-text search to filter log content. See https://aka.ms/aspire/cli-search for more details + Full-text search to filter log content. See https://aka.ms/aspire/cli-search for more details diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.fr.xlf index 9a89fe5cd05..298b00669c3 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.fr.xlf @@ -48,8 +48,8 @@ - Full-text search to filter log content - Full-text search to filter log content + Full-text search to filter log content. See https://aka.ms/aspire/cli-search for more details + Full-text search to filter log content. See https://aka.ms/aspire/cli-search for more details diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.it.xlf index f57adb4c418..607ad9aec59 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.it.xlf @@ -48,8 +48,8 @@ - Full-text search to filter log content - Full-text search to filter log content + Full-text search to filter log content. See https://aka.ms/aspire/cli-search for more details + Full-text search to filter log content. See https://aka.ms/aspire/cli-search for more details diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ja.xlf index 824533220da..46951ebf7bf 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ja.xlf @@ -48,8 +48,8 @@ - Full-text search to filter log content - Full-text search to filter log content + Full-text search to filter log content. See https://aka.ms/aspire/cli-search for more details + Full-text search to filter log content. See https://aka.ms/aspire/cli-search for more details diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ko.xlf index b1ee9325c22..f809cfaf2e7 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ko.xlf @@ -48,8 +48,8 @@ - Full-text search to filter log content - Full-text search to filter log content + Full-text search to filter log content. See https://aka.ms/aspire/cli-search for more details + Full-text search to filter log content. See https://aka.ms/aspire/cli-search for more details diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.pl.xlf index 6d914166a74..d4d767f854f 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.pl.xlf @@ -48,8 +48,8 @@ - Full-text search to filter log content - Full-text search to filter log content + Full-text search to filter log content. See https://aka.ms/aspire/cli-search for more details + Full-text search to filter log content. See https://aka.ms/aspire/cli-search for more details diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.pt-BR.xlf index 1b4d0110508..c6ce8f12ac9 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.pt-BR.xlf @@ -48,8 +48,8 @@ - Full-text search to filter log content - Full-text search to filter log content + Full-text search to filter log content. See https://aka.ms/aspire/cli-search for more details + Full-text search to filter log content. See https://aka.ms/aspire/cli-search for more details diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ru.xlf index 11beb9c7d1b..7fd53c958d2 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ru.xlf @@ -48,8 +48,8 @@ - Full-text search to filter log content - Full-text search to filter log content + Full-text search to filter log content. See https://aka.ms/aspire/cli-search for more details + Full-text search to filter log content. See https://aka.ms/aspire/cli-search for more details diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.tr.xlf index 466df8cc1ab..8ac03257e5b 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.tr.xlf @@ -48,8 +48,8 @@ - Full-text search to filter log content - Full-text search to filter log content + Full-text search to filter log content. See https://aka.ms/aspire/cli-search for more details + Full-text search to filter log content. See https://aka.ms/aspire/cli-search for more details diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.zh-Hans.xlf index de520e1ee1a..cd6cfa597d6 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.zh-Hans.xlf @@ -48,8 +48,8 @@ - Full-text search to filter log content - Full-text search to filter log content + Full-text search to filter log content. See https://aka.ms/aspire/cli-search for more details + Full-text search to filter log content. See https://aka.ms/aspire/cli-search for more details diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.zh-Hant.xlf index 3343f6fa089..7aca177793a 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.zh-Hant.xlf @@ -48,8 +48,8 @@ - Full-text search to filter log content - Full-text search to filter log content + Full-text search to filter log content. See https://aka.ms/aspire/cli-search for more details + Full-text search to filter log content. See https://aka.ms/aspire/cli-search for more details diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.cs.xlf index 45cc712dcd8..a26c2e5b929 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.cs.xlf @@ -168,8 +168,8 @@ - Full-text search across telemetry text fields, such as log messages, attribute values, names, source, and IDs - Full-text search across telemetry text fields, such as log messages, attribute values, names, source, and IDs + Search and filter telemetry fields such as log messages, attribute values, names, source, and IDs. Supports full-text search and field filters. See https://aka.ms/aspire/cli-search for more details + Search and filter telemetry fields such as log messages, attribute values, names, source, and IDs. Supports full-text search and field filters. See https://aka.ms/aspire/cli-search for more details diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.de.xlf index 9acf8e681c5..7bfa0cf1429 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.de.xlf @@ -168,8 +168,8 @@ - Full-text search across telemetry text fields, such as log messages, attribute values, names, source, and IDs - Full-text search across telemetry text fields, such as log messages, attribute values, names, source, and IDs + Search and filter telemetry fields such as log messages, attribute values, names, source, and IDs. Supports full-text search and field filters. See https://aka.ms/aspire/cli-search for more details + Search and filter telemetry fields such as log messages, attribute values, names, source, and IDs. Supports full-text search and field filters. See https://aka.ms/aspire/cli-search for more details diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.es.xlf index 462e69d593d..935ac571f38 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.es.xlf @@ -168,8 +168,8 @@ - Full-text search across telemetry text fields, such as log messages, attribute values, names, source, and IDs - Full-text search across telemetry text fields, such as log messages, attribute values, names, source, and IDs + Search and filter telemetry fields such as log messages, attribute values, names, source, and IDs. Supports full-text search and field filters. See https://aka.ms/aspire/cli-search for more details + Search and filter telemetry fields such as log messages, attribute values, names, source, and IDs. Supports full-text search and field filters. See https://aka.ms/aspire/cli-search for more details diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.fr.xlf index 13a0312da4b..50857d884a5 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.fr.xlf @@ -168,8 +168,8 @@ - Full-text search across telemetry text fields, such as log messages, attribute values, names, source, and IDs - Full-text search across telemetry text fields, such as log messages, attribute values, names, source, and IDs + Search and filter telemetry fields such as log messages, attribute values, names, source, and IDs. Supports full-text search and field filters. See https://aka.ms/aspire/cli-search for more details + Search and filter telemetry fields such as log messages, attribute values, names, source, and IDs. Supports full-text search and field filters. See https://aka.ms/aspire/cli-search for more details diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.it.xlf index 87223e646f4..7ca293bc153 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.it.xlf @@ -168,8 +168,8 @@ - Full-text search across telemetry text fields, such as log messages, attribute values, names, source, and IDs - Full-text search across telemetry text fields, such as log messages, attribute values, names, source, and IDs + Search and filter telemetry fields such as log messages, attribute values, names, source, and IDs. Supports full-text search and field filters. See https://aka.ms/aspire/cli-search for more details + Search and filter telemetry fields such as log messages, attribute values, names, source, and IDs. Supports full-text search and field filters. See https://aka.ms/aspire/cli-search for more details diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ja.xlf index a0e72c5788b..9c19d6f2d90 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ja.xlf @@ -168,8 +168,8 @@ - Full-text search across telemetry text fields, such as log messages, attribute values, names, source, and IDs - Full-text search across telemetry text fields, such as log messages, attribute values, names, source, and IDs + Search and filter telemetry fields such as log messages, attribute values, names, source, and IDs. Supports full-text search and field filters. See https://aka.ms/aspire/cli-search for more details + Search and filter telemetry fields such as log messages, attribute values, names, source, and IDs. Supports full-text search and field filters. See https://aka.ms/aspire/cli-search for more details diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ko.xlf index c16e5ceb3dc..53309a56ea5 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ko.xlf @@ -168,8 +168,8 @@ - Full-text search across telemetry text fields, such as log messages, attribute values, names, source, and IDs - Full-text search across telemetry text fields, such as log messages, attribute values, names, source, and IDs + Search and filter telemetry fields such as log messages, attribute values, names, source, and IDs. Supports full-text search and field filters. See https://aka.ms/aspire/cli-search for more details + Search and filter telemetry fields such as log messages, attribute values, names, source, and IDs. Supports full-text search and field filters. See https://aka.ms/aspire/cli-search for more details diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pl.xlf index 37c7c0792dd..6786dd35aa5 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pl.xlf @@ -168,8 +168,8 @@ - Full-text search across telemetry text fields, such as log messages, attribute values, names, source, and IDs - Full-text search across telemetry text fields, such as log messages, attribute values, names, source, and IDs + Search and filter telemetry fields such as log messages, attribute values, names, source, and IDs. Supports full-text search and field filters. See https://aka.ms/aspire/cli-search for more details + Search and filter telemetry fields such as log messages, attribute values, names, source, and IDs. Supports full-text search and field filters. See https://aka.ms/aspire/cli-search for more details diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pt-BR.xlf index a892ac03ba3..bd58576b60a 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pt-BR.xlf @@ -168,8 +168,8 @@ - Full-text search across telemetry text fields, such as log messages, attribute values, names, source, and IDs - Full-text search across telemetry text fields, such as log messages, attribute values, names, source, and IDs + Search and filter telemetry fields such as log messages, attribute values, names, source, and IDs. Supports full-text search and field filters. See https://aka.ms/aspire/cli-search for more details + Search and filter telemetry fields such as log messages, attribute values, names, source, and IDs. Supports full-text search and field filters. See https://aka.ms/aspire/cli-search for more details diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ru.xlf index c52093d147c..b27f24ec5e3 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ru.xlf @@ -168,8 +168,8 @@ - Full-text search across telemetry text fields, such as log messages, attribute values, names, source, and IDs - Full-text search across telemetry text fields, such as log messages, attribute values, names, source, and IDs + Search and filter telemetry fields such as log messages, attribute values, names, source, and IDs. Supports full-text search and field filters. See https://aka.ms/aspire/cli-search for more details + Search and filter telemetry fields such as log messages, attribute values, names, source, and IDs. Supports full-text search and field filters. See https://aka.ms/aspire/cli-search for more details diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.tr.xlf index 36c535956ce..f6be5d8e4c7 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.tr.xlf @@ -168,8 +168,8 @@ - Full-text search across telemetry text fields, such as log messages, attribute values, names, source, and IDs - Full-text search across telemetry text fields, such as log messages, attribute values, names, source, and IDs + Search and filter telemetry fields such as log messages, attribute values, names, source, and IDs. Supports full-text search and field filters. See https://aka.ms/aspire/cli-search for more details + Search and filter telemetry fields such as log messages, attribute values, names, source, and IDs. Supports full-text search and field filters. See https://aka.ms/aspire/cli-search for more details diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hans.xlf index 1a13eb9bdbf..ac254bcf887 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hans.xlf @@ -168,8 +168,8 @@ - Full-text search across telemetry text fields, such as log messages, attribute values, names, source, and IDs - Full-text search across telemetry text fields, such as log messages, attribute values, names, source, and IDs + Search and filter telemetry fields such as log messages, attribute values, names, source, and IDs. Supports full-text search and field filters. See https://aka.ms/aspire/cli-search for more details + Search and filter telemetry fields such as log messages, attribute values, names, source, and IDs. Supports full-text search and field filters. See https://aka.ms/aspire/cli-search for more details diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hant.xlf index 9efc6c0d3f6..a5d2c24e3b9 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hant.xlf @@ -168,8 +168,8 @@ - Full-text search across telemetry text fields, such as log messages, attribute values, names, source, and IDs - Full-text search across telemetry text fields, such as log messages, attribute values, names, source, and IDs + Search and filter telemetry fields such as log messages, attribute values, names, source, and IDs. Supports full-text search and field filters. See https://aka.ms/aspire/cli-search for more details + Search and filter telemetry fields such as log messages, attribute values, names, source, and IDs. Supports full-text search and field filters. See https://aka.ms/aspire/cli-search for more details From b8f29996d48c46aae9cc3ef36dd1d8407999ad71 Mon Sep 17 00:00:00 2001 From: "aspire-repo-bot[bot]" <268009190+aspire-repo-bot[bot]@users.noreply.github.com> Date: Thu, 28 May 2026 11:17:48 -0700 Subject: [PATCH 18/43] Remove ATS experimental markers (#17602) Graduate the ATS/polyglot surface by removing ASPIREATS001 attributes and obsolete suppressions now that TypeScript/polyglot support is GA for 13.4. Co-authored-by: Sebastien Ros Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Directory.Build.props | 3 +-- .../BlazorGatewayExtensions.cs | 1 - .../BlazorHostedExtensions.cs | 2 -- .../Resources/BlazorWasmAppResource.cs | 2 -- .../AssemblyInfo.cs | 6 ------ .../AssemblyInfo.cs | 6 ------ .../AssemblyInfo.cs | 6 ------ .../AssemblyInfo.cs | 6 ------ .../AssemblyInfo.cs | 6 ------ .../Aspire.Hosting.Integration.Analyzers.csproj | 2 +- .../README.md | 4 ++-- .../IResourceWithCustomWithReference.cs | 3 --- src/Aspire.Hosting/Ats/AspireDtoAttribute.cs | 3 --- src/Aspire.Hosting/Ats/AspireExportAttribute.cs | 3 --- .../Ats/AspireExportIgnoreAttribute.cs | 3 --- src/Aspire.Hosting/Ats/AspireUnionAttribute.cs | 3 --- src/Aspire.Hosting/Ats/AspireValueAttribute.cs | 3 --- .../Ats/ThirdPartyAtsAttributes.md | 2 +- src/Aspire.TypeSystem/AssemblyInfo.cs | 6 ------ .../ProjectReferenceTests.cs | 1 - .../AnalyzerTest.cs | 16 +--------------- 21 files changed, 6 insertions(+), 81 deletions(-) delete mode 100644 src/Aspire.Hosting.CodeGeneration.Go/AssemblyInfo.cs delete mode 100644 src/Aspire.Hosting.CodeGeneration.Java/AssemblyInfo.cs delete mode 100644 src/Aspire.Hosting.CodeGeneration.Python/AssemblyInfo.cs delete mode 100644 src/Aspire.Hosting.CodeGeneration.Rust/AssemblyInfo.cs delete mode 100644 src/Aspire.Hosting.CodeGeneration.TypeScript/AssemblyInfo.cs delete mode 100644 src/Aspire.TypeSystem/AssemblyInfo.cs diff --git a/Directory.Build.props b/Directory.Build.props index e06b96f9811..99b940da4e2 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -39,8 +39,7 @@ - - $(NoWarn);xUnit1051;NU1510;ASPIREATS001 + $(NoWarn);xUnit1051;NU1510 diff --git a/src/Aspire.Hosting.Blazor/BlazorGatewayExtensions.cs b/src/Aspire.Hosting.Blazor/BlazorGatewayExtensions.cs index 63cc01bdb25..99934ebaa3b 100644 --- a/src/Aspire.Hosting.Blazor/BlazorGatewayExtensions.cs +++ b/src/Aspire.Hosting.Blazor/BlazorGatewayExtensions.cs @@ -11,7 +11,6 @@ #pragma warning disable ASPIREDOCKERFILEBUILDER001 // DockerfileBuilder is experimental #pragma warning disable ASPIRECSHARPAPPS001 // AddCSharpApp is experimental -#pragma warning disable ASPIREATS001 // AspireExportIgnore is experimental namespace Aspire.Hosting; diff --git a/src/Aspire.Hosting.Blazor/BlazorHostedExtensions.cs b/src/Aspire.Hosting.Blazor/BlazorHostedExtensions.cs index c0974c39ee5..be2d14e7b63 100644 --- a/src/Aspire.Hosting.Blazor/BlazorHostedExtensions.cs +++ b/src/Aspire.Hosting.Blazor/BlazorHostedExtensions.cs @@ -5,8 +5,6 @@ using Aspire.Hosting.ApplicationModel; using Microsoft.Extensions.Logging; -#pragma warning disable ASPIREATS001 // AspireExportIgnore is experimental - namespace Aspire.Hosting; /// diff --git a/src/Aspire.Hosting.Blazor/Resources/BlazorWasmAppResource.cs b/src/Aspire.Hosting.Blazor/Resources/BlazorWasmAppResource.cs index 3a8fcfe2794..a4210b92ba8 100644 --- a/src/Aspire.Hosting.Blazor/Resources/BlazorWasmAppResource.cs +++ b/src/Aspire.Hosting.Blazor/Resources/BlazorWasmAppResource.cs @@ -1,8 +1,6 @@ // 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 ASPIREATS001 // AspireExport is experimental - using System.Diagnostics.CodeAnalysis; using Aspire.Hosting.ApplicationModel; diff --git a/src/Aspire.Hosting.CodeGeneration.Go/AssemblyInfo.cs b/src/Aspire.Hosting.CodeGeneration.Go/AssemblyInfo.cs deleted file mode 100644 index 79236a7dbec..00000000000 --- a/src/Aspire.Hosting.CodeGeneration.Go/AssemblyInfo.cs +++ /dev/null @@ -1,6 +0,0 @@ -// 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; - -[assembly: Experimental("ASPIREATS001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] diff --git a/src/Aspire.Hosting.CodeGeneration.Java/AssemblyInfo.cs b/src/Aspire.Hosting.CodeGeneration.Java/AssemblyInfo.cs deleted file mode 100644 index 79236a7dbec..00000000000 --- a/src/Aspire.Hosting.CodeGeneration.Java/AssemblyInfo.cs +++ /dev/null @@ -1,6 +0,0 @@ -// 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; - -[assembly: Experimental("ASPIREATS001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] diff --git a/src/Aspire.Hosting.CodeGeneration.Python/AssemblyInfo.cs b/src/Aspire.Hosting.CodeGeneration.Python/AssemblyInfo.cs deleted file mode 100644 index 79236a7dbec..00000000000 --- a/src/Aspire.Hosting.CodeGeneration.Python/AssemblyInfo.cs +++ /dev/null @@ -1,6 +0,0 @@ -// 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; - -[assembly: Experimental("ASPIREATS001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] diff --git a/src/Aspire.Hosting.CodeGeneration.Rust/AssemblyInfo.cs b/src/Aspire.Hosting.CodeGeneration.Rust/AssemblyInfo.cs deleted file mode 100644 index 79236a7dbec..00000000000 --- a/src/Aspire.Hosting.CodeGeneration.Rust/AssemblyInfo.cs +++ /dev/null @@ -1,6 +0,0 @@ -// 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; - -[assembly: Experimental("ASPIREATS001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] diff --git a/src/Aspire.Hosting.CodeGeneration.TypeScript/AssemblyInfo.cs b/src/Aspire.Hosting.CodeGeneration.TypeScript/AssemblyInfo.cs deleted file mode 100644 index 79236a7dbec..00000000000 --- a/src/Aspire.Hosting.CodeGeneration.TypeScript/AssemblyInfo.cs +++ /dev/null @@ -1,6 +0,0 @@ -// 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; - -[assembly: Experimental("ASPIREATS001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] diff --git a/src/Aspire.Hosting.Integration.Analyzers/Aspire.Hosting.Integration.Analyzers.csproj b/src/Aspire.Hosting.Integration.Analyzers/Aspire.Hosting.Integration.Analyzers.csproj index 831010d18ad..0676cb187ce 100644 --- a/src/Aspire.Hosting.Integration.Analyzers/Aspire.Hosting.Integration.Analyzers.csproj +++ b/src/Aspire.Hosting.Integration.Analyzers/Aspire.Hosting.Integration.Analyzers.csproj @@ -13,7 +13,7 @@ true README.md aspire hosting analyzers ats polyglot integration - Experimental analyzers for Aspire hosting integration authors. + Analyzers for Aspire hosting integration authors. 12.0 - - - + + @@ -88,12 +88,12 @@ - + - + diff --git a/extension/build.ps1 b/extension/build.ps1 index 21069d7ff13..ccd4be4305b 100644 --- a/extension/build.ps1 +++ b/extension/build.ps1 @@ -10,10 +10,9 @@ if (-not (Get-Command node -ErrorAction SilentlyContinue)) { exit 1 } -# 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" +# Check for Corepack so the build uses the Yarn Classic version that matches extension/yarn.lock. +if (-not (Get-Command corepack -ErrorAction SilentlyContinue)) { + Write-Error "Error: Corepack is not installed. Please install a Node.js version that includes Corepack." exit 1 } @@ -41,7 +40,7 @@ Set-Location $PSScriptRoot Write-Host "" Write-Host "Running yarn install..." -yarn install --frozen-lockfile --non-interactive +corepack yarn@1.22.22 install --frozen-lockfile --non-interactive if ($LASTEXITCODE -ne 0) { Write-Error "yarn install failed with exit code $LASTEXITCODE" @@ -50,7 +49,7 @@ if ($LASTEXITCODE -ne 0) { Write-Host "" Write-Host "Running yarn compile..." -yarn compile +corepack yarn@1.22.22 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 4b7c7e79bac..110967f4215 100755 --- a/extension/build.sh +++ b/extension/build.sh @@ -9,10 +9,9 @@ if ! command -v node &> /dev/null; then exit 1 fi -# 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" +# Check for Corepack so the build uses the Yarn Classic version that matches extension/yarn.lock. +if ! command -v corepack &> /dev/null; then + echo "Error: Corepack is not installed. Please install a Node.js version that includes Corepack." exit 1 fi @@ -38,11 +37,11 @@ cd "$SCRIPT_DIR" echo "" echo "Running yarn install..." -yarn install --frozen-lockfile --non-interactive +corepack yarn@1.22.22 install --frozen-lockfile --non-interactive echo "" echo "Running yarn compile..." -yarn compile +corepack yarn@1.22.22 compile echo "" echo "Building Aspire CLI..." diff --git a/extension/src/test/appHostDataRepository.test.ts b/extension/src/test/appHostDataRepository.test.ts index dd1b6031a6d..145f7f5ba27 100644 --- a/extension/src/test/appHostDataRepository.test.ts +++ b/extension/src/test/appHostDataRepository.test.ts @@ -260,7 +260,6 @@ suite('AppHostDataRepository', () => { appHostPid: 1234, cliPid: null, dashboardUrl: null, - resources: null, }])); assert.ok(repository.errorMessage?.includes('describe failed'), repository.errorMessage); @@ -274,7 +273,7 @@ suite('AppHostDataRepository', () => { let getAppHostsLineCallback: ((line: string) => void) | undefined; const getAppHostsProcess = new TestChildProcess(); const describeProcess = new TestChildProcess(); - const psResourcesProcess = new TestChildProcess(); + const psFollowProcess = new TestChildProcess(); const psFallbackProcess = new TestChildProcess(); const replacementDescribeProcess = new TestChildProcess(); const psSuccessProcess = new TestChildProcess(); @@ -283,7 +282,7 @@ suite('AppHostDataRepository', () => { return getAppHostsProcess; }); spawnStub.onSecondCall().returns(describeProcess); - spawnStub.onThirdCall().returns(psResourcesProcess); + spawnStub.onThirdCall().returns(psFollowProcess); spawnStub.onCall(3).returns(psFallbackProcess); spawnStub.onCall(4).returns(replacementDescribeProcess); spawnStub.onCall(5).returns(psSuccessProcess); @@ -312,12 +311,7 @@ suite('AppHostDataRepository', () => { psFollowOptions.exitCallback(1); await waitForAppHostDiscovery(); - const psResourcesOptions = spawnStub.getCall(3).args[3]; - psResourcesOptions.stderrCallback('resources unavailable'); - psResourcesOptions.exitCallback(1); - await waitForAppHostDiscovery(); - - const psFallbackOptions = spawnStub.getCall(4).args[3]; + const psFallbackOptions = spawnStub.getCall(3).args[3]; psFallbackOptions.stderrCallback('ps failed'); psFallbackOptions.exitCallback(1); assert.ok(repository.errorMessage?.includes('ps failed'), repository.errorMessage); @@ -375,7 +369,7 @@ suite('AppHostDataRepository', () => { assert.strictEqual(repository.viewMode, 'workspace'); assert.strictEqual(spawnStub.callCount, 2); - assert.deepStrictEqual(spawnStub.secondCall.args[2], ['ps', '--follow', '--format', 'json', '--resources']); + assert.deepStrictEqual(spawnStub.secondCall.args[2], ['ps', '--follow', '--format', 'json']); } finally { repository.dispose(); workspaceFoldersStub.restore(); @@ -421,7 +415,241 @@ suite('AppHostDataRepository', () => { assert.strictEqual(describeProcess.killed, false); assert.strictEqual(spawnStub.callCount, 3); assert.deepStrictEqual(spawnStub.secondCall.args[2], ['describe', '--follow', '--format', 'json', '--apphost', '/workspace/apps/Store/AppHost.csproj']); - assert.deepStrictEqual(spawnStub.thirdCall.args[2], ['ps', '--follow', '--format', 'json', '--resources']); + assert.deepStrictEqual(spawnStub.thirdCall.args[2], ['ps', '--follow', '--format', 'json']); + } finally { + repository.dispose(); + workspaceFoldersStub.restore(); + } + }); + + test('multi-AppHost workspace retargets describe to the only running AppHost', async () => { + const workspaceFoldersStub = stubWorkspaceFolders([{ + uri: vscode.Uri.file('/workspace'), + name: 'workspace', + index: 0, + }]); + let getAppHostsLineCallback: ((line: string) => void) | undefined; + const describeProcesses: TestChildProcess[] = []; + const describeCalls: { args: string[]; options: any }[] = []; + let psOptions: any; + spawnStub.callsFake((_terminalProvider, _command, args, options) => { + if (args[0] === 'ls') { + getAppHostsLineCallback = createLsLineCallback(options); + } + if (args[0] === 'describe') { + describeCalls.push({ args, options }); + const process = new TestChildProcess(); + describeProcesses.push(process); + return process; + } + if (args[0] === 'ps') { + psOptions = options; + } + return new TestChildProcess(); + }); + const repository = new AppHostDataRepository(terminalProvider); + + try { + repository.activate(); + repository.setPanelVisible(true); + await waitForAppHostDiscovery(); + assert.ok(getAppHostsLineCallback); + + getAppHostsLineCallback(JSON.stringify({ + selected_project_file: '/workspace/apps/Store/AppHost.csproj', + all_project_file_candidates: [ + '/workspace/apps/Store/AppHost.csproj', + '/workspace/samples/Store/AppHost.csproj', + ], + })); + await waitForAppHostDiscovery(); + + assert.strictEqual(describeCalls.length, 1); + assert.deepStrictEqual(describeCalls[0].args, ['describe', '--follow', '--format', 'json', '--apphost', '/workspace/apps/Store/AppHost.csproj']); + assert.ok(psOptions); + + psOptions.lineCallback(JSON.stringify([ + { + appHostPath: '/workspace/samples/Store/AppHost.csproj', + appHostPid: 125881, + cliPid: 125738, + dashboardUrl: 'https://localhost:17193/login?t=061212', + }, + ])); + await waitForMicrotasks(); + + assert.strictEqual(repository.workspaceAppHostPath, '/workspace/samples/Store/AppHost.csproj'); + assert.strictEqual(repository.workspaceAppHostName, 'samples/Store/AppHost.csproj'); + assert.strictEqual(repository.workspaceAppHost?.appHostPid, 125881); + assert.strictEqual(describeProcesses[0].killed, true); + assert.strictEqual(describeCalls.length, 2); + assert.deepStrictEqual(describeCalls[1].args, ['describe', '--follow', '--format', 'json', '--apphost', '/workspace/samples/Store/AppHost.csproj']); + + describeCalls[1].options.lineCallback(JSON.stringify({ name: 'api', resourceType: 'Project', state: 'Running' })); + assert.strictEqual(repository.workspaceResources.length, 1); + assert.strictEqual(repository.workspaceResources[0].name, 'api'); + } finally { + repository.dispose(); + workspaceFoldersStub.restore(); + } + }); + + test('multi-AppHost workspace does not retarget describe when multiple candidate AppHosts are running', async () => { + const workspaceFoldersStub = stubWorkspaceFolders([{ + uri: vscode.Uri.file('/workspace'), + name: 'workspace', + index: 0, + }]); + let getAppHostsLineCallback: ((line: string) => void) | undefined; + const describeProcesses: TestChildProcess[] = []; + const describeCalls: { args: string[]; options: any }[] = []; + let psOptions: any; + spawnStub.callsFake((_terminalProvider, _command, args, options) => { + if (args[0] === 'ls') { + getAppHostsLineCallback = createLsLineCallback(options); + } + if (args[0] === 'describe') { + describeCalls.push({ args, options }); + const process = new TestChildProcess(); + describeProcesses.push(process); + return process; + } + if (args[0] === 'ps') { + psOptions = options; + } + return new TestChildProcess(); + }); + const repository = new AppHostDataRepository(terminalProvider); + + try { + repository.activate(); + repository.setPanelVisible(true); + await waitForAppHostDiscovery(); + assert.ok(getAppHostsLineCallback); + + getAppHostsLineCallback(JSON.stringify({ + selected_project_file: '/workspace/apps/Store/AppHost.csproj', + all_project_file_candidates: [ + '/workspace/apps/Store/AppHost.csproj', + '/workspace/samples/Store/AppHost.csproj', + '/workspace/tools/Admin/AppHost.csproj', + ], + })); + await waitForAppHostDiscovery(); + + assert.strictEqual(describeCalls.length, 1); + assert.deepStrictEqual(describeCalls[0].args, ['describe', '--follow', '--format', 'json', '--apphost', '/workspace/apps/Store/AppHost.csproj']); + assert.ok(psOptions); + + psOptions.lineCallback(JSON.stringify([ + { + appHostPath: '/workspace/samples/Store/AppHost.csproj', + appHostPid: 125881, + cliPid: 125738, + dashboardUrl: 'https://localhost:17193/login?t=061212', + }, + { + appHostPath: '/workspace/tools/Admin/AppHost.csproj', + appHostPid: 125882, + cliPid: 125739, + dashboardUrl: 'https://localhost:17194/login?t=061213', + }, + ])); + await waitForMicrotasks(); + + assert.strictEqual(repository.workspaceAppHostPath, '/workspace/apps/Store/AppHost.csproj'); + assert.strictEqual(repository.workspaceAppHostName, 'apps/Store/AppHost.csproj'); + assert.strictEqual(repository.workspaceAppHost, undefined); + // No retarget, but global describe streams start for the non-selected running AppHosts + // so their resources appear in the workspace tree. + assert.strictEqual(describeCalls.length, 3); + assert.deepStrictEqual(describeCalls[1].args, ['describe', '--follow', '--format', 'json', '--apphost', '/workspace/samples/Store/AppHost.csproj']); + assert.deepStrictEqual(describeCalls[2].args, ['describe', '--follow', '--format', 'json', '--apphost', '/workspace/tools/Admin/AppHost.csproj']); + } finally { + repository.dispose(); + workspaceFoldersStub.restore(); + } + }); + + test('non-selected running AppHosts in workspace get resources from per-AppHost describe streams', async () => { + const workspaceFoldersStub = stubWorkspaceFolders([{ + uri: vscode.Uri.file('/workspace'), + name: 'workspace', + index: 0, + }]); + let getAppHostsLineCallback: ((line: string) => void) | undefined; + const describeProcesses: TestChildProcess[] = []; + const describeCalls: { args: string[]; options: any }[] = []; + let psOptions: any; + spawnStub.callsFake((_terminalProvider: any, _command: any, args: string[], options: any) => { + if (args[0] === 'ls') { + getAppHostsLineCallback = createLsLineCallback(options); + } + if (args[0] === 'describe') { + describeCalls.push({ args, options }); + const process = new TestChildProcess(); + describeProcesses.push(process); + return process; + } + if (args[0] === 'ps') { + psOptions = options; + } + return new TestChildProcess(); + }); + const repository = new AppHostDataRepository(terminalProvider); + + try { + repository.activate(); + repository.setPanelVisible(true); + await waitForAppHostDiscovery(); + assert.ok(getAppHostsLineCallback); + + getAppHostsLineCallback(JSON.stringify({ + selected_project_file: '/workspace/apps/Store/AppHost.csproj', + all_project_file_candidates: [ + '/workspace/apps/Store/AppHost.csproj', + '/workspace/samples/Store/AppHost.csproj', + ], + })); + await waitForAppHostDiscovery(); + assert.strictEqual(describeCalls.length, 1); + assert.ok(psOptions); + + // Simulate both AppHosts running + psOptions.lineCallback(JSON.stringify([ + { + appHostPath: '/workspace/apps/Store/AppHost.csproj', + appHostPid: 125880, + cliPid: 125737, + dashboardUrl: 'https://localhost:17192/login?t=061211', + }, + { + appHostPath: '/workspace/samples/Store/AppHost.csproj', + appHostPid: 125881, + cliPid: 125738, + dashboardUrl: 'https://localhost:17193/login?t=061212', + }, + ])); + await waitForMicrotasks(); + + // Global describe for non-selected AppHost spawns asynchronously after resolving CLI path + await waitForCondition(() => describeCalls.length >= 2, 'global describe for non-selected AppHost should start'); + + // Initial workspace describe + global describe for the non-selected AppHost + // (workspace describe restart is still pending on a timer) + assert.strictEqual(describeCalls.length, 2); + assert.deepStrictEqual(describeCalls[1].args, ['describe', '--follow', '--format', 'json', '--apphost', '/workspace/samples/Store/AppHost.csproj']); + + // Simulate resource data arriving on the non-selected AppHost's describe stream (NDJSON format) + describeCalls[1].options.lineCallback(JSON.stringify({ name: 'redis', resourceType: 'Container', state: 'Running' })); + await waitForMicrotasks(); + + // The non-selected AppHost should have its resources populated + const nonSelectedAppHost = repository.appHosts.find((a: any) => a.appHostPath === '/workspace/samples/Store/AppHost.csproj'); + assert.ok(nonSelectedAppHost); + assert.ok(nonSelectedAppHost.resources); + assert.strictEqual(nonSelectedAppHost.resources!.length, 1); + assert.strictEqual(nonSelectedAppHost.resources![0].name, 'redis'); } finally { repository.dispose(); workspaceFoldersStub.restore(); @@ -499,7 +727,6 @@ suite('AppHostDataRepository', () => { { appHostPath: configuredAppHostPath, appHostPid: 125881, - resources: [], }, ])); assert.strictEqual(repository.workspaceAppHost?.appHostPath, configuredAppHostPath); @@ -577,6 +804,10 @@ suite('AppHostDataRepository', () => { status: 'possibly-unbuildable', }, ])); + // aspire ls exit handler awaits getConfiguredAppHostPathFromWorkspaceRoot, which + // probes for aspire.config.json / .aspire/settings.json via vscode workspace fs. + // That probe can take more than one macrotask on Windows, so poll for completion + // instead of relying on a single setTimeout(0) tick. await waitForCondition( () => repository.workspaceAppHostPath === '/workspace/apps/Store/AppHost.csproj', 'buildable AppHost discovery did not finish'); @@ -689,13 +920,12 @@ suite('AppHostDataRepository', () => { await waitForAppHostDiscovery(); assert.ok(psOptions); - assert.deepStrictEqual(psArgs, ['ps', '--follow', '--format', 'json', '--resources']); + assert.deepStrictEqual(psArgs, ['ps', '--follow', '--format', 'json']); psOptions.lineCallback(JSON.stringify([{ appHostPath: '/workspace/apphost/apphost.cs', appHostPid: 125881, cliPid: 125738, dashboardUrl: 'https://localhost:17193/login?t=061212', - resources: [], }])); assert.strictEqual(repository.workspaceResources.length, 0); @@ -769,7 +999,6 @@ suite('AppHostDataRepository', () => { appHostPid: 125881, cliPid: 125738, dashboardUrl: 'https://localhost:17193/login?t=061212', - resources: [], }, ])); @@ -895,7 +1124,6 @@ suite('AppHostDataRepository', () => { { appHostPath: '/workspace/labs/ops/apphost.cs', appHostPid: 125881, - resources: [], }, ])); @@ -913,6 +1141,143 @@ suite('AppHostDataRepository', () => { } }); + test('workspace describe exit clears stale running AppHost before ps stop snapshot', async () => { + const workspaceFoldersStub = stubWorkspaceFolders([{ + uri: vscode.Uri.file('/workspace'), + name: 'workspace', + index: 0, + }]); + const executeCommandStub = sinon.stub(vscode.commands, 'executeCommand').resolves(undefined); + let getAppHostsLineCallback: ((line: string) => void) | undefined; + const describeProcess = new TestChildProcess(); + let describeOptions: any; + let psOptions: any; + spawnStub.callsFake((_terminalProvider, _command, args, options) => { + if (args[0] === 'ls') { + getAppHostsLineCallback = createLsLineCallback(options); + } + if (args[0] === 'describe') { + describeOptions = options; + return describeProcess; + } + if (args[0] === 'ps') { + psOptions = options; + } + return new TestChildProcess(); + }); + + const repository = new AppHostDataRepository(terminalProvider); + + try { + repository.activate(); + repository.setPanelVisible(true); + await waitForMicrotasks(); + + assert.ok(getAppHostsLineCallback); + getAppHostsLineCallback(JSON.stringify({ + selected_project_file: '/workspace/labs/ops/apphost.cs', + all_project_file_candidates: ['/workspace/labs/ops/apphost.cs'], + })); + await waitForAppHostDiscovery(); + + assert.ok(describeOptions); + assert.ok(psOptions); + describeOptions.lineCallback(JSON.stringify({ name: 'worker', resourceType: 'Project', state: 'Running' })); + psOptions.lineCallback(JSON.stringify([ + { + appHostPath: '/workspace/labs/ops/apphost.cs', + appHostPid: 125881, + }, + ])); + + assert.strictEqual(repository.workspaceResources.length, 1); + assert.strictEqual(repository.workspaceAppHost?.appHostPid, 125881); + assert.strictEqual(repository.appHosts.length, 1); + + describeOptions.exitCallback(0); + + assert.strictEqual(repository.workspaceResources.length, 0); + assert.strictEqual(repository.workspaceAppHost, undefined); + assert.strictEqual(repository.appHosts.length, 0); + + const noRunningContextCalls = executeCommandStub.getCalls().filter(call => + call.args[0] === 'setContext' && call.args[1] === 'aspire.noRunningAppHosts'); + assert.strictEqual(noRunningContextCalls.at(-1)?.args[2], true); + } finally { + repository.dispose(); + executeCommandStub.restore(); + workspaceFoldersStub.restore(); + } + }); + + test('workspace ps start restarts describe after earlier empty describe exit', async () => { + const workspaceFoldersStub = stubWorkspaceFolders([{ + uri: vscode.Uri.file('/workspace'), + name: 'workspace', + index: 0, + }]); + let getAppHostsLineCallback: ((line: string) => void) | undefined; + let psOptions: any; + const describeProcesses: TestChildProcess[] = []; + const describeOptions: any[] = []; + spawnStub.callsFake((_terminalProvider, _command, args, options) => { + if (args[0] === 'ls') { + getAppHostsLineCallback = createLsLineCallback(options); + } + if (args[0] === 'describe') { + describeOptions.push(options); + const process = new TestChildProcess(); + describeProcesses.push(process); + return process; + } + if (args[0] === 'ps') { + psOptions = options; + } + return new TestChildProcess(); + }); + + const repository = new AppHostDataRepository(terminalProvider); + + try { + repository.activate(); + repository.setPanelVisible(true); + await waitForMicrotasks(); + + assert.ok(getAppHostsLineCallback); + getAppHostsLineCallback(JSON.stringify({ + selected_project_file: '/workspace/labs/ops/apphost.cs', + all_project_file_candidates: ['/workspace/labs/ops/apphost.cs'], + })); + await waitForAppHostDiscovery(); + + assert.strictEqual(describeOptions.length, 1); + describeOptions[0].exitCallback(0); + assert.strictEqual(repository.workspaceResources.length, 0); + assert.strictEqual(Boolean(repository.workspaceAppHost), false); + + assert.ok(psOptions); + psOptions.lineCallback(JSON.stringify([ + { + appHostPath: '/workspace/labs/ops/apphost.cs', + appHostPid: 125881, + }, + ])); + await waitForMicrotasks(); + + assert.strictEqual(repository.workspaceAppHost?.appHostPid, 125881); + assert.strictEqual(describeOptions.length, 2); + + describeOptions[1].lineCallback(JSON.stringify({ name: 'worker', resourceType: 'Project', state: 'Running' })); + assert.strictEqual(repository.workspaceResources.length, 1); + assert.strictEqual(repository.workspaceResources[0].name, 'worker'); + assert.strictEqual(describeProcesses[0].killed, false); + assert.strictEqual(describeProcesses[1].killed, false); + } finally { + repository.dispose(); + workspaceFoldersStub.restore(); + } + }); + test('late close from stopped describe watch does not orphan replacement watch', async () => { const firstChildProcess = new TestChildProcess(); const secondChildProcess = new TestChildProcess(); @@ -1012,7 +1377,7 @@ suite('AppHostDataRepository global polling', () => { repository.setPanelVisible(true); await waitForMicrotasks(); - assert.deepStrictEqual(spawnStub.firstCall.args[2], ['ps', '--follow', '--format', 'json', '--resources']); + assert.deepStrictEqual(spawnStub.firstCall.args[2], ['ps', '--follow', '--format', 'json']); repository.setPanelVisible(false); @@ -1031,52 +1396,58 @@ suite('AppHostDataRepository global polling', () => { repository.setPanelVisible(true); await waitForMicrotasks(); - assert.deepStrictEqual(spawnStub.firstCall.args[2], ['ps', '--follow', '--format', 'json', '--resources']); + assert.deepStrictEqual(spawnStub.firstCall.args[2], ['ps', '--follow', '--format', 'json']); - const lineCallback = spawnStub.firstCall.args[3].lineCallback; - lineCallback(JSON.stringify({ + const psLineCallback = spawnStub.firstCall.args[3].lineCallback; + psLineCallback(JSON.stringify({ appHostPath: '/workspace/AppHost.csproj', appHostPid: 1234, status: 'running', - resources: [ - { name: 'api', resourceType: 'Project', state: 'Running' } - ] })); + await waitForMicrotasks(); assert.strictEqual(repository.appHosts.length, 1); assert.strictEqual(repository.appHosts[0].appHostPath, '/workspace/AppHost.csproj'); + + // The repository should now have spawned `aspire describe --follow --apphost ` + // for the discovered AppHost so the global tree can show resources. + const describeCall = spawnStub.getCalls().find(call => + Array.isArray(call.args[2]) && call.args[2][0] === 'describe' && call.args[2].includes('/workspace/AppHost.csproj')); + assert.ok(describeCall, 'expected aspire describe --follow to spawn for the discovered AppHost'); + assert.deepStrictEqual(describeCall.args[2], ['describe', '--follow', '--format', 'json', '--apphost', '/workspace/AppHost.csproj']); + + const describeLineCallback = describeCall.args[3].lineCallback; + describeLineCallback(JSON.stringify({ name: 'api', resourceType: 'Project', state: 'Running' })); assert.strictEqual(repository.appHosts[0].resources?.[0].name, 'api'); - lineCallback(JSON.stringify({ + psLineCallback(JSON.stringify({ appHostPath: '/workspace/OtherAppHost.csproj', appHostPid: 5678, status: 'running', - resources: [] })); + await waitForMicrotasks(); assert.strictEqual(repository.appHosts.length, 2); assert.strictEqual(repository.appHosts[1].appHostPath, '/workspace/OtherAppHost.csproj'); assert.deepStrictEqual(repository.appHosts[1].resources, []); - lineCallback(JSON.stringify({ + psLineCallback(JSON.stringify({ appHostPath: '/workspace/AppHost.csproj', appHostPid: 9999, status: 'running', - resources: [] })); + await waitForMicrotasks(); assert.strictEqual(repository.appHosts.length, 3); assert.strictEqual(repository.appHosts[2].appHostPath, '/workspace/AppHost.csproj'); assert.strictEqual(repository.appHosts[2].appHostPid, 9999); - lineCallback(JSON.stringify({ + psLineCallback(JSON.stringify({ appHostPath: '/workspace/AppHost.csproj', appHostPid: 1234, status: 'stopped', - resources: [ - { name: 'api', resourceType: 'Project', state: 'Running' } - ] })); + await waitForMicrotasks(); assert.strictEqual(repository.appHosts.length, 2); assert.strictEqual(repository.appHosts[0].appHostPath, '/workspace/OtherAppHost.csproj'); @@ -1107,7 +1478,7 @@ suite('AppHostDataRepository global polling', () => { repository.dispose(); }); - test('cli path failure does not disable resources polling', async () => { + test('cli path failure does not disable ps polling', async () => { const clock = sinon.useFakeTimers(); getCliPathStub.onFirstCall().rejects(new Error('CLI path unavailable')); getCliPathStub.onSecondCall().resolves('aspire'); @@ -1125,7 +1496,7 @@ suite('AppHostDataRepository global polling', () => { await waitForMicrotasks(); assert.strictEqual(spawnStub.calledOnce, true); - assert.deepStrictEqual(spawnStub.firstCall.args[2], ['ps', '--format', 'json', '--resources']); + assert.deepStrictEqual(spawnStub.firstCall.args[2], ['ps', '--format', 'json']); } finally { repository.dispose(); clock.restore(); @@ -1157,7 +1528,7 @@ suite('AppHostDataRepository global polling', () => { } }); - test('stopped ps does not start fallback after resources failure', async () => { + test('stopped ps does not start fallback after exit', async () => { const childProcess = new TestChildProcess(); spawnStub.returns(childProcess); const repository = new AppHostDataRepository(terminalProvider); @@ -1221,6 +1592,99 @@ suite('AppHostDataRepository global polling', () => { repository.dispose(); }); + + test('global mode spawns describe per AppHost and tears down on AppHost removal', async () => { + const spawned: { args: string[]; process: TestChildProcess; options: any }[] = []; + spawnStub.callsFake((_terminalProvider, _cliPath, args, options) => { + const process = new TestChildProcess(); + spawned.push({ args, process, options }); + return process; + }); + const repository = new AppHostDataRepository(terminalProvider); + + repository.activate(); + repository.setViewMode('global'); + repository.setPanelVisible(true); + await waitForMicrotasks(); + + const psCall = spawned.find(call => call.args[0] === 'ps'); + assert.ok(psCall); + + psCall.options.lineCallback(JSON.stringify({ + appHostPath: '/workspace/AppHost.csproj', + appHostPid: 1234, + status: 'running', + })); + psCall.options.lineCallback(JSON.stringify({ + appHostPath: '/workspace/OtherAppHost.csproj', + appHostPid: 5678, + status: 'running', + })); + await waitForMicrotasks(); + + const describeCalls = spawned.filter(call => call.args[0] === 'describe'); + assert.strictEqual(describeCalls.length, 2); + const paths = describeCalls.map(call => call.args[call.args.indexOf('--apphost') + 1]).sort(); + assert.deepStrictEqual(paths, ['/workspace/AppHost.csproj', '/workspace/OtherAppHost.csproj']); + + const firstDescribe = describeCalls.find(call => call.args.includes('/workspace/AppHost.csproj'))!; + firstDescribe.options.lineCallback(JSON.stringify({ name: 'api', resourceType: 'Project', state: 'Running' })); + firstDescribe.options.lineCallback(JSON.stringify({ name: 'db', resourceType: 'Container', state: 'Running' })); + + const first = repository.appHosts.find(a => a.appHostPath === '/workspace/AppHost.csproj'); + assert.ok(first); + assert.strictEqual(first.resources?.length, 2); + assert.deepStrictEqual(first.resources?.map(r => r.name).sort(), ['api', 'db']); + + // Stop the first AppHost — its describe stream should be torn down. + psCall.options.lineCallback(JSON.stringify({ + appHostPath: '/workspace/AppHost.csproj', + appHostPid: 1234, + status: 'stopped', + })); + await waitForMicrotasks(); + + assert.strictEqual(firstDescribe.process.killed, true); + assert.strictEqual(repository.appHosts.length, 1); + assert.strictEqual(repository.appHosts[0].appHostPath, '/workspace/OtherAppHost.csproj'); + + repository.dispose(); + }); + + test('global describe streams are stopped when switching to workspace mode', async () => { + const spawned: { args: string[]; process: TestChildProcess; options: any }[] = []; + spawnStub.callsFake((_terminalProvider, _cliPath, args, options) => { + const process = new TestChildProcess(); + spawned.push({ args, process, options }); + return process; + }); + const repository = new AppHostDataRepository(terminalProvider); + + repository.activate(); + repository.setViewMode('global'); + repository.setPanelVisible(true); + await waitForMicrotasks(); + + const psCall = spawned.find(call => call.args[0] === 'ps'); + assert.ok(psCall); + psCall.options.lineCallback(JSON.stringify({ + appHostPath: '/workspace/AppHost.csproj', + appHostPid: 1234, + status: 'running', + })); + await waitForMicrotasks(); + + const describeCall = spawned.find(call => call.args[0] === 'describe'); + assert.ok(describeCall); + assert.strictEqual(describeCall.process.killed, false); + + repository.setViewMode('workspace'); + await waitForMicrotasks(); + + assert.strictEqual(describeCall.process.killed, true); + + repository.dispose(); + }); }); suite('AppHostDataRepository AppHost-file gate', () => { diff --git a/extension/src/views/AppHostDataRepository.ts b/extension/src/views/AppHostDataRepository.ts index 42cd419fe54..4bb6b4e351a 100644 --- a/extension/src/views/AppHostDataRepository.ts +++ b/extension/src/views/AppHostDataRepository.ts @@ -90,13 +90,26 @@ export interface AppHostDisplayInfo { export type ViewMode = 'workspace' | 'global'; +interface GlobalDescribeStream { + appHostPath: string; + process: ChildProcessWithoutNullStreams | undefined; + resources: Map; + restartTimer: ReturnType | undefined; + restartDelay: number; + version: number; +} + /** * Central data repository for app host and resource information. * - * Owns two independent data sources: + * Owns three independent data sources: * - `aspire describe --follow` (workspace mode) — streams resource updates * via NDJSON for the selected workspace AppHost. Only active while the * tree-view panel is visible **and** workspace mode is selected. + * - `aspire describe --follow --apphost ` (global mode fan-out) — one + * stream per AppHost discovered by `ps`, merged into `appHost.resources` + * so the global multi-AppHost tree can show nested resources. `ps` itself + * only emits AppHost-level data. * - `aspire ps` polling — periodically fetches running app hosts. In global * mode this backs the full tree; in workspace mode it confirms whether the * selected workspace AppHost is running when the resource stream is empty. @@ -123,14 +136,25 @@ export class AppHostDataRepository { // ── Running AppHost state (ps polling) ── private _appHosts: AppHostDisplayInfo[] = []; + // Cached JSON serialization of `_appHosts` after the most recent reconcile so + // _handlePsOutput can detect real changes. We can't compare raw `ps` output to + // `_appHosts` directly because the in-memory state has merged resources, while + // `ps` no longer emits them (#17479) — see _handlePsOutput for the rationale. + private _appHostsSnapshot = '[]'; private _workspaceAppHost: AppHostDisplayInfo | undefined; private _pollingInterval: ReturnType | undefined; private _psProcesses = new Set(); private _psFetchVersion = 0; - private _supportsResources = true; private _supportsPsFollow = true; private _fetchInProgress = false; + // ── Global mode per-AppHost describe streams ── + // In global mode `ps` only returns AppHost-level data, so to populate + // `appHost.resources` for the multi-AppHost tree we fan out one + // `aspire describe --follow --apphost ` per discovered AppHost and + // merge the streams. Keyed by appHostPath. + private _globalDescribeStreams = new Map(); + // ── Workspace app host (from aspire ls) ── // The singular fields track a selected/default workspace AppHost. The candidate // paths track every buildable AppHost found by `aspire ls`, so workspace-mode @@ -255,6 +279,7 @@ export class AppHostDataRepository { refresh(): void { this._stopDescribeWatch(); + this._stopAllGlobalDescribes(); this._workspaceResources.clear(); this._clearErrors(); this._updateWorkspaceContext(); @@ -276,6 +301,7 @@ export class AppHostDataRepository { this._disposed = true; this._stopPolling(); this._stopDescribeWatch(); + this._stopAllGlobalDescribes(); this._configChangeDisposable.dispose(); this._appHostDiscoveryChangeDisposable.dispose(); this._onDidChangeData.dispose(); @@ -329,6 +355,12 @@ export class AppHostDataRepository { } else { this._stopPolling(); } + + // Global describe fan-out is only active while in global mode with the + // panel/editor showing. _reconcileGlobalDescribes handles both starting + // streams (when there are AppHosts to follow) and tearing them down + // (when we leave global mode or hide the panel). + this._reconcileGlobalDescribes(); } // ── Workspace app host (from aspire ls) ── @@ -400,6 +432,13 @@ export class AppHostDataRepository { this._workspaceAppHostName = candidateIndex >= 0 ? appHostLabels[candidateIndex] : shortenPath(appHostPath); } + private _setWorkspaceAppHostPathFromCurrentCandidates(appHostPath: string): void { + this._workspaceAppHostPath = appHostPath; + const appHostLabels = shortenPaths(this._workspaceAppHostCandidatePaths); + const candidateIndex = this._workspaceAppHostCandidatePaths.findIndex(candidatePath => isMatchingAppHostPath(candidatePath, appHostPath)); + this._workspaceAppHostName = candidateIndex >= 0 ? appHostLabels[candidateIndex] : shortenPath(appHostPath); + } + private _setWorkspaceAppHostCandidatePaths(appHostCandidates: readonly AppHostCandidate[]): void { this._workspaceAppHostCandidatePaths = appHostCandidates.map(candidate => candidate.path); } @@ -489,6 +528,7 @@ export class AppHostDataRepository { // once more with backoff in case the apphost is restarting; if that // attempt also produces no data we'll fall into the branch above. this._workspaceResources.clear(); + this._clearStoppedWorkspaceAppHost(); this._setDescribeError(undefined); this._updateWorkspaceContext(); @@ -572,6 +612,14 @@ export class AppHostDataRepository { } } + private _clearStoppedWorkspaceAppHost(): void { + const appHostPath = this._workspaceAppHost?.appHostPath ?? this._workspaceAppHostPath; + this._workspaceAppHost = undefined; + this._appHosts = appHostPath + ? this._appHosts.filter(appHost => !isMatchingAppHostPath(appHost.appHostPath, appHostPath)) + : []; + } + private _handleDescribeLine(line: string): boolean { const trimmed = line.trim(); if (!trimmed) { @@ -607,6 +655,184 @@ export class AppHostDataRepository { return undefined; } + // ── Global mode: per-AppHost describe fan-out ── + // `ps` is AppHost-level only, so to keep the global multi-AppHost tree + // populated with resources we spin up one `aspire describe --follow --apphost ` + // per AppHost in `_appHosts` and merge the streams into appHost.resources. + + private _reconcileGlobalDescribes(): void { + if (this._disposed || this._viewMode !== 'global' || !this._dataActive) { + this._stopAllGlobalDescribes(); + return; + } + + const currentPaths = new Set(this._appHosts.map(a => a.appHostPath)); + for (const path of Array.from(this._globalDescribeStreams.keys())) { + if (!currentPaths.has(path)) { + this._stopGlobalDescribe(path); + } + } + for (const appHost of this._appHosts) { + if (!this._globalDescribeStreams.has(appHost.appHostPath)) { + this._startGlobalDescribe(appHost.appHostPath); + } + } + this._attachGlobalResourcesToAppHosts(); + } + + private _attachGlobalResourcesToAppHosts(): void { + for (const appHost of this._appHosts) { + const stream = this._globalDescribeStreams.get(appHost.appHostPath); + appHost.resources = stream ? Array.from(stream.resources.values()) : null; + } + } + + private _startGlobalDescribe(appHostPath: string): void { + const stream: GlobalDescribeStream = { + appHostPath, + process: undefined, + resources: new Map(), + restartTimer: undefined, + restartDelay: 5000, + version: 0, + }; + this._globalDescribeStreams.set(appHostPath, stream); + const startVersion = ++stream.version; + + this._terminalProvider.getAspireCliExecutablePath().then(cliPath => { + // Bail if we were stopped, replaced, or torn down while resolving the cli path. + if (this._disposed || this._globalDescribeStreams.get(appHostPath) !== stream || startVersion !== stream.version) { + return; + } + + const args = ['describe', '--follow', '--format', 'json', '--apphost', appHostPath]; + extensionLogOutputChannel.info(`Starting aspire describe --follow for AppHost ${appHostPath}`); + + const childProcess = spawnCliProcess(this._terminalProvider, cliPath, args, { + noExtensionVariables: true, + lineCallback: (line) => { + if (this._globalDescribeStreams.get(appHostPath) !== stream || stream.process !== childProcess) { + return; + } + this._handleGlobalDescribeLine(stream, line); + }, + stderrCallback: (data) => { + // Per-AppHost describe errors should not pollute the global error banner, + // but they MUST be logged so users can diagnose missing resources for + // non-selected AppHosts (e.g., CLI too old to support `describe --apphost`). + extensionLogOutputChannel.warn(`aspire describe --follow stderr for ${appHostPath}: ${data}`); + }, + exitCallback: (code) => { + if (this._globalDescribeStreams.get(appHostPath) !== stream || stream.process !== childProcess) { + return; + } + extensionLogOutputChannel.info(`aspire describe --follow for ${appHostPath} exited with code ${code}`); + stream.process = undefined; + if (this._disposed) { + return; + } + + // AppHost is no longer running — drop the stream entirely; the + // next ps reconcile will recreate it if the AppHost comes back. + if (!this._appHosts.some(a => a.appHostPath === appHostPath)) { + this._globalDescribeStreams.delete(appHostPath); + return; + } + + stream.resources.clear(); + this._attachGlobalResourcesToAppHosts(); + this._onDidChangeData.fire(); + + const delay = stream.restartDelay; + stream.restartDelay = Math.min(stream.restartDelay * 2, this._getPollingIntervalMs()); + stream.restartTimer = setTimeout(() => { + stream.restartTimer = undefined; + if (this._disposed) { + return; + } + if (this._globalDescribeStreams.get(appHostPath) !== stream) { + return; + } + if (!this._appHosts.some(a => a.appHostPath === appHostPath)) { + this._globalDescribeStreams.delete(appHostPath); + return; + } + this._globalDescribeStreams.delete(appHostPath); + this._startGlobalDescribe(appHostPath); + }, delay); + }, + errorCallback: (error) => { + if (this._globalDescribeStreams.get(appHostPath) !== stream || stream.process !== childProcess) { + return; + } + extensionLogOutputChannel.warn(`aspire describe --follow for ${appHostPath} error: ${error.message}`); + stream.process = undefined; + // Node's `spawn` can fire `error` (e.g., ENOENT when the CLI binary is missing) + // without a subsequent `exit`, which would normally drive the restart loop. + // Drop the dead entry so the next ps reconcile recreates it instead of leaving + // a zombie that blocks reconcile from re-starting the stream. + this._globalDescribeStreams.delete(appHostPath); + stream.resources.clear(); + this._attachGlobalResourcesToAppHosts(); + this._onDidChangeData.fire(); + } + }); + stream.process = childProcess; + }).catch(error => { + extensionLogOutputChannel.warn(`Failed to start describe for ${appHostPath}: ${error}`); + // Same hazard as errorCallback above: getAspireCliExecutablePath() can reject + // (CLI missing, permission denied, etc.) without ever firing the spawn error/exit + // callbacks that would normally clean up. Drop the dead entry so the next + // reconcile recreates it instead of leaving a zombie that blocks reconcile + // from re-starting the stream. + if (this._globalDescribeStreams.get(appHostPath) === stream) { + this._globalDescribeStreams.delete(appHostPath); + } + }); + } + + private _handleGlobalDescribeLine(stream: GlobalDescribeStream, line: string): void { + const trimmed = line.trim(); + if (!trimmed) { + return; + } + try { + const resource: ResourceJson = JSON.parse(trimmed); + if (resource.name) { + stream.resources.set(resource.name, resource); + stream.restartDelay = 5000; + this._attachGlobalResourcesToAppHosts(); + this._onDidChangeData.fire(); + } + } catch (e) { + extensionLogOutputChannel.warn(`Failed to parse describe NDJSON line for ${stream.appHostPath}: ${e}`); + } + } + + private _stopGlobalDescribe(appHostPath: string): void { + const stream = this._globalDescribeStreams.get(appHostPath); + if (!stream) { + return; + } + this._globalDescribeStreams.delete(appHostPath); + stream.version++; + if (stream.restartTimer) { + clearTimeout(stream.restartTimer); + stream.restartTimer = undefined; + } + if (stream.process) { + const childProcess = stream.process; + stream.process = undefined; + this._terminateProcess(childProcess, `aspire describe --follow (${appHostPath})`); + } + } + + private _stopAllGlobalDescribes(): void { + for (const path of Array.from(this._globalDescribeStreams.keys())) { + this._stopGlobalDescribe(path); + } + } + private _updateWorkspaceContext(options?: { clearLoading?: boolean }): void { const hasWorkspaceAppHost = this._workspaceAppHost !== undefined; const hasResources = this._workspaceResources.size > 0; @@ -705,9 +931,6 @@ export class AppHostDataRepository { }; const args = ['ps', '--follow', '--format', 'json']; - if (this._supportsResources) { - args.push('--resources'); - } psProcess = spawnCliProcess(this._terminalProvider, cliPath, args, { noExtensionVariables: true, @@ -772,34 +995,16 @@ export class AppHostDataRepository { const fetchVersion = ++this._psFetchVersion; const args = ['ps', '--format', 'json']; - if (this._supportsResources) { - args.push('--resources'); - } this._runPsCommand(args, fetchVersion, (code, stdout, stderr) => { if (code === 0) { this._setPsError(undefined); this._handlePsOutput(stdout); - this._fetchInProgress = false; - } else if (this._supportsResources) { - this._supportsResources = false; - extensionLogOutputChannel.info('aspire ps --resources failed, falling back to aspire ps without --resources'); - this._runPsCommand(['ps', '--format', 'json'], fetchVersion, (retryCode, retryStdout, retryStderr) => { - if (retryCode === 0) { - this._setPsError(undefined); - this._handlePsOutput(retryStdout); - } else { - this._loadingGlobal = false; - this._updateLoadingContext(); - this._setPsError(errorFetchingAppHosts(retryStderr || `exit code ${retryCode}`)); - } - this._fetchInProgress = false; - }); } else { this._loadingGlobal = false; this._updateLoadingContext(); this._setPsError(errorFetchingAppHosts(stderr || `exit code ${code}`)); - this._fetchInProgress = false; } + this._fetchInProgress = false; }); } @@ -859,8 +1064,18 @@ export class AppHostDataRepository { return; } - const changed = JSON.stringify(appHosts) !== JSON.stringify(this._appHosts); + // Compare against the previous post-reconcile snapshot rather than the + // raw ps payload. `appHosts` here lacks the `resources` field (ps no longer + // emits it after #17479), while `this._appHosts` was mutated by the prior + // _attachGlobalResourcesToAppHosts call to include resources — a direct + // JSON.stringify compare would always report `changed` once any stream + // produced resources, triggering spurious _onDidChangeData.fire() calls. + const previousSnapshot = this._appHostsSnapshot; this._appHosts = appHosts; + this._reconcileGlobalDescribes(); + const nextSnapshot = JSON.stringify(this._appHosts); + const changed = nextSnapshot !== previousSnapshot; + this._appHostsSnapshot = nextSnapshot; if (this._loadingGlobal) { this._loadingGlobal = false; @@ -888,13 +1103,29 @@ export class AppHostDataRepository { } private _handleWorkspacePsOutput(appHosts: readonly AppHostDisplayInfo[]): void { - const workspaceAppHostPath = this._workspaceAppHostPath; + let workspaceAppHostPath = this._workspaceAppHostPath; const workspaceAppHosts = this._workspaceAppHostCandidatePaths.length > 0 ? appHosts.filter(appHost => this._workspaceAppHostCandidatePaths.some(candidatePath => isMatchingAppHostPath(appHost.appHostPath, candidatePath))) : []; - const workspaceAppHost = workspaceAppHostPath + let workspaceAppHost = workspaceAppHostPath ? workspaceAppHosts.find(appHost => isMatchingAppHostPath(appHost.appHostPath, workspaceAppHostPath)) : undefined; + let workspaceAppHostPathChanged = false; + + if (!workspaceAppHost && workspaceAppHosts.length === 1) { + workspaceAppHost = workspaceAppHosts[0]; + workspaceAppHostPathChanged = !isMatchingAppHostPath(workspaceAppHostPath, workspaceAppHost.appHostPath); + if (workspaceAppHostPathChanged) { + extensionLogOutputChannel.info(`Retargeting workspace AppHost describe to running AppHost ${workspaceAppHost.appHostPath}`); + this._stopDescribeWatch({ clearWorkspaceResources: true }); + this._setWorkspaceAppHostPathFromCurrentCandidates(workspaceAppHost.appHostPath); + workspaceAppHostPath = this._workspaceAppHostPath; + this._setDescribeError(undefined); + this._describeRestartDelay = 5000; + } + } + + const workspaceAppHostStarted = workspaceAppHost !== undefined && (this._workspaceAppHost === undefined || workspaceAppHostPathChanged); const changed = JSON.stringify(workspaceAppHosts) !== JSON.stringify(this._appHosts) || JSON.stringify(workspaceAppHost) !== JSON.stringify(this._workspaceAppHost); @@ -905,11 +1136,64 @@ export class AppHostDataRepository { this._appHosts = workspaceAppHosts; this._workspaceAppHost = workspaceAppHost; + // When multiple workspace AppHost candidates exist, start per-AppHost describe + // streams for running AppHosts that are NOT the selected one (the workspace + // describe stream already handles the selected AppHost). This ensures every + // running AppHost displayed in the multi-AppHost workspace tree has resources. + if (this._workspaceAppHostCandidatePaths.length > 1) { + this._reconcileWorkspaceDescribes(workspaceAppHosts); + } + + if (workspaceAppHostStarted + && this._shouldWatchWorkspace + && !this._describeProcess + && !this._describeStartPending + && !this._describeRestartTimer) { + this._startDescribeWatch(); + } + if (changed || this._loadingWorkspace) { this._updateWorkspaceContext({ clearLoading: true }); } } + /** + * In multi-candidate workspace mode, start/stop per-AppHost describe streams for + * running workspace AppHosts that are NOT the currently selected one. The workspace + * describe stream (via `_startDescribeWatch`) handles the selected AppHost; this + * method fans out global describe streams for the remaining running AppHosts so that + * each one displayed in the workspace tree has its resources populated. + */ + private _reconcileWorkspaceDescribes(workspaceAppHosts: readonly AppHostDisplayInfo[]): void { + const selectedPath = this._workspaceAppHostPath; + + // Determine which non-selected workspace AppHosts need a describe stream. + const desiredPaths = new Set( + workspaceAppHosts + .filter(a => !selectedPath || !isMatchingAppHostPath(a.appHostPath, selectedPath)) + .map(a => a.appHostPath) + ); + + // Stop streams for AppHosts that are no longer running (or became selected). + for (const path of Array.from(this._globalDescribeStreams.keys())) { + if (!desiredPaths.has(path)) { + this._stopGlobalDescribe(path); + } + } + + // Start streams for newly running non-selected AppHosts. + for (const appHost of workspaceAppHosts) { + if (selectedPath && isMatchingAppHostPath(appHost.appHostPath, selectedPath)) { + continue; + } + if (!this._globalDescribeStreams.has(appHost.appHostPath)) { + this._startGlobalDescribe(appHost.appHostPath); + } + } + + this._attachGlobalResourcesToAppHosts(); + } + private async _runPsCommand(args: string[], fetchVersion: number, callback: (code: number, stdout: string, stderr: string) => void): Promise { let cliPath: string; try { diff --git a/src/Aspire.Cli/Commands/PsCommand.cs b/src/Aspire.Cli/Commands/PsCommand.cs index d36e128e336..76082600915 100644 --- a/src/Aspire.Cli/Commands/PsCommand.cs +++ b/src/Aspire.Cli/Commands/PsCommand.cs @@ -4,7 +4,6 @@ using System.CommandLine; using System.Globalization; using System.Text.Json; -using System.Text.Json.Nodes; using System.Text.Json.Serialization; using System.Threading.Channels; using Aspire.Cli.Backchannel; @@ -13,7 +12,6 @@ using Aspire.Cli.Resources; using Aspire.Cli.Telemetry; using Aspire.Cli.Utils; -using Aspire.Shared.Model.Serialization; using Microsoft.Extensions.Logging; using Spectre.Console; @@ -35,9 +33,6 @@ internal sealed class AppHostDisplayInfo [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? LogFilePath { get; init; } - - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public List? Resources { get; set; } } internal static class AppHostDisplayStatus @@ -48,18 +43,6 @@ internal static class AppHostDisplayStatus [JsonSerializable(typeof(List))] [JsonSerializable(typeof(AppHostDisplayInfo))] -[JsonSerializable(typeof(ResourceJson))] -[JsonSerializable(typeof(ResourceUrlJson))] -[JsonSerializable(typeof(ResourceVolumeJson))] -[JsonSerializable(typeof(ResourceRelationshipJson))] -[JsonSerializable(typeof(ResourceHealthReportJson))] -[JsonSerializable(typeof(ResourceCommandJson))] -[JsonSerializable(typeof(ResourceCommandArgumentJson[]))] -[JsonSerializable(typeof(JsonNode))] -[JsonSerializable(typeof(Dictionary))] -[JsonSerializable(typeof(Dictionary))] -[JsonSerializable(typeof(Dictionary))] -[JsonSerializable(typeof(Dictionary))] [JsonSourceGenerationOptions(WriteIndented = true, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] internal sealed partial class PsCommandJsonContext : JsonSerializerContext { @@ -98,16 +81,6 @@ internal sealed partial class PsCommand : BaseCommand Description = PsCommandStrings.JsonOptionDescription }; - private static readonly Option s_resourcesOption = new("--resources") - { - Description = PsCommandStrings.ResourcesOptionDescription - }; - - private static readonly Option s_includeHiddenOption = new("--include-hidden") - { - Description = SharedCommandStrings.IncludeHiddenOptionDescription - }; - private static readonly Option s_followOption = new("--follow", "-f") { Description = PsCommandStrings.FollowOptionDescription @@ -128,8 +101,6 @@ public PsCommand( _logger = logger; Options.Add(s_formatOption); - Options.Add(s_resourcesOption); - Options.Add(s_includeHiddenOption); Options.Add(s_followOption); } @@ -138,12 +109,10 @@ protected override async Task ExecuteAsync(ParseResult parseResul using var activity = Telemetry.StartDiagnosticActivity(Name); var format = parseResult.GetValue(s_formatOption); - var includeResources = parseResult.GetValue(s_resourcesOption); - var includeHidden = parseResult.GetValue(s_includeHiddenOption); if (parseResult.GetValue(s_followOption)) { - return await ExecuteFollowAsync(format, includeResources, includeHidden, cancellationToken).ConfigureAwait(false); + return await ExecuteFollowAsync(format, cancellationToken).ConfigureAwait(false); } // Scan for running AppHosts (same as ListAppHostsTool). JSON output must not go @@ -174,7 +143,7 @@ protected override async Task ExecuteAsync(ParseResult parseResul .ToList(); // Gather info for each AppHost - var appHostInfos = await GatherAppHostInfosAsync(orderedConnections, includeResources && format == OutputFormat.Json, includeHidden, cancellationToken).ConfigureAwait(false); + var appHostInfos = await GatherAppHostInfosAsync(orderedConnections, cancellationToken).ConfigureAwait(false); if (format == OutputFormat.Json) { @@ -201,9 +170,7 @@ private abstract record PsFollowUpdate; private sealed record ConnectionsUpdate(IReadOnlyList Connections) : PsFollowUpdate; - private sealed record ResourceUpdate(IAppHostAuxiliaryBackchannel Connection) : PsFollowUpdate; - - private async Task ExecuteFollowAsync(OutputFormat format, bool includeResources, bool includeHidden, CancellationToken cancellationToken) + private async Task ExecuteFollowAsync(OutputFormat format, CancellationToken cancellationToken) { if (format != OutputFormat.Json) { @@ -215,12 +182,9 @@ private async Task ExecuteFollowAsync(OutputFormat format, bool i { SingleReader = true }); - var currentConnections = new List(); var appHostKeyComparer = GetAppHostKeyComparer(); var activeAppHosts = new Dictionary(appHostKeyComparer); var lastJsonByAppHost = new Dictionary(appHostKeyComparer); - CancellationTokenSource? resourceWatchCts = null; - List resourceWatchTasks = []; _ = Task.Run(async () => { @@ -251,10 +215,8 @@ private async Task ExecuteFollowAsync(OutputFormat format, bool i { if (update is ConnectionsUpdate connectionsUpdate) { - currentConnections = OrderConnections(connectionsUpdate.Connections); - await RestartResourceWatchersAsync().ConfigureAwait(false); - - var currentAppHosts = await GatherAppHostInfosAsync(currentConnections, includeResources, includeHidden, followCancellationToken).ConfigureAwait(false); + var currentConnections = OrderConnections(connectionsUpdate.Connections); + var currentAppHosts = await GatherAppHostInfosAsync(currentConnections, followCancellationToken).ConfigureAwait(false); var nextActiveAppHosts = new Dictionary(appHostKeyComparer); foreach (var appHost in currentAppHosts) @@ -277,27 +239,6 @@ private async Task ExecuteFollowAsync(OutputFormat format, bool i activeAppHosts = nextActiveAppHosts; } - else if (update is ResourceUpdate resourceUpdate) - { - var appHostInfos = await GatherAppHostInfosAsync([resourceUpdate.Connection], includeResources, includeHidden, followCancellationToken).ConfigureAwait(false); - var appHost = appHostInfos.SingleOrDefault(); - if (appHost is null) - { - continue; - } - - var key = GetAppHostKey(appHost); - if (!activeAppHosts.ContainsKey(key)) - { - continue; - } - - activeAppHosts[key] = appHost; - if (!await TryWriteAppHostInfoAsync(appHost).ConfigureAwait(false)) - { - return CommandResult.Success(); - } - } } } catch (OperationCanceledException) when (followCancellationToken.IsCancellationRequested) @@ -312,57 +253,10 @@ private async Task ExecuteFollowAsync(OutputFormat format, bool i finally { await followCancellationTokenSource.CancelAsync().ConfigureAwait(false); - if (resourceWatchCts is not null) - { - await resourceWatchCts.CancelAsync().ConfigureAwait(false); - await Task.WhenAll(resourceWatchTasks).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); - resourceWatchCts.Dispose(); - } } return CommandResult.Success(); - async Task RestartResourceWatchersAsync() - { - if (resourceWatchCts is not null) - { - await resourceWatchCts.CancelAsync().ConfigureAwait(false); - await Task.WhenAll(resourceWatchTasks).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); - resourceWatchCts.Dispose(); - resourceWatchCts = null; - resourceWatchTasks = []; - } - - if (!includeResources || format != OutputFormat.Json) - { - return; - } - - resourceWatchCts = CancellationTokenSource.CreateLinkedTokenSource(followCancellationToken); - var resourceCancellationToken = resourceWatchCts.Token; - foreach (var connection in currentConnections) - { - resourceWatchTasks.Add(Task.Run(async () => - { - try - { - await foreach (var _ in connection.WatchResourceSnapshotsAsync(includeHidden, resourceCancellationToken).WithCancellation(resourceCancellationToken).ConfigureAwait(false)) - { - await updates.Writer.WriteAsync(new ResourceUpdate(connection), resourceCancellationToken).ConfigureAwait(false); - } - } - catch (OperationCanceledException) when (resourceCancellationToken.IsCancellationRequested) - { - // Expected when the connection list changes or the command stops. - } - catch (Exception ex) - { - _logger.LogDebug(ex, "Failed while watching resource snapshots for {AppHostPath}.", connection.AppHostInfo?.AppHostPath); - } - }, CancellationToken.None)); - } - } - async Task TryWriteAppHostInfoAsync(AppHostDisplayInfo appHost) { var key = GetAppHostKey(appHost); @@ -414,8 +308,7 @@ private static AppHostDisplayInfo CopyWithStatus(AppHostDisplayInfo appHost, str SdkVersion = appHost.SdkVersion, CliPid = appHost.CliPid, DashboardUrl = appHost.DashboardUrl, - LogFilePath = appHost.LogFilePath, - Resources = appHost.Resources + LogFilePath = appHost.LogFilePath }; } @@ -426,7 +319,7 @@ private static List OrderConnections(IEnumerable> GatherAppHostInfosAsync(List connections, bool includeResources, bool includeHidden, CancellationToken cancellationToken) + private async Task> GatherAppHostInfosAsync(List connections, CancellationToken cancellationToken) { var appHostInfos = new List(); @@ -480,20 +373,6 @@ private async Task> GatherAppHostInfosAsync(List? resources = null; - if (includeResources) - { - try - { - var snapshots = await connection.GetResourceSnapshotsAsync(includeHidden, cancellationToken).ConfigureAwait(false); - resources = ResourceSnapshotMapper.MapToResourceJsonList(snapshots, dashboardUrl, includeEnvironmentVariableValues: false); - } - catch (Exception ex) - { - _logger.LogDebug(ex, "Failed to get resource snapshots for {AppHostPath}", info.AppHostPath); - } - } - appHostInfos.Add(new AppHostDisplayInfo { AppHostPath = appHostPath ?? PsCommandStrings.UnknownPath, @@ -502,8 +381,7 @@ private async Task> GatherAppHostInfosAsync(List Unknown - - Include resource details for each running AppHost. Only applies to JSON output. - - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. The --follow option only supports JSON output. Use --format json. diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.cs.xlf index 3caecff13d0..d16e817de7d 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.cs.xlf @@ -8,8 +8,8 @@ - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. @@ -47,11 +47,6 @@ Výstup ve formátu JSON - - Include resource details for each running AppHost. Only applies to JSON output. - Include resource details for each running AppHost. Only applies to JSON output. - - Unknown Neznámé diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.de.xlf index b72fb405eaa..302c834e8f2 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.de.xlf @@ -8,8 +8,8 @@ - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. @@ -47,11 +47,6 @@ Ausgabe im JSON-Format. - - Include resource details for each running AppHost. Only applies to JSON output. - Include resource details for each running AppHost. Only applies to JSON output. - - Unknown Unbekannt diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.es.xlf index ce2f85d6b41..0c4df433772 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.es.xlf @@ -8,8 +8,8 @@ - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. @@ -47,11 +47,6 @@ Salida en formato JSON. - - Include resource details for each running AppHost. Only applies to JSON output. - Include resource details for each running AppHost. Only applies to JSON output. - - Unknown Desconocido diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.fr.xlf index f10c909806d..8aa4be8f43d 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.fr.xlf @@ -8,8 +8,8 @@ - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. @@ -47,11 +47,6 @@ Sortie au format JSON. - - Include resource details for each running AppHost. Only applies to JSON output. - Include resource details for each running AppHost. Only applies to JSON output. - - Unknown Inconnu diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.it.xlf index cec06e8f1c7..10349c21b98 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.it.xlf @@ -8,8 +8,8 @@ - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. @@ -47,11 +47,6 @@ Output in formato JSON. - - Include resource details for each running AppHost. Only applies to JSON output. - Include resource details for each running AppHost. Only applies to JSON output. - - Unknown Sconosciuto diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ja.xlf index 398bfc4972b..f59b502a801 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ja.xlf @@ -8,8 +8,8 @@ - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. @@ -47,11 +47,6 @@ JSON 形式で出力します。 - - Include resource details for each running AppHost. Only applies to JSON output. - Include resource details for each running AppHost. Only applies to JSON output. - - Unknown 不明 diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ko.xlf index 0d78643506b..b5eb897baeb 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ko.xlf @@ -8,8 +8,8 @@ - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. @@ -47,11 +47,6 @@ JSON 형식의 출력입니다. - - Include resource details for each running AppHost. Only applies to JSON output. - Include resource details for each running AppHost. Only applies to JSON output. - - Unknown 알 수 없음 diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.pl.xlf index f078f83fe46..ac3b47aad3e 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.pl.xlf @@ -8,8 +8,8 @@ - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. @@ -47,11 +47,6 @@ Wynik w formacie JSON. - - Include resource details for each running AppHost. Only applies to JSON output. - Include resource details for each running AppHost. Only applies to JSON output. - - Unknown Nieznane diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.pt-BR.xlf index dc6d95aab59..7ca49e6015d 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.pt-BR.xlf @@ -8,8 +8,8 @@ - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. @@ -47,11 +47,6 @@ Saída no formato JSON. - - Include resource details for each running AppHost. Only applies to JSON output. - Include resource details for each running AppHost. Only applies to JSON output. - - Unknown Desconhecido diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ru.xlf index 6a8d805b322..e726bd4836c 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ru.xlf @@ -8,8 +8,8 @@ - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. @@ -47,11 +47,6 @@ Вывод в формате JSON. - - Include resource details for each running AppHost. Only applies to JSON output. - Include resource details for each running AppHost. Only applies to JSON output. - - Unknown Неизвестно diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.tr.xlf index 613a8aaec81..8e8c7059cc5 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.tr.xlf @@ -8,8 +8,8 @@ - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. @@ -47,11 +47,6 @@ Çıkışı JSON biçiminde oluşturun. - - Include resource details for each running AppHost. Only applies to JSON output. - Include resource details for each running AppHost. Only applies to JSON output. - - Unknown Bilinmiyor diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.zh-Hans.xlf index d9bf1dea07b..f63554406d8 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.zh-Hans.xlf @@ -8,8 +8,8 @@ - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. @@ -47,11 +47,6 @@ 以 JSON 格式输出。 - - Include resource details for each running AppHost. Only applies to JSON output. - Include resource details for each running AppHost. Only applies to JSON output. - - Unknown 未知 diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.zh-Hant.xlf index 0ce98849920..bda1455aa72 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.zh-Hant.xlf @@ -8,8 +8,8 @@ - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. @@ -47,11 +47,6 @@ 以 JSON 格式輸出。 - - Include resource details for each running AppHost. Only applies to JSON output. - Include resource details for each running AppHost. Only applies to JSON output. - - Unknown 未知 diff --git a/src/Shared/Model/Serialization/ResourceJson.cs b/src/Shared/Model/Serialization/ResourceJson.cs index 5212484429e..352d11a6c73 100644 --- a/src/Shared/Model/Serialization/ResourceJson.cs +++ b/src/Shared/Model/Serialization/ResourceJson.cs @@ -10,8 +10,8 @@ namespace Aspire.Shared.Model.Serialization; /// Represents a resource in JSON format. /// This is a shared representation used by both the Dashboard and CLI. /// -// CLI commands such as `aspire describe --format json` and `aspire ps --format json --resources` -// use this shape and the nested resource shapes below; keep docs/specs/cli-output-formats.md in sync when changing them. +// `aspire describe --format json` uses this shape and the nested resource shapes below; +// keep docs/specs/cli-output-formats.md in sync when changing them. internal sealed class ResourceJson { /// diff --git a/tests/Aspire.Cli.Tests/Commands/PsCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/PsCommandTests.cs index f4fc2c0919a..31ff6ec3e68 100644 --- a/tests/Aspire.Cli.Tests/Commands/PsCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/PsCommandTests.cs @@ -3,7 +3,6 @@ using System.Net; using System.Net.Sockets; -using System.Runtime.CompilerServices; using System.Text.Json; using Aspire.Cli.Backchannel; using Aspire.Cli.Commands; @@ -501,7 +500,7 @@ public async Task PsCommand_JsonFormat_DoesNotShowScanningStatus() using var provider = services.BuildServiceProvider(); var command = provider.GetRequiredService(); - var result = command.Parse("ps --format json --resources"); + var result = command.Parse("ps --format json"); var exitCode = await result.InvokeAsync().DefaultTimeout(); @@ -513,311 +512,6 @@ public async Task PsCommand_JsonFormat_DoesNotShowScanningStatus() Assert.Equal(JsonValueKind.Array, document.RootElement.ValueKind); } - [Fact] - public async Task PsCommand_ResourcesOption_IncludesResourcesInJsonOutput() - { - using var workspace = TemporaryWorkspace.Create(outputHelper); - var textWriter = new TestOutputTextWriter(outputHelper); - - var monitor = new TestAuxiliaryBackchannelMonitor(); - var connection = new TestAppHostAuxiliaryBackchannel - { - IsInScope = true, - AppHostInfo = new AppHostInformation - { - AppHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "App1", "App1.AppHost.csproj"), - ProcessId = 1234, - CliProcessId = 5678 - }, - DashboardUrlsState = new DashboardUrlsState - { - BaseUrlWithLoginToken = "http://localhost:18888/login?t=abc123" - }, - ResourceSnapshots = - [ - new ResourceSnapshot - { - Name = "apiservice", - DisplayName = "apiservice", - ResourceType = "Project", - State = "Running", - StateStyle = "success", - Urls = - [ - new ResourceSnapshotUrl { Name = "https", Url = "https://localhost:7001" } - ] - }, - new ResourceSnapshot - { - Name = "redis", - DisplayName = "redis", - ResourceType = "Container", - State = "Running", - StateStyle = "success" - }, - new ResourceSnapshot - { - Name = "aspire-dashboard", - DisplayName = "aspire-dashboard", - ResourceType = "Project", - State = "Hidden", - IsHidden = true - } - ] - }; - monitor.AddConnection("hash1", "socket.hash1", connection); - - var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => - { - options.OutputTextWriter = textWriter; - options.AuxiliaryBackchannelMonitorFactory = _ => monitor; - }); - using var provider = services.BuildServiceProvider(); - - var command = provider.GetRequiredService(); - var result = command.Parse("ps --format json --resources"); - - var exitCode = await result.InvokeAsync().DefaultTimeout(); - - Assert.Equal(CliExitCodes.Success, exitCode); - - var jsonOutput = string.Join(string.Empty, textWriter.Logs); - var appHosts = JsonSerializer.Deserialize(jsonOutput, PsCommandJsonContext.RelaxedEscaping.ListAppHostDisplayInfo); - Assert.NotNull(appHosts); - Assert.Single(appHosts); - - var appHost = appHosts[0]; - Assert.NotNull(appHost.Resources); - Assert.Equal(2, appHost.Resources.Count); - - var apiService = appHost.Resources.First(r => r.Name == "apiservice"); - Assert.Equal("Project", apiService.ResourceType); - Assert.Equal("Running", apiService.State); - Assert.NotNull(apiService.Urls); - Assert.Single(apiService.Urls); - Assert.Equal("https://localhost:7001", apiService.Urls[0].Url); - - var redis = appHost.Resources.First(r => r.Name == "redis"); - Assert.Equal("Container", redis.ResourceType); - Assert.Equal("Running", redis.State); - Assert.DoesNotContain(appHost.Resources, resource => resource.Name == "aspire-dashboard"); - } - - [Fact] - public async Task PsCommand_ResourcesOption_WithIncludeHidden_IncludesHiddenResources() - { - using var workspace = TemporaryWorkspace.Create(outputHelper); - var textWriter = new TestOutputTextWriter(outputHelper); - - var monitor = new TestAuxiliaryBackchannelMonitor(); - var connection = new TestAppHostAuxiliaryBackchannel - { - IsInScope = true, - AppHostInfo = new AppHostInformation - { - AppHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "App1", "App1.AppHost.csproj"), - ProcessId = 1234 - }, - ResourceSnapshots = - [ - new ResourceSnapshot - { - Name = "apiservice", - DisplayName = "apiservice", - ResourceType = "Project", - State = "Running" - }, - new ResourceSnapshot - { - Name = "aspire-dashboard", - DisplayName = "aspire-dashboard", - ResourceType = "Project", - State = "Hidden", - IsHidden = true - } - ] - }; - monitor.AddConnection("hash1", "socket.hash1", connection); - - var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => - { - options.OutputTextWriter = textWriter; - options.AuxiliaryBackchannelMonitorFactory = _ => monitor; - }); - using var provider = services.BuildServiceProvider(); - - var command = provider.GetRequiredService(); - var result = command.Parse("ps --format json --resources --include-hidden"); - - var exitCode = await result.InvokeAsync().DefaultTimeout(); - - Assert.Equal(CliExitCodes.Success, exitCode); - - var jsonOutput = string.Join(string.Empty, textWriter.Logs); - var appHosts = JsonSerializer.Deserialize(jsonOutput, PsCommandJsonContext.RelaxedEscaping.ListAppHostDisplayInfo); - Assert.NotNull(appHosts); - var resources = Assert.Single(appHosts).Resources; - Assert.NotNull(resources); - Assert.Contains(resources, resource => resource.Name == "apiservice"); - Assert.Contains(resources, resource => resource.Name == "aspire-dashboard"); - } - - [Theory] - [InlineData(false, 1)] - [InlineData(true, 2)] - public async Task PsCommand_FollowResourcesOption_HandlesHiddenResources(bool includeHidden, int expectedResourceCount) - { - using var workspace = TemporaryWorkspace.Create(outputHelper); - using var cancellationTokenSource = new CancellationTokenSource(); - var outputLines = new List(); - var textWriter = new TestOutputTextWriter(outputHelper, line => - { - outputLines.Add(line); - cancellationTokenSource.Cancel(); - }); - - var monitor = new TestAuxiliaryBackchannelMonitor(); - var connection = new TestAppHostAuxiliaryBackchannel - { - IsInScope = true, - AppHostInfo = new AppHostInformation - { - AppHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "App1", "App1.AppHost.csproj"), - ProcessId = 1234 - }, - ResourceSnapshots = - [ - new ResourceSnapshot - { - Name = "apiservice", - DisplayName = "apiservice", - ResourceType = "Project", - State = "Running" - }, - new ResourceSnapshot - { - Name = "aspire-dashboard", - DisplayName = "aspire-dashboard", - ResourceType = "Project", - State = "Hidden", - IsHidden = true - } - ] - }; - monitor.AddConnection("hash1", "socket.hash1", connection); - - var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => - { - options.OutputTextWriter = textWriter; - options.AuxiliaryBackchannelMonitorFactory = _ => monitor; - }); - using var provider = services.BuildServiceProvider(); - - var command = provider.GetRequiredService(); - var includeHiddenArg = includeHidden ? " --include-hidden" : string.Empty; - var result = command.Parse($"ps --format json --resources --follow{includeHiddenArg}"); - - var exitCode = await result.InvokeAsync(cancellationToken: cancellationTokenSource.Token).DefaultTimeout(); - - Assert.Equal(CliExitCodes.Success, exitCode); - var outputLine = Assert.Single(outputLines); - var appHost = JsonSerializer.Deserialize(outputLine, PsCommandJsonContext.RelaxedEscaping.AppHostDisplayInfo); - Assert.NotNull(appHost); - Assert.Equal(AppHostDisplayStatus.Running, appHost.Status); - var resources = appHost.Resources; - Assert.NotNull(resources); - Assert.Equal(expectedResourceCount, resources.Count); - Assert.Contains(resources, resource => resource.Name == "apiservice"); - Assert.Equal(includeHidden, resources.Any(resource => resource.Name == "aspire-dashboard")); - } - - [Fact] - public async Task PsCommand_FollowJsonFormat_StreamsChangedAppHostForResourceUpdates() - { - using var workspace = TemporaryWorkspace.Create(outputHelper); - using var cancellationTokenSource = new CancellationTokenSource(); - var outputLines = new List(); - var updateResource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var textWriter = new TestOutputTextWriter(outputHelper, line => - { - outputLines.Add(line); - if (outputLines.Count == 1) - { - updateResource.SetResult(); - } - else if (outputLines.Count == 2) - { - cancellationTokenSource.Cancel(); - } - }); - - var snapshots = new List - { - new() - { - Name = "apiservice", - DisplayName = "apiservice", - ResourceType = "Project", - State = "Starting", - StateStyle = "info" - } - }; - var monitor = new TestAuxiliaryBackchannelMonitor(); - var connection = new TestAppHostAuxiliaryBackchannel - { - IsInScope = true, - AppHostInfo = new AppHostInformation - { - AppHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "App1", "App1.AppHost.csproj"), - ProcessId = 1234, - CliProcessId = 5678 - }, - GetResourceSnapshotsHandler = _ => Task.FromResult(snapshots.ToList()), - WatchResourceSnapshotsHandler = WatchResourceSnapshotsAsync - }; - monitor.AddConnection("hash1", "socket.hash1", connection); - - var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => - { - options.OutputTextWriter = textWriter; - options.AuxiliaryBackchannelMonitorFactory = _ => monitor; - }); - using var provider = services.BuildServiceProvider(); - - var command = provider.GetRequiredService(); - var result = command.Parse("ps --format json --resources --follow"); - - var exitCode = await result.InvokeAsync(cancellationToken: cancellationTokenSource.Token).DefaultTimeout(); - - Assert.Equal(CliExitCodes.Success, exitCode); - Assert.Equal(2, outputLines.Count); - - var initialAppHost = JsonSerializer.Deserialize(outputLines[0], PsCommandJsonContext.RelaxedEscaping.AppHostDisplayInfo); - var updatedAppHost = JsonSerializer.Deserialize(outputLines[1], PsCommandJsonContext.RelaxedEscaping.AppHostDisplayInfo); - Assert.NotNull(initialAppHost); - Assert.NotNull(updatedAppHost); - Assert.Equal(AppHostDisplayStatus.Running, initialAppHost.Status); - Assert.Equal(AppHostDisplayStatus.Running, updatedAppHost.Status); - Assert.Equal("Starting", Assert.Single(initialAppHost.Resources!).State); - Assert.Equal("Running", Assert.Single(updatedAppHost.Resources!).State); - - async IAsyncEnumerable WatchResourceSnapshotsAsync(bool includeHidden, [EnumeratorCancellation] CancellationToken cancellationToken) - { - Assert.False(includeHidden); - await updateResource.Task.WaitAsync(cancellationToken).ConfigureAwait(false); - snapshots[0] = new ResourceSnapshot - { - Name = "apiservice", - DisplayName = "apiservice", - ResourceType = "Project", - State = "Running", - StateStyle = "success" - }; - yield return snapshots[0]; - await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false); - } - } - [Fact] public async Task PsCommand_FollowJsonFormat_ReturnsSuccessWhenOutputCloses() { @@ -854,79 +548,6 @@ public async Task PsCommand_FollowJsonFormat_ReturnsSuccessWhenOutputCloses() Assert.Single(interactionService.DisplayedRawText); } - [Fact] - public async Task PsCommand_FollowJsonFormat_WaitsForResourceWatchersBeforeReturning() - { - using var workspace = TemporaryWorkspace.Create(outputHelper); - using var cancellationTokenSource = new CancellationTokenSource(); - var watcherCleanupStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var allowWatcherCleanup = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var textWriter = new TestOutputTextWriter(outputHelper, _ => cancellationTokenSource.Cancel()); - var monitor = new TestAuxiliaryBackchannelMonitor(); - monitor.AddConnection("hash1", "socket.hash1", new TestAppHostAuxiliaryBackchannel - { - IsInScope = true, - AppHostInfo = new AppHostInformation - { - AppHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "App1", "App1.AppHost.csproj"), - ProcessId = 1234, - CliProcessId = 5678 - }, - ResourceSnapshots = - [ - new ResourceSnapshot - { - Name = "apiservice", - DisplayName = "apiservice", - ResourceType = "Project", - State = "Running" - } - ], - WatchResourceSnapshotsHandler = WatchResourceSnapshotsAsync - }); - - var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => - { - options.OutputTextWriter = textWriter; - options.AuxiliaryBackchannelMonitorFactory = _ => monitor; - }); - using var provider = services.BuildServiceProvider(); - - var command = provider.GetRequiredService(); - var result = command.Parse("ps --format json --resources --follow"); - - var invokeTask = result.InvokeAsync(cancellationToken: cancellationTokenSource.Token); - - await watcherCleanupStarted.Task.DefaultTimeout(); - try - { - Assert.False(invokeTask.IsCompleted, "The command returned before its resource watcher task completed cleanup."); - } - finally - { - allowWatcherCleanup.TrySetResult(); - } - - var exitCode = await invokeTask.DefaultTimeout(); - - Assert.Equal(CliExitCodes.Success, exitCode); - - async IAsyncEnumerable WatchResourceSnapshotsAsync(bool includeHidden, [EnumeratorCancellation] CancellationToken cancellationToken) - { - try - { - await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false); - } - finally - { - watcherCleanupStarted.TrySetResult(); - await allowWatcherCleanup.Task.ConfigureAwait(false); - } - - yield break; - } - } - [Fact] public async Task PsCommand_FollowWithoutJsonFormat_ReturnsInvalidCommand() { @@ -1001,103 +622,6 @@ public async Task PsCommand_FollowJsonFormat_StreamsStoppedAppHostWhenConnection Assert.Equal(initialAppHost.AppHostPid, stoppedAppHost.AppHostPid); } - [Fact] - public async Task PsCommand_WithoutResourcesOption_OmitsResourcesFromJsonOutput() - { - using var workspace = TemporaryWorkspace.Create(outputHelper); - var textWriter = new TestOutputTextWriter(outputHelper); - - var monitor = new TestAuxiliaryBackchannelMonitor(); - var connection = new TestAppHostAuxiliaryBackchannel - { - IsInScope = true, - AppHostInfo = new AppHostInformation - { - AppHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "App1", "App1.AppHost.csproj"), - ProcessId = 1234 - }, - ResourceSnapshots = - [ - new ResourceSnapshot - { - Name = "apiservice", - ResourceType = "Project", - State = "Running" - } - ] - }; - monitor.AddConnection("hash1", "socket.hash1", connection); - - var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => - { - options.OutputTextWriter = textWriter; - options.AuxiliaryBackchannelMonitorFactory = _ => monitor; - }); - using var provider = services.BuildServiceProvider(); - - var command = provider.GetRequiredService(); - var result = command.Parse("ps --format json"); - - var exitCode = await result.InvokeAsync().DefaultTimeout(); - - Assert.Equal(CliExitCodes.Success, exitCode); - - var jsonOutput = string.Join(string.Empty, textWriter.Logs); - var appHosts = JsonSerializer.Deserialize(jsonOutput, PsCommandJsonContext.RelaxedEscaping.ListAppHostDisplayInfo); - Assert.NotNull(appHosts); - Assert.Single(appHosts); - Assert.Null(appHosts[0].Resources); - - // Also verify the raw JSON doesn't contain a "resources" key - var document = JsonDocument.Parse(jsonOutput); - var firstElement = document.RootElement[0]; - Assert.False(firstElement.TryGetProperty("resources", out _)); - } - - [Fact] - public async Task PsCommand_ResourcesOption_TableFormat_DoesNotFetchResources() - { - using var workspace = TemporaryWorkspace.Create(outputHelper); - var textWriter = new TestOutputTextWriter(outputHelper); - - var resourcesFetched = false; - var monitor = new TestAuxiliaryBackchannelMonitor(); - var connection = new TestAppHostAuxiliaryBackchannel - { - IsInScope = true, - AppHostInfo = new AppHostInformation - { - AppHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "App1", "App1.AppHost.csproj"), - ProcessId = 1234 - }, - GetResourceSnapshotsHandler = _ => - { - resourcesFetched = true; - return Task.FromResult(new List - { - new ResourceSnapshot { Name = "apiservice", ResourceType = "Project", State = "Running" } - }); - } - }; - monitor.AddConnection("hash1", "socket.hash1", connection); - - var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => - { - options.OutputTextWriter = textWriter; - options.AuxiliaryBackchannelMonitorFactory = _ => monitor; - }); - using var provider = services.BuildServiceProvider(); - - var command = provider.GetRequiredService(); - // --resources with table format should not fetch resources - var result = command.Parse("ps --resources"); - - var exitCode = await result.InvokeAsync().DefaultTimeout(); - - Assert.Equal(CliExitCodes.Success, exitCode); - Assert.False(resourcesFetched, "Resources should not be fetched when output format is table"); - } - [Fact] public async Task PsCommand_JsonFormat_IncludesLogFilePath() { From a0823e66fe193356312eb37cff4cc7cbbc47b9c5 Mon Sep 17 00:00:00 2001 From: Jose Perez Rodriguez Date: Thu, 28 May 2026 14:09:43 -0700 Subject: [PATCH 20/43] 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> --- extension/Extension.proj | 10 +++++----- extension/build.ps1 | 11 ++++++----- extension/build.sh | 11 ++++++----- 3 files changed, 17 insertions(+), 15 deletions(-) diff --git a/extension/Extension.proj b/extension/Extension.proj index 5436af60c54..ff8c1048973 100644 --- a/extension/Extension.proj +++ b/extension/Extension.proj @@ -31,13 +31,13 @@ - - - + + @@ -88,12 +88,12 @@ - + - + diff --git a/extension/build.ps1 b/extension/build.ps1 index ccd4be4305b..21069d7ff13 100644 --- a/extension/build.ps1 +++ b/extension/build.ps1 @@ -10,9 +10,10 @@ if (-not (Get-Command node -ErrorAction SilentlyContinue)) { exit 1 } -# Check for Corepack so the build uses the Yarn Classic version that matches extension/yarn.lock. -if (-not (Get-Command corepack -ErrorAction SilentlyContinue)) { - Write-Error "Error: Corepack is not installed. Please install a Node.js version that includes Corepack." +# 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 } @@ -40,7 +41,7 @@ Set-Location $PSScriptRoot Write-Host "" Write-Host "Running yarn install..." -corepack yarn@1.22.22 install --frozen-lockfile --non-interactive +yarn install --frozen-lockfile --non-interactive if ($LASTEXITCODE -ne 0) { Write-Error "yarn install failed with exit code $LASTEXITCODE" @@ -49,7 +50,7 @@ if ($LASTEXITCODE -ne 0) { Write-Host "" Write-Host "Running yarn compile..." -corepack yarn@1.22.22 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 110967f4215..4b7c7e79bac 100755 --- a/extension/build.sh +++ b/extension/build.sh @@ -9,9 +9,10 @@ if ! command -v node &> /dev/null; then exit 1 fi -# Check for Corepack so the build uses the Yarn Classic version that matches extension/yarn.lock. -if ! command -v corepack &> /dev/null; then - echo "Error: Corepack is not installed. Please install a Node.js version that includes Corepack." +# 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 @@ -37,11 +38,11 @@ cd "$SCRIPT_DIR" echo "" echo "Running yarn install..." -corepack yarn@1.22.22 install --frozen-lockfile --non-interactive +yarn install --frozen-lockfile --non-interactive echo "" echo "Running yarn compile..." -corepack yarn@1.22.22 compile +yarn compile echo "" echo "Building Aspire CLI..." From 1e7134f76008dd798f56a0f1f5bfea85b8d55ffe Mon Sep 17 00:00:00 2001 From: "aspire-repo-bot[bot]" <268009190+aspire-repo-bot[bot]@users.noreply.github.com> Date: Thu, 28 May 2026 15:12:01 -0700 Subject: [PATCH 21/43] [release/13.4] Fix `aspire stop` falsely reporting failure on Unix (#17623) * Fix aspire stop falsely reporting failure on Unix The stop path treated the managing CLI PID as a success condition. On Unix the CLI process can remain observable (for example as an unreaped or briefly lingering process) after the AppHost has already stopped, so aspire stop reported '\u274c Failed to stop apphost.cs' even when shutdown completed successfully. Use the AppHost PID as the success condition, and keep the CLI PID as a shutdown handle that still gets force-killed when present so we never leave a true zombie CLI running after the AppHost is gone. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Signal AppHost directly on Unix and tree-terminate on force-stop The cascade path (signal launcher CLI, let it terminate 'dotnet run', and rely on that to terminate the AppHost) is racy on Unix: if the descendant walk inside 'dotnet run' misses the AppHost, the AppHost is orphaned to PID 1 and HasExited polling can't distinguish a zombie from a live process, so 'aspire stop' falsely reports failure after the full SIGTERM + SIGKILL timeout (~40s). On Unix we now: * Send SIGTERM directly to the AppHost PID so it shuts down through its own IHostApplicationLifetime path. The launcher CLI and dotnet run process exit naturally when their child exits. * Pass killEntireProcessTree:true when force-terminating on Unix. DCP is launched in a separate session there, so force-terminating the AppHost tree doesn't take DCP with it; DCP detects the parent gone and tears down its own children gracefully. Windows behavior is preserved: we keep cascading through the launcher CLI to DCP because DCP is an in-tree descendant of the AppHost on Windows, and a full tree termination would break DCP's orderly resource cleanup. Validation in the repo's Linux container (Ubuntu 24.04 ARM64): * Baseline: 5/25 failures (20%), failures hit the 40s timeout * CLI-visibility-only fix: 1/25 failures (4%) * This change: 0/30 failures, stop wall-time 0.7-2.3s Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: David Negstad Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Processes/ProcessShutdownService.cs | 79 +++++++++++++++--- src/Shared/ProcessSignaler.cs | 6 +- .../Processes/ProcessShutdownServiceTests.cs | 80 ++++++++++++++++++- 3 files changed, 151 insertions(+), 14 deletions(-) diff --git a/src/Aspire.Cli/Processes/ProcessShutdownService.cs b/src/Aspire.Cli/Processes/ProcessShutdownService.cs index 65064c3484d..d0089a68d54 100644 --- a/src/Aspire.Cli/Processes/ProcessShutdownService.cs +++ b/src/Aspire.Cli/Processes/ProcessShutdownService.cs @@ -46,14 +46,18 @@ public async Task StopAppHostAsync( return requestRpcStopAsync is not null && await TryRequestRpcStopAsync(requestRpcStopAsync, cancellationToken).ConfigureAwait(false); } - var processesToMonitor = new List { new(appHostInfo.ProcessId, appHostInfo.StartedAt) }; + var appHostProcess = new ProcessTarget(appHostInfo.ProcessId, appHostInfo.StartedAt); + var processesToForceKill = new List { appHostProcess }; if (appHostInfo.CliProcessId is int cliPid) { - processesToMonitor.Add(new ProcessTarget(cliPid, appHostInfo.CliStartedAt)); + // The CLI process is a shutdown handle, not the success condition. On Unix it can remain + // observable until its parent reaps it after the AppHost has already stopped. + processesToForceKill.Add(new ProcessTarget(cliPid, appHostInfo.CliStartedAt)); } return await StopProcessesAsync( - processesToMonitor, + processesToMonitor: [appHostProcess], + processesToForceKill, token => RequestAppHostGracefulShutdownAsync(appHostInfo, requestRpcStopAsync, token), cancellationToken).ConfigureAwait(false); } @@ -62,20 +66,55 @@ internal async Task StopProcessesAsync( IReadOnlyCollection processesToMonitorAndKill, Func> requestGracefulShutdownAsync, CancellationToken cancellationToken) + { + return await StopProcessesAsync( + processesToMonitorAndKill, + processesToMonitorAndKill, + requestGracefulShutdownAsync, + cancellationToken).ConfigureAwait(false); + } + + private async Task StopProcessesAsync( + IReadOnlyCollection processesToMonitor, + IReadOnlyCollection processesToForceKill, + Func> requestGracefulShutdownAsync, + CancellationToken cancellationToken) { var gracefulShutdownRequested = await TryRequestGracefulShutdownAsync(requestGracefulShutdownAsync, cancellationToken).ConfigureAwait(false); - if (gracefulShutdownRequested && await MonitorProcessesForTerminationAsync(processesToMonitorAndKill, cancellationToken).ConfigureAwait(false)) + if (gracefulShutdownRequested && await MonitorProcessesForTerminationAsync(processesToMonitor, cancellationToken).ConfigureAwait(false)) { + ForceKillRemainingProcesses(processesToForceKill.Except(processesToMonitor), afterTimeout: false); return true; } - foreach (var process in processesToMonitorAndKill.Distinct()) + ForceKillRemainingProcesses(processesToForceKill, afterTimeout: true); + + return await MonitorProcessesForTerminationAsync(processesToMonitor, cancellationToken).ConfigureAwait(false); + } + + private void ForceKillRemainingProcesses(IEnumerable processes, bool afterTimeout) + { + // On Unix the AppHost's process tree does not include DCP (it is launched in its own + // session/process group), so a tree kill of the AppHost is safe: DCP will detect the + // AppHost exiting and gracefully tear down its own children. The same applies to the + // launcher CLI handle - any leftover `dotnet run` / AppHost descendants get cleaned up. + // On Windows DCP is an in-tree descendant of the AppHost, so we must single-process-kill + // here and rely on the graceful DCP `stop-process-tree` path for orderly resource cleanup. + var killEntireProcessTree = !OperatingSystem.IsWindows(); + + foreach (var process in processes.Distinct()) { - logger.LogWarning("Process {Pid} did not stop gracefully within timeout. Forcing process to terminate.", process.Pid); - ProcessSignaler.ForceKill(process.Pid, process.StartTime, logger); - } + if (afterTimeout) + { + logger.LogWarning("Process {Pid} did not stop gracefully within timeout. Forcing process to terminate.", process.Pid); + } + else + { + logger.LogDebug("Forcing remaining shutdown handle process {Pid} to terminate.", process.Pid); + } - return await MonitorProcessesForTerminationAsync(processesToMonitorAndKill, cancellationToken).ConfigureAwait(false); + ProcessSignaler.ForceKill(process.Pid, process.StartTime, logger, killEntireProcessTree); + } } private async Task RequestAppHostGracefulShutdownAsync( @@ -83,6 +122,28 @@ private async Task RequestAppHostGracefulShutdownAsync( Func>? requestRpcStopAsync, CancellationToken cancellationToken) { + if (!OperatingSystem.IsWindows()) + { + // Signal the AppHost directly with SIGTERM. The AppHost catches SIGTERM via + // Microsoft.Extensions.Hosting and invokes IHostApplicationLifetime.StopApplication, + // which gives DCP and all in-process resources the orderly shutdown they expect. + // + // Routing the graceful signal through the launcher CLI (CliProcessId) cascades via + // `dotnet run`'s child kill. That walk depends on the AppHost being visible in /proc + // as a descendant of the `dotnet` process at the moment of the walk, and on the + // AppHost being reaped by its parent rather than orphaned. When either of those races + // misfires the AppHost is left running (or lingering as a zombie reparented to PID 1) + // and the StopCommand monitor then times out reporting "Failed to stop apphost". + // Targeting the AppHost PID directly avoids the cascade entirely. + logger.LogDebug("Sending graceful shutdown to AppHost PID {Pid}", appHostInfo.ProcessId); + return await RequestProcessTreeGracefulShutdownAsync(appHostInfo.ProcessId, appHostInfo.StartedAt, includeStartTimeForDcp: false, cancellationToken).ConfigureAwait(false); + } + + // On Windows DCP is an in-tree descendant of the AppHost, so we cannot tree-kill the + // AppHost without also taking DCP down. Instead, run the graceful shutdown against the + // launcher CLI's process tree (DCP performs `stop-process-tree --skip-descendants`), + // which signals the AppHost via DCP without disrupting the descendant cleanup DCP is + // responsible for. if (appHostInfo.CliProcessId is int cliPid) { logger.LogDebug("Requesting AppHost process tree shutdown via root CLI PID {Pid}", cliPid); diff --git a/src/Shared/ProcessSignaler.cs b/src/Shared/ProcessSignaler.cs index 8a8a3ad9919..0e3e7cb7ce6 100644 --- a/src/Shared/ProcessSignaler.cs +++ b/src/Shared/ProcessSignaler.cs @@ -30,15 +30,15 @@ public static void RequestGracefulShutdown(int pid, DateTimeOffset? expectedStar } } - public static void ForceKill(int pid, DateTimeOffset? expectedStartTime, ILogger logger) + public static void ForceKill(int pid, DateTimeOffset? expectedStartTime, ILogger logger, bool killEntireProcessTree = false) { using var process = TryGetRunningProcess(pid, expectedStartTime, logger); if (process is { }) { - logger.LogDebug("Killing process {Pid}...", pid); + logger.LogDebug("Killing process {Pid} (entireProcessTree={EntireProcessTree})...", pid, killEntireProcessTree); try { - process.Kill(entireProcessTree: false); + process.Kill(entireProcessTree: killEntireProcessTree); } catch (InvalidOperationException) { diff --git a/tests/Aspire.Cli.Tests/Processes/ProcessShutdownServiceTests.cs b/tests/Aspire.Cli.Tests/Processes/ProcessShutdownServiceTests.cs index da8d43e5310..bb0879e0d1d 100644 --- a/tests/Aspire.Cli.Tests/Processes/ProcessShutdownServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Processes/ProcessShutdownServiceTests.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using System.Globalization; +using Aspire.Cli.Backchannel; using Aspire.Cli.Bundles; using Aspire.Cli.Layout; using Aspire.Cli.Processes; @@ -10,6 +11,7 @@ using Aspire.Cli.Tests.Utils; using Aspire.Shared; using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Time.Testing; namespace Aspire.Cli.Tests.Processes; @@ -102,11 +104,51 @@ public async Task TryStopProcessTreeWithDcpAsync_UsesLeasedBundleDcpPathWhenAvai Assert.Equal(leasedDcpPath, executionFactory.LastFileName); } + [Fact] + public async Task StopAppHostAsync_CleansUpCliProcessWithoutWaitingForItAsSuccessCondition() + { + Assert.SkipWhen(OperatingSystem.IsWindows(), "The signal-ignoring shell process is Unix-specific."); + + using var workspace = TemporaryWorkspace.Create(outputHelper); + var dcpDirectory = workspace.WorkspaceRoot.CreateSubdirectory("dcp"); + File.WriteAllText(BundleDiscovery.GetDcpExecutablePath(dcpDirectory.FullName), string.Empty); + + using var cliProcess = StartSignalIgnoringShellProcess(); + try + { + var signaler = CreateService( + workspace, + dcpDirectory.FullName, + new TestProcessExecutionFactory(), + timeProvider: new FakeTimeProvider()); + + var result = await signaler.StopAppHostAsync( + new AppHostInformation + { + AppHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs"), + ProcessId = int.MaxValue, + StartedAt = null, + CliProcessId = cliProcess.Id, + CliStartedAt = new DateTimeOffset(cliProcess.StartTime) + }, + requestRpcStopAsync: null, + CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(2)); + + Assert.True(result); + await cliProcess.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(2)); + } + finally + { + await StopProcessAsync(cliProcess); + } + } + private static ProcessShutdownService CreateService( TemporaryWorkspace workspace, string dcpDirectory, TestProcessExecutionFactory executionFactory, - IBundleService? bundleService = null) + IBundleService? bundleService = null, + TimeProvider? timeProvider = null) { var executionContext = new CliExecutionContext( workspace.WorkspaceRoot, @@ -122,7 +164,41 @@ private static ProcessShutdownService CreateService( new LayoutProcessRunner(executionFactory), executionContext, NullLogger.Instance, - TimeProvider.System); + timeProvider ?? TimeProvider.System); + } + + private static Process StartSignalIgnoringShellProcess() + { + var startInfo = new ProcessStartInfo("/bin/sh") + { + RedirectStandardError = true, + RedirectStandardOutput = true, + UseShellExecute = false + }; + startInfo.ArgumentList.Add("-c"); + startInfo.ArgumentList.Add("trap '' TERM; exec sleep 60"); + + var process = Process.Start(startInfo); + Assert.NotNull(process); + return process; + } + + private static async Task StopProcessAsync(Process process) + { + if (process.HasExited) + { + return; + } + + try + { + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(5)); + } + catch (InvalidOperationException) + { + // The process exited between the HasExited check and Kill/WaitForExitAsync. + } } private sealed class FixedLayoutDiscovery(string dcpDirectory) : ILayoutDiscovery From 26b661bde83c08845b7ca3f1701cdb19bc44862a Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 29 May 2026 11:21:51 +1000 Subject: [PATCH 22/43] 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> --- 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 be7de6bab00..92e79ba658d 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); @@ -517,6 +543,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 d8c4e9645a029b4fca8ce20eb00def5526806eda Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 29 May 2026 12:37:49 +1000 Subject: [PATCH 23/43] 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> --- 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 34be339ec6da5c440dbf3b04c0e60de7d86bfb90 Mon Sep 17 00:00:00 2001 From: "aspire-repo-bot[bot]" <268009190+aspire-repo-bot[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 07:13:36 -0700 Subject: [PATCH 24/43] [release/13.4] Accept dev localhost resource service URLs (#17644) * Accept dev localhost resource service URLs Allow DashboardServiceHost to treat RFC-reserved .localhost subdomains as local resource service endpoints so polyglot AppHosts scaffolded with *.dev.localhost can run. Add unit coverage for local endpoint recognition and a CLI E2E regression test that scaffolds and runs an Express/React polyglot AppHost with dev-localhost URLs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR review follow-up comments Co-authored-by: danegsta <50252651+danegsta@users.noreply.github.com> * Use release E2E terminal lifecycle Adapt the backported polyglot dev-localhost CLI E2E test to the release/13.4 test harness, which does not have CliE2ETestHelpers.StartRun. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: David Negstad Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: danegsta <50252651+danegsta@users.noreply.github.com> --- .../Dashboard/DashboardServiceHost.cs | 19 +++++++- tests/Aspire.Cli.EndToEnd.Tests/SmokeTests.cs | 48 +++++++++++++++++++ .../Dashboard/DashboardServiceHostTests.cs | 17 +++++++ 3 files changed, 83 insertions(+), 1 deletion(-) diff --git a/src/Aspire.Hosting/Dashboard/DashboardServiceHost.cs b/src/Aspire.Hosting/Dashboard/DashboardServiceHost.cs index 3d66f7e2b7c..9753be2f352 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardServiceHost.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardServiceHost.cs @@ -138,7 +138,7 @@ void ConfigureKestrel(KestrelServerOptions kestrelOptions) kestrelOptions.Listen(IPAddress.Loopback, port: 0, ConfigureListen); _logger.LogDebug("Resource service endpoint not configured. Listening on {Scheme}://127.0.0.1:.", scheme); } - else if (uri.IsLoopback) + else if (IsLocalResourceServiceEndpoint(uri)) { // Listen on the requested localhost port. kestrelOptions.ListenLocalhost(uri.Port, ConfigureListen); @@ -178,6 +178,23 @@ internal static string ResolveScheme(Uri? configuredUri, bool allowUnsecuredTran return allowUnsecuredTransport ? "http" : "https"; } + /// + /// Determines whether the resource service endpoint is scoped to the local machine. + /// + internal static bool IsLocalResourceServiceEndpoint(Uri uri) + { + if (uri.IsLoopback) + { + return true; + } + + var host = uri.Host.EndsWith(".", StringComparison.Ordinal) + ? uri.Host[..^1] + : uri.Host; + + return EndpointHostHelpers.IsLocalhostOrLocalhostTld(host); + } + /// /// Gets the URI upon which the resource service is listening. /// diff --git a/tests/Aspire.Cli.EndToEnd.Tests/SmokeTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/SmokeTests.cs index 94af249296f..862a31953d8 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/SmokeTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/SmokeTests.cs @@ -71,6 +71,54 @@ await auto.WaitUntilAsync(s => await pendingRun; } + [CaptureWorkspaceOnFailure] + [Fact] + public async Task CreateAndRunPolyglotAppHostWithDevLocalhostUrls() + { + var repoRoot = CliE2ETestHelpers.GetRepoRoot(); + var strategy = CliInstallStrategy.Detect(output.WriteLine); + + var workspace = TemporaryWorkspace.Create(output); + + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); + + var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); + + var counter = new SequenceCounter(); + var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + + await auto.PrepareDockerEnvironmentAsync(counter, workspace); + await auto.InstallAspireCliAsync(strategy, counter); + + const string projectName = "PolyglotDevLocalhost"; + await auto.AspireNewAsync(projectName, counter, template: AspireTemplate.ExpressReact, useDevLocalhost: true); + + await auto.RunCommandAsync($"cd {projectName}", counter); + await auto.RunCommandAsync("grep -F 'ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL' aspire.config.json && grep -F 'polyglotdevlocalhost.dev.localhost' aspire.config.json", counter); + + await auto.TypeAsync("aspire run"); + await auto.EnterAsync(); + + await auto.WaitUntilAsync(s => + { + if (s.ContainsText("Capability Error") || + s.ContainsText("ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL must contain a local loopback address")) + { + throw new InvalidOperationException("Polyglot AppHost failed to start with a *.dev.localhost resource service endpoint."); + } + + return s.ContainsText("Press CTRL+C to stop the AppHost and exit."); + }, timeout: TimeSpan.FromMinutes(3), description: "Press CTRL+C message for polyglot AppHost with *.dev.localhost URLs"); + + await auto.Ctrl().KeyAsync(Hex1bKey.C); + await auto.WaitForSuccessPromptAsync(counter); + + await auto.TypeAsync("exit"); + await auto.EnterAsync(); + + await pendingRun; + } + [CaptureWorkspaceOnFailure] [Fact] public async Task LatestCliCanStartStableChannelAppHost() diff --git a/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceHostTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceHostTests.cs index c1dbe8d9627..84c00dfa4fa 100644 --- a/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceHostTests.cs +++ b/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceHostTests.cs @@ -22,4 +22,21 @@ public void ResolveScheme_ReturnsExpectedScheme(string? uriString, bool allowUns Assert.Equal(expectedScheme, scheme); } + + [Theory] + [InlineData("https://localhost:5001", true)] + [InlineData("https://localhost.:5001", true)] + [InlineData("https://127.0.0.1:5001", true)] + [InlineData("https://[::1]:5001", true)] + [InlineData("https://myapp.dev.localhost:5001", true)] + [InlineData("https://myapp.dev.localhost.:5001", true)] + [InlineData("https://example.com:5001", false)] + [InlineData("https://localhost.example.com:5001", false)] + [InlineData("https://example-localhost:5001", false)] + public void IsLocalResourceServiceEndpoint_ReturnsExpectedResult(string uriString, bool expectedResult) + { + var result = DashboardServiceHost.IsLocalResourceServiceEndpoint(new Uri(uriString)); + + Assert.Equal(expectedResult, result); + } } From 56e6967c85682a0d33e67a99782f2d7c0ee3095e Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Fri, 29 May 2026 22:15:19 +0800 Subject: [PATCH 25/43] Restore dotnet watch dashboard auto-launch signal (#17653) --- src/Shared/LoggingHelpers.cs | 6 +++++ .../FrontendBrowserTokenAuthTests.cs | 9 ++++++++ .../LoggingHelpersTests.cs | 22 ++++++++++++++----- .../Dashboard/DashboardEventHandlersTests.cs | 6 +++++ 4 files changed, 38 insertions(+), 5 deletions(-) diff --git a/src/Shared/LoggingHelpers.cs b/src/Shared/LoggingHelpers.cs index d5c2f0b5f3e..7633df7a9fd 100644 --- a/src/Shared/LoggingHelpers.cs +++ b/src/Shared/LoggingHelpers.cs @@ -37,6 +37,12 @@ public static void WriteDashboardSummary(ILogger logger, string? dashboardUrl, s ? $"{dashboardAuthority}/login?t={token}" : null; + if (loginUrl is not null) + { + // dotnet watch looks for this exact log message to launch the dashboard. Do not change it. + logger.LogInformation("Login to the dashboard at {LoginUrl}", loginUrl); + } + var templateBuilder = new StringBuilder(); var parameters = new List(); diff --git a/tests/Aspire.Dashboard.Tests/Integration/FrontendBrowserTokenAuthTests.cs b/tests/Aspire.Dashboard.Tests/Integration/FrontendBrowserTokenAuthTests.cs index 89a9a7b2697..48f7e85aa6e 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/FrontendBrowserTokenAuthTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/FrontendBrowserTokenAuthTests.cs @@ -405,6 +405,15 @@ public async Task LogOutput_NoToken_GeneratedTokenLogged() Assert.Equal(LogLevel.Warning, w.LogLevel); }, w => + { + Assert.Equal("Login to the dashboard at {LoginUrl}", LogTestHelpers.GetValue(w, "{OriginalFormat}")); + + var loginUrl = (string)LogTestHelpers.GetValue(w, "LoginUrl")!; + var uri = new Uri(loginUrl, UriKind.Absolute); + var queryString = HttpUtility.ParseQueryString(uri.Query); + Assert.NotNull(queryString["t"]); + }, + w => { Assert.StartsWith("Aspire Dashboard", (string)LogTestHelpers.GetValue(w, "{OriginalFormat}")!); diff --git a/tests/Aspire.Dashboard.Tests/LoggingHelpersTests.cs b/tests/Aspire.Dashboard.Tests/LoggingHelpersTests.cs index f26d9fcbd47..fba14609fe3 100644 --- a/tests/Aspire.Dashboard.Tests/LoggingHelpersTests.cs +++ b/tests/Aspire.Dashboard.Tests/LoggingHelpersTests.cs @@ -23,7 +23,15 @@ public void WriteDashboardSummary_WithTokenAndOtlpEndpoints_LogsSummaryAndStruct "http://localhost:18890", "abc123"); - var write = Assert.Single(sink.Writes); + Assert.Equal(2, sink.Writes.Count); + + var loginWrite = sink.Writes.ElementAt(0); + Assert.Equal(LogLevel.Information, loginWrite.LogLevel); + Assert.Equal("Login to the dashboard at http://localhost:18888/login?t=abc123", loginWrite.Message); + Assert.Equal("Login to the dashboard at {LoginUrl}", LogTestHelpers.GetValue(loginWrite, "{OriginalFormat}")); + Assert.Equal("http://localhost:18888/login?t=abc123", LogTestHelpers.GetValue(loginWrite, "LoginUrl")); + + var write = sink.Writes.ElementAt(1); Assert.Equal(LogLevel.Information, write.LogLevel); Assert.NotNull(write.Message); var lines = GetMessageLines(write.Message!); @@ -169,7 +177,9 @@ public void WriteDashboardSummary_WithoutOtlpEndpoints_DoesNotIncludeOtlpLines() otlpHttpUrl: null, token: "abc123"); - var write = Assert.Single(sink.Writes); + Assert.Equal(2, sink.Writes.Count); + + var write = sink.Writes.ElementAt(1); Assert.NotNull(write.Message); var lines = GetMessageLines(write.Message!); @@ -198,11 +208,13 @@ public void WriteDashboardSummary_IsContainer_IncludesContainerMessage() token: "abc123", isContainer: true); - var write = Assert.Single(sink.Writes); - Assert.NotNull(write.Message); + Assert.Equal(2, sink.Writes.Count); + + var containerWrite = sink.Writes.ElementAt(1); + Assert.NotNull(containerWrite.Message); var containerMessage = "URLs may need changes depending on how network access to the container is configured."; - Assert.Contains(containerMessage, write.Message); + Assert.Contains(containerMessage, containerWrite.Message); } private static string[] GetMessageLines(string message) diff --git a/tests/Aspire.Hosting.Tests/Dashboard/DashboardEventHandlersTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/DashboardEventHandlersTests.cs index 6ae294327ea..0069422ad8b 100644 --- a/tests/Aspire.Hosting.Tests/Dashboard/DashboardEventHandlersTests.cs +++ b/tests/Aspire.Hosting.Tests/Dashboard/DashboardEventHandlersTests.cs @@ -271,6 +271,12 @@ public async Task ResourceReadyEvent_LogsDashboardUrlFromAllocatedEndpoint(strin Assert.Equal(allocatedPort, uri.Port); Assert.Equal(expectedScheme, uri.Scheme); + var loginLog = testSink.Writes.FirstOrDefault(l => + LogTestHelpers.GetValue(l, "{OriginalFormat}")?.ToString() == "Login to the dashboard at {LoginUrl}"); + + Assert.NotNull(loginLog); + Assert.Equal($"{expectedScheme}://{expectedHost}:{allocatedPort}/login?t=test-token", LogTestHelpers.GetValue(loginLog, "LoginUrl")); + var summaryLog = testSink.Writes.FirstOrDefault(l => LogTestHelpers.GetValue(l, "{OriginalFormat}")?.ToString()?.Contains("OTLP/gRPC:") == true); From cc4b7eafe8e034f18404d7ae9df2d5b772d4f29d Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Fri, 29 May 2026 22:15:50 +0800 Subject: [PATCH 26/43] Backport CLI cancellation fixes to release/13.4 (#17641) * Add TerminalRun IAsyncDisposable for consistent CLI E2E diagnostics capture (#17576) * Fix CLI Ctrl+C/SIGTERM shutdown: responsive cancellation and double-signal bug (#17588) * Fix CLI Ctrl+C shutdown taking too long during AppHost startup - Make ConsoleCancellationManager.Cancel() non-blocking so Ctrl+C handler returns immediately - Pass cancellation token through to WaitAsync in CancelAppHostStartupAsync so Ctrl+C exits promptly instead of waiting for the full 5s timeout - Support second Ctrl+C for immediate force exit (Environment.Exit) - Add logging support to ConsoleCancellationManager via SetLogger() - Add comprehensive unit tests for ConsoleCancellationManager - Add integration test for RunCommand cancellation during startup timeout Fixes #17569 * Fix review comments: volatile logger, accurate comments, clearer warning * Clean up * Use WaitForSuccessPromptFailFastAsync in CLI E2E tests Replace WaitForSuccessPromptAsync with WaitForSuccessPromptFailFastAsync across all CLI E2E tests. The fail-fast variant detects error prompts immediately and throws instead of hanging for up to 500s waiting for a success prompt that will never arrive. This prevents 10+ minute CI timeouts when a command fails with a non-zero exit code. * Fix double-signal bug and consolidate test helpers - Fix ConsoleCancellationManager double-signal bug: move Console.CancelKeyPress to else branch so it only registers on platforms without PosixSignalRegistration. Previously both handlers fired for the same SIGINT, causing immediate force-kill. - Add SIGQUIT/Ctrl+Break registration for Windows parity. - Remove old WaitForSuccessPromptAsync (no fail-fast) and rename WaitForSuccessPromptFailFastAsync to WaitForSuccessPromptAsync. - Remove duplicate RunCommandFailFastAsync (identical to RunCommandAsync). * Fix stale comment and restrict SIGQUIT to Windows only * Remove unused legacy builder methods and update E2E skill docs * Don't force when debugging * More logging --- .github/skills/cli-e2e-testing/SKILL.md | 47 +- src/Aspire.Cli/Commands/RunCommand.cs | 13 +- src/Aspire.Cli/ConsoleCancellationManager.cs | 115 +++- src/Aspire.Cli/Program.cs | 6 +- .../Projects/ProcessGuestLauncher.cs | 16 +- .../AgentCommandTests.cs | 36 +- .../AgentMcpLogsTests.cs | 9 +- .../AppHostSyntaxErrorOutputTests.cs | 60 +- .../Aspire.Cli.EndToEnd.Tests/BannerTests.cs | 21 +- .../BundleSmokeTests.cs | 8 +- .../CSharpInitTests.cs | 8 +- .../CSharpProjectModeInitTests.cs | 23 +- .../CentralPackageManagementTests.cs | 24 +- .../CertificatesCommandTests.cs | 21 +- .../ChannelUpdateWorkflowTests.cs | 41 +- .../ConfigDiscoveryTests.cs | 8 +- .../ConfigHealingTests.cs | 8 +- .../ConfigMigrationTests.cs | 49 +- .../DashboardRunTests.cs | 18 +- .../DescribeCommandTests.cs | 18 +- .../DockerDeploymentTests.cs | 18 +- .../DocsCommandE2ETests.cs | 7 +- .../DoctorCommandTests.cs | 23 +- .../DotnetToolSmokeTests.cs | 9 +- .../EmptyAppHostTemplateTests.cs | 8 +- .../Helpers/CliE2EAutomatorHelpers.cs | 49 +- .../Helpers/CliE2ETestHelpers.cs | 17 + .../Helpers/TerminalRun.cs | 157 +++++ .../JavaCodegenValidationTests.cs | 9 +- .../JavaEmptyAppHostTemplateTests.cs | 9 +- .../JavaPolyglotApphostDirectoryTests.cs | 9 +- .../JavaPolyglotTests.cs | 8 +- .../JavaScriptPublishTests.cs | 45 +- .../JsReactTemplateTests.cs | 9 +- .../KubernetesDeployBasicApiServiceTests.cs | 8 +- .../KubernetesDeployTypeScriptTests.cs | 8 +- .../KubernetesDeployWithGarnetTests.cs | 8 +- .../KubernetesDeployWithHelmChartTests.cs | 8 +- .../KubernetesDeployWithMongoDBTests.cs | 8 +- .../KubernetesDeployWithMySqlTests.cs | 8 +- .../KubernetesDeployWithNatsTests.cs | 8 +- .../KubernetesDeployWithPostgresTests.cs | 8 +- .../KubernetesDeployWithRabbitMQTests.cs | 8 +- .../KubernetesDeployWithRedisTests.cs | 8 +- .../KubernetesDeployWithSqlServerTests.cs | 8 +- .../KubernetesDeployWithValkeyTests.cs | 8 +- ...tesPublishRequiresExternalEndpointTests.cs | 166 +++-- .../KubernetesPublishTests.cs | 9 +- .../ListStepsTests.cs | 7 +- .../LocalConfigMigrationTests.cs | 18 +- .../LogLevelTests.cs | 83 +-- .../LogsCommandTests.cs | 10 +- .../MultipleAppHostTests.cs | 16 +- .../NewWithAgentInitTests.cs | 9 +- .../OtelLogsTests.cs | 10 +- .../PlaywrightCliInstallTests.cs | 26 +- .../PodmanDeploymentTests.cs | 9 +- .../ProjectReferenceTests.cs | 8 +- .../PsCommandTests.cs | 20 +- .../PythonReactTemplateTests.cs | 11 +- .../ResourceCommandTests.cs | 579 +++++++----------- .../SecretDotNetAppHostTests.cs | 9 +- .../SecretTypeScriptAppHostTests.cs | 9 +- .../SingleFileAppHostInitDotnetRunTests.cs | 8 +- tests/Aspire.Cli.EndToEnd.Tests/SmokeTests.cs | 29 +- .../StagingChannelTests.cs | 9 +- .../StartStopTests.cs | 105 +--- .../StopNonInteractiveTests.cs | 39 +- .../TypeScriptCodegenValidationTests.cs | 34 +- .../TypeScriptEmptyAppHostTemplateTests.cs | 11 +- .../TypeScriptLegacyAppHostTests.cs | 11 +- ...TypeScriptPolyglotApphostDirectoryTests.cs | 9 +- .../TypeScriptPolyglotTests.cs | 37 +- .../TypeScriptPublishTests.cs | 42 +- .../TypeScriptReusablePackageTests.cs | 13 +- ...eScriptSqlServerNativeAssetsBundleTests.cs | 9 +- .../TypeScriptStarterSmokeTests.cs | 11 +- .../TypeScriptStarterTemplateTests.cs | 11 +- .../UpdateChannelNuGetConfigOrderingTests.cs | 8 +- .../WaitCommandTests.cs | 9 +- .../Commands/RunCommandTests.cs | 61 ++ .../ConsoleCancellationManagerTests.cs | 148 +++++ .../AcaManagedRedisDeploymentTests.cs | 4 +- .../Helpers/DeploymentE2EAutomatorHelpers.cs | 8 +- ...riptAzureContainerAppJobDeploymentTests.cs | 6 +- ...eScriptJavaScriptHostingDeploymentTests.cs | 6 +- tests/Shared/Hex1bAutomatorTestHelpers.cs | 45 +- tests/Shared/Hex1bTestHelpers.cs | 91 --- 88 files changed, 1097 insertions(+), 1688 deletions(-) create mode 100644 tests/Aspire.Cli.EndToEnd.Tests/Helpers/TerminalRun.cs create mode 100644 tests/Aspire.Cli.Tests/ConsoleCancellationManagerTests.cs diff --git a/.github/skills/cli-e2e-testing/SKILL.md b/.github/skills/cli-e2e-testing/SKILL.md index a9ae8dbe883..15a5ba8b778 100644 --- a/.github/skills/cli-e2e-testing/SKILL.md +++ b/.github/skills/cli-e2e-testing/SKILL.md @@ -45,29 +45,53 @@ public sealed class SmokeTests(ITestOutputHelper output) [Fact] public async Task MyCliTest() { + var repoRoot = CliE2ETestHelpers.GetRepoRoot(); + var strategy = CliInstallStrategy.Detect(output.WriteLine); var workspace = TemporaryWorkspace.Create(output); - var installMode = CliE2ETestHelpers.DetectDockerInstallMode(); - using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(); - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace); var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); - await auto.InstallAspireCliInDockerAsync(installMode, counter); + await auto.InstallAspireCliAsync(strategy, counter); await auto.TypeAsync("aspire --version"); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - await pendingRun; } } ``` +### TerminalRun Pattern + +**Always use `CliE2ETestHelpers.StartRun`** to wrap the terminal run. This returns a `TerminalRun` (implements `IAsyncDisposable`) that automatically: +1. Captures Aspire diagnostics via `CaptureAspireDiagnosticsAsync` (best effort) +2. Types `exit` and presses Enter to close the terminal +3. Awaits the pending run task + +This eliminates the need for manual `exit`/`await pendingRun` at the end of every test and ensures diagnostics are always captured, even when tests fail. + +```csharp +// DO: Use StartRun for consistent diagnostics capture and cleanup +using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace); + +var counter = new SequenceCounter(); +var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); +await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, TestContext.Current.CancellationToken); + +// ... test body — no exit/pendingRun needed at the end + +// DON'T: Manually handle exit and pendingRun +var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); +// ... test body ... +await auto.TypeAsync("exit"); +await auto.EnterAsync(); +await pendingRun; +``` + ## Running Tests Locally CLI E2E tests run inside Docker containers on Linux. The workflow is: build a portable archive with `localhive`, then point the tests at it. This is the primary way to iterate on E2E tests during development. @@ -246,10 +270,10 @@ await auto.WaitUntilAsync( | Method | Description | |--------|-------------| -| `WaitForSuccessPromptAsync(counter, timeout?)` | Waits for `[N OK] $ ` prompt and increments counter | +| `WaitForSuccessPromptAsync(counter, timeout?)` | Waits for `[N OK] $ ` prompt, fails immediately if error prompt appears, and increments counter | | `WaitForAnyPromptAsync(counter, timeout?)` | Waits for any prompt (`OK` or `ERR`) and increments counter | | `WaitForErrorPromptAsync(counter, timeout?)` | Waits for `[N ERR:code] $ ` prompt and increments counter | -| `WaitForSuccessPromptFailFastAsync(counter, timeout?)` | Waits for success prompt, fails immediately if error prompt appears | +| `RunCommandAsync(command, counter, timeout?)` | Types a command, presses Enter, and waits for success prompt (fails fast on error) | | `DeclineAgentInitPromptAsync()` | Declines the `aspire agent init` prompt if it appears | | `AspireNewAsync(projectName, counter, template?, useRedisCache?)` | Runs `aspire new` interactively, handling template selection, project name, output path, URLs, Redis, and test project prompts | @@ -277,8 +301,7 @@ The following extensions on `Hex1bTerminalInputSequenceBuilder` are still availa |--------|-------------| | `WaitForSuccessPrompt(counter, timeout?)` | *(legacy)* Waits for `[N OK] $ ` prompt and increments counter | | `PrepareEnvironment(workspace, counter)` | *(legacy)* Sets up custom prompt with command tracking | -| `InstallAspireCliFromPullRequest(prNumber, counter)` | *(legacy)* Downloads and installs CLI from PR artifacts | -| `SourceAspireCliEnvironment(counter)` | *(legacy)* Adds `~/.aspire/bin` to PATH | +| `SourceAspireBundleEnvironment(counter)` | *(legacy)* Sources bundle PATH environment variables | ## DO: Use CellPatternSearcher for Output Detection diff --git a/src/Aspire.Cli/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs index 57eda7152fb..6399edd8d0a 100644 --- a/src/Aspire.Cli/Commands/RunCommand.cs +++ b/src/Aspire.Cli/Commands/RunCommand.cs @@ -306,7 +306,7 @@ protected override async Task ExecuteAsync(ParseResult parseResul catch (TimeoutException) { runActivity?.SetTag(TelemetryConstants.Tags.ErrorType, "startup_timeout"); - await CancelAppHostStartupAsync(runCancellationTokenSource, pendingRun).ConfigureAwait(false); + await CancelAppHostStartupAsync(runCancellationTokenSource, pendingRun, cancellationToken).ConfigureAwait(false); return CreateStartupTimeoutResult(timeoutSeconds); } @@ -351,7 +351,7 @@ protected override async Task ExecuteAsync(ParseResult parseResul catch (TimeoutException) { runActivity?.SetTag(TelemetryConstants.Tags.ErrorType, "startup_timeout"); - await CancelAppHostStartupAsync(runCancellationTokenSource, pendingRun).ConfigureAwait(false); + await CancelAppHostStartupAsync(runCancellationTokenSource, pendingRun, cancellationToken).ConfigureAwait(false); return CreateStartupTimeoutResult(timeoutSeconds); } @@ -1124,15 +1124,18 @@ private TimeSpan GetRemainingStartupTimeout(long startupStartTimestamp, TimeSpan return elapsed >= startupTimeout ? TimeSpan.Zero : startupTimeout - elapsed; } - private async Task CancelAppHostStartupAsync(CancellationTokenSource runCancellationTokenSource, Task pendingRun) + private async Task CancelAppHostStartupAsync(CancellationTokenSource runCancellationTokenSource, Task pendingRun, CancellationToken cancellationToken) { runCancellationTokenSource.Cancel(); try { - await pendingRun.WaitAsync(s_appHostStartupCancellationTimeout, _timeProvider).ConfigureAwait(false); + // The timeout is a safety net for the startup-timeout path (no Ctrl+C). When the user + // presses Ctrl+C, cancellationToken fires and WaitAsync exits immediately via the token + // rather than waiting for the full timeout duration. + await pendingRun.WaitAsync(s_appHostStartupCancellationTimeout, _timeProvider, cancellationToken).ConfigureAwait(false); } - catch (OperationCanceledException) when (runCancellationTokenSource.IsCancellationRequested) + catch (OperationCanceledException) when (runCancellationTokenSource.IsCancellationRequested || cancellationToken.IsCancellationRequested) { } catch (TimeoutException ex) diff --git a/src/Aspire.Cli/ConsoleCancellationManager.cs b/src/Aspire.Cli/ConsoleCancellationManager.cs index d4588f3a550..74a6c3d16b0 100644 --- a/src/Aspire.Cli/ConsoleCancellationManager.cs +++ b/src/Aspire.Cli/ConsoleCancellationManager.cs @@ -1,18 +1,25 @@ // 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; using System.Runtime.InteropServices; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; namespace Aspire.Cli; /// /// Manages Ctrl+C, SIGINT, and SIGTERM signal handling with a shared CancellationTokenSource. -/// After cancellation is requested, waits up to processTerminationTimeout for the running -/// handler to complete before signaling forced termination via . +/// After cancellation is requested, schedules an asynchronous timeout for the running handler +/// to complete before signaling forced termination via . +/// A second signal forces immediate termination without waiting for the timeout. /// Disposing this instance unregisters all signal handlers and disposes the token source. /// internal sealed class ConsoleCancellationManager : IDisposable { + // Standard Unix exit codes: 128 + signal number (SIGINT=2, SIGTERM=15). + // SigIntExitCode (130): used when the user presses Ctrl+C (SIGINT) or Ctrl+Break/SIGQUIT. + // SigTermExitCode (143): used when the process receives SIGTERM (e.g. container stop, ProcessExit). private const int SigIntExitCode = 130; private const int SigTermExitCode = 143; @@ -20,7 +27,9 @@ internal sealed class ConsoleCancellationManager : IDisposable private readonly TimeSpan _processTerminationTimeout; private readonly PosixSignalRegistration? _sigIntRegistration; private readonly PosixSignalRegistration? _sigTermRegistration; + private readonly PosixSignalRegistration? _sigQuitRegistration; private readonly CancellationToken _token; + private ILogger _logger; private Task? _startedHandler; private int _cancelCalled; @@ -38,9 +47,16 @@ internal sealed class ConsoleCancellationManager : IDisposable /// internal void SetStartedHandler(Task handler) => Volatile.Write(ref _startedHandler, handler); + /// + /// Sets the logger instance used for diagnostic messages during signal handling. + /// Call this once the logging infrastructure is available. + /// + internal void SetLogger(ILogger logger) => Volatile.Write(ref _logger, logger); + public ConsoleCancellationManager(TimeSpan processTerminationTimeout) { _processTerminationTimeout = processTerminationTimeout; + _logger = NullLogger.Instance; // Set to a field so getting the token doesn't error after dispose. _token = _cts.Token; @@ -56,9 +72,22 @@ public ConsoleCancellationManager(TimeSpan processTerminationTimeout) { _sigIntRegistration = PosixSignalRegistration.Create(PosixSignal.SIGINT, OnPosixSignal); _sigTermRegistration = PosixSignalRegistration.Create(PosixSignal.SIGTERM, OnPosixSignal); + + // SIGQUIT maps to CTRL_BREAK_EVENT on Windows. Register it to maintain parity with + // Console.CancelKeyPress which handled both Ctrl+C and Ctrl+Break. + // On Linux/macOS, SIGQUIT's default action produces a core dump which is useful for + // debugging hung processes — don't intercept it there. + if (OperatingSystem.IsWindows()) + { + _sigQuitRegistration = PosixSignalRegistration.Create(PosixSignal.SIGQUIT, OnPosixSignal); + } + } + else + { + // Fall back to Console.CancelKeyPress on platforms that don't support PosixSignalRegistration. + Console.CancelKeyPress += OnCancelKeyPress; } - Console.CancelKeyPress += OnCancelKeyPress; AppDomain.CurrentDomain.ProcessExit += OnProcessExit; } @@ -69,7 +98,13 @@ public ConsoleCancellationManager(TimeSpan processTerminationTimeout) private void OnPosixSignal(PosixSignalContext context) { context.Cancel = true; - Cancel(context.Signal == PosixSignal.SIGINT ? SigIntExitCode : SigTermExitCode); + var exitCode = context.Signal switch + { + PosixSignal.SIGINT => SigIntExitCode, + PosixSignal.SIGQUIT => SigIntExitCode, + _ => SigTermExitCode + }; + Cancel(exitCode); } private void OnCancelKeyPress(object? sender, ConsoleCancelEventArgs e) @@ -80,40 +115,73 @@ private void OnCancelKeyPress(object? sender, ConsoleCancelEventArgs e) private void OnProcessExit(object? sender, EventArgs e) => Cancel(SigTermExitCode); - private void Cancel(int forcedTerminationExitCode) + internal void Cancel(int forcedTerminationExitCode) { - // Ensure only the first signal triggers cancellation logic; subsequent signals are no-ops. - if (Interlocked.CompareExchange(ref _cancelCalled, 1, 0) != 0) - { - return; - } + var signalCount = Interlocked.Increment(ref _cancelCalled); - // Request cancellation so cooperative listeners can begin shutting down. - try + if (signalCount == 1) { - _cts.Cancel(); + // First signal: request cooperative cancellation and schedule an async timeout + // that will force-terminate if the handler doesn't complete in time. + _logger.LogInformation("Termination signal received, requesting cancellation."); + + try + { + _cts.Cancel(); + } + catch (ObjectDisposedException) + { + // A signal can race with process shutdown after cancellation resources are disposed. + return; + } + + // Schedule the forced-completion timeout asynchronously so the signal handler + // returns immediately. This allows Program.Main's Task.WhenAny to observe + // handlerTask completion without being blocked by the signal handler thread. + _ = ForceTerminationAfterTimeoutAsync(forcedTerminationExitCode); } - catch (ObjectDisposedException) + else { - // A signal can race with process shutdown after cancellation resources are disposed. - return; + // Second (or subsequent) signal: force immediate termination without waiting. + _logger.LogWarning("Second termination signal received, forcing immediate exit."); + _processTerminationCompletionSource.TrySetResult(forcedTerminationExitCode); } + } + private async Task ForceTerminationAfterTimeoutAsync(int forcedTerminationExitCode) + { try { + // When a debugger is attached, don't force-terminate — the developer needs + // unlimited time to step through cancellation/cleanup logic. + if (Debugger.IsAttached) + { + return; + } + var startedHandler = Volatile.Read(ref _startedHandler); - // Wait for the configured interval to allow graceful shutdown. - if (startedHandler is null || !startedHandler.Wait(_processTerminationTimeout)) + if (startedHandler is not null) { - // If the handler does not finish within configured time, use the completion - // source to signal forced completion (preserving native exit code). - _processTerminationCompletionSource.TrySetResult(forcedTerminationExitCode); + // Give the handler a chance to finish gracefully within the configured timeout. + // Task.WhenAny completes when either the handler or the delay finishes first, + // without propagating exceptions from the losing task. + // It's ok that this delay isn't cancellable. The process is ending. + var timeoutTask = Task.Delay(_processTerminationTimeout); + if (await Task.WhenAny(startedHandler, timeoutTask).ConfigureAwait(false) == startedHandler) + { + // Handler finished within the timeout; no forced termination needed. + return; + } } + + _logger.LogWarning("Handler did not complete within {Timeout}s, forcing termination.", _processTerminationTimeout.TotalSeconds); + _processTerminationCompletionSource.TrySetResult(forcedTerminationExitCode); } - catch (AggregateException) + catch (Exception) { - // The task was cancelled or an exception was thrown during task execution. + // Any failure in the timeout path should still force termination rather than hang. + _processTerminationCompletionSource.TrySetResult(forcedTerminationExitCode); } } @@ -121,6 +189,7 @@ public void Dispose() { _sigIntRegistration?.Dispose(); _sigTermRegistration?.Dispose(); + _sigQuitRegistration?.Dispose(); Console.CancelKeyPress -= OnCancelKeyPress; AppDomain.CurrentDomain.ProcessExit -= OnProcessExit; diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index 3a78e399b7b..4cb97e53d8c 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -801,6 +801,7 @@ public static async Task Main(string[] args) var logBufferContext = new ConsoleLogBufferContext(); var (loggerFactory, fileLoggerProvider) = CreateLoggerFactory(args, loggingOptions, errorWriter, logBufferContext); var logger = loggerFactory.CreateLogger(RootLoggerName); + cancellationManager.SetLogger(logger); using var startupContext = new CliStartupContext(loggingOptions, errorWriter, loggerFactory, fileLoggerProvider, logBufferContext, logger); logger.LogInformation("Aspire CLI version: {Version}", AspireCliTelemetry.GetCliVersion()); @@ -899,9 +900,10 @@ public static async Task Main(string[] args) var firstCompletedTask = await Task.WhenAny(handlerTask, cancellationManager.ProcessTerminationCompletionSource.Task); if (firstCompletedTask != handlerTask) { - // The termination signal triggered cancellation and the timeout has completed. Kill the process. + // ProcessTerminationCompletionSource was signaled — either the graceful-shutdown + // timeout elapsed, or a second signal forced immediate termination. // handlerTask is not awaited because the process is shutting down and we assume the task is hung. - logger.LogWarning("Timeout waiting for cancellation from termination signal."); + logger.LogWarning("Termination signal forced process exit."); } exitCode = await firstCompletedTask; // return the result or propagate the exception diff --git a/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs b/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs index fdf137100f7..3df478d333a 100644 --- a/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs +++ b/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs @@ -123,6 +123,7 @@ public ProcessGuestLauncher(string language, ILogger logger, FileLoggerProvider? AddEvent(activity, ProfilingTelemetry.Events.GuestProcessStart); process.Start(); + _logger.LogDebug("{Language} guest process {ProcessId} started: {Command}", _language, process.Id, resolvedCommandPath); activity?.SetTag(TelemetryConstants.Tags.ProcessPid, process.Id); AddEvent(activity, ProfilingTelemetry.Events.GuestProcessStarted, TelemetryConstants.Tags.ProcessPid, process.Id); if (afterLaunchAsync is not null) @@ -135,7 +136,12 @@ public ProcessGuestLauncher(string language, ILogger logger, FileLoggerProvider? try { - await process.WaitForExitAsync(cancellationToken); + var waitForExitTask = process.WaitForExitAsync(cancellationToken); + + using var _ = cancellationToken.Register(() => + _logger.LogInformation("Cancellation requested while waiting for {Language} guest process {ProcessId} to exit", _language, process.Id)); + + await waitForExitTask.ConfigureAwait(false); } catch (OperationCanceledException) { @@ -152,6 +158,7 @@ public ProcessGuestLauncher(string language, ILogger logger, FileLoggerProvider? // the redirected output streams have time to drain. if (!process.HasExited) { + _logger.LogInformation("Killing {Language} guest process tree {ProcessId}", _language, process.Id); try { process.Kill(entireProcessTree: true); @@ -161,10 +168,17 @@ public ProcessGuestLauncher(string language, ILogger logger, FileLoggerProvider? _logger.LogDebug(killEx, "Failed to kill guest process {ProcessId} after cancellation", process.Id); } } + else + { + _logger.LogDebug("{Language} guest process {ProcessId} already exited before kill", _language, process.Id); + } + _logger.LogDebug("Waiting for {Language} guest process {ProcessId} to exit after kill", _language, process.Id); await process.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false); } + _logger.LogDebug("{Language} guest process {ProcessId} exited with code {ExitCode}", _language, process.Id, process.ExitCode); + activity?.SetTag(TelemetryConstants.Tags.ProcessExitCode, process.ExitCode); AddEvent(activity, ProfilingTelemetry.Events.GuestProcessExited, TelemetryConstants.Tags.ProcessExitCode, process.ExitCode); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/AgentCommandTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/AgentCommandTests.cs index 98c153aed56..917c86c5d67 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/AgentCommandTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/AgentCommandTests.cs @@ -31,10 +31,9 @@ public async Task AgentCommands_AllHelpOutputs_AreCorrect() using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace); - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); @@ -73,11 +72,6 @@ await auto.WaitUntilAsync( await auto.EnterAsync(); await auto.WaitUntilTextAsync("aspire mcp tools [options]", timeout: TimeSpan.FromSeconds(30)); await auto.WaitForSuccessPromptAsync(counter); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } /// @@ -93,8 +87,6 @@ public async Task AgentInitCommand_MigratesDeprecatedConfig() using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace); - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - // Use .mcp.json (Claude Code format) for simpler testing // This is the same format used by the doctor test that passes var configPath = Path.Combine(workspace.WorkspaceRoot.FullName, ".mcp.json"); @@ -102,6 +94,7 @@ public async Task AgentInitCommand_MigratesDeprecatedConfig() var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); @@ -129,7 +122,7 @@ public async Task AgentInitCommand_MigratesDeprecatedConfig() await auto.TypeAsync("aspire agent init --workspace-root . --skill-locations none --skills none"); await auto.EnterAsync(); await auto.WaitUntilTextAsync("configuration complete", timeout: TimeSpan.FromSeconds(30)); - await auto.WaitForSuccessPromptFailFastAsync(counter); + await auto.WaitForSuccessPromptAsync(counter); // Step 3: Verify config was updated to new format // The updated config should contain "agent" and "mcp" but not "start" @@ -137,11 +130,6 @@ public async Task AgentInitCommand_MigratesDeprecatedConfig() Assert.Contains("\"agent\"", fileContent); Assert.Contains("\"mcp\"", fileContent); Assert.DoesNotContain("\"start\"", fileContent); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } /// @@ -156,12 +144,11 @@ public async Task DoctorCommand_DetectsDeprecatedAgentConfig() using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace); - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var configPath = Path.Combine(workspace.WorkspaceRoot.FullName, ".mcp.json"); var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); @@ -175,11 +162,6 @@ await auto.WaitUntilAsync( s => s.ContainsText("dev-certs") && s.ContainsText("deprecated") && s.ContainsText("aspire agent init"), timeout: TimeSpan.FromSeconds(60), description: "doctor output with deprecated warning and fix suggestion"); await auto.WaitForSuccessPromptAsync(counter); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } /// @@ -196,13 +178,12 @@ public async Task AgentInitCommand_DefaultSelection_InstallsDefaultSkills() using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace); - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - // Set up .vscode folder so VS Code scanner detects it var vscodePath = Path.Combine(workspace.WorkspaceRoot.FullName, ".vscode"); var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); @@ -230,7 +211,7 @@ await auto.WaitUntilAsync( // the default Aspire skills from the seeded bundle. await auto.EnterAsync(); await auto.WaitUntilTextAsync("configuration complete", timeout: TimeSpan.FromSeconds(30)); - await auto.WaitForSuccessPromptFailFastAsync(counter); + await auto.WaitForSuccessPromptAsync(counter); // Verify skill files were created (skills are now installed at .agents/skills/ by StandardLocationAgentEnvironmentScanner) var skillFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, ".agents", "skills", "aspire", "SKILL.md"); @@ -239,11 +220,6 @@ await auto.WaitUntilAsync( var deploymentSkillFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, ".agents", "skills", "aspire-deployment", "SKILL.md"); var deploymentFileContent = File.ReadAllText(deploymentSkillFilePath); Assert.Contains("Aspire Deployment", deploymentFileContent); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } private static async Task SeedAspireSkillsBundleCacheAsync(Hex1bTerminalAutomator auto, TemporaryWorkspace workspace, SequenceCounter counter) diff --git a/tests/Aspire.Cli.EndToEnd.Tests/AgentMcpLogsTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/AgentMcpLogsTests.cs index 53e2d3677b6..01f7b0ece04 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/AgentMcpLogsTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/AgentMcpLogsTests.cs @@ -38,10 +38,9 @@ private async Task AgentMcpListStructuredLogsFromStarterAppCore(bool isolated, b using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -68,11 +67,5 @@ private async Task AgentMcpListStructuredLogsFromStarterAppCore(bool isolated, b // Stop the AppHost await auto.AspireStopAsync(counter); - - // Exit the shell - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/AppHostSyntaxErrorOutputTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/AppHostSyntaxErrorOutputTests.cs index b2bc70249e0..010ef85f315 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/AppHostSyntaxErrorOutputTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/AppHostSyntaxErrorOutputTests.cs @@ -94,51 +94,25 @@ private async Task RunSyntaxErrorScenarioAsync( workspace: workspace, testName: testName); - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); - var testBodyFailed = false; - - try - { - await auto.PrepareDockerEnvironmentAsync(counter, workspace); - await auto.InstallAspireCliAsync(strategy, counter); - - await auto.AspireNewAsync(projectName, counter, template: template); - configureProject(Path.Combine(workspace.WorkspaceRoot.FullName, projectName)); - - await AssertAspireCommandOutputAsync( - auto, - counter, - projectName, - command, - expectedExitCode, - outputExpectation, - recordingPath, - timeout); - } - catch - { - testBodyFailed = true; - throw; - } - finally - { - try - { - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - await pendingRun; - } - catch - { - if (!testBodyFailed) - { - throw; - } - } - } + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); + + await auto.PrepareDockerEnvironmentAsync(counter, workspace); + await auto.InstallAspireCliAsync(strategy, counter); + + await auto.AspireNewAsync(projectName, counter, template: template); + configureProject(Path.Combine(workspace.WorkspaceRoot.FullName, projectName)); + + await AssertAspireCommandOutputAsync( + auto, + counter, + projectName, + command, + expectedExitCode, + outputExpectation, + recordingPath, + timeout); } private static async Task AssertAspireCommandOutputAsync( diff --git a/tests/Aspire.Cli.EndToEnd.Tests/BannerTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/BannerTests.cs index 24ef5b2a85d..849bcfed550 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/BannerTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/BannerTests.cs @@ -24,10 +24,9 @@ public async Task Banner_DisplayedOnFirstRun() using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace); - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); @@ -50,10 +49,6 @@ await auto.WaitUntilAsync( s => s.ContainsText(RootCommandStrings.BannerWelcomeText) && s.ContainsText("Telemetry"), timeout: TimeSpan.FromSeconds(30), description: "waiting for banner and telemetry notice on first run"); await auto.WaitForSuccessPromptAsync(counter); - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } [Fact] @@ -65,10 +60,9 @@ public async Task Banner_DisplayedWithExplicitFlag() using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace); - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); @@ -82,10 +76,6 @@ await auto.WaitUntilAsync( s => s.ContainsText(RootCommandStrings.BannerWelcomeText) && s.ContainsText("CLI"), timeout: TimeSpan.FromSeconds(30), description: "waiting for banner with version info"); await auto.WaitForSuccessPromptAsync(counter); - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } [Fact] @@ -97,10 +87,9 @@ public async Task Banner_NotDisplayedWithNoLogoFlag() using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace); - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); @@ -130,9 +119,5 @@ await auto.WaitUntilAsync(s => return s.ContainsText(HelpGroupStrings.HelpHint); }, timeout: TimeSpan.FromSeconds(30), description: "waiting for help output to complete"); await auto.WaitForSuccessPromptAsync(counter); - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/BundleSmokeTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/BundleSmokeTests.cs index 5a72dd22291..fa5a69fa1d2 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/BundleSmokeTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/BundleSmokeTests.cs @@ -26,10 +26,9 @@ public async Task CreateAndRunAspireStarterProjectWithBundle() using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -38,10 +37,5 @@ public async Task CreateAndRunAspireStarterProjectWithBundle() await auto.AspireStartAsync(counter); await auto.AspireStopAsync(counter); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/CSharpInitTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/CSharpInitTests.cs index 702ef4c1fe2..7b7fd55c8f2 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/CSharpInitTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/CSharpInitTests.cs @@ -31,10 +31,9 @@ public async Task InteractiveCSharpInitCreatesExpectedFiles() using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace); - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -73,10 +72,5 @@ await auto.WaitUntilAsync( var language = appHostNode["language"]?.GetValue(); Assert.NotNull(language); Assert.Contains("csharp", language, StringComparison.OrdinalIgnoreCase); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/CSharpProjectModeInitTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/CSharpProjectModeInitTests.cs index 0c31e632919..9700eb307e5 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/CSharpProjectModeInitTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/CSharpProjectModeInitTests.cs @@ -69,11 +69,9 @@ public async Task AspireInitWithSolutionFileGeneratesAppHostThatBuildsAgainstCha File.WriteAllText(solutionPath, "Fake solution file"); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: false, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -106,17 +104,12 @@ public async Task AspireInitWithSolutionFileGeneratesAppHostThatBuildsAgainstCha // `dotnet build` fails with `error MSB4236: The SDK 'Aspire.AppHost.Sdk/...' // could not be found.` 3 minutes is enough headroom for a cold restore + build on // CI; the cache-hit case (the template's `restore` post-action already populated - // ~/.nuget/packages during init) finishes well under 30 seconds. Using the - // fail-fast helper so a build failure surfaces immediately via the shell's - // numbered ERR prompt instead of timing out. - await auto.RunCommandFailFastAsync( + // ~/.nuget/packages during init) finishes well under 30 seconds. A build failure + // surfaces immediately via the shell's ERR prompt instead of timing out. + await auto.RunCommandAsync( "dotnet build Test.AppHost/Test.AppHost.csproj", counter, TimeSpan.FromMinutes(3)); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - await pendingRun; } /// @@ -152,11 +145,9 @@ public async Task AspireInitWithExistingAppHostDirRecreatesMissingNuGetConfigAnd File.WriteAllText(leftoverPath, LeftoverContent); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: false, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -174,9 +165,5 @@ public async Task AspireInitWithExistingAppHostDirRecreatesMissingNuGetConfigAnd + "Directory.Exists(appHostDirPath) early return so reruns recover the missing config."); Assert.True(File.Exists(leftoverPath), $"Pre-existing AppHost file should be preserved: {leftoverPath}"); Assert.Equal(LeftoverContent, File.ReadAllText(leftoverPath)); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - await pendingRun; } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/CentralPackageManagementTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/CentralPackageManagementTests.cs index 779393d04ba..5b2eee07896 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/CentralPackageManagementTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/CentralPackageManagementTests.cs @@ -25,11 +25,9 @@ public async Task AspireUpdateRemovesAppHostPackageVersionFromDirectoryPackagesP var workspace = TemporaryWorkspace.Create(output); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); @@ -114,10 +112,6 @@ public async Task AspireUpdateRemovesAppHostPackageVersionFromDirectoryPackagesP await auto.TypeAsync("aspire config delete features.updateNotificationsEnabled -g"); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter); - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } [Fact] @@ -150,11 +144,9 @@ public async Task AspireUpdateRemovesOrphanAppHostPackageVersionWhenSdkAlreadyCu var workspace = TemporaryWorkspace.Create(output); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); @@ -277,10 +269,6 @@ public async Task AspireUpdateRemovesOrphanAppHostPackageVersionWhenSdkAlreadyCu await auto.TypeAsync("aspire config delete features.updateNotificationsEnabled -g"); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter); - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } [Fact] @@ -291,11 +279,9 @@ public async Task AspireAddPackageVersionToDirectoryPackagesProps() var workspace = TemporaryWorkspace.Create(output); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); @@ -369,9 +355,5 @@ static IEnumerable FindRedisProperties(XDocument document, string prop await auto.TypeAsync($"dotnet restore \"{containerAppHostCsprojPath}\""); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(120)); - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/CertificatesCommandTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/CertificatesCommandTests.cs index c455dcb0f71..fba51c7ae2f 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/CertificatesCommandTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/CertificatesCommandTests.cs @@ -23,10 +23,9 @@ public async Task CertificatesTrust_WithUntrustedCert_TrustsCertificate() using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace); - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -52,10 +51,6 @@ public async Task CertificatesTrust_WithUntrustedCert_TrustsCertificate() await auto.EnterAsync(); await auto.WaitUntilTextAsync("certificate is trusted", timeout: TimeSpan.FromSeconds(60)); await auto.WaitForSuccessPromptAsync(counter); - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } [Fact] @@ -67,10 +62,9 @@ public async Task CertificatesClean_RemovesCertificates() using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace); - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -95,10 +89,6 @@ public async Task CertificatesClean_RemovesCertificates() await auto.EnterAsync(); await auto.WaitUntilTextAsync("No HTTPS development certificate", timeout: TimeSpan.FromSeconds(60)); await auto.WaitForSuccessPromptAsync(counter); - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } [Fact] @@ -110,10 +100,9 @@ public async Task CertificatesTrust_WithNoCert_CreatesAndTrustsCertificate() using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace); - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -134,9 +123,5 @@ public async Task CertificatesTrust_WithNoCert_CreatesAndTrustsCertificate() await auto.EnterAsync(); await auto.WaitUntilTextAsync("certificate is trusted", timeout: TimeSpan.FromSeconds(60)); await auto.WaitForSuccessPromptAsync(counter); - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/ChannelUpdateWorkflowTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/ChannelUpdateWorkflowTests.cs index bcb737db9c2..dae5f7d44a7 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/ChannelUpdateWorkflowTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/ChannelUpdateWorkflowTests.cs @@ -73,11 +73,9 @@ public async Task UpdateProjectChannelToStable_TypeScript_PreviewsStablePackages variant: CliE2ETestHelpers.DockerfileVariant.Polyglot, mountDockerSocket: true, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -118,7 +116,7 @@ public async Task UpdateProjectChannelToStable_TypeScript_PreviewsStablePackages ? "./.aspire/modules/aspire.mjs" : "./.aspire/modules/aspire.js"; - await auto.RunCommandFailFastAsync($"cd {projectName}", counter); + await auto.RunCommandAsync($"cd {projectName}", counter); // Step 3: Add the first package on the non-stable channel. Don't pass --non-interactive — the // helper handles both direct success and the "based on NuGet.config" version picker that @@ -210,11 +208,6 @@ await File.WriteAllTextAsync(appHostPath, { } } - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } // ---------------------------------------------------------------------------------- @@ -251,11 +244,9 @@ public async Task UpdateProjectChannelToStable_CSharpSingleFileInit_PreservesAsp repoRoot, strategy, output, variant: CliE2ETestHelpers.DockerfileVariant.DotNet, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -265,7 +256,7 @@ public async Task UpdateProjectChannelToStable_CSharpSingleFileInit_PreservesAsp const string projectName = "ChannelUpdateCsharpInitApp"; var projectPath = Path.Combine(workspace.WorkspaceRoot.FullName, projectName); Directory.CreateDirectory(projectPath); - await auto.RunCommandFailFastAsync($"cd {projectName}", counter); + await auto.RunCommandAsync($"cd {projectName}", counter); await auto.AspireInitAsync(counter); @@ -275,10 +266,6 @@ public async Task UpdateProjectChannelToStable_CSharpSingleFileInit_PreservesAsp } await RunStableChannelUpdateAndAssertChannelPreservedAsync(auto, counter, Path.Combine(projectPath, "aspire.config.json")); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - await pendingRun; } [Fact] @@ -300,11 +287,9 @@ public async Task UpdateProjectChannelToStable_CSharpEmptyAppHost_PreservesAspir repoRoot, strategy, output, variant: CliE2ETestHelpers.DockerfileVariant.DotNet, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -319,12 +304,8 @@ public async Task UpdateProjectChannelToStable_CSharpEmptyAppHost_PreservesAspir CliE2ETestHelpers.WriteLocalChannelSettings(projectPath, localChannel.SdkVersion); } - await auto.RunCommandFailFastAsync($"cd {projectName}", counter); + await auto.RunCommandAsync($"cd {projectName}", counter); await RunStableChannelUpdateAndAssertChannelPreservedAsync(auto, counter, Path.Combine(projectPath, "aspire.config.json")); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - await pendingRun; } [Fact] @@ -346,11 +327,9 @@ public async Task UpdateProjectChannelToStable_TypeScriptSingleFileInit_Preserve repoRoot, strategy, output, variant: CliE2ETestHelpers.DockerfileVariant.Polyglot, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -360,7 +339,7 @@ public async Task UpdateProjectChannelToStable_TypeScriptSingleFileInit_Preserve const string projectName = "ChannelUpdateTsInitApp"; var projectPath = Path.Combine(workspace.WorkspaceRoot.FullName, projectName); Directory.CreateDirectory(projectPath); - await auto.RunCommandFailFastAsync($"cd {projectName}", counter); + await auto.RunCommandAsync($"cd {projectName}", counter); await auto.TypeAsync("aspire init --language typescript --non-interactive"); await auto.EnterAsync(); @@ -373,10 +352,6 @@ public async Task UpdateProjectChannelToStable_TypeScriptSingleFileInit_Preserve } await RunStableChannelUpdateAndAssertChannelPreservedAsync(auto, counter, Path.Combine(projectPath, "aspire.config.json")); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - await pendingRun; } /// diff --git a/tests/Aspire.Cli.EndToEnd.Tests/ConfigDiscoveryTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/ConfigDiscoveryTests.cs index 3aef230bcfd..8fb01078aba 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/ConfigDiscoveryTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/ConfigDiscoveryTests.cs @@ -40,10 +40,9 @@ public async Task RunFromParentDirectory_UsesExistingConfigNearAppHost() mountDockerSocket: true, workspace: workspace); - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -140,10 +139,5 @@ await auto.WaitUntilAsync(s => } Assert.True(hasApplicationUrl, $"No profile has 'applicationUrl'. Content:\n{currentContent}"); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/ConfigHealingTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/ConfigHealingTests.cs index fa617337cb9..cf77b30f89f 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/ConfigHealingTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/ConfigHealingTests.cs @@ -34,10 +34,9 @@ public async Task InvalidAppHostPathWithComments_IsHealedOnRun() mountDockerSocket: true, workspace: workspace); - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -97,10 +96,5 @@ public async Task InvalidAppHostPathWithComments_IsHealedOnRun() throw new InvalidOperationException( $"Config file still contains invalid path after healing. Content:\n{content}"); } - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/ConfigMigrationTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/ConfigMigrationTests.cs index fc1b2d7417d..c814ab9ecc4 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/ConfigMigrationTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/ConfigMigrationTests.cs @@ -115,10 +115,9 @@ public async Task GlobalSettings_MigratedFromLegacyFormat() var (aspireHomeDir, terminal) = CreateMigrationTerminal(repoRoot, strategy, workspace); using var _ = terminal; - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -163,10 +162,6 @@ public async Task GlobalSettings_MigratedFromLegacyFormat() await auto.TypeAsync("aspire config delete features.polyglotSupportEnabled -g"); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter); - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } /// @@ -182,10 +177,9 @@ public async Task GlobalMigration_SkipsWhenNewConfigExists() var (aspireHomeDir, terminal) = CreateMigrationTerminal(repoRoot, strategy, workspace); using var _ = terminal; - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -213,10 +207,6 @@ public async Task GlobalMigration_SkipsWhenNewConfigExists() await auto.TypeAsync("aspire config delete channel -g"); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter); - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } /// @@ -232,10 +222,9 @@ public async Task GlobalMigration_HandlesMalformedLegacyJson() var (aspireHomeDir, terminal) = CreateMigrationTerminal(repoRoot, strategy, workspace); using var _ = terminal; - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -272,10 +261,6 @@ public async Task GlobalMigration_HandlesMalformedLegacyJson() await auto.TypeAsync("aspire config delete channel -g"); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter); - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } /// @@ -293,10 +278,9 @@ public async Task GlobalMigration_HandlesCommentsAndTrailingCommas() var (aspireHomeDir, terminal) = CreateMigrationTerminal(repoRoot, strategy, workspace); using var _ = terminal; - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -348,10 +332,6 @@ public async Task GlobalMigration_HandlesCommentsAndTrailingCommas() await auto.TypeAsync("aspire config delete features.polyglotSupportEnabled -g"); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter); - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } /// @@ -368,10 +348,9 @@ public async Task ConfigSetGet_CreatesNestedJsonFormat() var (aspireHomeDir, terminal) = CreateMigrationTerminal(repoRoot, strategy, workspace); using var _ = terminal; - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -432,10 +411,6 @@ public async Task ConfigSetGet_CreatesNestedJsonFormat() await auto.TypeAsync("aspire config delete features.stagingChannelEnabled -g"); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter); - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } /// @@ -453,10 +428,9 @@ public async Task GlobalMigration_PreservesAllValueTypes() var (aspireHomeDir, terminal) = CreateMigrationTerminal(repoRoot, strategy, workspace); using var _ = terminal; - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -521,10 +495,6 @@ public async Task GlobalMigration_PreservesAllValueTypes() await auto.TypeAsync("aspire config delete packages -g"); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter); - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } /// @@ -543,10 +513,9 @@ public async Task FullUpgrade_LegacyCliToNewCli_MigratesGlobalSettings() var (aspireHomeDir, terminal) = CreateMigrationTerminal(repoRoot, strategy, workspace); using var _ = terminal; - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); @@ -608,9 +577,5 @@ public async Task FullUpgrade_LegacyCliToNewCli_MigratesGlobalSettings() await auto.TypeAsync("aspire config delete features.polyglotSupportEnabled -g"); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter); - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/DashboardRunTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/DashboardRunTests.cs index 952078bd794..b37e8db0417 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/DashboardRunTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/DashboardRunTests.cs @@ -37,10 +37,9 @@ private async Task DashboardRunWithOtelTracesReturnsNoTracesCore(string frontend using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: false, workspace: workspace); - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -96,12 +95,6 @@ private async Task DashboardRunWithOtelTracesReturnsNoTracesCore(string frontend await auto.TypeAsync("kill -9 $DASHBOARD_PID 2>/dev/null; wait $DASHBOARD_PID 2>/dev/null; true"); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter); - - // Exit the shell - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } [Fact] @@ -123,10 +116,9 @@ private async Task DashboardRunWithAgentMcpCore(string frontendUrl, string local using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: false, workspace: workspace); - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -167,11 +159,5 @@ private async Task DashboardRunWithAgentMcpCore(string frontendUrl, string local await auto.TypeAsync("kill -9 $DASHBOARD_PID 2>/dev/null; wait $DASHBOARD_PID 2>/dev/null; true"); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter); - - // Exit the shell - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/DescribeCommandTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/DescribeCommandTests.cs index 3b2ad14b891..f51e8128265 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/DescribeCommandTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/DescribeCommandTests.cs @@ -25,10 +25,9 @@ public async Task DescribeCommandShowsRunningResources() using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); @@ -77,12 +76,6 @@ public async Task DescribeCommandShowsRunningResources() await auto.EnterAsync(); await auto.WaitUntilAppHostStoppedSuccessfullyAsync(timeout: TimeSpan.FromMinutes(1)); await auto.WaitForSuccessPromptAsync(counter); - - // Exit the shell - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } [Fact] @@ -95,14 +88,13 @@ public async Task DescribeCommandResolvesReplicaNames() using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - // Pattern for describe output showing a specific replica var waitForApiserviceReplicaName = new CellPatternSearcher() .FindPattern("apiservice-[a-z0-9]+"); var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); @@ -202,11 +194,5 @@ public async Task DescribeCommandResolvesReplicaNames() await auto.EnterAsync(); await auto.WaitUntilAppHostStoppedSuccessfullyAsync(timeout: TimeSpan.FromMinutes(1)); await auto.WaitForSuccessPromptAsync(counter); - - // Exit the shell - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/DockerDeploymentTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/DockerDeploymentTests.cs index 0994a341fe5..f256e4591d6 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/DockerDeploymentTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/DockerDeploymentTests.cs @@ -28,11 +28,9 @@ public async Task CreateAndDeployToDockerCompose() using var workspace = TemporaryWorkspace.Create(output); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -118,11 +116,6 @@ public async Task CreateAndDeployToDockerCompose() // Step 11: Clean up - destroy the deployment using aspire destroy await auto.AspireDestroyAsync(counter); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } [Fact] @@ -135,11 +128,9 @@ public async Task CreateAndDeployToDockerComposeInteractive() using var workspace = TemporaryWorkspace.Create(output); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -226,10 +217,5 @@ public async Task CreateAndDeployToDockerComposeInteractive() // Step 11: Clean up - destroy the deployment using aspire destroy await auto.AspireDestroyAsync(counter); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/DocsCommandE2ETests.cs b/tests/Aspire.Cli.EndToEnd.Tests/DocsCommandE2ETests.cs index 09d475f2652..9c50dfdf9f5 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/DocsCommandE2ETests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/DocsCommandE2ETests.cs @@ -43,10 +43,10 @@ aspire docs get docs-smoke-test """); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace); - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -90,10 +90,5 @@ await auto.WaitUntilAsync(snapshot => && snapshot.ContainsText("Target Azure subscription"); }, timeout: TimeSpan.FromSeconds(60), description: "waiting for docs get rendered output"); await auto.WaitForSuccessPromptAsync(counter); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/DoctorCommandTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/DoctorCommandTests.cs index f6e65fcd297..aacd7ab4860 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/DoctorCommandTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/DoctorCommandTests.cs @@ -30,10 +30,9 @@ public async Task DoctorCommand_WithoutSslCertDir_ShowsPartiallyTrusted() using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace); - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); @@ -54,10 +53,6 @@ await auto.WaitUntilAsync( s => s.ContainsText("dev-certs") && s.ContainsText("partially trusted"), timeout: TimeSpan.FromSeconds(60), description: "doctor to complete with partial trust warning"); await auto.WaitForSuccessPromptAsync(counter); - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } [Fact] @@ -69,10 +64,9 @@ public async Task DoctorCommand_WithSslCertDir_ShowsTrusted() using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace); - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); @@ -101,10 +95,6 @@ await auto.WaitUntilAsync(s => return s.ContainsText("certificate is trusted"); }, timeout: TimeSpan.FromSeconds(60), description: "doctor to complete with trusted certificate"); await auto.WaitForSuccessPromptAsync(counter); - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } [Theory] @@ -118,10 +108,9 @@ public async Task DoctorCommand_TypeScriptAppHostReportsMissingConfiguredToolcha using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace); - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -136,7 +125,7 @@ public async Task DoctorCommand_TypeScriptAppHostReportsMissingConfiguredToolcha TypeScriptAppHostToolchainTestHelpers.SetPackageManager(workspace.WorkspaceRoot.FullName, toolchain, cleanInstallState: true); if (TypeScriptAppHostToolchainTestHelpers.UsesCorepack(toolchain)) { - await auto.RunCommandFailFastAsync( + await auto.RunCommandAsync( $"COREPACK_ENABLE_DOWNLOAD_PROMPT=0 corepack prepare {TypeScriptAppHostToolchainTestHelpers.GetPackageManager(toolchain)} --activate", counter, TimeSpan.FromMinutes(2)); @@ -160,9 +149,5 @@ await auto.WaitUntilAsync( timeout: TimeSpan.FromSeconds(60), description: $"doctor to report missing {toolchain} tooling"); await auto.WaitForAnyPromptAsync(counter); - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/DotnetToolSmokeTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/DotnetToolSmokeTests.cs index 4c56392bec8..a1242a6f971 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/DotnetToolSmokeTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/DotnetToolSmokeTests.cs @@ -37,10 +37,9 @@ public async Task CreateAndRunAspireStarterProject() using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); // Prepare Docker environment (prompt counting, umask, env vars) await auto.PrepareDockerEnvironmentAsync(counter, workspace); @@ -102,12 +101,6 @@ await auto.WaitUntilAsync(s => // Stop the running apphost with Ctrl+C await auto.Ctrl().KeyAsync(Hex1bKey.C); await auto.WaitForSuccessPromptAsync(counter); - - // Exit the shell - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } /// diff --git a/tests/Aspire.Cli.EndToEnd.Tests/EmptyAppHostTemplateTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/EmptyAppHostTemplateTests.cs index 5c8703ffbe8..15fdae380a9 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/EmptyAppHostTemplateTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/EmptyAppHostTemplateTests.cs @@ -24,10 +24,9 @@ public async Task CreateAndRunEmptyAppHostProject() using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -41,10 +40,5 @@ public async Task CreateAndRunEmptyAppHostProject() await auto.AspireStartAsync(counter); await auto.AspireStopAsync(counter); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2EAutomatorHelpers.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2EAutomatorHelpers.cs index 579eee358b5..b2d86ca72e5 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2EAutomatorHelpers.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2EAutomatorHelpers.cs @@ -106,7 +106,7 @@ internal static async Task InstallAspireCliAsync( case CliInstallMode.PullRequest: var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); - await auto.RunCommandFailFastAsync( + await auto.RunCommandAsync( AspireCliShellCommandHelpers.GetPullRequestInstallCommand(prNumber, AspireCliShellCommandHelpers.DockerPullRequestInstallCommandPrefix), counter, TimeSpan.FromSeconds(300)); @@ -114,7 +114,7 @@ await auto.RunCommandFailFastAsync( break; case CliInstallMode.LocalArchive: - await auto.RunCommandFailFastAsync( + await auto.RunCommandAsync( AspireCliShellCommandHelpers.GetLocalArchiveInstallCommand("/tmp/aspire-cli-archives", AspireCliShellCommandHelpers.DockerPullRequestInstallCommandPrefix), counter, TimeSpan.FromSeconds(120)); @@ -122,7 +122,7 @@ await auto.RunCommandFailFastAsync( break; case CliInstallMode.InstallScript: - await auto.RunCommandFailFastAsync( + await auto.RunCommandAsync( AspireCliShellCommandHelpers.GetInstallScriptCommand(strategy, AspireCliShellCommandHelpers.DockerInstallScriptCommandPrefix), counter, TimeSpan.FromSeconds(120)); @@ -131,7 +131,7 @@ await auto.RunCommandFailFastAsync( case CliInstallMode.DotnetTool: await auto.SourceDotnetToolEnvironmentAsync(counter); - await auto.RunCommandFailFastAsync( + await auto.RunCommandAsync( AspireCliShellCommandHelpers.GetDotnetToolInstallCommandInDocker(strategy), counter, TimeSpan.FromSeconds(120)); @@ -404,7 +404,7 @@ internal static async Task InstallAspireCliInShellAsync( case CliInstallMode.LocalArchive: var archiveDir = strategy.ArchiveDir ?? throw new InvalidOperationException("LocalArchive strategy is missing the archive directory."); var localDirPrScript = AspireCliShellCommandHelpers.QuoteBashArg(Path.Combine(CliE2ETestHelpers.GetRepoRoot(), "eng", "scripts", "get-aspire-cli-pr.sh")); - await auto.RunCommandFailFastAsync( + await auto.RunCommandAsync( AspireCliShellCommandHelpers.GetLocalArchiveInstallCommand(archiveDir, $"bash {localDirPrScript}"), counter, TimeSpan.FromSeconds(120)); @@ -413,7 +413,7 @@ await auto.RunCommandFailFastAsync( case CliInstallMode.InstallScript: var getAspireCliScript = AspireCliShellCommandHelpers.QuoteBashArg(Path.Combine(CliE2ETestHelpers.GetRepoRoot(), "eng", "scripts", "get-aspire-cli.sh")); - await auto.RunCommandFailFastAsync( + await auto.RunCommandAsync( AspireCliShellCommandHelpers.GetInstallScriptCommand(strategy, $"bash {getAspireCliScript}"), counter, TimeSpan.FromSeconds(120)); @@ -568,7 +568,7 @@ internal static async Task InstallAspireCliFromPullRequestAsync( SequenceCounter counter) { var command = AspireCliShellCommandHelpers.GetPullRequestInstallCommand(prNumber, AspireCliShellCommandHelpers.MainPullRequestInstallCommandPrefix); - await auto.RunCommandFailFastAsync(command, counter, TimeSpan.FromSeconds(300)); + await auto.RunCommandAsync(command, counter, TimeSpan.FromSeconds(300)); } /// @@ -705,7 +705,7 @@ internal static async Task InstallAspireCliVersionAsync( var command = AspireCliShellCommandHelpers.GetInstallScriptCommand( CliInstallStrategy.FromVersion(version), AspireCliShellCommandHelpers.MainInstallScriptCommandPrefix); - await auto.RunCommandFailFastAsync(command, counter, TimeSpan.FromSeconds(300)); + await auto.RunCommandAsync(command, counter, TimeSpan.FromSeconds(300)); } /// @@ -774,7 +774,7 @@ await auto.TypeAsync( throw new InvalidOperationException( workspacePath is null || !ShouldCaptureWorkspaceDiagnostics() ? "aspire start failed. Check terminal output for CLI logs." - : $"aspire start failed. Workspace: {workspacePath}. See _aspire-detach.log, _aspire-cli.log, .aspire-logs, and _aspire-start.json in the captured workspace."); + : $"aspire start failed. Workspace: {workspacePath}. See {DiagnosticsDirectoryName}/ in the captured workspace."); } await auto.TypeAsync( @@ -833,7 +833,7 @@ await auto.WaitUntilAsync(snapshot => throw new InvalidOperationException( workspacePath is null || !ShouldCaptureWorkspaceDiagnostics() ? "aspire start did not return a dashboard URL. Check terminal output for detached child and CLI logs." - : $"aspire start did not return a dashboard URL. Workspace: {workspacePath}. See _aspire-detach.log, _aspire-cli.log, .aspire-logs, and _aspire-start.json in the captured workspace."); + : $"aspire start did not return a dashboard URL. Workspace: {workspacePath}. See {DiagnosticsDirectoryName}/ in the captured workspace."); } // Check whether $DASHBOARD_URL was set using variable expansion so the marker @@ -995,22 +995,29 @@ await auto.TypeAsync( await auto.WaitForSuccessPromptAsync(counter); } + /// + /// The well-known subdirectory name under the workspace where diagnostics are captured. + /// Both the in-Docker bash capture and the host-side copy use this name. + /// + internal const string DiagnosticsDirectoryName = ".aspire-diagnostics"; + private static string BuildAspireDiagnosticsCaptureCommand(string destinationExpression) { // This returns a single bash fragment because it is reused from EXIT traps and failure paths where the helper // needs to inject one inline shell command rather than orchestrate several terminal round-trips. + // All diagnostics are placed under a single .aspire-diagnostics/ subdirectory so the host-side + // capture in TerminalRun can copy one directory instead of enumerating individual files. + var diag = $"{destinationExpression}/{DiagnosticsDirectoryName}"; return - $"mkdir -p \"{destinationExpression}\"; " + - $"rm -rf \"{destinationExpression}/.aspire-logs\" \"{destinationExpression}/.aspire-packages\" \"{destinationExpression}/.aspire-dcp-logs\"; " + - $"cp -r ~/.aspire/logs \"{destinationExpression}/.aspire-logs\" 2>/dev/null || true; " + - $"cp -r ~/.aspire/packages \"{destinationExpression}/.aspire-packages\" 2>/dev/null || true; " + - $"cp -r ~/.aspire/dcp-logs \"{destinationExpression}/.aspire-dcp-logs\" 2>/dev/null || true; " + - $"cp {AspireStartJsonFile} \"{destinationExpression}/_aspire-start.json\" 2>/dev/null || true; " + - "DETACH_LOG=$(ls -t ~/.aspire/logs/cli_*detach*.log 2>/dev/null | head -1); " + - $"[ -n \"$DETACH_LOG\" ] && cp \"$DETACH_LOG\" \"{destinationExpression}/_aspire-detach.log\" 2>/dev/null || true; " + - "CLI_LOG=$(ls -t ~/.aspire/logs/cli_*.log 2>/dev/null | grep -v 'detach' | head -1); " + - "if [ -z \"$CLI_LOG\" ]; then CLI_LOG=$(ls -t ~/.aspire/logs/cli_*.log 2>/dev/null | head -1); fi; " + - $"[ -n \"$CLI_LOG\" ] && cp \"$CLI_LOG\" \"{destinationExpression}/_aspire-cli.log\" 2>/dev/null || true; "; + $"mkdir -p \"{diag}\"; " + + $"rm -rf \"{diag}/logs\" \"{diag}/packages\" \"{diag}/dcp-logs\"; " + + $"cp -r ~/.aspire/logs \"{diag}/logs\" 2>/dev/null || true; " + + $"cp -r ~/.aspire/packages \"{diag}/packages\" 2>/dev/null || true; " + + $"cp -r ~/.aspire/dcp-logs \"{diag}/dcp-logs\" 2>/dev/null || true; " + + $"cp {AspireStartJsonFile} \"{diag}/aspire-start.json\" 2>/dev/null || true; " + + $"echo \"diagnostics: logs=$(find \"{diag}/logs\" -type f 2>/dev/null | wc -l) " + + $"packages=$(find \"{diag}/packages\" -type f 2>/dev/null | wc -l) " + + $"dcp-logs=$(find \"{diag}/dcp-logs\" -type f 2>/dev/null | wc -l)\"; "; } private static string? GetRegisteredWorkspacePath() diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs index 0b70b1db12f..da85d0f916f 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs @@ -8,6 +8,7 @@ using System.Xml.Linq; using Aspire.Cli.Tests.Utils; using Hex1b; +using Hex1b.Automation; using Xunit; namespace Aspire.Cli.EndToEnd.Tests.Helpers; @@ -127,6 +128,22 @@ internal static Hex1bTerminal CreateTestTerminal(int width = 160, int height = 4 .Build(); } + /// + /// Starts the terminal run and returns a that captures diagnostics + /// and exits the terminal on disposal. + /// + /// The Hex1b terminal to run. + /// The workspace for diagnostic capture. + /// The automator used to drive the terminal. + /// The sequence counter for prompt tracking. + /// Cancellation token passed to . + /// A that ensures diagnostics capture and clean exit on disposal. + internal static TerminalRun StartRun(Hex1bTerminal terminal, TemporaryWorkspace workspace, Hex1bTerminalAutomator automator, SequenceCounter counter, ITestOutputHelper output, CancellationToken cancellationToken) + { + var pendingRun = terminal.RunAsync(cancellationToken); + return new TerminalRun(pendingRun, automator, counter, workspace, output); + } + /// /// Specifies which Dockerfile variant to use for the test container. /// diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/TerminalRun.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/TerminalRun.cs new file mode 100644 index 00000000000..d8e25e951ef --- /dev/null +++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/TerminalRun.cs @@ -0,0 +1,157 @@ +// 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.Tests.Utils; +using Hex1b.Automation; +using Xunit; + +namespace Aspire.Cli.EndToEnd.Tests.Helpers; + +/// +/// Wraps a terminal run session and ensures diagnostics are captured and the terminal is properly +/// exited on disposal. Use via to consistently capture +/// diagnostics at the end of every CLI E2E test. +/// +internal sealed class TerminalRun : IAsyncDisposable +{ + private readonly Task _pendingRun; + private readonly Hex1bTerminalAutomator _automator; + private readonly SequenceCounter _counter; + private readonly TemporaryWorkspace _workspace; + private readonly ITestOutputHelper _output; + + internal TerminalRun(Task pendingRun, Hex1bTerminalAutomator automator, SequenceCounter counter, TemporaryWorkspace workspace, ITestOutputHelper output) + { + _pendingRun = pendingRun; + _automator = automator; + _counter = counter; + _workspace = workspace; + _output = output; + } + + public async ValueTask DisposeAsync() + { + // Capture diagnostics (best effort) + try + { + await _automator.CaptureAspireDiagnosticsAsync(_counter, _workspace); + } + catch + { + // Best effort diagnostics capture — don't mask the original test failure. + } + + // Exit the terminal (best effort) + try + { + await _automator.TypeAsync("exit"); + await _automator.EnterAsync(); + } + catch + { + // Best effort exit — the terminal may already be closed. + } + + // Wait for the terminal process to finish + try + { + await _pendingRun; + } + catch + { + // Best effort — if the test body threw, we don't want to mask it. + } + + // Copy workspace diagnostics to the host-side testresults directory so they appear + // in CI artifacts. The in-Docker capture (CaptureAspireDiagnosticsAsync / EXIT trap) + // writes files to the workspace volume mount, but that temp directory is not in the + // CI-uploaded testresults/ path. This step bridges that gap. + try + { + CaptureWorkspaceDiagnosticsToTestResults(); + } + catch + { + // Best effort — don't mask the original test failure. + } + } + + /// + /// Copies the diagnostics directory from the workspace temp directory to the testresults path + /// that CI uploads as artifacts. The in-Docker capture writes everything under a single + /// subdirectory, so the host + /// side only needs to copy that one directory. + /// + private void CaptureWorkspaceDiagnosticsToTestResults() + { + var diagnosticsSource = Path.Combine(_workspace.WorkspaceRoot.FullName, CliE2EAutomatorHelpers.DiagnosticsDirectoryName); + if (!Directory.Exists(diagnosticsSource)) + { + WriteTestOutput($"[TerminalRun] No diagnostics directory found at: {diagnosticsSource}"); + return; + } + + var testName = TestContext.Current?.TestCase is { TestMethodName: { } methodName } + ? methodName + : "unknown"; + + var destDir = GetDiagnosticsCapturePath(testName); + CopyDirectoryIfExists(diagnosticsSource, destDir); + + WriteTestOutput($"[TerminalRun] Captured diagnostics to: {destDir}"); + WriteTestOutput($"[TerminalRun] Source workspace: {_workspace.WorkspaceRoot.FullName}"); + + // Report file counts per subdirectory so CI logs show what was actually captured. + foreach (var subDir in Directory.GetDirectories(destDir)) + { + var fileCount = Directory.GetFiles(subDir, "*", SearchOption.AllDirectories).Length; + WriteTestOutput($"[TerminalRun] {Path.GetFileName(subDir)}/: {fileCount} file(s)"); + } + + // Count top-level files (e.g. aspire-start.json) + var topLevelFiles = Directory.GetFiles(destDir); + if (topLevelFiles.Length > 0) + { + WriteTestOutput($"[TerminalRun] (root): {topLevelFiles.Length} file(s)"); + } + } + + private static string GetDiagnosticsCapturePath(string testName) + { + var githubWorkspace = Environment.GetEnvironmentVariable("GITHUB_WORKSPACE"); + + if (!string.IsNullOrEmpty(githubWorkspace)) + { + // CI environment — write to testresults/ so upload-artifact includes these files. + return Path.Combine(githubWorkspace, "testresults", "workspaces", testName); + } + + // Local development — keep diagnostics with other test output. + return Path.Combine(AppContext.BaseDirectory, "TestResults", "workspaces", testName); + } + + private static void CopyDirectoryIfExists(string source, string destination) + { + if (!Directory.Exists(source)) + { + return; + } + + Directory.CreateDirectory(destination); + + foreach (var file in Directory.GetFiles(source)) + { + File.Copy(file, Path.Combine(destination, Path.GetFileName(file)), overwrite: true); + } + + foreach (var dir in Directory.GetDirectories(source)) + { + CopyDirectoryIfExists(dir, Path.Combine(destination, Path.GetFileName(dir))); + } + } + + private void WriteTestOutput(string message) + { + _output.WriteLine(message); + } +} diff --git a/tests/Aspire.Cli.EndToEnd.Tests/JavaCodegenValidationTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/JavaCodegenValidationTests.cs index 86642259236..3337b8eae0a 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/JavaCodegenValidationTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/JavaCodegenValidationTests.cs @@ -22,11 +22,9 @@ public async Task RestoreGeneratesSdkFiles() var workspace = TemporaryWorkspace.Create(output); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, variant: CliE2ETestHelpers.DockerfileVariant.PolyglotJava, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -96,10 +94,5 @@ public async Task RestoreGeneratesSdkFiles() { throw new InvalidOperationException("IDistributedApplicationBuilder.java does not contain addSqlServer from Aspire.Hosting.SqlServer"); } - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/JavaEmptyAppHostTemplateTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/JavaEmptyAppHostTemplateTests.cs index 9c32d1a5355..462117918bc 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/JavaEmptyAppHostTemplateTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/JavaEmptyAppHostTemplateTests.cs @@ -24,11 +24,9 @@ public async Task CreateAndRunJavaEmptyAppHostProject() var workspace = TemporaryWorkspace.Create(output); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, variant: CliE2ETestHelpers.DockerfileVariant.PolyglotJava, mountDockerSocket: true, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -46,10 +44,5 @@ public async Task CreateAndRunJavaEmptyAppHostProject() await auto.AspireStartAsync(counter); await auto.AspireStopAsync(counter); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/JavaPolyglotApphostDirectoryTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/JavaPolyglotApphostDirectoryTests.cs index a4225e9e3c2..9afd2c67471 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/JavaPolyglotApphostDirectoryTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/JavaPolyglotApphostDirectoryTests.cs @@ -32,11 +32,9 @@ public async Task StopJavaPolyglotAppHostUsingApphostDirectory() var workspace = TemporaryWorkspace.Create(output); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, variant: CliE2ETestHelpers.DockerfileVariant.PolyglotJava, mountDockerSocket: true, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -109,10 +107,5 @@ void main(String[] args) throws Exception { await auto.EnterAsync(); await auto.WaitUntilAppHostStoppedSuccessfullyAsync(timeout: TimeSpan.FromMinutes(1)); await auto.WaitForSuccessPromptAsync(counter); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/JavaPolyglotTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/JavaPolyglotTests.cs index f8eb0bcaa7c..16a50de7505 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/JavaPolyglotTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/JavaPolyglotTests.cs @@ -22,11 +22,9 @@ public async Task CreateJavaAppHostWithViteApp() var workspace = TemporaryWorkspace.Create(output); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, variant: CliE2ETestHelpers.DockerfileVariant.PolyglotJava, mountDockerSocket: true, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -76,9 +74,5 @@ void main(String[] args) throws Exception { await auto.Ctrl().KeyAsync(Hex1b.Input.Hex1bKey.C); await auto.WaitForSuccessPromptAsync(counter); - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/JavaScriptPublishTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/JavaScriptPublishTests.cs index 3a11453cfcc..f3badac9aae 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/JavaScriptPublishTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/JavaScriptPublishTests.cs @@ -31,9 +31,9 @@ public async Task AllPublishMethodsBuildDockerImages() using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -85,10 +85,6 @@ public async Task AllPublishMethodsBuildDockerImages() await auto.TypeAsync("docker ps -q --filter label=com.docker.compose.project | xargs -r docker rm -f 2>/dev/null || true"); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30)); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - await pendingRun; } [Fact] @@ -103,17 +99,16 @@ public async Task JavaScriptHostingApisRunFromTypeScriptAppHost() using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, variant: CliE2ETestHelpers.DockerfileVariant.Polyglot, workspace: workspace); - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); - var testBodyFailed = false; + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); try { await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); - await auto.RunCommandFailFastAsync("aspire init --language typescript --non-interactive", counter, TimeSpan.FromMinutes(2)); + await auto.RunCommandAsync("aspire init --language typescript --non-interactive", counter, TimeSpan.FromMinutes(2)); if (localChannel is not null) { @@ -128,15 +123,10 @@ public async Task JavaScriptHostingApisRunFromTypeScriptAppHost() WriteRuntimeAppHost(workspace); WriteRuntimeVerificationScript(workspace); - await auto.RunCommandFailFastAsync("unset ASPIRE_PLAYGROUND", counter); + await auto.RunCommandAsync("unset ASPIRE_PLAYGROUND", counter); - await auto.RunCommandFailFastAsync("aspire run > aspire-run.log 2>&1 & echo $! > aspire-run.pid", counter); - await auto.RunCommandFailFastAsync("bash verify-runtime.sh", counter, TimeSpan.FromMinutes(2)); - } - catch - { - testBodyFailed = true; - throw; + await auto.RunCommandAsync("aspire run > aspire-run.log 2>&1 & echo $! > aspire-run.pid", counter); + await auto.RunCommandAsync("bash verify-runtime.sh", counter, TimeSpan.FromMinutes(2)); } finally { @@ -148,29 +138,6 @@ public async Task JavaScriptHostingApisRunFromTypeScriptAppHost() { // Best effort. A failure before aspire run writes its PID leaves no process to stop. } - - try - { - await auto.CaptureAspireDiagnosticsAsync(counter, workspace); - } - catch - { - // Best effort diagnostics capture. - } - - try - { - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - await pendingRun; - } - catch - { - if (!testBodyFailed) - { - throw; - } - } } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/JsReactTemplateTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/JsReactTemplateTests.cs index d092a928b88..89e187dcc19 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/JsReactTemplateTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/JsReactTemplateTests.cs @@ -24,11 +24,9 @@ public async Task CreateAndRunJsReactProject() var workspace = TemporaryWorkspace.Create(output); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -53,10 +51,5 @@ await auto.WaitUntilAsync(s => await auto.Ctrl().KeyAsync(Hex1bKey.C); await auto.WaitForSuccessPromptAsync(counter); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployBasicApiServiceTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployBasicApiServiceTests.cs index d533effc530..1621be4b058 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployBasicApiServiceTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployBasicApiServiceTests.cs @@ -30,10 +30,9 @@ public async Task DeployK8sBasicApiService() output.WriteLine($"Namespace: {k8sNamespace}"); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); // Prepare environment await auto.PrepareDockerEnvironmentAsync(counter, workspace); @@ -135,15 +134,10 @@ await auto.VerifyDeploymentAsync( // ===================================================================== await auto.CleanupKubernetesDeploymentAsync(counter, clusterName); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); } finally { await KubernetesDeployTestHelpers.CleanupKindClusterOutOfBandAsync(clusterName, output); } - - await pendingRun; } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployTypeScriptTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployTypeScriptTests.cs index 79ff1b79c47..2b8dd03ee0e 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployTypeScriptTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployTypeScriptTests.cs @@ -31,10 +31,9 @@ public async Task DeployTypeScriptAppToKubernetes() output.WriteLine($"Namespace: {k8sNamespace}"); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -136,15 +135,10 @@ await auto.AspireDeployInteractiveAsync( // ===================================================================== await auto.CleanupKubernetesDeploymentAsync(counter, clusterName); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); } finally { await KubernetesDeployTestHelpers.CleanupKindClusterOutOfBandAsync(clusterName, output); } - - await pendingRun; } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithGarnetTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithGarnetTests.cs index 07ffc696c85..1aad9a2d420 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithGarnetTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithGarnetTests.cs @@ -30,10 +30,9 @@ public async Task DeployK8sWithGarnet() output.WriteLine($"Namespace: {k8sNamespace}"); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -129,15 +128,10 @@ await auto.VerifyDeploymentAsync( testPath: "/test-deployment"); await auto.CleanupKubernetesDeploymentAsync(counter, clusterName); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); } finally { await KubernetesDeployTestHelpers.CleanupKindClusterOutOfBandAsync(clusterName, output); } - - await pendingRun; } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithHelmChartTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithHelmChartTests.cs index 5f9dee01004..42647d84fd4 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithHelmChartTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithHelmChartTests.cs @@ -32,10 +32,9 @@ public async Task DeployK8sWithExternalHelmChart() output.WriteLine($"Namespace: {k8sNamespace}"); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); // Prepare environment await auto.PrepareDockerEnvironmentAsync(counter, workspace); @@ -162,15 +161,10 @@ await auto.TypeAsync( // ===================================================================== await auto.CleanupKubernetesDeploymentAsync(counter, clusterName); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); } finally { await KubernetesDeployTestHelpers.CleanupKindClusterOutOfBandAsync(clusterName, output); } - - await pendingRun; } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithMongoDBTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithMongoDBTests.cs index b83c7290738..d04293e73c4 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithMongoDBTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithMongoDBTests.cs @@ -30,10 +30,9 @@ public async Task DeployK8sWithMongoDB() output.WriteLine($"Namespace: {k8sNamespace}"); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -132,15 +131,10 @@ await auto.VerifyDeploymentAsync( testPath: "/test-deployment"); await auto.CleanupKubernetesDeploymentAsync(counter, clusterName); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); } finally { await KubernetesDeployTestHelpers.CleanupKindClusterOutOfBandAsync(clusterName, output); } - - await pendingRun; } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithMySqlTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithMySqlTests.cs index f097f0036be..1969e908c90 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithMySqlTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithMySqlTests.cs @@ -30,10 +30,9 @@ public async Task DeployK8sWithMySql() output.WriteLine($"Namespace: {k8sNamespace}"); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -126,15 +125,10 @@ await auto.VerifyDeploymentAsync( testPath: "/test-deployment"); await auto.CleanupKubernetesDeploymentAsync(counter, clusterName); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); } finally { await KubernetesDeployTestHelpers.CleanupKindClusterOutOfBandAsync(clusterName, output); } - - await pendingRun; } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithNatsTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithNatsTests.cs index e9f751454c1..bef24caadfe 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithNatsTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithNatsTests.cs @@ -32,10 +32,9 @@ public async Task DeployK8sWithNats() output.WriteLine($"Namespace: {k8sNamespace}"); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -125,15 +124,10 @@ await auto.VerifyDeploymentAsync( testPath: "/test-deployment"); await auto.CleanupKubernetesDeploymentAsync(counter, clusterName); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); } finally { await KubernetesDeployTestHelpers.CleanupKindClusterOutOfBandAsync(clusterName, output); } - - await pendingRun; } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithPostgresTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithPostgresTests.cs index c824c32e840..fe30d5644da 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithPostgresTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithPostgresTests.cs @@ -30,10 +30,9 @@ public async Task DeployK8sWithPostgres() output.WriteLine($"Namespace: {k8sNamespace}"); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -126,15 +125,10 @@ await auto.VerifyDeploymentAsync( testPath: "/test-deployment"); await auto.CleanupKubernetesDeploymentAsync(counter, clusterName); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); } finally { await KubernetesDeployTestHelpers.CleanupKindClusterOutOfBandAsync(clusterName, output); } - - await pendingRun; } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithRabbitMQTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithRabbitMQTests.cs index ed0498db66e..1d3a802a581 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithRabbitMQTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithRabbitMQTests.cs @@ -30,10 +30,9 @@ public async Task DeployK8sWithRabbitMQ() output.WriteLine($"Namespace: {k8sNamespace}"); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -122,15 +121,10 @@ await auto.VerifyDeploymentAsync( testPath: "/test-deployment"); await auto.CleanupKubernetesDeploymentAsync(counter, clusterName); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); } finally { await KubernetesDeployTestHelpers.CleanupKindClusterOutOfBandAsync(clusterName, output); } - - await pendingRun; } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithRedisTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithRedisTests.cs index 3dda99453f0..edb3b6fc2b6 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithRedisTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithRedisTests.cs @@ -30,10 +30,9 @@ public async Task DeployK8sWithRedis() output.WriteLine($"Namespace: {k8sNamespace}"); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -136,15 +135,10 @@ await auto.VerifyDeploymentAsync( testPath: "/test-deployment"); await auto.CleanupKubernetesDeploymentAsync(counter, clusterName); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); } finally { await KubernetesDeployTestHelpers.CleanupKindClusterOutOfBandAsync(clusterName, output); } - - await pendingRun; } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithSqlServerTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithSqlServerTests.cs index c9c1a467019..241e71c5e61 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithSqlServerTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithSqlServerTests.cs @@ -30,10 +30,9 @@ public async Task DeployK8sWithSqlServer() output.WriteLine($"Namespace: {k8sNamespace}"); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -126,15 +125,10 @@ await auto.VerifyDeploymentAsync( testPath: "/test-deployment"); await auto.CleanupKubernetesDeploymentAsync(counter, clusterName); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); } finally { await KubernetesDeployTestHelpers.CleanupKindClusterOutOfBandAsync(clusterName, output); } - - await pendingRun; } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithValkeyTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithValkeyTests.cs index fa6aadbce44..4063f2fd77c 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithValkeyTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesDeployWithValkeyTests.cs @@ -30,10 +30,9 @@ public async Task DeployK8sWithValkey() output.WriteLine($"Namespace: {k8sNamespace}"); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -129,15 +128,10 @@ await auto.VerifyDeploymentAsync( testPath: "/test-deployment"); await auto.CleanupKubernetesDeploymentAsync(counter, clusterName); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); } finally { await KubernetesDeployTestHelpers.CleanupKindClusterOutOfBandAsync(clusterName, output); } - - await pendingRun; } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishRequiresExternalEndpointTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishRequiresExternalEndpointTests.cs index 37155f272f4..15c539a16de 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishRequiresExternalEndpointTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishRequiresExternalEndpointTests.cs @@ -57,103 +57,77 @@ private async Task RunPublishFailureScenarioAsync(string appHostBodyExtension) using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace); - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); - var testBodyFailed = false; - - try - { - await auto.PrepareDockerEnvironmentAsync(counter, workspace); - await auto.InstallAspireCliAsync(strategy, counter); - - // The starter template gives us the conventional - // `{ProjectName}/{ProjectName}.AppHost/AppHost.cs` layout, matching - // KubernetesPublishTests so the AppHost-mutation logic below stays - // consistent across both tests. - await auto.AspireNewAsync(ProjectName, counter, useRedisCache: false); - - // cd into the project so subsequent `aspire add` and `aspire publish` - // commands resolve the AppHost via repo-root discovery. - await auto.TypeAsync($"cd {ProjectName}"); - await auto.EnterAsync(); - await auto.WaitForSuccessPromptAsync(counter); - - // The Kubernetes hosting package is required to compile the AppHost code - // we're about to write. `aspire add` resolves the version against the - // same feed configuration the rest of the CLI uses (including PR builds). - await auto.TypeAsync("aspire add Aspire.Hosting.Kubernetes"); - await auto.EnterAsync(); - await auto.WaitForAspireAddCompletionAsync(counter, TimeSpan.FromSeconds(180)); - - // Patch AppHost.cs in-place. The Starter template's AppHost.cs ends - // with `builder.Build().Run();`; we insert the K8s wiring immediately - // before it. Failing to find the marker should surface as a clear - // test failure rather than a silently no-op publish. - var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, ProjectName); - var appHostDir = Path.Combine(projectDir, $"{ProjectName}.AppHost"); - var appHostFilePath = Path.Combine(appHostDir, "AppHost.cs"); - var content = File.ReadAllText(appHostFilePath); - const string buildRunPattern = "builder.Build().Run();"; - Assert.Contains(buildRunPattern, content); - content = content.Replace(buildRunPattern, appHostBodyExtension + Environment.NewLine + buildRunPattern); - File.WriteAllText(appHostFilePath, content); - - // ASPIRE_PLAYGROUND interferes with `--non-interactive`. See - // KubernetesPublishTests for full context. - await auto.TypeAsync("unset ASPIRE_PLAYGROUND"); - await auto.EnterAsync(); - await auto.WaitForSuccessPromptAsync(counter); - - // Drive aspire publish. The validation throws an InvalidOperationException - // during model materialization, so publish should exit with a non-zero code - // and surface our guidance message verbatim in stderr/stdout. - await auto.TypeAsync("aspire publish -o helm-output --non-interactive"); - await auto.EnterAsync(); - - var expectedCounter = counter.Value; - // We don't pin to a specific exit code — the publish pipeline currently - // surfaces validation failures as exit 1, but treating any non-zero - // ERR:* prompt as the success condition keeps this test stable across - // future exit-code refactors. - var errorPromptSearcher = new CellPatternSearcher() - .FindPattern(expectedCounter.ToString(CultureInfo.InvariantCulture)) - .RightText(" ERR:"); - - await auto.WaitUntilAsync( - snapshot => errorPromptSearcher.Search(snapshot).Count > 0, - TimeSpan.FromMinutes(5), - description: "waiting for aspire publish to fail"); - counter.Increment(); - - // After the publish exits, scrape the screen for the guidance fragments. - // We use a generous WaitUntilTextAsync so any in-progress rendering - // settles before we assert. - await auto.WaitUntilTextAsync("WithExternalHttpEndpoints", timeout: TimeSpan.FromSeconds(30)); - await auto.WaitUntilTextAsync("'api'", timeout: TimeSpan.FromSeconds(30)); - await auto.WaitUntilTextAsync("'public'", timeout: TimeSpan.FromSeconds(30)); - } - catch - { - testBodyFailed = true; - throw; - } - finally - { - try - { - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - await pendingRun; - } - catch - { - if (!testBodyFailed) - { - throw; - } - } - } + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); + + await auto.PrepareDockerEnvironmentAsync(counter, workspace); + await auto.InstallAspireCliAsync(strategy, counter); + + // The starter template gives us the conventional + // `{ProjectName}/{ProjectName}.AppHost/AppHost.cs` layout, matching + // KubernetesPublishTests so the AppHost-mutation logic below stays + // consistent across both tests. + await auto.AspireNewAsync(ProjectName, counter, useRedisCache: false); + + // cd into the project so subsequent `aspire add` and `aspire publish` + // commands resolve the AppHost via repo-root discovery. + await auto.TypeAsync($"cd {ProjectName}"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + + // The Kubernetes hosting package is required to compile the AppHost code + // we're about to write. `aspire add` resolves the version against the + // same feed configuration the rest of the CLI uses (including PR builds). + await auto.TypeAsync("aspire add Aspire.Hosting.Kubernetes"); + await auto.EnterAsync(); + await auto.WaitForAspireAddCompletionAsync(counter, TimeSpan.FromSeconds(180)); + + // Patch AppHost.cs in-place. The Starter template's AppHost.cs ends + // with `builder.Build().Run();`; we insert the K8s wiring immediately + // before it. Failing to find the marker should surface as a clear + // test failure rather than a silently no-op publish. + var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, ProjectName); + var appHostDir = Path.Combine(projectDir, $"{ProjectName}.AppHost"); + var appHostFilePath = Path.Combine(appHostDir, "AppHost.cs"); + var content = File.ReadAllText(appHostFilePath); + const string buildRunPattern = "builder.Build().Run();"; + Assert.Contains(buildRunPattern, content); + content = content.Replace(buildRunPattern, appHostBodyExtension + Environment.NewLine + buildRunPattern); + File.WriteAllText(appHostFilePath, content); + + // ASPIRE_PLAYGROUND interferes with `--non-interactive`. See + // KubernetesPublishTests for full context. + await auto.TypeAsync("unset ASPIRE_PLAYGROUND"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + + // Drive aspire publish. The validation throws an InvalidOperationException + // during model materialization, so publish should exit with a non-zero code + // and surface our guidance message verbatim in stderr/stdout. + await auto.TypeAsync("aspire publish -o helm-output --non-interactive"); + await auto.EnterAsync(); + + var expectedCounter = counter.Value; + // We don't pin to a specific exit code — the publish pipeline currently + // surfaces validation failures as exit 1, but treating any non-zero + // ERR:* prompt as the success condition keeps this test stable across + // future exit-code refactors. + var errorPromptSearcher = new CellPatternSearcher() + .FindPattern(expectedCounter.ToString(CultureInfo.InvariantCulture)) + .RightText(" ERR:"); + + await auto.WaitUntilAsync( + snapshot => errorPromptSearcher.Search(snapshot).Count > 0, + TimeSpan.FromMinutes(5), + description: "waiting for aspire publish to fail"); + counter.Increment(); + + // After the publish exits, scrape the screen for the guidance fragments. + // We use a generous WaitUntilTextAsync so any in-progress rendering + // settles before we assert. + await auto.WaitUntilTextAsync("WithExternalHttpEndpoints", timeout: TimeSpan.FromSeconds(30)); + await auto.WaitUntilTextAsync("'api'", timeout: TimeSpan.FromSeconds(30)); + await auto.WaitUntilTextAsync("'public'", timeout: TimeSpan.FromSeconds(30)); } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishTests.cs index 02960bd64a3..757c6b50a61 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishTests.cs @@ -42,11 +42,9 @@ public async Task CreateAndPublishToKubernetes() output.WriteLine($"Using cluster name: {clusterName}"); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -291,9 +289,6 @@ await auto.TypeAsync("helm install aspire-app helm-output " + await auto.TypeAsync($"kind delete cluster --name={clusterName}"); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(60)); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); } finally { @@ -316,7 +311,5 @@ await auto.TypeAsync("helm install aspire-app helm-output " + output.WriteLine($"Cleanup: Failed to delete KinD cluster '{clusterName}': {ex.Message}"); } } - - await pendingRun; } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/ListStepsTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/ListStepsTests.cs index 831248ddb38..65800cca286 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/ListStepsTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/ListStepsTests.cs @@ -25,11 +25,9 @@ public async Task DoPublishAndDeployListStepsWork() var workspace = TemporaryWorkspace.Create(output); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -96,8 +94,5 @@ await auto.WaitUntilAsync(s => await auto.WaitForSuccessPromptAsync(counter); // Exit the terminal - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - await pendingRun; } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/LocalConfigMigrationTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/LocalConfigMigrationTests.cs index 0655bf36673..20ed5a162f7 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/LocalConfigMigrationTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/LocalConfigMigrationTests.cs @@ -47,11 +47,9 @@ public async Task LegacySettingsMigration_AdjustsRelativeAppHostPath() variant: CliE2ETestHelpers.DockerfileVariant.Polyglot, mountDockerSocket: true, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -107,11 +105,6 @@ public async Task LegacySettingsMigration_AdjustsRelativeAppHostPath() var content = File.ReadAllText(configPath); Assert.DoesNotContain("\"../apphost.mts\"", content); Assert.Contains("\"apphost.mts\"", content); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } [CaptureWorkspaceOnFailure] @@ -127,11 +120,9 @@ public async Task AspireStartUpdatesStaleTypeScriptAppHostPath() variant: CliE2ETestHelpers.DockerfileVariant.Polyglot, mountDockerSocket: true, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -170,10 +161,5 @@ public async Task AspireStartUpdatesStaleTypeScriptAppHostPath() await auto.TypeAsync("aspire stop --apphost apphost.mts"); await auto.EnterAsync(); await auto.WaitForAnyPromptAsync(counter, timeout: TimeSpan.FromMinutes(1)); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/LogLevelTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/LogLevelTests.cs index 2f87ece2713..1db58abd5b2 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/LogLevelTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/LogLevelTests.cs @@ -25,82 +25,49 @@ public async Task LogLevelTrace_ProducesTraceEntriesInCliLogFile() using var workspace = TemporaryWorkspace.Create(output); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); - var testBodyFailed = false; + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); - try - { - await auto.PrepareDockerEnvironmentAsync(counter, workspace, enableDcpDiagnostics: true); - await auto.InstallAspireCliAsync(strategy, counter); + await auto.PrepareDockerEnvironmentAsync(counter, workspace, enableDcpDiagnostics: true); + await auto.InstallAspireCliAsync(strategy, counter); - // Create a new empty AppHost project - await auto.AspireNewCSharpEmptyAppHostAsync("LogLevelApp", counter); + // Create a new empty AppHost project + await auto.AspireNewCSharpEmptyAppHostAsync("LogLevelApp", counter); - // Navigate to the AppHost directory - await auto.TypeAsync("cd LogLevelApp"); - await auto.EnterAsync(); - await auto.WaitForSuccessPromptAsync(counter); + // Navigate to the AppHost directory + await auto.TypeAsync("cd LogLevelApp"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); - // Start the AppHost with --log-level trace so both the CLI and the - // AppHost produce trace-level output in the CLI log file. - await auto.TypeAsync("aspire start --log-level trace"); - await auto.EnterAsync(); - await auto.WaitForSuccessPromptAsync(counter, timeout: TimeSpan.FromMinutes(3)); + // Start the AppHost with --log-level trace so both the CLI and the + // AppHost produce trace-level output in the CLI log file. + await auto.TypeAsync("aspire start --log-level trace"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter, timeout: TimeSpan.FromMinutes(3)); - // Stop the AppHost so the log file is flushed and closed. - await auto.AspireStopAsync(counter); + // Stop the AppHost so the log file is flushed and closed. + await auto.AspireStopAsync(counter); - // Find the most recent CLI log file (the detached child writes its own log). - // The detached process log usually contains "detach" in the name. - await auto.TypeAsync( + // Find the most recent CLI log file (the detached child writes its own log). + // The detached process log usually contains "detach" in the name. + await auto.TypeAsync( "DETACH_LOG=$(ls -t ~/.aspire/logs/cli_*detach*.log 2>/dev/null | head -1); " + "if [ -z \"$DETACH_LOG\" ]; then DETACH_LOG=$(ls -t ~/.aspire/logs/cli_*.log 2>/dev/null | head -1); fi; " + "echo \"LOG_FILE:$DETACH_LOG\""); - await auto.EnterAsync(); - await auto.WaitForSuccessPromptAsync(counter); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); - // Check for trace-level AppHost log entry (format: [TRCE] [AppHost/...]) - await auto.RunCommandFailFastAsync( + // Check for trace-level AppHost log entry (format: [TRCE] [AppHost/...]) + await auto.RunCommandAsync( "test -n \"$DETACH_LOG\" && grep -q '\\[TRCE\\] \\[AppHost/' \"$DETACH_LOG\"", counter, TimeSpan.FromSeconds(10)); - // Check for trace-level CLI log entry from the Features category - await auto.RunCommandFailFastAsync( + // Check for trace-level CLI log entry from the Features category + await auto.RunCommandAsync( "test -n \"$DETACH_LOG\" && grep -q '\\[TRCE\\] \\[Features\\]' \"$DETACH_LOG\"", counter, TimeSpan.FromSeconds(10)); - } - catch - { - testBodyFailed = true; - throw; - } - finally - { - try - { - await auto.CaptureAspireDiagnosticsAsync(counter, workspace); - } - catch { } // Best effort - - try - { - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - await pendingRun; - } - catch - { - if (!testBodyFailed) - { - throw; - } - } - } } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/LogsCommandTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/LogsCommandTests.cs index 58b36fdc1a4..9034518c671 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/LogsCommandTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/LogsCommandTests.cs @@ -24,11 +24,9 @@ public async Task LogsCommandShowsResourceLogs() var workspace = TemporaryWorkspace.Create(output); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); @@ -85,11 +83,5 @@ public async Task LogsCommandShowsResourceLogs() await auto.EnterAsync(); await auto.WaitUntilAppHostStoppedSuccessfullyAsync(timeout: TimeSpan.FromMinutes(1)); await auto.WaitForSuccessPromptAsync(counter); - - // Exit the shell - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/MultipleAppHostTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/MultipleAppHostTests.cs index 4966eadb5cd..5f2bb9b91e2 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/MultipleAppHostTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/MultipleAppHostTests.cs @@ -23,11 +23,9 @@ public async Task DetachFormatJsonProducesValidJson() var workspace = TemporaryWorkspace.Create(output); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); @@ -71,10 +69,6 @@ public async Task DetachFormatJsonProducesValidJson() // Clean up: stop any running instances await auto.AspireStopAsync(counter); - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } [Fact] @@ -86,11 +80,9 @@ public async Task DetachFormatJsonProducesValidJsonWhenRestartingExistingInstanc var workspace = TemporaryWorkspace.Create(output); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); @@ -126,9 +118,5 @@ public async Task DetachFormatJsonProducesValidJsonWhenRestartingExistingInstanc await auto.TypeAsync("aspire stop --all 2>/dev/null || true"); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter); - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/NewWithAgentInitTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/NewWithAgentInitTests.cs index e886e27db21..5682523299a 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/NewWithAgentInitTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/NewWithAgentInitTests.cs @@ -37,11 +37,9 @@ public async Task AspireNew_WithAgentInit_InstallsPlaywrightWithoutErrors() var workspace = TemporaryWorkspace.Create(output); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -158,10 +156,5 @@ await auto.WaitUntilAsync(s => await auto.EnterAsync(); await auto.WaitUntilTextAsync("SKILL.md", timeout: TimeSpan.FromSeconds(10)); await auto.WaitForSuccessPromptAsync(counter); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/OtelLogsTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/OtelLogsTests.cs index a0351924f3d..71bd820b9bd 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/OtelLogsTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/OtelLogsTests.cs @@ -33,11 +33,9 @@ private async Task OtelLogsReturnsStructuredLogsFromStarterAppCore(bool isolated using var workspace = TemporaryWorkspace.Create(output); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace, testName: testName); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -88,11 +86,5 @@ private async Task OtelLogsReturnsStructuredLogsFromStarterAppCore(bool isolated // Stop the AppHost await auto.AspireStopAsync(counter); - - // Exit the shell - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/PlaywrightCliInstallTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/PlaywrightCliInstallTests.cs index 7c9bec1b3ce..1093a54bbf6 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/PlaywrightCliInstallTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/PlaywrightCliInstallTests.cs @@ -32,11 +32,9 @@ public async Task AgentInit_InstallsPlaywrightCli_AndGeneratesSkillFiles() var workspace = TemporaryWorkspace.Create(output); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); @@ -74,11 +72,6 @@ public async Task AgentInit_InstallsPlaywrightCli_AndGeneratesSkillFiles() await auto.EnterAsync(); await auto.WaitUntilTextAsync("SKILL.md", timeout: TimeSpan.FromSeconds(10)); await auto.WaitForSuccessPromptAsync(counter); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } /// @@ -98,11 +91,9 @@ public async Task AgentInit_WhenCwdDiffersFromWorkspaceRoot_PlacesSkillFilesInWo var workspace = TemporaryWorkspace.Create(output); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); @@ -115,7 +106,7 @@ public async Task AgentInit_WhenCwdDiffersFromWorkspaceRoot_PlacesSkillFilesInWo // Crucially, do NOT cd into the project — stay in the parent directory. await auto.TypeAsync("mkdir -p TestProject/.claude"); await auto.EnterAsync(); - await auto.WaitForSuccessPromptFailFastAsync(counter); + await auto.WaitForSuccessPromptAsync(counter); // Step 3: Run aspire agent init from the PARENT directory for Playwright // only. When provided as options, the workspace root and skill selection @@ -124,23 +115,18 @@ public async Task AgentInit_WhenCwdDiffersFromWorkspaceRoot_PlacesSkillFilesInWo await auto.EnterAsync(); await auto.WaitUntilTextAsync("configuration complete", timeout: TimeSpan.FromMinutes(3)); - await auto.WaitForSuccessPromptFailFastAsync(counter); + await auto.WaitForSuccessPromptAsync(counter); // Step 4: Verify skill file exists in the workspace root (project subdirectory). await auto.TypeAsync("ls TestProject/.claude/skills/playwright-cli/SKILL.md"); await auto.EnterAsync(); await auto.WaitUntilTextAsync("SKILL.md", timeout: TimeSpan.FromSeconds(10)); - await auto.WaitForSuccessPromptFailFastAsync(counter); + await auto.WaitForSuccessPromptAsync(counter); // Step 5: Verify no stray skill files were created in the CWD (parent directory). await auto.TypeAsync("test -d .claude/skills/playwright-cli && echo 'STRAY_FILES_FOUND' || echo 'NO_STRAY_FILES'"); await auto.EnterAsync(); await auto.WaitUntilTextAsync("NO_STRAY_FILES", timeout: TimeSpan.FromSeconds(10)); - await auto.WaitForSuccessPromptFailFastAsync(counter); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; + await auto.WaitForSuccessPromptAsync(counter); } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/PodmanDeploymentTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/PodmanDeploymentTests.cs index d5137e9432e..31ff35eed39 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/PodmanDeploymentTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/PodmanDeploymentTests.cs @@ -27,11 +27,9 @@ public async Task CreateAndDeployToDockerComposeWithPodman() var strategy = CliInstallStrategy.Detect(output.WriteLine); using var terminal = CliE2ETestHelpers.CreatePodmanDockerTestTerminal(repoRoot, strategy, output, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -113,10 +111,5 @@ public async Task CreateAndDeployToDockerComposeWithPodman() // Step 11: Clean up - destroy the deployment using aspire destroy await auto.AspireDestroyAsync(counter); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/ProjectReferenceTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/ProjectReferenceTests.cs index 9abd2a3a6c3..49349afa229 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/ProjectReferenceTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/ProjectReferenceTests.cs @@ -27,11 +27,9 @@ public async Task TypeScriptAppHostWithProjectReferenceIntegration() var workspace = TemporaryWorkspace.Create(output); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); @@ -185,9 +183,5 @@ await auto.WaitUntilAsync(s => await auto.TypeAsync("aspire stop"); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter); - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/PsCommandTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/PsCommandTests.cs index 00091c464cc..84bb0960557 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/PsCommandTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/PsCommandTests.cs @@ -24,11 +24,9 @@ public async Task PsCommandListsRunningAppHost() var workspace = TemporaryWorkspace.Create(output); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); @@ -77,12 +75,6 @@ public async Task PsCommandListsRunningAppHost() await auto.EnterAsync(); await auto.WaitUntilTextAsync(SharedCommandStrings.AppHostNotRunning, timeout: TimeSpan.FromSeconds(30)); await auto.WaitForSuccessPromptAsync(counter); - - // Exit the shell - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } [Fact] @@ -94,11 +86,9 @@ public async Task PsFormatJsonOutputsOnlyJsonToStdout() var workspace = TemporaryWorkspace.Create(output); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); @@ -119,11 +109,5 @@ public async Task PsFormatJsonOutputsOnlyJsonToStdout() // Verify the file contains only the expected JSON output (empty array). var content = File.ReadAllText(outputFilePath).Trim(); Assert.Equal("[]", content); - - // Exit the shell - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/PythonReactTemplateTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/PythonReactTemplateTests.cs index 9d16f783c7b..064a10d2772 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/PythonReactTemplateTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/PythonReactTemplateTests.cs @@ -23,11 +23,9 @@ public async Task CreateAndRunPythonReactProject() var workspace = TemporaryWorkspace.Create(output); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -47,15 +45,10 @@ public async Task CreateAndRunPythonReactProject() await auto.WaitForSuccessPromptAsync(counter); // Step 3: Verify the generated TypeScript AppHost builds successfully. - await auto.RunCommandFailFastAsync("npm run build", counter, TimeSpan.FromMinutes(2)); + await auto.RunCommandAsync("npm run build", counter, TimeSpan.FromMinutes(2)); // Step 4: Start and stop the project await auto.AspireStartAsync(counter); await auto.AspireStopAsync(counter); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/ResourceCommandTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/ResourceCommandTests.cs index b32fd53f209..19203418cf3 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/ResourceCommandTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/ResourceCommandTests.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// 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.EndToEnd.Tests.Helpers; @@ -27,121 +27,84 @@ public async Task ResourceCommand_SetAndDeleteParameterUpdatesDescribeOutput() var workspace = TemporaryWorkspace.Create(output); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); - var testBodyFailed = false; - - try - { - await auto.PrepareDockerEnvironmentAsync(counter, workspace); - await auto.InstallAspireCliAsync(strategy, counter); - await auto.AspireNewAsync(projectName, counter, template: AspireTemplate.EmptyAppHost); - - await auto.TypeAsync($"cd {projectName}"); - await auto.EnterAsync(); - await auto.WaitForSuccessPromptAsync(counter); - - var appHostFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, projectName, "apphost.cs"); - var content = File.ReadAllText(appHostFilePath); - var sdkLine = content.Split('\n', 2)[0].TrimEnd('\r'); - - var newContent = $$""" - {{sdkLine}} - - var builder = DistributedApplication.CreateBuilder(args); - - builder.AddParameter("greeting"); - - builder.Build().Run(); - """; - - File.WriteAllText(appHostFilePath, newContent); - - await auto.TypeAsync("aspire start"); - await auto.EnterAsync(); - await auto.WaitUntilTextAsync(RunCommandStrings.AppHostStartedSuccessfully, timeout: TimeSpan.FromMinutes(3)); - await auto.WaitForSuccessPromptAsync(counter); - - await auto.TypeAsync("aspire describe greeting --format json > greeting-unset.json"); - await auto.EnterAsync(); - await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30)); - - await auto.TypeAsync("jq -er '.resources[0].state' greeting-unset.json"); - await auto.EnterAsync(); - await auto.WaitUntilTextAsync("ValueMissing", timeout: TimeSpan.FromSeconds(10)); - await auto.WaitForSuccessPromptAsync(counter); - - await auto.TypeAsync("aspire resource greeting set-parameter --value 'Hello world'"); - await auto.EnterAsync(); - await auto.WaitUntilTextAsync("Resource 'greeting' set successfully.", timeout: TimeSpan.FromSeconds(30)); - await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30)); - - await auto.TypeAsync("aspire describe greeting --format json > greeting-set.json"); - await auto.EnterAsync(); - await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30)); - - await auto.TypeAsync("jq -er '.resources[0].state' greeting-set.json"); - await auto.EnterAsync(); - await auto.WaitUntilTextAsync("Running", timeout: TimeSpan.FromSeconds(10)); - await auto.WaitForSuccessPromptAsync(counter); - - await auto.TypeAsync("jq -er '.resources[0].properties.Value' greeting-set.json"); - await auto.EnterAsync(); - await auto.WaitUntilTextAsync("Hello world", timeout: TimeSpan.FromSeconds(10)); - await auto.WaitForSuccessPromptAsync(counter); - - await auto.TypeAsync("aspire resource greeting delete-parameter"); - await auto.EnterAsync(); - await auto.WaitUntilTextAsync("Resource 'greeting' deleted successfully.", timeout: TimeSpan.FromSeconds(30)); - await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30)); - - await auto.TypeAsync("aspire describe greeting --format json > greeting-deleted.json"); - await auto.EnterAsync(); - await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30)); - - await auto.TypeAsync("jq -er '.resources[0].state' greeting-deleted.json"); - await auto.EnterAsync(); - await auto.WaitUntilTextAsync("ValueMissing", timeout: TimeSpan.FromSeconds(10)); - await auto.WaitForSuccessPromptAsync(counter); - - await auto.TypeAsync("aspire stop"); - await auto.EnterAsync(); - await auto.WaitUntilAppHostStoppedSuccessfullyAsync(timeout: TimeSpan.FromMinutes(1)); - await auto.WaitForSuccessPromptAsync(counter); - } - catch - { - testBodyFailed = true; - throw; - } - finally - { - try - { - await auto.CaptureAspireDiagnosticsAsync(counter, workspace); - } - catch - { - // Best effort diagnostics capture. - } - - try - { - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - await pendingRun; - } - catch - { - if (!testBodyFailed) - { - throw; - } - } - } + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); + await auto.PrepareDockerEnvironmentAsync(counter, workspace); + await auto.InstallAspireCliAsync(strategy, counter); + await auto.AspireNewAsync(projectName, counter, template: AspireTemplate.EmptyAppHost); + + await auto.TypeAsync($"cd {projectName}"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + + var appHostFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, projectName, "apphost.cs"); + var content = File.ReadAllText(appHostFilePath); + var sdkLine = content.Split('\n', 2)[0].TrimEnd('\r'); + + var newContent = $$""" + {{sdkLine}} + + var builder = DistributedApplication.CreateBuilder(args); + + builder.AddParameter("greeting"); + + builder.Build().Run(); + """; + + File.WriteAllText(appHostFilePath, newContent); + + await auto.TypeAsync("aspire start"); + await auto.EnterAsync(); + await auto.WaitUntilTextAsync(RunCommandStrings.AppHostStartedSuccessfully, timeout: TimeSpan.FromMinutes(3)); + await auto.WaitForSuccessPromptAsync(counter); + + await auto.TypeAsync("aspire describe greeting --format json > greeting-unset.json"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30)); + + await auto.TypeAsync("jq -er '.resources[0].state' greeting-unset.json"); + await auto.EnterAsync(); + await auto.WaitUntilTextAsync("ValueMissing", timeout: TimeSpan.FromSeconds(10)); + await auto.WaitForSuccessPromptAsync(counter); + + await auto.TypeAsync("aspire resource greeting set-parameter --value 'Hello world'"); + await auto.EnterAsync(); + await auto.WaitUntilTextAsync("Resource 'greeting' set successfully.", timeout: TimeSpan.FromSeconds(30)); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30)); + + await auto.TypeAsync("aspire describe greeting --format json > greeting-set.json"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30)); + + await auto.TypeAsync("jq -er '.resources[0].state' greeting-set.json"); + await auto.EnterAsync(); + await auto.WaitUntilTextAsync("Running", timeout: TimeSpan.FromSeconds(10)); + await auto.WaitForSuccessPromptAsync(counter); + + await auto.TypeAsync("jq -er '.resources[0].properties.Value' greeting-set.json"); + await auto.EnterAsync(); + await auto.WaitUntilTextAsync("Hello world", timeout: TimeSpan.FromSeconds(10)); + await auto.WaitForSuccessPromptAsync(counter); + + await auto.TypeAsync("aspire resource greeting delete-parameter"); + await auto.EnterAsync(); + await auto.WaitUntilTextAsync("Resource 'greeting' deleted successfully.", timeout: TimeSpan.FromSeconds(30)); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30)); + + await auto.TypeAsync("aspire describe greeting --format json > greeting-deleted.json"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30)); + + await auto.TypeAsync("jq -er '.resources[0].state' greeting-deleted.json"); + await auto.EnterAsync(); + await auto.WaitUntilTextAsync("ValueMissing", timeout: TimeSpan.FromSeconds(10)); + await auto.WaitForSuccessPromptAsync(counter); + + await auto.TypeAsync("aspire stop"); + await auto.EnterAsync(); + await auto.WaitUntilAppHostStoppedSuccessfullyAsync(timeout: TimeSpan.FromMinutes(1)); + await auto.WaitForSuccessPromptAsync(counter); } [Fact] @@ -157,129 +120,92 @@ public async Task ResourceCommand_FailsWhenInteractionServiceIsRequired() var workspace = TemporaryWorkspace.Create(output); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); - var testBodyFailed = false; + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); + await auto.PrepareDockerEnvironmentAsync(counter, workspace, enableDcpDiagnostics: true); + await auto.InstallAspireCliAsync(strategy, counter); + await auto.AspireNewAsync(projectName, counter, template: AspireTemplate.EmptyAppHost); - try - { - await auto.PrepareDockerEnvironmentAsync(counter, workspace, enableDcpDiagnostics: true); - await auto.InstallAspireCliAsync(strategy, counter); - await auto.AspireNewAsync(projectName, counter, template: AspireTemplate.EmptyAppHost); + await auto.TypeAsync($"cd {projectName}"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); - await auto.TypeAsync($"cd {projectName}"); - await auto.EnterAsync(); - await auto.WaitForSuccessPromptAsync(counter); + // Read the generated apphost.cs so we can extract the #:sdk line with the + // resolved version, then replace the entire file with a minimal AppHost + // that has a placeholder resource and a command that uses IInteractionService. + var appHostFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, projectName, "apphost.cs"); + var content = File.ReadAllText(appHostFilePath); - // Read the generated apphost.cs so we can extract the #:sdk line with the - // resolved version, then replace the entire file with a minimal AppHost - // that has a placeholder resource and a command that uses IInteractionService. - var appHostFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, projectName, "apphost.cs"); - var content = File.ReadAllText(appHostFilePath); + // Extract the first line (#:sdk directive) so the replacement uses the same SDK version. + var sdkLine = content.Split('\n', 2)[0].TrimEnd('\r'); - // Extract the first line (#:sdk directive) so the replacement uses the same SDK version. - var sdkLine = content.Split('\n', 2)[0].TrimEnd('\r'); + var newContent = $$""" + {{sdkLine}} - var newContent = $$""" - {{sdkLine}} + #pragma warning disable ASPIREINTERACTION001 - #pragma warning disable ASPIREINTERACTION001 + var builder = DistributedApplication.CreateBuilder(args); - var builder = DistributedApplication.CreateBuilder(args); + var cache = builder.AddContainer("cache", "redis"); - var cache = builder.AddContainer("cache", "redis"); + cache.WithCommand( + name: "needs-interaction", + displayName: "Needs interaction", + executeCommand: async context => + { + var interactionService = (IInteractionService)context.ServiceProvider.GetService(typeof(IInteractionService))!; - cache.WithCommand( - name: "needs-interaction", - displayName: "Needs interaction", - executeCommand: async context => + try { - var interactionService = (IInteractionService)context.ServiceProvider.GetService(typeof(IInteractionService))!; - - try - { - // This should throw because InteractionService is not available in non-interactive mode. - // Bound the wait to avoid hanging the E2E run if behavior regresses. - _ = await interactionService.PromptInputAsync( - title: "Prompt title", - message: "Prompt message", - inputLabel: "Name", - placeHolder: "placeholder").WaitAsync(TimeSpan.FromSeconds(10)); - - // We're looking for a failure. Treat a successful prompt completion as a test failure since that would - // indicate the interaction service is available when it shouldn't be. - return CommandResults.Success("Prompt unexpectedly completed without throwing."); - } - catch (TimeoutException) - { - // We're looking for a failure, and the most likely failure mode if the interaction service is incorrectly - // available would be that the prompt hangs waiting for input that will never come. - // Treat a timeout as a success since that indicates the interaction service is not available to show the prompt. - return CommandResults.Success("Prompt timed out after 10 seconds."); - } - }); - - builder.Build().Run(); - """; - - File.WriteAllText(appHostFilePath, newContent); - - await auto.TypeAsync("aspire start"); - await auto.EnterAsync(); - await auto.WaitUntilTextAsync(RunCommandStrings.AppHostStartedSuccessfully, timeout: TimeSpan.FromMinutes(3)); - await auto.WaitForSuccessPromptAsync(counter); - - await auto.TypeAsync("aspire resource cache needs-interaction"); - await auto.EnterAsync(); - await auto.WaitUntilTextAsync("Failed to execute command 'needs-interaction' on resource 'cache'", timeout: TimeSpan.FromSeconds(30)); - await auto.WaitUntilTextAsync("InteractionService is not available", timeout: TimeSpan.FromSeconds(30)); - await auto.WaitUntilTextAsync("See logs at", timeout: TimeSpan.FromSeconds(30)); - await auto.WaitUntilTextAsync("See AppHost logs at", timeout: TimeSpan.FromSeconds(30)); - await auto.WaitForAnyPromptAsync(counter, timeout: TimeSpan.FromSeconds(30)); - - await auto.TypeAsync($"if [ $? -eq {CliExitCodes.FailedToExecuteResourceCommand} ]; then echo RESOURCE_CMD_EXIT_CODE_CORRECT; else echo RESOURCE_CMD_UNEXPECTED_EXIT_CODE; fi"); - await auto.EnterAsync(); - await auto.WaitUntilTextAsync("RESOURCE_CMD_EXIT_CODE_CORRECT", timeout: TimeSpan.FromSeconds(10)); - await auto.WaitForSuccessPromptAsync(counter); - - await auto.TypeAsync("aspire stop"); - await auto.EnterAsync(); - await auto.WaitUntilAppHostStoppedSuccessfullyAsync(timeout: TimeSpan.FromMinutes(1)); - await auto.WaitForSuccessPromptAsync(counter); - } - catch - { - testBodyFailed = true; - throw; - } - finally - { - try - { - await auto.CaptureAspireDiagnosticsAsync(counter, workspace); - } - catch - { - // Best effort diagnostics capture. - } - - try - { - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - await pendingRun; - } - catch - { - if (!testBodyFailed) - { - throw; - } - } - } + // This should throw because InteractionService is not available in non-interactive mode. + // Bound the wait to avoid hanging the E2E run if behavior regresses. + _ = await interactionService.PromptInputAsync( + title: "Prompt title", + message: "Prompt message", + inputLabel: "Name", + placeHolder: "placeholder").WaitAsync(TimeSpan.FromSeconds(10)); + + // We're looking for a failure. Treat a successful prompt completion as a test failure since that would + // indicate the interaction service is available when it shouldn't be. + return CommandResults.Success("Prompt unexpectedly completed without throwing."); + } + catch (TimeoutException) + { + // We're looking for a failure, and the most likely failure mode if the interaction service is incorrectly + // available would be that the prompt hangs waiting for input that will never come. + // Treat a timeout as a success since that indicates the interaction service is not available to show the prompt. + return CommandResults.Success("Prompt timed out after 10 seconds."); + } + }); + + builder.Build().Run(); + """; + + File.WriteAllText(appHostFilePath, newContent); + + await auto.TypeAsync("aspire start"); + await auto.EnterAsync(); + await auto.WaitUntilTextAsync(RunCommandStrings.AppHostStartedSuccessfully, timeout: TimeSpan.FromMinutes(3)); + await auto.WaitForSuccessPromptAsync(counter); + + await auto.TypeAsync("aspire resource cache needs-interaction"); + await auto.EnterAsync(); + await auto.WaitUntilTextAsync("Failed to execute command 'needs-interaction' on resource 'cache'", timeout: TimeSpan.FromSeconds(30)); + await auto.WaitUntilTextAsync("InteractionService is not available", timeout: TimeSpan.FromSeconds(30)); + await auto.WaitUntilTextAsync("See logs at", timeout: TimeSpan.FromSeconds(30)); + await auto.WaitUntilTextAsync("See AppHost logs at", timeout: TimeSpan.FromSeconds(30)); + await auto.WaitForAnyPromptAsync(counter, timeout: TimeSpan.FromSeconds(30)); + + await auto.TypeAsync($"if [ $? -eq {CliExitCodes.FailedToExecuteResourceCommand} ]; then echo RESOURCE_CMD_EXIT_CODE_CORRECT; else echo RESOURCE_CMD_UNEXPECTED_EXIT_CODE; fi"); + await auto.EnterAsync(); + await auto.WaitUntilTextAsync("RESOURCE_CMD_EXIT_CODE_CORRECT", timeout: TimeSpan.FromSeconds(10)); + await auto.WaitForSuccessPromptAsync(counter); + + await auto.TypeAsync("aspire stop"); + await auto.EnterAsync(); + await auto.WaitUntilAppHostStoppedSuccessfullyAsync(timeout: TimeSpan.FromMinutes(1)); + await auto.WaitForSuccessPromptAsync(counter); } [Fact] @@ -294,136 +220,99 @@ public async Task ResourceCommand_FailedExecution_DisplaysAppHostLogPathAndLogCo var workspace = TemporaryWorkspace.Create(output); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); - var testBodyFailed = false; - - try - { - await auto.PrepareDockerEnvironmentAsync(counter, workspace, enableDcpDiagnostics: true); - await auto.InstallAspireCliAsync(strategy, counter); - await auto.AspireNewAsync(projectName, counter, template: AspireTemplate.EmptyAppHost); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); + await auto.PrepareDockerEnvironmentAsync(counter, workspace, enableDcpDiagnostics: true); + await auto.InstallAspireCliAsync(strategy, counter); + await auto.AspireNewAsync(projectName, counter, template: AspireTemplate.EmptyAppHost); - await auto.TypeAsync($"cd {projectName}"); - await auto.EnterAsync(); - await auto.WaitForSuccessPromptAsync(counter); + await auto.TypeAsync($"cd {projectName}"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); - // Replace the generated apphost.cs with a minimal AppHost that has a - // command writing to context.Logger before returning a failure result. - var appHostFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, projectName, "apphost.cs"); - var content = File.ReadAllText(appHostFilePath); + // Replace the generated apphost.cs with a minimal AppHost that has a + // command writing to context.Logger before returning a failure result. + var appHostFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, projectName, "apphost.cs"); + var content = File.ReadAllText(appHostFilePath); - // Extract the first line (#:sdk directive) so the replacement uses the same SDK version. - var sdkLine = content.Split('\n', 2)[0].TrimEnd('\r'); + // Extract the first line (#:sdk directive) so the replacement uses the same SDK version. + var sdkLine = content.Split('\n', 2)[0].TrimEnd('\r'); - var newContent = $$""" - {{sdkLine}} + var newContent = $$""" + {{sdkLine}} - using Microsoft.Extensions.DependencyInjection; - using Microsoft.Extensions.Logging; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Logging; - var builder = DistributedApplication.CreateBuilder(args); + var builder = DistributedApplication.CreateBuilder(args); - var cache = builder.AddContainer("cache", "redis"); + var cache = builder.AddContainer("cache", "redis"); - cache.WithCommand( - name: "fail-with-log", - displayName: "Fail with log", - executeCommand: context => - { - var logger = context.ServiceProvider.GetRequiredService>(); - logger.LogInformation("CUSTOM_E2E_LOG_ENTRY_FOR_VERIFICATION"); - - return Task.FromResult(CommandResults.Failure("Command failed intentionally.")); - }); - - builder.Build().Run(); - """; - - File.WriteAllText(appHostFilePath, newContent); - - // Start the AppHost in detached mode. - await auto.TypeAsync("aspire start"); - await auto.EnterAsync(); - await auto.WaitUntilTextAsync(RunCommandStrings.AppHostStartedSuccessfully, timeout: TimeSpan.FromMinutes(3)); - await auto.WaitForSuccessPromptAsync(counter); - - // Run the failing resource command, capturing stdout and stderr so we can extract the - // AppHost log path from the "See AppHost logs at " message. - await auto.TypeAsync("aspire resource cache fail-with-log > /tmp/resource-cmd-output.txt 2>&1"); - await auto.EnterAsync(); - await auto.WaitForAnyPromptAsync(counter, timeout: TimeSpan.FromSeconds(30)); - - await auto.TypeAsync($"if [ $? -eq {CliExitCodes.FailedToExecuteResourceCommand} ]; then echo RESOURCE_CMD_EXIT_CODE_CORRECT; else echo RESOURCE_CMD_UNEXPECTED_EXIT_CODE; fi"); - await auto.EnterAsync(); - await auto.WaitUntilTextAsync("RESOURCE_CMD_EXIT_CODE_CORRECT", timeout: TimeSpan.FromSeconds(10)); - await auto.WaitForSuccessPromptAsync(counter); - - // Extract the AppHost log file path from the captured output and verify - // it exists and is non-empty. - // Extract the AppHost log file path from the captured output. The CLI - // wraps paths in OSC 8 terminal hyperlinks (\e]8;...;\e\\path\e]8;;\e\\), - // so we must strip those escape sequences before extracting the path. - await auto.RunCommandAsync( - "APPHOST_LOG=$(sed 's/\\x1b\\][^\\x1b]*\\x1b\\\\//g' /tmp/resource-cmd-output.txt | grep 'See AppHost logs at' | sed 's/.*See AppHost logs at //')", - counter); - - // Debug: Show what was captured in stderr and the extracted APPHOST_LOG value. - // Run these in a single command with || true so they always complete even if one step fails. - await auto.RunCommandAsync( - "echo '=== Contents of /tmp/resource-cmd-output.txt ===' && cat /tmp/resource-cmd-output.txt && echo && echo '=== APPHOST_LOG value ===' && echo \"$APPHOST_LOG\" && echo '=== End debug output ===' || true", - counter); - - await auto.RunCommandFailFastAsync( - "test -n \"$APPHOST_LOG\" && test -s \"$APPHOST_LOG\"", - counter, - TimeSpan.FromSeconds(10)); - - // Verify the log file contains the custom log entry written by the - // command handler via ILogger before returning the failure result. - await auto.RunCommandFailFastAsync( - "grep -q 'CUSTOM_E2E_LOG_ENTRY_FOR_VERIFICATION' \"$APPHOST_LOG\"", - counter, - TimeSpan.FromSeconds(10)); - - // Stop the AppHost. - await auto.TypeAsync("aspire stop"); - await auto.EnterAsync(); - await auto.WaitUntilAppHostStoppedSuccessfullyAsync(timeout: TimeSpan.FromMinutes(1)); - await auto.WaitForSuccessPromptAsync(counter); - } - catch - { - testBodyFailed = true; - throw; - } - finally - { - try - { - await auto.CaptureAspireDiagnosticsAsync(counter, workspace); - } - catch - { - // Best effort diagnostics capture. - } - - try - { - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - await pendingRun; - } - catch - { - if (!testBodyFailed) + cache.WithCommand( + name: "fail-with-log", + displayName: "Fail with log", + executeCommand: context => { - throw; - } - } - } + var logger = context.ServiceProvider.GetRequiredService>(); + logger.LogInformation("CUSTOM_E2E_LOG_ENTRY_FOR_VERIFICATION"); + + return Task.FromResult(CommandResults.Failure("Command failed intentionally.")); + }); + + builder.Build().Run(); + """; + + File.WriteAllText(appHostFilePath, newContent); + + // Start the AppHost in detached mode. + await auto.TypeAsync("aspire start"); + await auto.EnterAsync(); + await auto.WaitUntilTextAsync(RunCommandStrings.AppHostStartedSuccessfully, timeout: TimeSpan.FromMinutes(3)); + await auto.WaitForSuccessPromptAsync(counter); + + // Run the failing resource command, capturing stdout and stderr so we can extract the + // AppHost log path from the "See AppHost logs at " message. + await auto.TypeAsync("aspire resource cache fail-with-log > /tmp/resource-cmd-output.txt 2>&1"); + await auto.EnterAsync(); + await auto.WaitForAnyPromptAsync(counter, timeout: TimeSpan.FromSeconds(30)); + + await auto.TypeAsync($"if [ $? -eq {CliExitCodes.FailedToExecuteResourceCommand} ]; then echo RESOURCE_CMD_EXIT_CODE_CORRECT; else echo RESOURCE_CMD_UNEXPECTED_EXIT_CODE; fi"); + await auto.EnterAsync(); + await auto.WaitUntilTextAsync("RESOURCE_CMD_EXIT_CODE_CORRECT", timeout: TimeSpan.FromSeconds(10)); + await auto.WaitForSuccessPromptAsync(counter); + + // Extract the AppHost log file path from the captured output and verify + // it exists and is non-empty. + // Extract the AppHost log file path from the captured output. The CLI + // wraps paths in OSC 8 terminal hyperlinks (\e]8;...;\e\\path\e]8;;\e\\), + // so we must strip those escape sequences before extracting the path. + await auto.RunCommandAsync( + "APPHOST_LOG=$(sed 's/\\x1b\\][^\\x1b]*\\x1b\\\\//g' /tmp/resource-cmd-output.txt | grep 'See AppHost logs at' | sed 's/.*See AppHost logs at //')", + counter); + + // Debug: Show what was captured in stderr and the extracted APPHOST_LOG value. + // Run these in a single command with || true so they always complete even if one step fails. + await auto.RunCommandAsync( + "echo '=== Contents of /tmp/resource-cmd-output.txt ===' && cat /tmp/resource-cmd-output.txt && echo && echo '=== APPHOST_LOG value ===' && echo \"$APPHOST_LOG\" && echo '=== End debug output ===' || true", + counter); + + await auto.RunCommandAsync( + "test -n \"$APPHOST_LOG\" && test -s \"$APPHOST_LOG\"", + counter, + TimeSpan.FromSeconds(10)); + + // Verify the log file contains the custom log entry written by the + // command handler via ILogger before returning the failure result. + await auto.RunCommandAsync( + "grep -q 'CUSTOM_E2E_LOG_ENTRY_FOR_VERIFICATION' \"$APPHOST_LOG\"", + counter, + TimeSpan.FromSeconds(10)); + + // Stop the AppHost. + await auto.TypeAsync("aspire stop"); + await auto.EnterAsync(); + await auto.WaitUntilAppHostStoppedSuccessfullyAsync(timeout: TimeSpan.FromMinutes(1)); + await auto.WaitForSuccessPromptAsync(counter); } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/SecretDotNetAppHostTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/SecretDotNetAppHostTests.cs index 8c6f93abec8..ea65d377981 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/SecretDotNetAppHostTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/SecretDotNetAppHostTests.cs @@ -21,11 +21,9 @@ public async Task SecretCrudOnDotNetAppHost() var workspace = TemporaryWorkspace.Create(output); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); @@ -73,10 +71,5 @@ public async Task SecretCrudOnDotNetAppHost() await auto.EnterAsync(); await auto.WaitUntilTextAsync("db-password", timeout: TimeSpan.FromSeconds(30)); await auto.WaitForSuccessPromptAsync(counter); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/SecretTypeScriptAppHostTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/SecretTypeScriptAppHostTests.cs index 2e315d3c0ef..ec0a9bd6ab1 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/SecretTypeScriptAppHostTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/SecretTypeScriptAppHostTests.cs @@ -21,11 +21,9 @@ public async Task SecretCrudOnTypeScriptAppHost() var workspace = TemporaryWorkspace.Create(output); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, variant: CliE2ETestHelpers.DockerfileVariant.Polyglot, mountDockerSocket: true, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); @@ -75,10 +73,5 @@ public async Task SecretCrudOnTypeScriptAppHost() await auto.EnterAsync(); await auto.WaitUntilTextAsync("ConnectionStrings:Db", timeout: TimeSpan.FromSeconds(30)); await auto.WaitForSuccessPromptAsync(counter); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/SingleFileAppHostInitDotnetRunTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/SingleFileAppHostInitDotnetRunTests.cs index 0f8ad468896..46da7adc2e4 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/SingleFileAppHostInitDotnetRunTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/SingleFileAppHostInitDotnetRunTests.cs @@ -40,11 +40,9 @@ public async Task AspireInitSingleFileAppHostRunsViaDotnetRunAppHost() var workspace = TemporaryWorkspace.Create(output); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: false, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -107,10 +105,6 @@ await auto.WaitUntilTextAsync( // Stop the running AppHost with Ctrl+C and wait for the shell prompt. await auto.Ctrl().KeyAsync(Hex1bKey.C); await auto.WaitForAnyPromptAsync(counter, TimeSpan.FromMinutes(1)); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - await pendingRun; } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/SmokeTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/SmokeTests.cs index 862a31953d8..327eee3b82f 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/SmokeTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/SmokeTests.cs @@ -28,10 +28,9 @@ public async Task CreateAndRunAspireStarterProject() using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); // Prepare Docker environment (prompt counting, umask, env vars) await auto.PrepareDockerEnvironmentAsync(counter, workspace); @@ -63,12 +62,6 @@ await auto.WaitUntilAsync(s => // Stop the running apphost with Ctrl+C await auto.Ctrl().KeyAsync(Hex1bKey.C); await auto.WaitForSuccessPromptAsync(counter); - - // Exit the shell - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } [CaptureWorkspaceOnFailure] @@ -130,10 +123,9 @@ public async Task LatestCliCanStartStableChannelAppHost() using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -151,14 +143,9 @@ public async Task LatestCliCanStartStableChannelAppHost() output.WriteLine($"Stable AppHost SDK version: {appHostSdkVersion}"); - await auto.RunCommandFailFastAsync($"cd {projectName}", counter); + await auto.RunCommandAsync($"cd {projectName}", counter); await auto.AspireStartAsync(counter); await auto.AspireStopAsync(counter); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } [CaptureWorkspaceOnFailure] @@ -172,10 +159,9 @@ public async Task LatestCliCanStartStableChannelTypeScriptAppHost() using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -194,14 +180,9 @@ public async Task LatestCliCanStartStableChannelTypeScriptAppHost() output.WriteLine("Stable TypeScript AppHost config verified."); - await auto.RunCommandFailFastAsync($"cd {projectName}", counter); + await auto.RunCommandAsync($"cd {projectName}", counter); await auto.AspireStartAsync(counter); await auto.AspireStopAsync(counter); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } private static string GetAppHostSdkVersion(string appHostPath) diff --git a/tests/Aspire.Cli.EndToEnd.Tests/StagingChannelTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/StagingChannelTests.cs index ec1bbae58e4..312a34f855c 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/StagingChannelTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/StagingChannelTests.cs @@ -23,11 +23,9 @@ public async Task StagingChannel_ConfigureAndVerifySettings_ThenSwitchChannels() var workspace = TemporaryWorkspace.Create(output); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); @@ -113,10 +111,5 @@ public async Task StagingChannel_ConfigureAndVerifySettings_ThenSwitchChannels() await auto.TypeAsync("aspire config delete channel -g"); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/StartStopTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/StartStopTests.cs index 20801b43cf6..173362e700e 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/StartStopTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/StartStopTests.cs @@ -30,71 +30,39 @@ public async Task CreateStartAndStopAspireProject() using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); - var testBodyFailed = false; + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); - try - { - // Prepare Docker environment (prompt counting, umask, env vars) - await auto.PrepareDockerEnvironmentAsync(counter, workspace, enableDcpDiagnostics: true); + // Prepare Docker environment (prompt counting, umask, env vars) + await auto.PrepareDockerEnvironmentAsync(counter, workspace, enableDcpDiagnostics: true); - // Install the Aspire CLI - await auto.InstallAspireCliAsync(strategy, counter); + // Install the Aspire CLI + await auto.InstallAspireCliAsync(strategy, counter); - // Create a new project using aspire new - await auto.AspireNewAsync(projectName, counter); + // Create a new project using aspire new + await auto.AspireNewAsync(projectName, counter); - // Navigate to the AppHost directory - await auto.TypeAsync($"cd {projectName}/{projectName}.AppHost"); - await auto.EnterAsync(); - await auto.WaitForSuccessPromptAsync(counter); + // Navigate to the AppHost directory + await auto.TypeAsync($"cd {projectName}/{projectName}.AppHost"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); - // Start the AppHost in the background using aspire start - await auto.TypeAsync("aspire start"); - await auto.EnterAsync(); - await auto.WaitForSuccessPromptAsync(counter); + // Start the AppHost in the background using aspire start + await auto.TypeAsync("aspire start"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); - // Stop the AppHost using aspire stop - await auto.TypeAsync("aspire stop"); - await auto.EnterAsync(); - await auto.WaitUntilAppHostStoppedSuccessfullyAsync(timeout: TimeSpan.FromMinutes(1)); - await auto.WaitForSuccessPromptAsync(counter); + // Stop the AppHost using aspire stop + await auto.TypeAsync("aspire stop"); + await auto.EnterAsync(); + await auto.WaitUntilAppHostStoppedSuccessfullyAsync(timeout: TimeSpan.FromMinutes(1)); + await auto.WaitForSuccessPromptAsync(counter); - await auto.ClearScreenAsync(counter); + await auto.ClearScreenAsync(counter); - // Docker network cleanup can lag behind aspire stop on contended CI runners. - await auto.ExecuteCommandUntilOutputAsync(counter, $"docker network ls --format json | grep -i -- '{projectName}' | wc -l", "0", timeout: TimeSpan.FromMinutes(5)); - } - catch - { - testBodyFailed = true; - throw; - } - finally - { - try - { - await auto.CaptureAspireDiagnosticsAsync(counter, workspace); - } - catch { } // Best effort - - try - { - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - await pendingRun; - } - catch - { - if (!testBodyFailed) - { - throw; - } - } - } + // Docker network cleanup can lag behind aspire stop on contended CI runners. + await auto.ExecuteCommandUntilOutputAsync(counter, $"docker network ls --format json | grep -i -- '{projectName}' | wc -l", "0", timeout: TimeSpan.FromMinutes(5)); } [Fact] @@ -107,10 +75,9 @@ public async Task StopWithNoRunningAppHostExitsSuccessfully() using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); // Prepare Docker environment (prompt counting, umask, env vars) await auto.PrepareDockerEnvironmentAsync(counter, workspace); @@ -122,12 +89,6 @@ public async Task StopWithNoRunningAppHostExitsSuccessfully() await auto.TypeAsync("aspire stop"); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter); - - // Exit the shell - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } [Fact] @@ -140,10 +101,9 @@ public async Task AddPackageWhileAppHostRunningDetached() using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); // Prepare Docker environment (prompt counting, umask, env vars) await auto.PrepareDockerEnvironmentAsync(counter, workspace); @@ -178,12 +138,6 @@ public async Task AddPackageWhileAppHostRunningDetached() await auto.TypeAsync("aspire stop"); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter, timeout: TimeSpan.FromMinutes(1)); - - // Exit the shell - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } [Fact] @@ -196,10 +150,9 @@ public async Task AddPackageInteractiveWhileAppHostRunningDetached() using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); // Prepare Docker environment (prompt counting, umask, env vars) await auto.PrepareDockerEnvironmentAsync(counter, workspace); @@ -249,11 +202,5 @@ await auto.WaitUntilAsync(snapshot => await auto.TypeAsync("aspire stop"); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter, timeout: TimeSpan.FromMinutes(1)); - - // Exit the shell - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/StopNonInteractiveTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/StopNonInteractiveTests.cs index 3f2353f60e4..3d84dc1f57a 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/StopNonInteractiveTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/StopNonInteractiveTests.cs @@ -27,10 +27,9 @@ public async Task StopNonInteractiveSingleAppHost() using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -72,12 +71,6 @@ public async Task StopNonInteractiveSingleAppHost() await auto.EnterAsync(); await auto.WaitUntilTextAsync(SharedCommandStrings.AppHostNotRunning, timeout: TimeSpan.FromSeconds(30)); await auto.WaitForAnyPromptAsync(counter, TimeSpan.FromSeconds(30)); - - // Exit the shell - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } [Fact] @@ -89,11 +82,9 @@ public async Task StopAllAppHostsFromAppHostDirectory() var workspace = TemporaryWorkspace.Create(output); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -139,12 +130,6 @@ public async Task StopAllAppHostsFromAppHostDirectory() await auto.EnterAsync(); await auto.WaitUntilTextAsync(SharedCommandStrings.AppHostNotRunning, timeout: TimeSpan.FromSeconds(30)); await auto.WaitForAnyPromptAsync(counter, TimeSpan.FromSeconds(30)); - - // Exit the shell - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } [Fact] @@ -157,11 +142,9 @@ public async Task StopAllAppHostsFromUnrelatedDirectory() var workspace = TemporaryWorkspace.Create(output); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -212,12 +195,6 @@ public async Task StopAllAppHostsFromUnrelatedDirectory() await auto.EnterAsync(); await auto.WaitUntilTextAsync(SharedCommandStrings.AppHostNotRunning, timeout: TimeSpan.FromSeconds(30)); await auto.WaitForAnyPromptAsync(counter, TimeSpan.FromSeconds(30)); - - // Exit the shell - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } [Fact] @@ -230,11 +207,9 @@ public async Task StopNonInteractiveMultipleAppHostsShowsError() var workspace = TemporaryWorkspace.Create(output); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -287,11 +262,5 @@ await auto.WaitUntilTextAsync( await auto.EnterAsync(); await auto.WaitUntilAppHostStoppedSuccessfullyAsync(timeout: TimeSpan.FromMinutes(1)); await auto.WaitForSuccessPromptAsync(counter); - - // Exit the shell - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptCodegenValidationTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptCodegenValidationTests.cs index 1f693e69f9a..059268070f7 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptCodegenValidationTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptCodegenValidationTests.cs @@ -35,11 +35,9 @@ public async Task RestoreGeneratesSdkFiles_WithConfiguredToolchain(string toolch ["Aspire.Hosting.CodeGeneration.TypeScript.", "Aspire.Hosting.Redis.", "Aspire.Hosting.SqlServer."]); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, variant: CliE2ETestHelpers.DockerfileVariant.DotNet, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); @@ -90,7 +88,7 @@ public async Task RestoreGeneratesSdkFiles_WithConfiguredToolchain(string toolch await auto.TypeAsync(TypeScriptAppHostToolchainTestHelpers.GetTypeCheckCommand(toolchain, "tsconfig.apphost.json")); await auto.EnterAsync(); - await auto.WaitForSuccessPromptFailFastAsync(counter, TimeSpan.FromMinutes(2)); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2)); // Step 4: Verify generated SDK files exist. var modulesDir = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "modules"); @@ -125,11 +123,6 @@ public async Task RestoreGeneratesSdkFiles_WithConfiguredToolchain(string toolch { throw new InvalidOperationException("aspire.mts does not contain addSqlServer from Aspire.Hosting.SqlServer"); } - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } [Fact] @@ -150,11 +143,9 @@ public async Task RestoreRefreshesGeneratedSdkAfterAddingIntegration() variant: CliE2ETestHelpers.DockerfileVariant.DotNet, mountDockerSocket: false, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); @@ -256,12 +247,7 @@ public async Task RestoreRefreshesGeneratedSdkAfterAddingIntegration() await auto.TypeAsync("npx tsc --noEmit --project tsconfig.apphost.json"); await auto.EnterAsync(); - await auto.WaitForSuccessPromptFailFastAsync(counter, TimeSpan.FromMinutes(2)); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2)); } [Fact] @@ -279,10 +265,9 @@ public async Task UnAwaitedChainsCompileWithAutoResolvePromises() variant: CliE2ETestHelpers.DockerfileVariant.DotNet, mountDockerSocket: true, workspace: workspace); - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -333,7 +318,7 @@ public async Task UnAwaitedChainsCompileWithAutoResolvePromises() // withReference(db) should accept PromiseLike from the un-awaited addDatabase(). await auto.TypeAsync("npx tsc --noEmit --project tsconfig.apphost.json"); await auto.EnterAsync(); - await auto.WaitForSuccessPromptFailFastAsync(counter, TimeSpan.FromMinutes(2)); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2)); // Validate runtime behavior: aspire start launches the apphost, which calls // build() and triggers flushPendingPromises(). If the flush deadlocks (e.g. the @@ -346,12 +331,5 @@ public async Task UnAwaitedChainsCompileWithAutoResolvePromises() await auto.AssertResourcesExistAsync(counter, "postgres", "db", "consumer"); await auto.AspireStopAsync(counter); - - await auto.CaptureAspireDiagnosticsAsync(counter, workspace); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptEmptyAppHostTemplateTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptEmptyAppHostTemplateTests.cs index d76991579d5..1847a78b052 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptEmptyAppHostTemplateTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptEmptyAppHostTemplateTests.cs @@ -24,11 +24,9 @@ public async Task CreateAndRunTypeScriptEmptyAppHostProject() var workspace = TemporaryWorkspace.Create(output); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -44,14 +42,9 @@ public async Task CreateAndRunTypeScriptEmptyAppHostProject() await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter); - await auto.RunCommandFailFastAsync("npm run build", counter, TimeSpan.FromMinutes(2)); + await auto.RunCommandAsync("npm run build", counter, TimeSpan.FromMinutes(2)); await auto.AspireStartAsync(counter); await auto.AspireStopAsync(counter); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptLegacyAppHostTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptLegacyAppHostTests.cs index 944b29872a2..ce06769005f 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptLegacyAppHostTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptLegacyAppHostTests.cs @@ -40,11 +40,9 @@ public async Task AspireAddAndStartWorkAgainstLegacyAppHostTs() variant: CliE2ETestHelpers.DockerfileVariant.DotNet, mountDockerSocket: true, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -84,7 +82,7 @@ public async Task AspireAddAndStartWorkAgainstLegacyAppHostTs() // legacy `.modules/` folder — the contract the conversion enforces. await auto.TypeAsync("npx --no-install tsc --noEmit -p tsconfig.apphost.json"); await auto.EnterAsync(); - await auto.WaitForSuccessPromptFailFastAsync(counter, TimeSpan.FromMinutes(2)); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2)); // Step 5: `aspire start` exercises apphost.ts at runtime — proving the generated // SDK is dynamically importable AND that addRedis (added via aspire add in step 2) @@ -93,11 +91,6 @@ public async Task AspireAddAndStartWorkAgainstLegacyAppHostTs() await auto.AspireStartAsync(counter); await auto.AssertResourcesExistAsync(counter, "cache"); await auto.AspireStopAsync(counter); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } /// diff --git a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptPolyglotApphostDirectoryTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptPolyglotApphostDirectoryTests.cs index 7084c0c9e69..4c869c728d1 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptPolyglotApphostDirectoryTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptPolyglotApphostDirectoryTests.cs @@ -38,11 +38,9 @@ public async Task StopTypeScriptPolyglotAppHostUsingApphostDirectory() var channelArgument = localChannel is not null ? " --channel local" : string.Empty; using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, variant: CliE2ETestHelpers.DockerfileVariant.Polyglot, mountDockerSocket: true, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -113,10 +111,5 @@ public async Task StopTypeScriptPolyglotAppHostUsingApphostDirectory() await auto.EnterAsync(); await auto.WaitUntilAppHostStoppedSuccessfullyAsync(timeout: TimeSpan.FromMinutes(1)); await auto.WaitForSuccessPromptAsync(counter); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptPolyglotTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptPolyglotTests.cs index 5efb23c8a96..2be2fb330ce 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptPolyglotTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptPolyglotTests.cs @@ -43,11 +43,9 @@ public async Task CreateTypeScriptAppHostWithViteApp_UsesConfiguredToolchain(str var channelArgument = localChannel is not null ? " --channel local" : string.Empty; using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, variant: CliE2ETestHelpers.DockerfileVariant.Polyglot, mountDockerSocket: true, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); @@ -135,7 +133,7 @@ public async Task CreateTypeScriptAppHostWithViteApp_UsesConfiguredToolchain(str await auto.TypeAsync(TypeScriptAppHostToolchainTestHelpers.GetTypeCheckCommand(toolchain, "tsconfig.apphost.json")); await auto.EnterAsync(); - await auto.WaitForSuccessPromptFailFastAsync(counter, TimeSpan.FromMinutes(2)); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2)); // Step 7: Run the apphost await auto.TypeAsync("aspire run"); @@ -155,10 +153,6 @@ await auto.WaitUntilAsync(s => // Step 8: Stop the apphost await auto.Ctrl().KeyAsync(Hex1bKey.C); await auto.WaitForSuccessPromptAsync(counter); - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } [Fact] @@ -176,11 +170,9 @@ public async Task CreateTypeScriptAppHostWithViteApp_AllowsGuestAppPackageManage var channelArgument = localChannel is not null ? " --channel local" : string.Empty; using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, variant: CliE2ETestHelpers.DockerfileVariant.Polyglot, mountDockerSocket: true, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -247,12 +239,7 @@ public async Task CreateTypeScriptAppHostWithViteApp_AllowsGuestAppPackageManage await auto.TypeAsync(TypeScriptAppHostToolchainTestHelpers.GetTypeCheckCommand(appHostToolchain, "tsconfig.apphost.json")); await auto.EnterAsync(); - await auto.WaitForSuccessPromptFailFastAsync(counter, TimeSpan.FromMinutes(2)); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2)); } [Theory] @@ -269,11 +256,9 @@ public async Task GeneratedAspireDevScript_StartsWatchMode_WithConfiguredToolcha var channelArgument = localChannel is not null ? " --channel local" : string.Empty; using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -310,10 +295,6 @@ await auto.WaitUntilAsync( await auto.Ctrl().KeyAsync(Hex1bKey.C); await auto.WaitForAnyPromptAsync(counter); - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } [Fact] @@ -332,9 +313,6 @@ public async Task InitTypeScriptAppHost_AugmentsExistingViteRepoInWorkspaceSubdi var channelArgument = localChannel is not null ? " --channel local" : string.Empty; using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, variant: CliE2ETestHelpers.DockerfileVariant.DotNet, mountDockerSocket: true, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - string? originalDevScript = null; string? originalBuildScript = null; string? originalPreviewScript = null; @@ -343,6 +321,7 @@ public async Task InitTypeScriptAppHost_AugmentsExistingViteRepoInWorkspaceSubdi var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); @@ -485,9 +464,5 @@ await auto.WaitUntilAsync(s => await auto.Ctrl().KeyAsync(Hex1bKey.C); await auto.WaitForSuccessPromptAsync(counter); - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptPublishTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptPublishTests.cs index cc68058ebe4..2b9adcae0ef 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptPublishTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptPublishTests.cs @@ -26,11 +26,9 @@ public async Task PublishWithDockerComposeServiceCallbackSucceeds() using var workspace = TemporaryWorkspace.Create(output); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, variant: CliE2ETestHelpers.DockerfileVariant.DotNet, mountDockerSocket: true, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); @@ -97,11 +95,6 @@ public async Task PublishWithDockerComposeServiceCallbackSucceeds() await auto.TypeAsync("grep -F \"postgres:\" artifacts/docker-compose.yaml"); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } [Fact] @@ -115,11 +108,9 @@ public async Task PublishJavaScriptPatternsGeneratesExpectedDockerComposeArtifac ["Aspire.Hosting.CodeGeneration.TypeScript.", "Aspire.Hosting.JavaScript.", "Aspire.Hosting.Docker."]); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, variant: CliE2ETestHelpers.DockerfileVariant.Polyglot, mountDockerSocket: true, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); @@ -152,7 +143,7 @@ public async Task PublishJavaScriptPatternsGeneratesExpectedDockerComposeArtifac await auto.TypeAsync("aspire publish -o artifacts --non-interactive"); await auto.EnterAsync(); - await auto.WaitForSuccessPromptFailFastAsync(counter, timeout: TimeSpan.FromMinutes(5)); + await auto.WaitForSuccessPromptAsync(counter, timeout: TimeSpan.FromMinutes(5)); var artifactsPath = Path.Combine(workspace.WorkspaceRoot.FullName, "artifacts"); var composeContent = await File.ReadAllTextAsync(Path.Combine(artifactsPath, "docker-compose.yaml")); @@ -194,11 +185,6 @@ public async Task PublishJavaScriptPatternsGeneratesExpectedDockerComposeArtifac "COPY --from=build --chown=node:node /app/.next/static ./.next/static", "USER node", "ENTRYPOINT [\"node\",\"server.js\"]"); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } [Fact] @@ -216,11 +202,9 @@ public async Task PublishWithoutOutputPathUsesAppHostDirectoryDefault() using var workspace = TemporaryWorkspace.Create(output); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, variant: CliE2ETestHelpers.DockerfileVariant.DotNet, mountDockerSocket: true, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); @@ -278,18 +262,13 @@ public async Task PublishWithoutOutputPathUsesAppHostDirectoryDefault() await auto.TypeAsync("aspire publish --non-interactive"); await auto.EnterAsync(); - await auto.WaitForSuccessPromptFailFastAsync(counter, timeout: TimeSpan.FromMinutes(5)); + await auto.WaitForSuccessPromptAsync(counter, timeout: TimeSpan.FromMinutes(5)); var dockerComposePath = Path.Combine(workspace.WorkspaceRoot.FullName, "aspire-output", "docker-compose.yaml"); Assert.True(File.Exists(dockerComposePath), $"Expected docker-compose output at {dockerComposePath}"); var dockerComposeContent = await File.ReadAllTextAsync(dockerComposePath); Assert.Contains("postgres:", dockerComposeContent); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } [Fact] @@ -307,11 +286,9 @@ public async Task PublishWithConfigureEnvFileUpdatesEnvOutput() using var workspace = TemporaryWorkspace.Create(output); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, variant: CliE2ETestHelpers.DockerfileVariant.Polyglot, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); @@ -363,7 +340,7 @@ await compose.configureEnvFile(async (envVars) => { await auto.TypeAsync("aspire publish -o artifacts --non-interactive"); await auto.EnterAsync(); - await auto.WaitForSuccessPromptFailFastAsync(counter, timeout: TimeSpan.FromMinutes(5)); + await auto.WaitForSuccessPromptAsync(counter, timeout: TimeSpan.FromMinutes(5)); var envFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, "artifacts", ".env"); Assert.True(File.Exists(envFilePath), $"Expected env file at {envFilePath}"); @@ -371,11 +348,6 @@ await compose.configureEnvFile(async (envVars) => { var envFileContent = await File.ReadAllTextAsync(envFilePath); Assert.Contains("# Customized bind mount source", envFileContent); Assert.DoesNotContain("# Bind mount source for my-container:/container/data", envFileContent); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } private static void WriteJavaScriptPublishAppHost(TemporaryWorkspace workspace) diff --git a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptReusablePackageTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptReusablePackageTests.cs index 3d227ff7105..493b900e4fb 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptReusablePackageTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptReusablePackageTests.cs @@ -22,11 +22,9 @@ public async Task RestoreSupportsConfigOnlyHelperPackageAndCrossPackageTypes() var workspace = TemporaryWorkspace.Create(output); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, variant: CliE2ETestHelpers.DockerfileVariant.Polyglot, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -75,7 +73,7 @@ public async Task RestoreSupportsConfigOnlyHelperPackageAndCrossPackageTypes() await auto.TypeAsync("npx tsc --noEmit"); await auto.EnterAsync(); - await auto.WaitForSuccessPromptFailFastAsync(counter, TimeSpan.FromMinutes(2)); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2)); await auto.TypeAsync($"cd {CliE2ETestHelpers.ToContainerPath(appDirectory.FullName, workspace)}"); await auto.EnterAsync(); @@ -88,12 +86,7 @@ public async Task RestoreSupportsConfigOnlyHelperPackageAndCrossPackageTypes() await auto.TypeAsync("npx tsc --noEmit"); await auto.EnterAsync(); - await auto.WaitForSuccessPromptFailFastAsync(counter, TimeSpan.FromMinutes(2)); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2)); } private static string GetSdkVersion(DirectoryInfo appDirectory) diff --git a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptSqlServerNativeAssetsBundleTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptSqlServerNativeAssetsBundleTests.cs index ac5c91f3f66..61b87688dc4 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptSqlServerNativeAssetsBundleTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptSqlServerNativeAssetsBundleTests.cs @@ -30,11 +30,9 @@ public async Task StartAndWaitForTypeScriptSqlServerAppHostWithNativeAssets() variant: CliE2ETestHelpers.DockerfileVariant.Polyglot, mountDockerSocket: true, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -74,10 +72,5 @@ public async Task StartAndWaitForTypeScriptSqlServerAppHostWithNativeAssets() await auto.WaitForSuccessPromptAsync(counter); await auto.AspireStopAsync(counter); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptStarterSmokeTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptStarterSmokeTests.cs index 42325df5e54..9e5c7629f1f 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptStarterSmokeTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptStarterSmokeTests.cs @@ -35,11 +35,9 @@ public async Task CreateAndRunTypeScriptStarterProject() var workspace = TemporaryWorkspace.Create(output); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -86,15 +84,10 @@ public async Task CreateAndRunTypeScriptStarterProject() await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter); - await auto.RunCommandFailFastAsync("npm run build", counter, TimeSpan.FromMinutes(2)); + await auto.RunCommandAsync("npm run build", counter, TimeSpan.FromMinutes(2)); await auto.AspireStartAsync(counter); await auto.AspireStopAsync(counter); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } /// diff --git a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptStarterTemplateTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptStarterTemplateTests.cs index 65fb09c1e9a..eb52b975719 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptStarterTemplateTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptStarterTemplateTests.cs @@ -24,11 +24,9 @@ public async Task CreateAndRunTypeScriptStarterProject() var workspace = TemporaryWorkspace.Create(output); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -57,14 +55,9 @@ public async Task CreateAndRunTypeScriptStarterProject() await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter); - await auto.RunCommandFailFastAsync("npm run build", counter, TimeSpan.FromMinutes(2)); + await auto.RunCommandAsync("npm run build", counter, TimeSpan.FromMinutes(2)); await auto.AspireStartAsync(counter); await auto.AspireStopAsync(counter); - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/UpdateChannelNuGetConfigOrderingTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/UpdateChannelNuGetConfigOrderingTests.cs index aa56a803dee..10acabbfe2f 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/UpdateChannelNuGetConfigOrderingTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/UpdateChannelNuGetConfigOrderingTests.cs @@ -72,11 +72,9 @@ public async Task AspireUpdateAppliesAllPackageEditsBeforeRestoringWhenNuGetConf using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal( repoRoot, strategy, output, mountDockerSocket: false, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); @@ -205,9 +203,5 @@ await File.WriteAllTextAsync(nugetConfigPath, catch { } - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - await pendingRun; } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/WaitCommandTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/WaitCommandTests.cs index 025bf6d812d..47349a86ad5 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/WaitCommandTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/WaitCommandTests.cs @@ -26,10 +26,9 @@ public async Task CreateStartWaitAndStopAspireProject() using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); // Prepare Docker environment (prompt counting, umask, env vars) await auto.PrepareDockerEnvironmentAsync(counter, workspace); @@ -63,11 +62,5 @@ public async Task CreateStartWaitAndStopAspireProject() await auto.EnterAsync(); await auto.WaitUntilAppHostStoppedSuccessfullyAsync(timeout: TimeSpan.FromMinutes(1)); await auto.WaitForSuccessPromptAsync(counter); - - // Exit the shell - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } } diff --git a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs index 2a95c68af13..d558f25b7d2 100644 --- a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs @@ -130,6 +130,67 @@ public async Task RunCommand_WhenAppHostStartupTimesOut_DisplaysTimeoutGuidance( Assert.True(runCancellationObserved.Task.IsCompletedSuccessfully); } + [Fact] + public async Task RunCommand_WhenCancelledDuringStartupTimeout_ExitsWithoutWaitingForFullTimeout() + { + // Verifies that when Ctrl+C fires (cancellationToken) during startup, the command exits + // promptly rather than blocking for the 5-second CancelAppHostStartupAsync timeout. + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var cts = new CancellationTokenSource(); + var interactionService = new TestInteractionService(); + var buildCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var appHostDir = workspace.WorkspaceRoot.CreateSubdirectory("AppHost"); + var appHostFile = new FileInfo(Path.Combine(appHostDir.FullName, "AppHost.csproj")); + await File.WriteAllTextAsync(appHostFile.FullName, ""); + + var projectLocator = new TestProjectLocator + { + UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, _) => + Task.FromResult(new AppHostProjectSearchResult(appHostFile, [appHostFile])) + }; + + var projectFactory = new TestAppHostProjectFactory + { + RunAsyncCallback = async (context, _) => + { + context.BuildCompletionSource?.TrySetResult(true); + buildCompleted.SetResult(); + + // Never signal BackchannelCompletionSource and ignore cancellation to + // simulate a hung AppHost process. + await Task.Delay(TimeSpan.FromSeconds(30), CancellationToken.None); + return 0; + } + }; + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.InteractionServiceFactory = _ => interactionService; + options.ProjectLocatorFactory = _ => projectLocator; + options.AppHostProjectFactory = _ => projectFactory; + }); + + using var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse($"run --apphost {appHostFile.FullName}"); + + var pendingRun = result.InvokeAsync(cancellationToken: cts.Token); + + // Cancel after build completes to simulate Ctrl+C during startup. + await buildCompleted.Task.DefaultTimeout(); + cts.Cancel(); + + var stopwatch = Stopwatch.StartNew(); + var exitCode = await pendingRun.DefaultTimeout(); + stopwatch.Stop(); + + // Without the cancellationToken plumbing, this would block for the full 5-second + // CancelAppHostStartupAsync timeout. With the fix, it exits promptly. + Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(3), $"Expected prompt exit after Ctrl+C, but took {stopwatch.Elapsed}."); + Assert.Equal(CliExitCodes.Success, exitCode); + } + [Fact] public async Task RunCommand_StartupTimeoutBudgetIncludesBuildAndBackchannelWaits() { diff --git a/tests/Aspire.Cli.Tests/ConsoleCancellationManagerTests.cs b/tests/Aspire.Cli.Tests/ConsoleCancellationManagerTests.cs new file mode 100644 index 00000000000..8555e99c39c --- /dev/null +++ b/tests/Aspire.Cli.Tests/ConsoleCancellationManagerTests.cs @@ -0,0 +1,148 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.InternalTesting; + +namespace Aspire.Cli.Tests; + +public class ConsoleCancellationManagerTests +{ + [Fact] + public void FirstSignal_RequestsCancellation() + { + using var manager = new ConsoleCancellationManager(TimeSpan.FromSeconds(5)); + + Assert.False(manager.IsCancellationRequested); + + manager.Cancel(130); + + Assert.True(manager.IsCancellationRequested); + } + + [Fact] + public void FirstSignal_TokenIsCancelled() + { + using var manager = new ConsoleCancellationManager(TimeSpan.FromSeconds(5)); + var token = manager.Token; + + Assert.False(token.IsCancellationRequested); + + manager.Cancel(130); + + Assert.True(token.IsCancellationRequested); + } + + [Fact] + public async Task SecondSignal_ForcesImmediateTermination() + { + using var manager = new ConsoleCancellationManager(TimeSpan.FromSeconds(30)); + + // Set a handler that never completes so the first signal doesn't resolve ProcessTerminationCompletionSource + manager.SetStartedHandler(new TaskCompletionSource().Task); + + manager.Cancel(130); + manager.Cancel(130); + + var exitCode = await manager.ProcessTerminationCompletionSource.Task.DefaultTimeout(); + Assert.Equal(130, exitCode); + } + + [Fact] + public async Task FirstSignal_WithNoHandler_ForcesTerminationAfterTimeout() + { + using var manager = new ConsoleCancellationManager(TimeSpan.FromMilliseconds(50)); + + // No handler set, so ForceTerminationAfterTimeoutAsync should complete quickly + manager.Cancel(143); + + var exitCode = await manager.ProcessTerminationCompletionSource.Task.DefaultTimeout(); + Assert.Equal(143, exitCode); + } + + [Fact] + public async Task FirstSignal_HandlerCompletesWithinTimeout_DoesNotForceTermination() + { + using var manager = new ConsoleCancellationManager(TimeSpan.FromSeconds(5)); + + // Set a handler that completes immediately + manager.SetStartedHandler(Task.FromResult(0)); + + manager.Cancel(130); + + // Give the async timeout path time to evaluate + await Task.Delay(100); + + // ProcessTerminationCompletionSource should NOT be signaled because the handler completed in time + Assert.False(manager.ProcessTerminationCompletionSource.Task.IsCompleted); + } + + [Fact] + public async Task FirstSignal_HandlerExceedsTimeout_ForcesTermination() + { + using var manager = new ConsoleCancellationManager(TimeSpan.FromMilliseconds(50)); + + // Set a handler that never completes + manager.SetStartedHandler(new TaskCompletionSource().Task); + + manager.Cancel(143); + + var exitCode = await manager.ProcessTerminationCompletionSource.Task.DefaultTimeout(); + Assert.Equal(143, exitCode); + } + + [Fact] + public void Cancel_IsNonBlocking() + { + using var manager = new ConsoleCancellationManager(TimeSpan.FromSeconds(30)); + + // Set a handler that never completes + manager.SetStartedHandler(new TaskCompletionSource().Task); + + // Cancel should return immediately without blocking (this would hang if Cancel were synchronous) + var sw = System.Diagnostics.Stopwatch.StartNew(); + manager.Cancel(130); + sw.Stop(); + + // Cancel should complete in well under a second (it's non-blocking) + Assert.True(sw.ElapsedMilliseconds < 1000, $"Cancel took {sw.ElapsedMilliseconds}ms, expected < 1000ms"); + Assert.True(manager.IsCancellationRequested); + } + + [Fact] + public async Task MultipleSignals_OnlyFirstAndSecondHaveEffect() + { + using var manager = new ConsoleCancellationManager(TimeSpan.FromSeconds(30)); + + // Set a handler that never completes + manager.SetStartedHandler(new TaskCompletionSource().Task); + + // Third signal should not throw or cause issues + manager.Cancel(130); + manager.Cancel(130); + manager.Cancel(130); + + var exitCode = await manager.ProcessTerminationCompletionSource.Task.DefaultTimeout(); + Assert.Equal(130, exitCode); + } + + [Fact] + public void Dispose_AllowsSubsequentCancelWithoutException() + { + var manager = new ConsoleCancellationManager(TimeSpan.FromSeconds(5)); + manager.Dispose(); + + // Cancel after dispose should not throw (signal can race with shutdown) + manager.Cancel(130); + } + + [Fact] + public void Token_RemainsAccessibleAfterDispose() + { + var manager = new ConsoleCancellationManager(TimeSpan.FromSeconds(5)); + var token = manager.Token; + manager.Dispose(); + + // Token should still be accessible (stored in field before dispose) + Assert.False(token.IsCancellationRequested); + } +} diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AcaManagedRedisDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AcaManagedRedisDeploymentTests.cs index 50e88fa2e5f..f596e596d8b 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AcaManagedRedisDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AcaManagedRedisDeploymentTests.cs @@ -177,7 +177,7 @@ await auto.RunCommandAsync( // Step 13: Verify deployed endpoints with retry // Retry each endpoint for up to 3 minutes (18 attempts * 10 seconds) output.WriteLine("Step 13: Verifying deployed endpoints..."); - await auto.RunCommandFailFastAsync( + await auto.RunCommandAsync( $"RG_NAME=\"{resourceGroupName}\" && " + "echo \"Resource group: $RG_NAME\" && " + "if ! az group show -n \"$RG_NAME\" &>/dev/null; then echo \"❌ Resource group not found\"; exit 1; fi && " + @@ -200,7 +200,7 @@ await auto.RunCommandFailFastAsync( // Step 14: Verify /api/weatherforecast returns valid JSON (exercises Redis output cache) output.WriteLine("Step 14: Verifying /api/weatherforecast returns valid JSON..."); - await auto.RunCommandFailFastAsync( + await auto.RunCommandAsync( $"RG_NAME=\"{resourceGroupName}\" && " + "SERVER_FQDN=$(az containerapp list -g \"$RG_NAME\" --query \"[?contains(name,'server')].properties.configuration.ingress.fqdn\" -o tsv 2>/dev/null | head -1) && " + "if [ -z \"$SERVER_FQDN\" ]; then echo \"❌ Server container app not found\"; exit 1; fi && " + diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/Helpers/DeploymentE2EAutomatorHelpers.cs b/tests/Aspire.Deployment.EndToEnd.Tests/Helpers/DeploymentE2EAutomatorHelpers.cs index 7ac226532c2..7d2f249c758 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/Helpers/DeploymentE2EAutomatorHelpers.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/Helpers/DeploymentE2EAutomatorHelpers.cs @@ -54,14 +54,14 @@ internal static async Task InstallAspireCliAsync( if (includeBundlePath) { - await auto.RunCommandFailFastAsync( + await auto.RunCommandAsync( AspireCliShellCommandHelpers.GetBundlePullRequestInstallCommand(prNumber), counter, TimeSpan.FromSeconds(300)); } else { - await auto.RunCommandFailFastAsync( + await auto.RunCommandAsync( AspireCliShellCommandHelpers.GetPullRequestInstallCommand(prNumber, AspireCliShellCommandHelpers.MainPullRequestInstallCommandPrefix), counter, TimeSpan.FromSeconds(300)); @@ -72,7 +72,7 @@ await auto.RunCommandFailFastAsync( case CliInstallMode.LocalArchive: var archiveDir = strategy.ArchiveDir ?? throw new InvalidOperationException("LocalArchive strategy is missing the archive directory."); - await auto.RunCommandFailFastAsync( + await auto.RunCommandAsync( AspireCliShellCommandHelpers.GetLocalArchiveInstallCommandFromCurrentRef(archiveDir), counter, TimeSpan.FromSeconds(120)); @@ -80,7 +80,7 @@ await auto.RunCommandFailFastAsync( break; case CliInstallMode.InstallScript: - await auto.RunCommandFailFastAsync( + await auto.RunCommandAsync( AspireCliShellCommandHelpers.GetInstallScriptCommand(strategy, AspireCliShellCommandHelpers.AkaMsInstallScriptCommandPrefix), counter, TimeSpan.FromSeconds(300)); diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/TypeScriptAzureContainerAppJobDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/TypeScriptAzureContainerAppJobDeploymentTests.cs index 2af5ceaa12a..57cecd137c6 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/TypeScriptAzureContainerAppJobDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/TypeScriptAzureContainerAppJobDeploymentTests.cs @@ -64,19 +64,19 @@ private async Task DeployTypeScriptContainerAppJobsToAzureContainerAppsCore(Canc await auto.PrepareEnvironmentAsync(workspace, counter); await auto.InstallCurrentBuildAspireBundleAsync(counter, output); - await auto.RunCommandFailFastAsync("aspire init --language typescript --non-interactive", counter, TimeSpan.FromMinutes(2)); + await auto.RunCommandAsync("aspire init --language typescript --non-interactive", counter, TimeSpan.FromMinutes(2)); await AddPackageAsync(auto, counter, "Aspire.Hosting.Azure.AppContainers"); WriteContainerAppJobsAppHost(workspace); - await auto.RunCommandFailFastAsync($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}", counter); + await auto.RunCommandAsync($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}", counter); await auto.TypeAsync("aspire deploy --clear-cache"); await auto.EnterAsync(); await auto.WaitForPipelineSuccessAsync(timeout: TimeSpan.FromMinutes(25)); await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2)); - await auto.RunCommandFailFastAsync(BuildJobVerificationCommand(resourceGroupName), counter, TimeSpan.FromMinutes(5)); + await auto.RunCommandAsync(BuildJobVerificationCommand(resourceGroupName), counter, TimeSpan.FromMinutes(5)); await auto.TypeAsync("exit"); await auto.EnterAsync(); diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/TypeScriptJavaScriptHostingDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/TypeScriptJavaScriptHostingDeploymentTests.cs index ab80fd5dfe2..bed9c64c532 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/TypeScriptJavaScriptHostingDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/TypeScriptJavaScriptHostingDeploymentTests.cs @@ -64,21 +64,21 @@ private async Task DeployTypeScriptStaticWebsiteWithNodeApiToAzureContainerAppsC await auto.PrepareEnvironmentAsync(workspace, counter); await auto.InstallCurrentBuildAspireBundleAsync(counter, output); - await auto.RunCommandFailFastAsync("aspire init --language typescript --non-interactive", counter, TimeSpan.FromMinutes(2)); + await auto.RunCommandAsync("aspire init --language typescript --non-interactive", counter, TimeSpan.FromMinutes(2)); await AddPackageAsync(auto, counter, "Aspire.Hosting.JavaScript"); await AddPackageAsync(auto, counter, "Aspire.Hosting.Azure.AppContainers"); WriteStaticWebsiteWithNodeApiAppHost(workspace); - await auto.RunCommandFailFastAsync($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}", counter); + await auto.RunCommandAsync($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}", counter); await auto.TypeAsync("aspire deploy --clear-cache"); await auto.EnterAsync(); await auto.WaitForPipelineSuccessAsync(timeout: TimeSpan.FromMinutes(30)); await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2)); - await auto.RunCommandFailFastAsync(BuildEndpointVerificationCommand(resourceGroupName), counter, TimeSpan.FromMinutes(10)); + await auto.RunCommandAsync(BuildEndpointVerificationCommand(resourceGroupName), counter, TimeSpan.FromMinutes(10)); await auto.TypeAsync("exit"); await auto.EnterAsync(); diff --git a/tests/Shared/Hex1bAutomatorTestHelpers.cs b/tests/Shared/Hex1bAutomatorTestHelpers.cs index 8fb6be3e3a9..ce3be0b01f6 100644 --- a/tests/Shared/Hex1bAutomatorTestHelpers.cs +++ b/tests/Shared/Hex1bAutomatorTestHelpers.cs @@ -14,29 +14,6 @@ namespace Aspire.Tests.Shared; /// internal static class Hex1bAutomatorTestHelpers { - /// - /// Waits for a shell success prompt matching the current sequence counter value, - /// then increments the counter. Looks for the pattern: [N OK] $ - /// - internal static async Task WaitForSuccessPromptAsync( - this Hex1bTerminalAutomator auto, - SequenceCounter counter, - TimeSpan? timeout = null) - { - var effectiveTimeout = timeout ?? TimeSpan.FromSeconds(500); - - await auto.WaitUntilAsync(snapshot => - { - var successPromptSearcher = new CellPatternSearcher() - .FindPattern(counter.Value.ToString()) - .RightText(" OK] $ "); - - return successPromptSearcher.Search(snapshot).Count > 0; - }, timeout: effectiveTimeout, description: $"success prompt [{counter.Value} OK] $"); - - counter.Increment(); - } - /// /// Waits for any prompt (success or error) matching the current sequence counter. /// @@ -241,7 +218,7 @@ private static int FindCommandLineIndex(IHex1bTerminalRegion snapshot, string co /// /// Waits for a successful command prompt, but fails fast if an error prompt is detected. /// - internal static async Task WaitForSuccessPromptFailFastAsync( + internal static async Task WaitForSuccessPromptAsync( this Hex1bTerminalAutomator auto, SequenceCounter counter, TimeSpan? timeout = null) @@ -296,20 +273,6 @@ internal static async Task RunCommandAsync( await auto.WaitForSuccessPromptAsync(counter, timeout); } - /// - /// Types a shell command, waits for it to complete successfully, and fails immediately on a shell error prompt. - /// - internal static async Task RunCommandFailFastAsync( - this Hex1bTerminalAutomator auto, - string command, - SequenceCounter counter, - TimeSpan? timeout = null) - { - await auto.TypeAsync(command); - await auto.EnterAsync(); - await auto.WaitForSuccessPromptFailFastAsync(counter, timeout); - } - /// /// Configures a numbered bash prompt and changes into the provided workspace directory. /// @@ -435,12 +398,12 @@ await auto.WaitUntilAsync(s => if (!sawVersionPrompt) { - await auto.WaitForSuccessPromptFailFastAsync(counter, effectiveTimeout); + await auto.WaitForSuccessPromptAsync(counter, effectiveTimeout); return; } await auto.EnterAsync(); - await auto.WaitForSuccessPromptFailFastAsync(counter, effectiveTimeout); + await auto.WaitForSuccessPromptAsync(counter, effectiveTimeout); } /// @@ -503,7 +466,7 @@ await auto.WaitUntilAsync(s => // Enter and executes a phantom blank command, advancing CMDCOUNT and desyncing // the test counter from the shell counter. - await auto.WaitForSuccessPromptFailFastAsync(counter, effectiveTimeout); + await auto.WaitForSuccessPromptAsync(counter, effectiveTimeout); } /// diff --git a/tests/Shared/Hex1bTestHelpers.cs b/tests/Shared/Hex1bTestHelpers.cs index 2b958615030..3abbc0a848b 100644 --- a/tests/Shared/Hex1bTestHelpers.cs +++ b/tests/Shared/Hex1bTestHelpers.cs @@ -225,56 +225,6 @@ internal static Hex1bTerminalInputSequenceBuilder WaitForErrorPrompt( .IncrementSequence(counter); } - /// - /// Waits for a successful command prompt, but fails fast if an error prompt is detected. - /// Unlike , this method also watches for error prompts - /// (ERR:N pattern) and throws immediately instead of waiting for the full timeout. - /// Use this for commands that may fail due to transient errors (e.g., CLI downloads). - /// - internal static Hex1bTerminalInputSequenceBuilder WaitForSuccessPromptFailFast( - this Hex1bTerminalInputSequenceBuilder builder, - SequenceCounter counter, - TimeSpan? timeout = null) - { - var effectiveTimeout = timeout ?? TimeSpan.FromSeconds(500); - var sawError = false; - - return builder.WaitUntil(snapshot => - { - var successSearcher = new CellPatternSearcher() - .FindPattern(counter.Value.ToString()) - .RightText(" OK] $ "); - - if (successSearcher.Search(snapshot).Count > 0) - { - return true; - } - - var errorSearcher = new CellPatternSearcher() - .FindPattern(counter.Value.ToString()) - .RightText(" ERR:"); - - if (errorSearcher.Search(snapshot).Count > 0) - { - sawError = true; - return true; - } - - return false; - }, effectiveTimeout) - .WaitUntil(_ => - { - if (sawError) - { - throw new InvalidOperationException( - $"Command failed with non-zero exit code (detected ERR prompt at sequence {counter.Value}). Check the terminal recording for details."); - } - - counter.Increment(); - return true; - }, TimeSpan.FromSeconds(1)); - } - /// /// Increments the sequence counter. /// @@ -564,47 +514,6 @@ internal static Hex1bTerminalInputSequenceBuilder AspireInit( .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); } - /// - /// Installs the Aspire CLI Bundle from a specific pull request's artifacts. - /// The bundle is a self-contained distribution that includes: - /// - Native AOT Aspire CLI - /// - .NET runtime - /// - Dashboard, DCP, AppHost Server (for polyglot apps) - /// This is required for polyglot (TypeScript, Python) AppHost scenarios which - /// cannot use SDK-based fallback mode. - /// - /// The sequence builder. - /// The pull request number to download from. - /// The sequence counter for prompt detection. - /// The builder for chaining. - internal static Hex1bTerminalInputSequenceBuilder InstallAspireBundleFromPullRequest( - this Hex1bTerminalInputSequenceBuilder builder, - int prNumber, - SequenceCounter counter) - { - // The install script may not be on main yet, so we need to fetch it from the PR's branch. - // Use the PR head SHA (not branch ref) to avoid CDN caching on raw.githubusercontent.com - // which can serve stale script content for several minutes after a push. - string command; - if (OperatingSystem.IsWindows()) - { - // PowerShell: Get PR head SHA, then fetch and run install script from that SHA - command = $"$ref = (gh api repos/microsoft/aspire/pulls/{prNumber} --jq '.head.sha'); " + - $"iex \"& {{ $(irm https://raw.githubusercontent.com/microsoft/aspire/$ref/eng/scripts/get-aspire-cli-pr.ps1) }} {prNumber}\""; - } - else - { - // Bash: Get PR head SHA, then fetch and run install script from that SHA - command = $"ref=$(gh api repos/microsoft/aspire/pulls/{prNumber} --jq '.head.sha') && " + - $"curl -fsSL https://raw.githubusercontent.com/microsoft/aspire/$ref/eng/scripts/get-aspire-cli-pr.sh | bash -s -- {prNumber}"; - } - - return builder - .Type(command) - .Enter() - .WaitForSuccessPromptFailFast(counter, TimeSpan.FromSeconds(300)); - } - /// /// Sources the Aspire Bundle environment after installation. /// Adds both the bundle's bin/ directory and root directory to PATH so the CLI From cbc352350f1a9bafbaff10d14a2c8de4ac186a48 Mon Sep 17 00:00:00 2001 From: "aspire-repo-bot[bot]" <268009190+aspire-repo-bot[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 09:14:19 -0700 Subject: [PATCH 27/43] [release/13.4] Improve dashboard summary log formatting (#17640) * Improve dashboard summary log formatting - Indent URL lines with 6 spaces and '- ' prefix so they align with the standard log output indentation - Move container warning to a separate log message with clearer text - Update tests to match new format * Update FrontendBrowserTokenAuthTests to assert new standalone container log message Co-authored-by: JamesNK <303201+JamesNK@users.noreply.github.com> --------- Co-authored-by: James Newton-King Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: JamesNK <303201+JamesNK@users.noreply.github.com> --- src/Shared/LoggingHelpers.cs | 20 ++++++----- .../FrontendBrowserTokenAuthTests.cs | 7 ++-- .../LoggingHelpersTests.cs | 36 +++++++++---------- 3 files changed, 31 insertions(+), 32 deletions(-) diff --git a/src/Shared/LoggingHelpers.cs b/src/Shared/LoggingHelpers.cs index 7633df7a9fd..8329ae1d412 100644 --- a/src/Shared/LoggingHelpers.cs +++ b/src/Shared/LoggingHelpers.cs @@ -50,37 +50,41 @@ public static void WriteDashboardSummary(ILogger logger, string? dashboardUrl, s .Append("Aspire Dashboard").Append('\n') .Append('\n'); + // The default .NET console logger indents the first line 6 characters because of the level. + // This prefix aligns the URLs under the dashboard title and makes them easier to read. + // In other logging layouts the prefix is just extra spaces, but it doesn't cause any real issues. + var prefix = " "; + if (dashboardAuthority is not null) { - templateBuilder.Append("Dashboard: {DashboardUrl}").Append('\n'); + templateBuilder.Append(prefix).Append("- Dashboard: {DashboardUrl}").Append('\n'); parameters.Add(dashboardAuthority); } if (loginUrl is not null) { - templateBuilder.Append("Login URL: {LoginUrl}").Append('\n'); + templateBuilder.Append(prefix).Append("- Login URL: {LoginUrl}").Append('\n'); parameters.Add(loginUrl); } if (otlpGrpcAuthority is not null) { - templateBuilder.Append("OTLP/gRPC: {OtlpGrpcUrl}").Append('\n'); + templateBuilder.Append(prefix).Append("- OTLP/gRPC: {OtlpGrpcUrl}").Append('\n'); parameters.Add(otlpGrpcAuthority); } if (otlpHttpAuthority is not null) { - templateBuilder.Append("OTLP/HTTP: {OtlpHttpUrl}").Append('\n'); + templateBuilder.Append(prefix).Append("- OTLP/HTTP: {OtlpHttpUrl}").Append('\n'); parameters.Add(otlpHttpAuthority); } + logger.LogInformation(templateBuilder.ToString(), parameters.ToArray()); + if (isContainer) { - templateBuilder.Append('\n'); - templateBuilder.Append("URLs may need changes depending on how network access to the container is configured.").Append('\n'); + logger.LogInformation("Dashboard is running in a container. Access the dashboard from the host using port forwarding."); } - - logger.LogInformation(templateBuilder.ToString(), parameters.ToArray()); } [Conditional("DEBUG")] diff --git a/tests/Aspire.Dashboard.Tests/Integration/FrontendBrowserTokenAuthTests.cs b/tests/Aspire.Dashboard.Tests/Integration/FrontendBrowserTokenAuthTests.cs index 48f7e85aa6e..325df3ad705 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/FrontendBrowserTokenAuthTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/FrontendBrowserTokenAuthTests.cs @@ -473,9 +473,8 @@ public async Task LogOutput_InContainer_LoginLinkContainerMessage() // Assert var l = testSink.Writes.Where(w => w.LoggerName == typeof(DashboardWebApplication).FullName).ToList(); - // The container message is now part of the summary log message. - var summaryLog = l.Single(w => ((string?)LogTestHelpers.GetValue(w, "{OriginalFormat}"))?.StartsWith("Aspire Dashboard") == true); - var containerMessage = "URLs may need changes depending on how network access to the container is configured."; - Assert.Contains(containerMessage, summaryLog.Message); + // The container message is a separate log entry from the summary. + var containerLog = l.Single(w => w.Message == "Dashboard is running in a container. Access the dashboard from the host using port forwarding."); + Assert.NotNull(containerLog); } } diff --git a/tests/Aspire.Dashboard.Tests/LoggingHelpersTests.cs b/tests/Aspire.Dashboard.Tests/LoggingHelpersTests.cs index fba14609fe3..dad0cfa7466 100644 --- a/tests/Aspire.Dashboard.Tests/LoggingHelpersTests.cs +++ b/tests/Aspire.Dashboard.Tests/LoggingHelpersTests.cs @@ -39,10 +39,10 @@ public void WriteDashboardSummary_WithTokenAndOtlpEndpoints_LogsSummaryAndStruct Assert.Collection(lines, line => Assert.Equal("Aspire Dashboard", line), line => Assert.Equal(string.Empty, line), - line => Assert.Equal("Dashboard: http://localhost:18888", line), - line => Assert.Equal("Login URL: http://localhost:18888/login?t=abc123", line), - line => Assert.Equal("OTLP/gRPC: http://localhost:18889", line), - line => Assert.Equal("OTLP/HTTP: http://localhost:18890", line), + line => Assert.Equal(" - Dashboard: http://localhost:18888", line), + line => Assert.Equal(" - Login URL: http://localhost:18888/login?t=abc123", line), + line => Assert.Equal(" - OTLP/gRPC: http://localhost:18889", line), + line => Assert.Equal(" - OTLP/HTTP: http://localhost:18890", line), line => Assert.Equal(string.Empty, line)); Assert.Equal("http://localhost:18888", LogTestHelpers.GetValue(write, "DashboardUrl")); @@ -72,9 +72,9 @@ public void WriteDashboardSummary_WithoutToken_DoesNotIncludeLoginUrl() Assert.Collection(lines, line => Assert.Equal("Aspire Dashboard", line), line => Assert.Equal(string.Empty, line), - line => Assert.Equal("Dashboard: http://localhost:18888", line), - line => Assert.Equal("OTLP/gRPC: http://localhost:18889", line), - line => Assert.Equal("OTLP/HTTP: http://localhost:18890", line), + line => Assert.Equal(" - Dashboard: http://localhost:18888", line), + line => Assert.Equal(" - OTLP/gRPC: http://localhost:18889", line), + line => Assert.Equal(" - OTLP/HTTP: http://localhost:18890", line), line => Assert.Equal(string.Empty, line)); Assert.Null(LogTestHelpers.GetValue(write, "LoginUrl")); @@ -96,8 +96,8 @@ public void WriteDashboardSummary_InvalidDashboardUrl_LogsOtlpEndpoints() Assert.Collection(lines, line => Assert.Equal("Aspire Dashboard", line), line => Assert.Equal(string.Empty, line), - line => Assert.Equal("OTLP/gRPC: http://localhost:18889", line), - line => Assert.Equal("OTLP/HTTP: http://localhost:18890", line), + line => Assert.Equal(" - OTLP/gRPC: http://localhost:18889", line), + line => Assert.Equal(" - OTLP/HTTP: http://localhost:18890", line), line => Assert.Equal(string.Empty, line)); } @@ -122,8 +122,8 @@ public void WriteDashboardSummary_NullDashboardUrl_LogsOtlpEndpoints() Assert.Collection(lines, line => Assert.Equal("Aspire Dashboard", line), line => Assert.Equal(string.Empty, line), - line => Assert.Equal("OTLP/gRPC: http://localhost:18889", line), - line => Assert.Equal("OTLP/HTTP: http://localhost:18890", line), + line => Assert.Equal(" - OTLP/gRPC: http://localhost:18889", line), + line => Assert.Equal(" - OTLP/HTTP: http://localhost:18890", line), line => Assert.Equal(string.Empty, line)); Assert.Null(LogTestHelpers.GetValue(write, "DashboardUrl")); @@ -186,8 +186,8 @@ public void WriteDashboardSummary_WithoutOtlpEndpoints_DoesNotIncludeOtlpLines() Assert.Collection(lines, line => Assert.Equal("Aspire Dashboard", line), line => Assert.Equal(string.Empty, line), - line => Assert.Equal("Dashboard: http://localhost:18888", line), - line => Assert.Equal("Login URL: http://localhost:18888/login?t=abc123", line), + line => Assert.Equal(" - Dashboard: http://localhost:18888", line), + line => Assert.Equal(" - Login URL: http://localhost:18888/login?t=abc123", line), line => Assert.Equal(string.Empty, line)); Assert.Null(LogTestHelpers.GetValue(write, "OtlpGrpcUrl")); @@ -208,13 +208,9 @@ public void WriteDashboardSummary_IsContainer_IncludesContainerMessage() token: "abc123", isContainer: true); - Assert.Equal(2, sink.Writes.Count); - - var containerWrite = sink.Writes.ElementAt(1); - Assert.NotNull(containerWrite.Message); - var containerMessage = "URLs may need changes depending on how network access to the container is configured."; - - Assert.Contains(containerMessage, containerWrite.Message); + Assert.Equal(3, sink.Writes.Count); + var containerWrite = sink.Writes.Last(); + Assert.Equal("Dashboard is running in a container. Access the dashboard from the host using port forwarding.", containerWrite.Message); } private static string[] GetMessageLines(string message) From a364a66748935fabf15b0b15be51812cded55035 Mon Sep 17 00:00:00 2001 From: David Pine Date: Fri, 29 May 2026 14:20:19 -0500 Subject: [PATCH 28/43] Backport PR #17553 to release/13.4 (#17673) Co-authored-by: IEvangelist <7679720+IEvangelist@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Agents/AspireSkills/AspireSkillsBundle.cs | 49 +- .../AspireSkills/AspireSkillsInstaller.cs | 87 ++- .../AspireSkills/SkillBundleManifest.cs | 2 - src/Aspire.Cli/Agents/SkillDefinition.cs | 62 +- src/Aspire.Cli/Commands/AgentInitCommand.cs | 288 +++++++-- src/Aspire.Cli/Commands/InitCommand.cs | 11 +- src/Aspire.Cli/Commands/NewCommand.cs | 4 +- .../Interaction/ConsoleInteractionService.cs | 20 +- .../ExtensionInteractionService.cs | 7 +- .../Interaction/IInteractionService.cs | 2 +- src/Aspire.Cli/KnownFeatures.cs | 8 +- .../Resources/AgentCommandStrings.Designer.cs | 2 +- .../Resources/AgentCommandStrings.resx | 2 +- .../Resources/xlf/AgentCommandStrings.cs.xlf | 4 +- .../Resources/xlf/AgentCommandStrings.de.xlf | 4 +- .../Resources/xlf/AgentCommandStrings.es.xlf | 4 +- .../Resources/xlf/AgentCommandStrings.fr.xlf | 4 +- .../Resources/xlf/AgentCommandStrings.it.xlf | 4 +- .../Resources/xlf/AgentCommandStrings.ja.xlf | 4 +- .../Resources/xlf/AgentCommandStrings.ko.xlf | 4 +- .../Resources/xlf/AgentCommandStrings.pl.xlf | 4 +- .../xlf/AgentCommandStrings.pt-BR.xlf | 4 +- .../Resources/xlf/AgentCommandStrings.ru.xlf | 4 +- .../Resources/xlf/AgentCommandStrings.tr.xlf | 4 +- .../xlf/AgentCommandStrings.zh-Hans.xlf | 4 +- .../xlf/AgentCommandStrings.zh-Hant.xlf | 4 +- .../AgentCommandTests.cs | 116 +++- .../Agents/AspireSkillsBundleTests.cs | 190 +++++- .../Agents/AspireSkillsInstallerTests.cs | 162 ++++- .../Agents/CommonAgentApplicatorsTests.cs | 58 +- .../Commands/AgentInitCommandTests.cs | 573 ++++++++++++++++-- .../Commands/InitCommandTests.cs | 12 +- .../Commands/NewCommandTests.cs | 11 +- ...PublishCommandPromptingIntegrationTests.cs | 2 +- .../Commands/UpdateCommandTests.cs | 12 +- .../ConsoleInteractionServiceTests.cs | 61 ++ .../Projects/ExtensionGuestLauncherTests.cs | 2 +- .../Templating/DotNetTemplateFactoryTests.cs | 2 +- .../TestServices/FakePlaywrightServices.cs | 45 +- .../TestExtensionInteractionService.cs | 2 +- .../TestServices/TestInteractionService.cs | 2 +- 41 files changed, 1573 insertions(+), 273 deletions(-) diff --git a/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsBundle.cs b/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsBundle.cs index 49c726ea960..a0691f22058 100644 --- a/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsBundle.cs +++ b/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsBundle.cs @@ -41,13 +41,24 @@ public static async Task LoadAsync(DirectoryInfo bundleDirec bundleDirectory, VersionHelper.GetDefaultSdkVersion(), VersionHelper.GetDefaultSdkVersion(), + skipCompatibilityCheck: false, cancellationToken).ConfigureAwait(false); } + internal static Task LoadAsync( + DirectoryInfo bundleDirectory, + string currentCliVersion, + string currentSdkVersion, + CancellationToken cancellationToken) + { + return LoadAsync(bundleDirectory, currentCliVersion, currentSdkVersion, skipCompatibilityCheck: false, cancellationToken); + } + internal static async Task LoadAsync( DirectoryInfo bundleDirectory, string currentCliVersion, string currentSdkVersion, + bool skipCompatibilityCheck, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(bundleDirectory); @@ -71,7 +82,7 @@ internal static async Task LoadAsync( throw new InvalidOperationException("Aspire skills bundle manifest is empty or invalid."); } - ValidateManifest(bundleDirectory, manifest, currentCliVersion, currentSdkVersion); + ValidateManifest(bundleDirectory, manifest, currentCliVersion, currentSdkVersion, skipCompatibilityCheck); return new AspireSkillsBundle(bundleDirectory, manifest); } @@ -107,18 +118,43 @@ public async Task> GetSkillFilesAsync(SkillDefinit return files; } + /// + /// Gets the installable skill definitions declared by the bundle manifest. + /// + public IReadOnlyList GetSkillDefinitions() + { + return _manifest.Skills + .Select(static skill => SkillDefinition.CreateAspireSkillsBundle( + skill.Name!, + skill.Description!, + (skill.InstallExcludedRelativePaths ?? []).Select(NormalizeRelativePath).ToArray(), + skill.ApplicableLanguages ?? [])) + .ToList(); + } + private static void ValidateManifest( DirectoryInfo bundleDirectory, SkillBundleManifest manifest, string currentCliVersion, - string currentSdkVersion) + string currentSdkVersion, + bool skipCompatibilityCheck) { if (string.IsNullOrWhiteSpace(manifest.Version)) { throw new InvalidOperationException("Aspire skills bundle manifest must specify a version."); } - ValidateCompatibility(manifest.Supports, currentCliVersion, currentSdkVersion); + // The bundle's `supports` range gates whether a bundle pulled fresh from GitHub + // is allowed at runtime. For bundles we already trust locally — the snapshot + // embedded in the CLI binary, and bundles already written to our own cache — + // we skip the range check because the CLI's effective version may have moved + // past the snapshot's stamped range (e.g., a dogfood build of 13.5.x using a + // bundle whose supports declares ">=13.4.0 <13.5.0"). The bundle's `version` + // field plus the version-keyed cache directory still gate matching content. + if (!skipCompatibilityCheck) + { + ValidateCompatibility(manifest.Supports, currentCliVersion, currentSdkVersion); + } var skills = manifest.Skills; if (skills is not { Length: > 0 }) @@ -126,7 +162,7 @@ private static void ValidateManifest( throw new InvalidOperationException("Aspire skills bundle manifest must contain at least one skill."); } - var skillNames = new HashSet(StringComparer.Ordinal); + var skillNames = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var skill in skills) { if (string.IsNullOrWhiteSpace(skill.Name)) @@ -139,6 +175,11 @@ private static void ValidateManifest( throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Aspire skills bundle manifest contains duplicate skill '{0}'.", skill.Name)); } + if (string.IsNullOrWhiteSpace(skill.Description)) + { + throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Aspire skills bundle skill '{0}' must specify a description.", skill.Name)); + } + if (skill.Files is not { Length: > 0 }) { throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Aspire skills bundle skill '{0}' does not contain any files.", skill.Name)); diff --git a/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsInstaller.cs b/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsInstaller.cs index dec2cca8e41..42fbb10a90d 100644 --- a/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsInstaller.cs +++ b/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsInstaller.cs @@ -8,9 +8,11 @@ using System.Net; using System.Security.Cryptography; using System.Text.Json; +using Aspire.Cli.Configuration; using Aspire.Cli.Interaction; using Aspire.Cli.Resources; using Aspire.Cli.Telemetry; +using Aspire.Cli.Utils; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; @@ -26,6 +28,7 @@ internal sealed class AspireSkillsInstaller( IInteractionService interactionService, CliExecutionContext executionContext, IConfiguration configuration, + IFeatures features, AspireCliTelemetry telemetry, ILogger logger) : IAspireSkillsInstaller { @@ -72,16 +75,33 @@ private async Task InstallCoreAsync(CancellationToken var validationDisabled = string.Equals(configuration[DisablePackageValidationKey], "true", StringComparison.OrdinalIgnoreCase); - var githubResult = await InstallFromGitHubAsync(cacheRoot, effectiveVersion, validationDisabled, activity, cancellationToken).ConfigureAwait(false); - if (githubResult.Status == AcquisitionStatus.Installed) + // The remote fetch path is opt-in. Ship 13.4 with this disabled so users only + // get the embedded snapshot (no unattended network call out to GitHub on every + // `aspire agent init`). Toggle the feature on to opt in to the GitHub release path, + // which still falls back to the embedded snapshot if the network call fails. + var remoteFetchEnabled = features.IsFeatureEnabled( + KnownFeatures.AspireSkillsRemoteFetchEnabled, + KnownFeatures.GetFeatureMetadata(KnownFeatures.AspireSkillsRemoteFetchEnabled)!.DefaultValue); + activity?.SetTag("aspire.skills.remote_fetch_enabled", remoteFetchEnabled); + + AcquisitionResult? githubResult = null; + if (remoteFetchEnabled) { - CleanupStaleCacheEntries(cacheRoot, effectiveVersion); - return AspireSkillsInstallResult.Installed(githubResult.Bundle!); - } + githubResult = await InstallFromGitHubAsync(cacheRoot, effectiveVersion, validationDisabled, activity, cancellationToken).ConfigureAwait(false); + if (githubResult.Status == AcquisitionStatus.Installed) + { + CleanupStaleCacheEntries(cacheRoot, effectiveVersion); + return AspireSkillsInstallResult.Installed(githubResult.Bundle!); + } - if (githubResult.Status == AcquisitionStatus.Failed) + if (githubResult.Status == AcquisitionStatus.Failed) + { + logger.LogDebug("Aspire skills GitHub acquisition failed for version {Version}; falling back to embedded snapshot. Failure: {Failure}", effectiveVersion, githubResult.Message); + } + } + else { - logger.LogDebug("Aspire skills GitHub acquisition failed for version {Version}; falling back to embedded snapshot. Failure: {Failure}", effectiveVersion, githubResult.Message); + logger.LogDebug("Aspire skills remote fetch feature '{Feature}' is disabled; using the embedded snapshot.", KnownFeatures.AspireSkillsRemoteFetchEnabled); } var embeddedResult = await InstallFromEmbeddedAsync(cacheRoot, effectiveVersion, activity, cancellationToken).ConfigureAwait(false); @@ -93,8 +113,8 @@ private async Task InstallCoreAsync(CancellationToken var failureMessage = embeddedResult.Status == AcquisitionStatus.Failed ? embeddedResult.Message ?? AgentCommandStrings.AspireSkillsInstaller_GitHubUnavailable - : githubResult.Status == AcquisitionStatus.Failed - ? githubResult.Message ?? AgentCommandStrings.AspireSkillsInstaller_GitHubUnavailable + : githubResult is { Status: AcquisitionStatus.Failed, Message: { } githubMessage } + ? githubMessage : AgentCommandStrings.AspireSkillsInstaller_GitHubUnavailable; activity?.SetStatus(ActivityStatusCode.Error, failureMessage); @@ -158,14 +178,15 @@ private async Task InstallFromGitHubAsync( try { - var bundle = await CacheArchiveAsync(cacheRoot, archivePath, version, cancellationToken).ConfigureAwait(false); + var bundle = await CacheArchiveAsync(cacheRoot, archivePath, version, skipCompatibilityCheck: false, cancellationToken).ConfigureAwait(false); activity?.SetTag("aspire.skills.source", "github"); activity?.SetTag("aspire.skills.cache_hit", false); return AcquisitionResult.Installed(bundle); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or InvalidOperationException) { - logger.LogWarning(ex, "Downloaded Aspire skills GitHub release asset {AssetName} is invalid.", asset.Name); + // Includes version-mismatch failures from ValidateCompatibility, which fall back to the embedded snapshot. + logger.LogDebug(ex, "Downloaded Aspire skills GitHub release asset {AssetName} is invalid.", asset.Name); return AcquisitionResult.Failed(string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.AspireSkillsInstaller_InvalidBundle, ex.Message)); } } @@ -230,14 +251,20 @@ private async Task InstallFromEmbeddedAsync( try { - var bundle = await CacheArchiveAsync(cacheRoot, archivePath, version, cancellationToken).ConfigureAwait(false); + // The embedded snapshot ships inside the CLI binary as the trusted last-resort + // fallback. Its `supports` range is stamped at the time the snapshot was built, + // which can lag the actual CLI version (especially for prerelease/dogfood builds) + // and would otherwise reject a perfectly usable local copy. Skip the bundle's + // CLI/SDK compatibility check here so the embedded skills are always offered when + // the network path is unavailable. + var bundle = await CacheArchiveAsync(cacheRoot, archivePath, version, skipCompatibilityCheck: true, cancellationToken).ConfigureAwait(false); activity?.SetTag("aspire.skills.source", "embedded"); activity?.SetTag("aspire.skills.cache_hit", false); return AcquisitionResult.Installed(bundle); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or InvalidOperationException) { - logger.LogWarning(ex, "Embedded Aspire skills bundle {AssetName} is invalid.", metadata.AssetName); + logger.LogDebug(ex, "Embedded Aspire skills bundle {AssetName} is invalid.", metadata.AssetName); return AcquisitionResult.Failed(string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.AspireSkillsInstaller_InvalidBundle, ex.Message)); } } @@ -434,7 +461,12 @@ private static HttpRequestMessage CreateGitHubRequest(string url) try { - var bundle = await AspireSkillsBundle.LoadAsync(new DirectoryInfo(cacheDirectory), cancellationToken).ConfigureAwait(false); + // Cached bundles are written by this installer (from GitHub or embedded sources). + // The cache directory is keyed by bundle version, which is the right invalidation + // signal, so skip the `supports` range check here — a previously-embedded snapshot + // whose range no longer covers the current CLI is still the local artifact we + // chose to use and should not be re-evicted on every invocation. + var bundle = await LoadCachedBundleAsync(cacheDirectory, cancellationToken).ConfigureAwait(false); ValidateBundleVersion(bundle, version); TouchLastUsed(cacheDirectory); activity?.SetTag("aspire.skills.cache_hit", true); @@ -448,10 +480,21 @@ private static HttpRequestMessage CreateGitHubRequest(string url) } } + private static Task LoadCachedBundleAsync(string cacheDirectory, CancellationToken cancellationToken) + { + return AspireSkillsBundle.LoadAsync( + new DirectoryInfo(cacheDirectory), + VersionHelper.GetDefaultSdkVersion(), + VersionHelper.GetDefaultSdkVersion(), + skipCompatibilityCheck: true, + cancellationToken); + } + private async Task CacheArchiveAsync( string cacheRoot, string archivePath, string version, + bool skipCompatibilityCheck, CancellationToken cancellationToken) { var extractDir = Path.Combine(cacheRoot, $".extract-{Guid.NewGuid():N}"); @@ -465,7 +508,7 @@ private async Task CacheArchiveAsync( var bundleRoot = FindBundleRoot(extractDir); CopyDirectory(bundleRoot.FullName, stageDir); - var stagedBundle = await AspireSkillsBundle.LoadAsync(new DirectoryInfo(stageDir), cancellationToken).ConfigureAwait(false); + var stagedBundle = await LoadStagedBundleAsync(stageDir, skipCompatibilityCheck, cancellationToken).ConfigureAwait(false); ValidateBundleVersion(stagedBundle, version); await using var cacheLock = await AcquireCacheLockAsync(cacheRoot, version, cancellationToken).ConfigureAwait(false); @@ -474,7 +517,7 @@ private async Task CacheArchiveAsync( { try { - var existingBundle = await AspireSkillsBundle.LoadAsync(new DirectoryInfo(targetDir), cancellationToken).ConfigureAwait(false); + var existingBundle = await LoadCachedBundleAsync(targetDir, cancellationToken).ConfigureAwait(false); ValidateBundleVersion(existingBundle, version); TouchLastUsed(targetDir); return existingBundle; @@ -493,7 +536,7 @@ private async Task CacheArchiveAsync( Directory.Move(stageDir, targetDir); TouchLastUsed(targetDir); - var installedBundle = await AspireSkillsBundle.LoadAsync(new DirectoryInfo(targetDir), cancellationToken).ConfigureAwait(false); + var installedBundle = await LoadCachedBundleAsync(targetDir, cancellationToken).ConfigureAwait(false); ValidateBundleVersion(installedBundle, version); return installedBundle; @@ -505,6 +548,16 @@ private async Task CacheArchiveAsync( } } + private static Task LoadStagedBundleAsync(string stageDir, bool skipCompatibilityCheck, CancellationToken cancellationToken) + { + return AspireSkillsBundle.LoadAsync( + new DirectoryInfo(stageDir), + VersionHelper.GetDefaultSdkVersion(), + VersionHelper.GetDefaultSdkVersion(), + skipCompatibilityCheck, + cancellationToken); + } + private static async Task AcquireCacheLockAsync(string cacheRoot, string version, CancellationToken cancellationToken) { var lockPath = Path.Combine(cacheRoot, $".{GetSafeFileName(version)}.lock"); diff --git a/src/Aspire.Cli/Agents/AspireSkills/SkillBundleManifest.cs b/src/Aspire.Cli/Agents/AspireSkills/SkillBundleManifest.cs index 5e48ed3f573..6f5a469deae 100644 --- a/src/Aspire.Cli/Agents/AspireSkills/SkillBundleManifest.cs +++ b/src/Aspire.Cli/Agents/AspireSkills/SkillBundleManifest.cs @@ -37,8 +37,6 @@ internal sealed class SkillBundleSkill public string? Description { get; init; } - public bool IsDefault { get; init; } - public string[] ApplicableLanguages { get; init; } = []; public string[] InstallExcludedRelativePaths { get; init; } = []; diff --git a/src/Aspire.Cli/Agents/SkillDefinition.cs b/src/Aspire.Cli/Agents/SkillDefinition.cs index b9a05156657..f2afd03936e 100644 --- a/src/Aspire.Cli/Agents/SkillDefinition.cs +++ b/src/Aspire.Cli/Agents/SkillDefinition.cs @@ -13,28 +13,6 @@ namespace Aspire.Cli.Agents; [DebuggerDisplay("Name = {Name}, Description = {Description}, IsDefault = {IsDefault}")] internal sealed class SkillDefinition { - /// - /// The Aspire skill for CLI commands and workflows. - /// - public static readonly SkillDefinition Aspire = new( - CommonAgentApplicators.AspireSkillName, - AgentCommandStrings.SkillDescription_Aspire, - skillContent: null, - sourceKind: SkillSourceKind.AspireSkillsBundle, - installExcludedRelativePaths: [Path.Combine("evals")], - isDefault: true); - - /// - /// The Aspire deployment skill for target selection, preflight, publish, and deploy workflows. - /// - public static readonly SkillDefinition AspireDeployment = new( - CommonAgentApplicators.AspireDeploymentSkillName, - AgentCommandStrings.SkillDescription_AspireDeployment, - skillContent: null, - sourceKind: SkillSourceKind.AspireSkillsBundle, - installExcludedRelativePaths: [], - isDefault: true); - /// /// The Playwright CLI skill for browser automation. /// @@ -60,16 +38,29 @@ internal sealed class SkillDefinition applicableLanguages: [KnownLanguageId.CSharp]); /// - /// One-time skill for completing Aspire initialization. - /// Installed by aspire init to scan the repo, wire up the AppHost, and configure dependencies. + /// Creates a skill definition sourced from the Aspire skills bundle. All bundle-sourced + /// skills are pre-selected by default in the install prompt; callers like aspire new + /// and standalone aspire agent init can still narrow that set with a predicate + /// (see AgentInitCommand.ExcludeOneTimeSetupSkillsFromDefaults). /// - public static readonly SkillDefinition Aspireify = new( - CommonAgentApplicators.AspireifySkillName, - AgentCommandStrings.SkillDescription_Aspireify, - skillContent: null, - sourceKind: SkillSourceKind.AspireSkillsBundle, - installExcludedRelativePaths: [], - isDefault: true); + internal static SkillDefinition CreateAspireSkillsBundle( + string name, + string description, + IReadOnlyList? installExcludedRelativePaths = null, + IReadOnlyList? applicableLanguages = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentException.ThrowIfNullOrWhiteSpace(description); + + return new( + name, + description, + skillContent: null, + sourceKind: SkillSourceKind.AspireSkillsBundle, + installExcludedRelativePaths: installExcludedRelativePaths ?? [], + isDefault: true, + applicableLanguages); + } private SkillDefinition(string name, string description, string? skillContent, SkillSourceKind sourceKind, IReadOnlyList installExcludedRelativePaths, bool isDefault, IReadOnlyList? applicableLanguages = null) { @@ -161,6 +152,11 @@ public bool IsApplicableToLanguage(LanguageId? detectedLanguage) return ApplicableLanguages.Any(l => string.Equals(l, detectedLanguage.Value.Value, StringComparison.OrdinalIgnoreCase)); } + /// + /// Returns whether this skill has the specified name. + /// + public bool HasName(string name, StringComparison comparison = StringComparison.Ordinal) => string.Equals(Name, name, comparison); + private static bool PathMatchesOrIsUnder(string relativePath, string excludedPath) { if (string.Equals(relativePath, excludedPath, StringComparison.Ordinal)) @@ -177,9 +173,9 @@ private static bool PathMatchesOrIsUnder(string relativePath, string excludedPat } /// - /// Gets all available skill definitions. + /// Gets CLI-defined skills that are not sourced from the Aspire skills bundle. /// - public static IReadOnlyList All { get; } = [Aspire, Aspireify, AspireDeployment, PlaywrightCli, DotnetInspect]; + public static IReadOnlyList CliDefined { get; } = [PlaywrightCli, DotnetInspect]; /// public override string ToString() => Name; diff --git a/src/Aspire.Cli/Commands/AgentInitCommand.cs b/src/Aspire.Cli/Commands/AgentInitCommand.cs index 5788f880841..64d1e77d5c7 100644 --- a/src/Aspire.Cli/Commands/AgentInitCommand.cs +++ b/src/Aspire.Cli/Commands/AgentInitCommand.cs @@ -84,7 +84,7 @@ public AgentInitCommand( private static readonly Option s_skillsOption = new("--skills") { Description = string.Format(CultureInfo.InvariantCulture, AgentCommandStrings.InitCommand_SkillsOptionDescription, - string.Join(",", SkillDefinition.All.Select(s => s.Name)), + string.Join(",", SkillDefinition.CliDefined.Select(s => s.Name)), ConsoleInteractionService.AllChoice, ConsoleInteractionService.NoneChoice) }; @@ -101,13 +101,18 @@ internal Task ExecuteCommandAsync(ParseResult parseResult, Cancel /// /// Prompts the user to run agent init after a successful command, then chains into agent init if accepted. /// Used by commands (e.g. aspire init, aspire new) to offer agent init as a follow-up step. + /// When is every bundle-sourced skill is + /// pre-selected, which is what aspire init wants because aspireify is the natural follow-up. + /// Other callers (e.g. aspire new) can pass a predicate to additionally filter out skills that + /// don't fit their context (such as one-time setup skills after a template has already produced the AppHost). /// internal async Task PromptAndChainAsync( IInteractionService interactionService, int previousResultExitCode, DirectoryInfo workspaceRoot, PromptBinding agentInitBinding, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + Func? selectByDefault = null) { if (previousResultExitCode != CliExitCodes.Success) { @@ -124,7 +129,7 @@ internal async Task PromptAndChainAsync( if (runAgentInit) { - return await ExecuteAgentInitAsync(workspaceRoot, parseResult: null, AgentInitErrorMode.BestEffort, cancellationToken); + return await ExecuteAgentInitAsync(workspaceRoot, parseResult: null, selectByDefault, cancellationToken); } return new(CliExitCodes.Success, [], []); @@ -133,10 +138,39 @@ internal async Task PromptAndChainAsync( protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) { var workspaceRoot = await PromptForWorkspaceRootAsync(parseResult, cancellationToken); - var result = await ExecuteAgentInitAsync(workspaceRoot, parseResult, AgentInitErrorMode.Strict, cancellationToken); + // Standalone `aspire agent init` is typically run against an existing project, so don't + // pre-select the one-time aspireify wiring skill even though every other bundle skill + // is default-on. Users can still opt into it from the prompt or via --skills. + var result = await ExecuteAgentInitAsync(workspaceRoot, parseResult, ExcludeOneTimeSetupSkillsFromDefaults, cancellationToken); return CommandResult.FromExitCode(result.ExitCode); } + /// + /// Names of bundle skills that perform one-time workspace setup and should NOT be + /// pre-selected after a workspace was just produced by a template flow such as + /// aspire new or after standalone aspire agent init (typically run + /// against an existing project). + /// + /// + /// This is the single source of truth the CLI consults when filtering bundle skills out + /// of the auto-preselection set. All bundle skills are default-on, so if the bundle ships + /// a new wiring or bootstrap-style skill that should NOT auto-run in an already-bootstrapped + /// workspace, add its name here. + /// + internal static readonly IReadOnlySet s_oneTimeSetupSkillNames = new HashSet(StringComparer.OrdinalIgnoreCase) + { + CommonAgentApplicators.AspireifySkillName, + }; + + /// + /// Default-skill predicate used by flows that do not want one-time setup skills + /// pre-selected — namely aspire new (template already created the AppHost) and + /// standalone aspire agent init (typically run against an existing project). + /// Skills filtered here remain available to opt into from the prompt or via --skills. + /// + internal static bool ExcludeOneTimeSetupSkillsFromDefaults(SkillDefinition skill) + => skill.IsDefault && !s_oneTimeSetupSkillNames.Contains(skill.Name); + private async Task PromptForWorkspaceRootAsync(ParseResult parseResult, CancellationToken cancellationToken) { // Try to discover the git repository root to use as the default workspace root @@ -167,7 +201,7 @@ private async Task PromptForWorkspaceRootAsync(ParseResult parseR return new DirectoryInfo(workspaceRootPath); } - private async Task ExecuteAgentInitAsync(DirectoryInfo workspaceRoot, ParseResult? parseResult, AgentInitErrorMode errorMode, CancellationToken cancellationToken) + private async Task ExecuteAgentInitAsync(DirectoryInfo workspaceRoot, ParseResult? parseResult, Func? selectByDefault, CancellationToken cancellationToken) { var context = new AgentEnvironmentScanContext { @@ -184,11 +218,6 @@ private async Task ExecuteAgentInitAsync(DirectoryInfo // When no language is detected (e.g., standalone `aspire agent init`), language-restricted skills are excluded. var detectedLanguage = await _languageDiscovery.DetectLanguageRecursiveAsync(workspaceRoot, cancellationToken); - // Filter skills based on language applicability - var availableSkills = SkillDefinition.All - .Where(s => s.IsApplicableToLanguage(detectedLanguage)) - .ToList(); - // Apply deprecated config migrations silently (these are fixes, not choices) var configUpdates = applicators.Where(a => a.PromptGroup == McpInitPromptGroup.ConfigUpdates).ToList(); var userChoices = applicators.Where(a => a.PromptGroup != McpInitPromptGroup.ConfigUpdates).ToList(); @@ -224,11 +253,30 @@ private async Task ExecuteAgentInitAsync(DirectoryInfo // --- Phase 2: Skill and MCP server selection (only if locations were selected) --- IReadOnlyList selectedSkills = []; + AspireSkillsBundle? aspireSkillsBundle = null; + string? bundleInstallFailureMessage = null; AgentEnvironmentApplicator? combinedMcpApplicator = null; var mcpApplicators = userChoices.Where(a => a.PromptGroup == McpInitPromptGroup.AgentEnvironments).ToList(); if (selectedLocations.Count > 0) { + IReadOnlyList availableSkills; + if (ShouldSkipBundleCatalogResolution(parseResult)) + { + availableSkills = SkillDefinition.CliDefined + .Where(s => s.IsApplicableToLanguage(detectedLanguage)) + .ToList(); + } + else + { + (availableSkills, aspireSkillsBundle, bundleInstallFailureMessage) = await ResolveAvailableSkillsAsync(detectedLanguage, cancellationToken); + } + + // Order the merged catalog deterministically by name so the prompt is stable + // regardless of manifest order. OrdinalIgnoreCase matches the case-insensitive + // --skills parsing used elsewhere. + availableSkills = [.. availableSkills.OrderBy(static s => s.Name, StringComparer.OrdinalIgnoreCase)]; + // Build prompt items: skills first, then MCP as a separate non-default item var skillChoices = new List(); skillChoices.AddRange(availableSkills); @@ -250,26 +298,46 @@ private async Task ExecuteAgentInitAsync(DirectoryInfo } var preSelectedItems = new List(); - preSelectedItems.AddRange(availableSkills.Where(s => s.IsDefault)); + var defaultSkills = GetDefaultSkills(availableSkills, selectByDefault); + preSelectedItems.AddRange(defaultSkills); // MCP is intentionally NOT pre-selected - var defaultSkillNames = string.Join(",", availableSkills.Where(s => s.IsDefault).Select(s => s.Name)); + var defaultSkillNames = string.Join(",", defaultSkills.Select(s => s.Name)); var skillsBinding = parseResult is not null ? PromptBinding.Create(parseResult, s_skillsOption, defaultSkillNames) : PromptBinding.CreateDefault(defaultSkillNames); + // When the bundle failed to install and the caller passed an explicit --skills value + // that names a bundle-only skill, the upcoming MatchChoicesOrThrow will reject the + // value as "not a valid choice" with no hint that the underlying cause was the + // bundle. Surface the install failure first so users can see why the catalog is short. + // We only do this when the value contains a name that is not in the available catalog + // and not a CLI-defined skill, so happy-path runs stay silent. + if (bundleInstallFailureMessage is not null) + { + var (wasProvided, requestedSkills, _) = PromptBinding.Resolve(skillsBinding); + if (wasProvided && requestedSkills is not null && HasUnknownBundleSkillCandidate(requestedSkills, availableSkills)) + { + _interactionService.DisplayError(bundleInstallFailureMessage); + } + } + var selectedItems = await _interactionService.PromptForSelectionsAsync( AgentCommandStrings.InitCommand_SelectSkills, skillChoices, item => item switch { - SkillDefinition skill => $"{skill.Name} — {skill.Description}", + SkillDefinition skill => $"{skill.Name.EscapeMarkup()} — {SimplifyDescription(skill.Description).EscapeMarkup()}", AgentEnvironmentApplicator app => $"[bold]{app.Description}[/] [dim]{AgentCommandStrings.InitCommand_ConfiguresDetectedAgentEnvironments}[/]", _ => item.ToString()! }, preSelected: preSelectedItems, optional: true, binding: skillsBinding, + // The MCP applicator participates in the interactive multi-select prompt for UX, + // but it is not a skill and must not be addressable via `--skills`. Restrict + // non-interactive validation to the actual SkillDefinition catalog. + bindingChoices: availableSkills.Cast(), echoSelected: false, cancellationToken: cancellationToken); @@ -286,30 +354,6 @@ private async Task ExecuteAgentInitAsync(DirectoryInfo // Each skill file write is fast (small markdown files), so sequential execution // is fine — parallelizing would complicate error handling for no meaningful gain. var hasErrors = false; - AspireSkillsBundle? aspireSkillsBundle = null; - if (selectedLocations.Count > 0 && selectedSkills.Any(static skill => skill.SourceKind is SkillSourceKind.AspireSkillsBundle)) - { - var result = await _aspireSkillsInstaller.InstallAsync(cancellationToken); - if (result.Status is AspireSkillsInstallStatus.Installed) - { - aspireSkillsBundle = result.Bundle; - } - else - { - if (errorMode is AgentInitErrorMode.Strict) - { - _interactionService.DisplayError(result.Message!); - hasErrors = true; - } - else - { - _interactionService.DisplayMessage(KnownEmojis.Warning, result.Message!); - selectedSkills = selectedSkills - .Where(static skill => skill.SourceKind is not SkillSourceKind.AspireSkillsBundle) - .ToList(); - } - } - } var installedSkills = new List(); @@ -435,6 +479,170 @@ private async Task ExecuteAgentInitAsync(DirectoryInfo selectedSkills); } + private async Task<(IReadOnlyList Skills, AspireSkillsBundle? Bundle, string? FailureMessage)> ResolveAvailableSkillsAsync(LanguageId? detectedLanguage, CancellationToken cancellationToken) + { + var skills = new List(); + AspireSkillsBundle? bundle = null; + string? failureMessage = null; + + var result = await _aspireSkillsInstaller.InstallAsync(cancellationToken); + if (result.Status is AspireSkillsInstallStatus.Installed) + { + bundle = result.Bundle ?? throw new InvalidOperationException("Aspire skills installer returned an installed result without a bundle."); + skills.AddRange(bundle.GetSkillDefinitions().Where(static skill => !IsCliDefinedSkillName(skill.Name))); + } + else + { + // Preserve the install failure so the caller can surface it only when the user + // passed an explicit --skills value that names a bundle-only skill. Happy-path + // (interactive prompt with the embedded fallback) stays silent. + failureMessage = result.Message; + } + + // When the bundle is unavailable (network failure, version mismatch, etc.), fall back + // silently to the CLI-defined skills. The installer already logs the underlying cause + // at debug level, so the user is not interrupted with a warning they cannot act on. + skills.AddRange(SkillDefinition.CliDefined); + + return (skills + .Where(s => s.IsApplicableToLanguage(detectedLanguage)) + .ToList(), bundle, failureMessage); + } + + private static bool HasUnknownBundleSkillCandidate(string requestedSkills, IReadOnlyList availableSkills) + { + // Tokens like "all" / "none" don't name skills, so the "looks like a bundle skill but missing" + // diagnostic doesn't apply — let the normal validation path handle them. + if (string.IsNullOrWhiteSpace(requestedSkills) || + string.Equals(requestedSkills, ConsoleInteractionService.AllChoice, StringComparison.OrdinalIgnoreCase) || + string.Equals(requestedSkills, ConsoleInteractionService.NoneChoice, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var requested = requestedSkills.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + foreach (var name in requested) + { + if (IsCliDefinedSkillName(name)) + { + continue; + } + + if (!availableSkills.Any(s => s.HasName(name, StringComparison.OrdinalIgnoreCase))) + { + // A non-CLI name that isn't in the catalog is exactly the case the bundle would have provided. + return true; + } + } + + return false; + } + + private static bool ShouldSkipBundleCatalogResolution(ParseResult? parseResult) + { + if (parseResult is null) + { + return false; + } + + var optionResult = parseResult.GetResult(s_skillsOption); + if (optionResult is null || optionResult.Implicit) + { + return false; + } + + var value = parseResult.GetValue(s_skillsOption); + if (string.Equals(value, ConsoleInteractionService.NoneChoice, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (string.IsNullOrWhiteSpace(value) || + string.Equals(value, ConsoleInteractionService.AllChoice, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var selectedSkillNames = value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + return selectedSkillNames.Length > 0 && + selectedSkillNames.All(static name => IsCliDefinedSkillName(name)); + } + + private static bool IsCliDefinedSkillName(string name) + { + return SkillDefinition.CliDefined.Any(skill => skill.HasName(name, StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Extracts the single short sentence from a skill description so the selection prompt + /// stays readable. + /// + /// + /// Bundle manifest descriptions can include a bold skill-type prefix followed by a + /// short tagline and additional usage guidance, for example: + /// "**WORKFLOW SKILL** - Top-level router for Aspire 13.4 distributed apps. Detects the AppHost. USE FOR: ..." + /// This trims the prefix and returns only the first sentence. Inputs without the prefix + /// or sentence terminator are returned trimmed-but-otherwise-unchanged so CLI-defined + /// short descriptions are preserved as-is. + /// + internal static string SimplifyDescription(string description) + { + if (string.IsNullOrWhiteSpace(description)) + { + return description; + } + + var simplified = description.Trim(); + + // Strip the leading bold "TYPE SKILL" prefix when present, and only then strip the + // separator characters that typically follow it. Gating the separator strip on the + // prefix match avoids silently mutating descriptions that legitimately start with + // a dash, em-dash, or colon (e.g. "-mode flag explained" or ":memo notes"). + var strippedBoldPrefix = false; + if (simplified.StartsWith("**", StringComparison.Ordinal)) + { + var endBold = simplified.IndexOf("**", 2, StringComparison.Ordinal); + if (endBold > 0) + { + simplified = simplified[(endBold + 2)..].TrimStart(); + strippedBoldPrefix = true; + } + } + + if (strippedBoldPrefix) + { + // Separators that typically follow the bold prefix (" - ", " — ", " – ", ": "). + while (simplified.Length > 0 && simplified[0] is '-' or '\u2013' or '\u2014' or ':') + { + simplified = simplified[1..].TrimStart(); + } + } + + // Return up to and including the first sentence-ending punctuation followed by + // whitespace or end-of-string. This avoids splitting on inline punctuation such + // as "13.4" or "github.com" inside the first sentence. + for (var i = 0; i < simplified.Length; i++) + { + if (simplified[i] is '.' or '!' or '?' + && (i + 1 >= simplified.Length || char.IsWhiteSpace(simplified[i + 1]))) + { + return simplified[..(i + 1)]; + } + } + + return simplified; + } + + private static IReadOnlyList GetDefaultSkills(IEnumerable availableSkills, Func? selectByDefault) + { + // When the caller doesn't customize default selection, fall back to SkillDefinition.IsDefault. + // Bundle-sourced skills are uniformly IsDefault=true; CLI-defined skills (playwright-cli, + // dotnet-inspect) are IsDefault=false so they stay opt-in. Callers like `aspire new` pass + // a predicate to additionally filter out skills that don't fit their flow. + var predicate = selectByDefault ?? (static skill => skill.IsDefault); + return availableSkills.Where(predicate).ToList(); + } + /// /// Installs the files for a skill at the specified location, creating or updating them as needed. /// @@ -555,12 +763,6 @@ private static async Task> GetSkillFilesAsync(Skil throw new InvalidOperationException($"Skill '{skill.Name}' does not define installable files."); } - private enum AgentInitErrorMode - { - Strict, - BestEffort - } - private sealed record InstalledSkillSummaryItem(string SkillName, string DisplayLocation); private readonly record struct SkillInstallResult(bool Succeeded, InstalledSkillSummaryItem? UpdatedSkill); diff --git a/src/Aspire.Cli/Commands/InitCommand.cs b/src/Aspire.Cli/Commands/InitCommand.cs index 7397dbd192c..4e8eae7e996 100644 --- a/src/Aspire.Cli/Commands/InitCommand.cs +++ b/src/Aspire.Cli/Commands/InitCommand.cs @@ -168,11 +168,18 @@ protected override async Task ExecuteAsync(ParseResult parseResul // This prompt lets users choose which skills to install — including aspireify. var workspaceRoot = solutionFile?.Directory ?? workingDirectory; var agentInitBinding = PromptBinding.CreateInvertedBoolConfirm(parseResult, NewCommand.s_suppressAgentInitOption, defaultValue: true); - var agentInitResult = await _agentInitCommand.PromptAndChainAsync(InteractionService, CliExitCodes.Success, workspaceRoot, agentInitBinding, cancellationToken); + // aspire init creates an AppHost in an existing repo, so pre-select every bundle skill + // (which includes aspireify as the natural follow-up wiring skill). + var agentInitResult = await _agentInitCommand.PromptAndChainAsync( + InteractionService, + CliExitCodes.Success, + workspaceRoot, + agentInitBinding, + cancellationToken); // Step 5: Print follow-up commands only when the user selected the one-time init skill. if (agentInitResult.ExitCode == CliExitCodes.Success && - agentInitResult.SelectedSkills.Contains(SkillDefinition.Aspireify)) + agentInitResult.SelectedSkills.Any(static skill => skill.HasName(CommonAgentApplicators.AspireifySkillName))) { var commands = GetAspireifyCommands(agentInitResult.SelectedLocations); if (commands.Count > 0) diff --git a/src/Aspire.Cli/Commands/NewCommand.cs b/src/Aspire.Cli/Commands/NewCommand.cs index 92e79ba658d..9a2f47624df 100644 --- a/src/Aspire.Cli/Commands/NewCommand.cs +++ b/src/Aspire.Cli/Commands/NewCommand.cs @@ -528,7 +528,9 @@ protected override async Task ExecuteAsync(ParseResult parseResul var workspaceRoot = new DirectoryInfo(templateResult.OutputPath ?? ExecutionContext.WorkingDirectory.FullName); var agentInitBinding = PromptBinding.CreateInvertedBoolConfirm(parseResult, s_suppressAgentInitOption, defaultValue: true); - var agentInitResult = await _agentInitCommand.PromptAndChainAsync(InteractionService, templateResult.ExitCode, workspaceRoot, agentInitBinding, cancellationToken); + // The template already produced the AppHost, so don't pre-select the one-time aspireify + // wiring skill — users can still opt into it from the prompt. + var agentInitResult = await _agentInitCommand.PromptAndChainAsync(InteractionService, templateResult.ExitCode, workspaceRoot, agentInitBinding, cancellationToken, AgentInitCommand.ExcludeOneTimeSetupSkillsFromDefaults); if (templateResult.OutputPath is not null && ExtensionHelper.IsExtensionHost(InteractionService, out var extensionInteractionService, out _)) { diff --git a/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs b/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs index ce88d074460..18345c835da 100644 --- a/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs +++ b/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs @@ -347,7 +347,7 @@ public async Task PromptForSelectionAsync(string promptText, IEnumerable> PromptForSelectionsAsync(string promptText, IEnumerable choices, Func choiceFormatter, IEnumerable? preSelected = null, bool optional = false, PromptBinding? binding = null, bool echoSelected = true, CancellationToken cancellationToken = default) where T : notnull + public async Task> PromptForSelectionsAsync(string promptText, IEnumerable choices, Func choiceFormatter, IEnumerable? preSelected = null, bool optional = false, PromptBinding? binding = null, bool echoSelected = true, IEnumerable? bindingChoices = null, CancellationToken cancellationToken = default) where T : notnull { ArgumentNullException.ThrowIfNull(promptText, nameof(promptText)); ArgumentNullException.ThrowIfNull(choices, nameof(choices)); @@ -356,10 +356,18 @@ public async Task> PromptForSelectionsAsync(string promptTex // Materialize once to avoid re-enumerating the choices enumerable. var choicesList = choices as IReadOnlyList ?? choices.ToList(); + // The non-interactive validation set defaults to the visible choices, but callers + // can pass a narrower bindingChoices subset when some visible items should never + // be addressable from the command-line option (e.g., a UX-only "configure MCP + // server" entry that lives in the same multi-select prompt as the real catalog). + var bindingChoicesList = bindingChoices is null + ? choicesList + : bindingChoices as IReadOnlyList ?? bindingChoices.ToList(); + var (wasProvided, value, defaultValue) = PromptBinding.Resolve(binding); if (wasProvided && value is not null) { - return MatchChoicesOrThrow(value, binding!, choicesList, choiceFormatter); + return MatchChoicesOrThrow(value, binding!, bindingChoicesList, choiceFormatter); } if (!_hostEnvironment.SupportsInteractiveInput) @@ -368,7 +376,7 @@ public async Task> PromptForSelectionsAsync(string promptTex { if (binding.NonInteractiveDefaultValue != null) { - return MatchChoicesOrThrow(binding.NonInteractiveDefaultValue, binding, choicesList, choiceFormatter); + return MatchChoicesOrThrow(binding.NonInteractiveDefaultValue, binding, bindingChoicesList, choiceFormatter); } ThrowNonInteractiveError(binding.SymbolDisplayName); @@ -792,7 +800,11 @@ internal void ValidateResolvedStringValue(string value, bool required, Func(string value, string symbolDisplayName, IEnumerable choices, Func choiceFormatter) where T : notnull { DisplayError(string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.NonInteractiveInvalidValue, value, symbolDisplayName)); - var availableChoices = string.Join(", ", choices.Select(c => choiceFormatter(c))); + // Strip Spectre markup from each formatted choice so non-interactive callers see plain + // text. Some choice formatters intentionally include [bold]/[dim]/etc. tokens for the + // interactive multi-select renderer; those tokens would otherwise leak verbatim through + // DisplaySubtleMessage and confuse anyone diagnosing a typoed --option value. + var availableChoices = string.Join(", ", choices.Select(c => choiceFormatter(c).RemoveSpectreFormatting())); DisplaySubtleMessage(string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.NonInteractiveAvailableValues, availableChoices)); throw new NonInteractiveException(symbolDisplayName); } diff --git a/src/Aspire.Cli/Interaction/ExtensionInteractionService.cs b/src/Aspire.Cli/Interaction/ExtensionInteractionService.cs index 195b0d6312b..6ffe3bd1d34 100644 --- a/src/Aspire.Cli/Interaction/ExtensionInteractionService.cs +++ b/src/Aspire.Cli/Interaction/ExtensionInteractionService.cs @@ -314,12 +314,13 @@ await _extensionTaskChannel.Writer.WriteAsync(async () => } public async Task> PromptForSelectionsAsync(string promptText, IEnumerable choices, Func choiceFormatter, - IEnumerable? preSelected = null, bool optional = false, PromptBinding? binding = null, bool echoSelected = true, CancellationToken cancellationToken = default) where T : notnull + IEnumerable? preSelected = null, bool optional = false, PromptBinding? binding = null, bool echoSelected = true, IEnumerable? bindingChoices = null, CancellationToken cancellationToken = default) where T : notnull { var (wasProvided, value, _) = PromptBinding.Resolve(binding); if (wasProvided && value is not null) { - return _consoleInteractionService.MatchChoicesOrThrow(value, binding!, choices, choiceFormatter); + var validationChoices = bindingChoices ?? choices; + return _consoleInteractionService.MatchChoicesOrThrow(value, binding!, validationChoices, choiceFormatter); } if (_extensionPromptEnabled) @@ -349,7 +350,7 @@ await _extensionTaskChannel.Writer.WriteAsync(async () => } else { - return await _consoleInteractionService.PromptForSelectionsAsync(promptText, choices, choiceFormatter, preSelected, optional, binding, echoSelected, cancellationToken); + return await _consoleInteractionService.PromptForSelectionsAsync(promptText, choices, choiceFormatter, preSelected, optional, binding, echoSelected, bindingChoices, cancellationToken); } } diff --git a/src/Aspire.Cli/Interaction/IInteractionService.cs b/src/Aspire.Cli/Interaction/IInteractionService.cs index 9f453e9079e..df40e0b12bc 100644 --- a/src/Aspire.Cli/Interaction/IInteractionService.cs +++ b/src/Aspire.Cli/Interaction/IInteractionService.cs @@ -26,7 +26,7 @@ internal interface IInteractionService Task PromptForFilePathAsync(string promptText, Func? validator = null, bool directory = false, bool required = false, PromptBinding? binding = null, CancellationToken cancellationToken = default); public Task PromptConfirmAsync(string promptText, PromptBinding? binding = null, CancellationToken cancellationToken = default); Task PromptForSelectionAsync(string promptText, IEnumerable choices, Func choiceFormatter, PromptBinding? binding = null, bool echoSelected = true, CancellationToken cancellationToken = default) where T : notnull; - Task> PromptForSelectionsAsync(string promptText, IEnumerable choices, Func choiceFormatter, IEnumerable? preSelected = null, bool optional = false, PromptBinding? binding = null, bool echoSelected = true, CancellationToken cancellationToken = default) where T : notnull; + Task> PromptForSelectionsAsync(string promptText, IEnumerable choices, Func choiceFormatter, IEnumerable? preSelected = null, bool optional = false, PromptBinding? binding = null, bool echoSelected = true, IEnumerable? bindingChoices = null, CancellationToken cancellationToken = default) where T : notnull; int DisplayIncompatibleVersionError(AppHostIncompatibleException ex, string appHostHostingVersion); void DisplayError(string errorMessage, bool allowMarkup = false); void DisplayMessage(KnownEmoji emoji, string message, bool allowMarkup = false, ConsoleOutput? consoleOverride = null); diff --git a/src/Aspire.Cli/KnownFeatures.cs b/src/Aspire.Cli/KnownFeatures.cs index dc0f3202b23..d830b694490 100644 --- a/src/Aspire.Cli/KnownFeatures.cs +++ b/src/Aspire.Cli/KnownFeatures.cs @@ -29,6 +29,7 @@ internal static class KnownFeatures public static string ExperimentalPolyglotGo => "experimentalPolyglot:go"; public static string ExperimentalPolyglotPython => "experimentalPolyglot:python"; public static string NuGetSignatureVerificationEnabled => "nugetSignatureVerificationEnabled"; + public static string AspireSkillsRemoteFetchEnabled => "aspireSkillsRemoteFetchEnabled"; private static readonly Dictionary s_featureMetadata = new() { @@ -80,7 +81,12 @@ internal static class KnownFeatures [NuGetSignatureVerificationEnabled] = new( NuGetSignatureVerificationEnabled, "Enable or disable defaulting the DOTNET_NUGET_SIGNATURE_VERIFICATION environment variable for spawned processes", - DefaultValue: true) + DefaultValue: true), + + [AspireSkillsRemoteFetchEnabled] = new( + AspireSkillsRemoteFetchEnabled, + "(Preview) Allow the Aspire CLI to download the aspire-skills bundle from GitHub. When disabled (the 13.4 default), the CLI only uses the cached bundle and the embedded snapshot baked into the CLI; toggle on to opt in to the remote fetch path.", + DefaultValue: false) }; /// diff --git a/src/Aspire.Cli/Resources/AgentCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/AgentCommandStrings.Designer.cs index 6133c22ac5e..ce85dae225d 100644 --- a/src/Aspire.Cli/Resources/AgentCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/AgentCommandStrings.Designer.cs @@ -484,7 +484,7 @@ internal static string InitCommand_SkillLocationsOptionDescription { } /// - /// Looks up a localized string similar to Comma-separated list of skills to install (e.g. {0}), '{1}', or '{2}'. + /// Looks up a localized string similar to Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}'. /// internal static string InitCommand_SkillsOptionDescription { get { diff --git a/src/Aspire.Cli/Resources/AgentCommandStrings.resx b/src/Aspire.Cli/Resources/AgentCommandStrings.resx index 2364ff91d8d..b05f1f72458 100644 --- a/src/Aspire.Cli/Resources/AgentCommandStrings.resx +++ b/src/Aspire.Cli/Resources/AgentCommandStrings.resx @@ -202,6 +202,6 @@ Comma-separated list of skill locations to install (e.g. {0}), '{1}', or '{2}' - Comma-separated list of skills to install (e.g. {0}), '{1}', or '{2}' + Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}' diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.cs.xlf index 7139b9c497a..df08463f3fb 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.cs.xlf @@ -83,8 +83,8 @@ - Comma-separated list of skills to install (e.g. {0}), '{1}', or '{2}' - Comma-separated list of skills to install (e.g. {0}), '{1}', or '{2}' + Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}' + Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}' diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.de.xlf index a09a22f0b5d..bba085c2c41 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.de.xlf @@ -83,8 +83,8 @@ - Comma-separated list of skills to install (e.g. {0}), '{1}', or '{2}' - Comma-separated list of skills to install (e.g. {0}), '{1}', or '{2}' + Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}' + Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}' diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.es.xlf index 416a73644e6..eeed9427c05 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.es.xlf @@ -83,8 +83,8 @@ - Comma-separated list of skills to install (e.g. {0}), '{1}', or '{2}' - Comma-separated list of skills to install (e.g. {0}), '{1}', or '{2}' + Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}' + Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}' diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.fr.xlf index 38117b59c88..6dadce5acca 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.fr.xlf @@ -83,8 +83,8 @@ - Comma-separated list of skills to install (e.g. {0}), '{1}', or '{2}' - Comma-separated list of skills to install (e.g. {0}), '{1}', or '{2}' + Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}' + Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}' diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.it.xlf index 670db6c9e77..419da678d4a 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.it.xlf @@ -83,8 +83,8 @@ - Comma-separated list of skills to install (e.g. {0}), '{1}', or '{2}' - Comma-separated list of skills to install (e.g. {0}), '{1}', or '{2}' + Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}' + Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}' diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ja.xlf index 297b0f9eb54..1136e60b057 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ja.xlf @@ -83,8 +83,8 @@ - Comma-separated list of skills to install (e.g. {0}), '{1}', or '{2}' - Comma-separated list of skills to install (e.g. {0}), '{1}', or '{2}' + Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}' + Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}' diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ko.xlf index 05a8359420d..3339af60aad 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ko.xlf @@ -83,8 +83,8 @@ - Comma-separated list of skills to install (e.g. {0}), '{1}', or '{2}' - Comma-separated list of skills to install (e.g. {0}), '{1}', or '{2}' + Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}' + Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}' diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pl.xlf index d87eccc448a..147afe86cd2 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pl.xlf @@ -83,8 +83,8 @@ - Comma-separated list of skills to install (e.g. {0}), '{1}', or '{2}' - Comma-separated list of skills to install (e.g. {0}), '{1}', or '{2}' + Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}' + Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}' diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pt-BR.xlf index 66b4e8820f1..a0b404b68e7 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pt-BR.xlf @@ -83,8 +83,8 @@ - Comma-separated list of skills to install (e.g. {0}), '{1}', or '{2}' - Comma-separated list of skills to install (e.g. {0}), '{1}', or '{2}' + Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}' + Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}' diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ru.xlf index 76dc2665fa2..7ea833b2e9a 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ru.xlf @@ -83,8 +83,8 @@ - Comma-separated list of skills to install (e.g. {0}), '{1}', or '{2}' - Comma-separated list of skills to install (e.g. {0}), '{1}', or '{2}' + Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}' + Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}' diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.tr.xlf index 65b85538393..8e7822dfebf 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.tr.xlf @@ -83,8 +83,8 @@ - Comma-separated list of skills to install (e.g. {0}), '{1}', or '{2}' - Comma-separated list of skills to install (e.g. {0}), '{1}', or '{2}' + Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}' + Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}' diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hans.xlf index f4c08201732..8e37e2ccd1c 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hans.xlf @@ -83,8 +83,8 @@ - Comma-separated list of skills to install (e.g. {0}), '{1}', or '{2}' - Comma-separated list of skills to install (e.g. {0}), '{1}', or '{2}' + Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}' + Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}' diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hant.xlf index 62de972aa38..2e7d6672223 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hant.xlf @@ -83,8 +83,8 @@ - Comma-separated list of skills to install (e.g. {0}), '{1}', or '{2}' - Comma-separated list of skills to install (e.g. {0}), '{1}', or '{2}' + Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}' + Comma-separated list of skills to install. Bundle skills are loaded dynamically; CLI-provided skills include {0}. Use '{1}' or '{2}' diff --git a/tests/Aspire.Cli.EndToEnd.Tests/AgentCommandTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/AgentCommandTests.cs index 917c86c5d67..ca023986c70 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/AgentCommandTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/AgentCommandTests.cs @@ -222,6 +222,63 @@ await auto.WaitUntilAsync( Assert.Contains("Aspire Deployment", deploymentFileContent); } + /// + /// Regression guard for the original bug: bundle-only skill names (aspire-init, + /// aspire-monitoring, aspire-orchestration) were not surfaced by the CLI because the + /// install prompt was driven by a hardcoded list. End-to-end this means passing those + /// names to aspire agent init --skills must materialize their SKILL.md files. + /// The CLI-hardcoded skills (aspire/aspireify/aspire-deployment) worked before, so they + /// aren't part of the regression and are covered by the broader integration test. + /// + [Fact] + public async Task AgentInitCommand_NonInteractive_BundleOnlySkillsBeyondCliCatalog_AreInstallable() + { + var repoRoot = CliE2ETestHelpers.GetRepoRoot(); + var strategy = CliInstallStrategy.Detect(output.WriteLine); + var workspace = TemporaryWorkspace.Create(output); + + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace); + + var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); + + var counter = new SequenceCounter(); + var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + + await auto.PrepareDockerEnvironmentAsync(counter, workspace); + + await auto.InstallAspireCliAsync(strategy, counter); + + // Seed the user-level cache with the six bundle skills so the CLI exercises the cached + // path without needing the unpublished npm package. The fixture mirrors the published + // bundle's manifest shape. + await SeedAspireSkillsBundleCacheAsync(auto, workspace, counter); + + // The names below are the ones the original bug hid from the CLI. Naming them explicitly + // (rather than `--skills all`) avoids pulling in playwright/dotnet-inspect, which would + // attempt real npm registry calls inside the container, and keeps the assertion narrowly + // focused on the regression. Extra skills added to the bundle in the future are + // intentionally outside the scope of this snapshot test. + var bundleOnlySkills = new[] { "aspire-init", "aspire-monitoring", "aspire-orchestration" }; + var skillsArg = string.Join(",", bundleOnlySkills); + + await auto.TypeAsync($"aspire agent init --workspace-root . --skill-locations standard --skills {skillsArg}"); + await auto.EnterAsync(); + await auto.WaitUntilTextAsync("configuration complete", timeout: TimeSpan.FromSeconds(60)); + await auto.WaitForSuccessPromptAsync(counter); + + var skillsRoot = Path.Combine(workspace.WorkspaceRoot.FullName, ".agents", "skills"); + foreach (var skillName in bundleOnlySkills) + { + var skillFile = Path.Combine(skillsRoot, skillName, "SKILL.md"); + Assert.True(File.Exists(skillFile), $"Expected {skillName} SKILL.md at {skillFile}"); + } + + await auto.TypeAsync("exit"); + await auto.EnterAsync(); + + await pendingRun; + } + private static async Task SeedAspireSkillsBundleCacheAsync(Hex1bTerminalAutomator auto, TemporaryWorkspace workspace, SequenceCounter counter) { const string aspireSkillsVersion = "0.0.1"; @@ -237,7 +294,10 @@ private static async Task SeedAspireSkillsBundleCacheAsync(Hex1bTerminalAutomato "$cache/skills/aspire/references" \ "$cache/skills/aspire/evals" \ "$cache/skills/aspireify" \ - "$cache/skills/aspire-deployment/references" + "$cache/skills/aspire-deployment/references" \ + "$cache/skills/aspire-init" \ + "$cache/skills/aspire-monitoring" \ + "$cache/skills/aspire-orchestration" cat > "$cache/skills/aspire/SKILL.md" <<'SKILL' --- @@ -271,12 +331,42 @@ private static async Task SeedAspireSkillsBundleCacheAsync(Hex1bTerminalAutomato SKILL printf '%s\n' '# Preflight' > "$cache/skills/aspire-deployment/references/preflight.md" + cat > "$cache/skills/aspire-init/SKILL.md" <<'SKILL' + --- + name: aspire-init + description: "First-run flow for adding Aspire to a repo" + --- + + # Aspire Init + SKILL + + cat > "$cache/skills/aspire-monitoring/SKILL.md" <<'SKILL' + --- + name: aspire-monitoring + description: "Observe Aspire apps with logs, traces, metrics, and resource state" + --- + + # Aspire Monitoring + SKILL + + cat > "$cache/skills/aspire-orchestration/SKILL.md" <<'SKILL' + --- + name: aspire-orchestration + description: "Manage Aspire AppHost lifecycle and resource commands" + --- + + # Aspire Orchestration + SKILL + aspire_skill_hash="$(sha256sum "$cache/skills/aspire/SKILL.md" | awk '{print $1}')" aspire_commands_hash="$(sha256sum "$cache/skills/aspire/references/app-commands.md" | awk '{print $1}')" aspire_evals_hash="$(sha256sum "$cache/skills/aspire/evals/evals.json" | awk '{print $1}')" aspireify_skill_hash="$(sha256sum "$cache/skills/aspireify/SKILL.md" | awk '{print $1}')" deployment_skill_hash="$(sha256sum "$cache/skills/aspire-deployment/SKILL.md" | awk '{print $1}')" deployment_preflight_hash="$(sha256sum "$cache/skills/aspire-deployment/references/preflight.md" | awk '{print $1}')" + init_skill_hash="$(sha256sum "$cache/skills/aspire-init/SKILL.md" | awk '{print $1}')" + monitoring_skill_hash="$(sha256sum "$cache/skills/aspire-monitoring/SKILL.md" | awk '{print $1}')" + orchestration_skill_hash="$(sha256sum "$cache/skills/aspire-orchestration/SKILL.md" | awk '{print $1}')" cat > "$cache/skill-manifest.json" < SkillDefinition.CreateAspireSkillsBundle( + CommonAgentApplicators.AspireSkillName, + AspireSkillDescription, + installExcludedRelativePaths: ["evals"]); + + private static SkillDefinition AspireifySkillDefinition => SkillDefinition.CreateAspireSkillsBundle( + CommonAgentApplicators.AspireifySkillName, + AspireifySkillDescription); + [Fact] public async Task LoadAsync_ValidatesManifestAndReturnsInstallableFiles() { @@ -25,7 +37,7 @@ public async Task LoadAsync_ValidatesManifestAndReturnsInstallableFiles() }); var bundle = await AspireSkillsBundle.LoadAsync(new DirectoryInfo(bundleDirectory), CancellationToken.None); - var files = await bundle.GetSkillFilesAsync(SkillDefinition.Aspire, CancellationToken.None); + var files = await bundle.GetSkillFilesAsync(AspireSkillDefinition, CancellationToken.None); Assert.Equal(AspireSkillsInstaller.Version, bundle.Version); Assert.Contains(files, file => file.RelativePath == "SKILL.md"); @@ -38,6 +50,35 @@ public async Task LoadAsync_ValidatesManifestAndReturnsInstallableFiles() } } + [Fact] + public async Task GetSkillDefinitions_ReturnsManifestSkills() + { + var bundleDirectory = CreateTempDirectory(); + + try + { + await CreateBundleAsync(bundleDirectory, new Dictionary + { + ["SKILL.md"] = CreateSkillFileContent(), + ["references/app-commands.md"] = "# App commands" + }); + + var bundle = await AspireSkillsBundle.LoadAsync(new DirectoryInfo(bundleDirectory), CancellationToken.None); + var skill = Assert.Single(bundle.GetSkillDefinitions()); + + Assert.Equal(CommonAgentApplicators.AspireSkillName, skill.Name); + Assert.Equal(AspireSkillDescription, skill.Description); + Assert.True(skill.IsDefault); + Assert.Equal(SkillSourceKind.AspireSkillsBundle, skill.SourceKind); + Assert.Equal(["evals"], skill.InstallExcludedRelativePaths); + Assert.Empty(skill.ApplicableLanguages); + } + finally + { + Directory.Delete(bundleDirectory, recursive: true); + } + } + [Fact] public async Task LoadAsync_ThrowsWhenHashDoesNotMatch() { @@ -83,12 +124,45 @@ public async Task LoadAsync_ThrowsWhenSkillDescriptionExceedsAgentHostLimit() } } + [Fact] + public async Task LoadAsync_ThrowsWhenSkillNamesDifferOnlyByCase() + { + var bundleDirectory = CreateTempDirectory(); + + try + { + await WriteSkillAsync(bundleDirectory, CommonAgentApplicators.AspireSkillName, CreateSkillFileContent()); + await WriteSkillAsync(bundleDirectory, "Aspire", CreateSkillFileContent("Aspire")); + + var manifest = new SkillBundleManifest + { + Version = AspireSkillsInstaller.Version, + Supports = CreateSupports(), + Skills = + [ + CreateManifestSkill(bundleDirectory, CommonAgentApplicators.AspireSkillName, AspireSkillDescription), + CreateManifestSkill(bundleDirectory, "Aspire", AspireSkillDescription) + ] + }; + + await WriteManifestAsync(bundleDirectory, manifest); + + var exception = await Assert.ThrowsAsync(() => AspireSkillsBundle.LoadAsync(new DirectoryInfo(bundleDirectory), CancellationToken.None)); + + Assert.Contains("duplicate skill", exception.Message, StringComparison.OrdinalIgnoreCase); + } + finally + { + Directory.Delete(bundleDirectory, recursive: true); + } + } + [Fact] public async Task LoadAsync_ThrowsWhenFilePathEscapesSkillRoot() { var bundleDirectory = CreateTempDirectory(); - Directory.CreateDirectory(Path.Combine(bundleDirectory, "skills", SkillDefinition.Aspire.Name)); - await File.WriteAllTextAsync(Path.Combine(bundleDirectory, "skills", SkillDefinition.Aspire.Name, "SKILL.md"), CreateSkillFileContent()); + Directory.CreateDirectory(Path.Combine(bundleDirectory, "skills", CommonAgentApplicators.AspireSkillName)); + await File.WriteAllTextAsync(Path.Combine(bundleDirectory, "skills", CommonAgentApplicators.AspireSkillName, "SKILL.md"), CreateSkillFileContent()); try { @@ -100,9 +174,8 @@ public async Task LoadAsync_ThrowsWhenFilePathEscapesSkillRoot() [ new SkillBundleSkill { - Name = SkillDefinition.Aspire.Name, - Description = SkillDefinition.Aspire.Description, - IsDefault = true, + Name = CommonAgentApplicators.AspireSkillName, + Description = AspireSkillDescription, Files = [ new SkillBundleFile @@ -131,10 +204,10 @@ public async Task LoadAsync_ThrowsWhenFilePathEscapesSkillRoot() public async Task GetSkillFilesAsync_TreatsMissingOptionalPathArraysAsEmpty() { var bundleDirectory = CreateTempDirectory(); - var skillDirectory = Path.Combine(bundleDirectory, "skills", SkillDefinition.Aspireify.Name); + var skillDirectory = Path.Combine(bundleDirectory, "skills", CommonAgentApplicators.AspireifySkillName); Directory.CreateDirectory(skillDirectory); var skillPath = Path.Combine(skillDirectory, "SKILL.md"); - var skillContent = CreateSkillFileContent(SkillDefinition.Aspireify.Name, SkillDefinition.Aspireify.Description, "# Aspireify"); + var skillContent = CreateSkillFileContent(CommonAgentApplicators.AspireifySkillName, AspireifySkillDescription, "# Aspireify"); await File.WriteAllTextAsync(skillPath, skillContent); try @@ -149,9 +222,8 @@ public async Task GetSkillFilesAsync_TreatsMissingOptionalPathArraysAsEmpty() }, "skills": [ { - "name": "{{SkillDefinition.Aspireify.Name}}", - "description": "{{SkillDefinition.Aspireify.Description}}", - "isDefault": true, + "name": "{{CommonAgentApplicators.AspireifySkillName}}", + "description": "{{AspireifySkillDescription}}", "files": [ { "relativePath": "SKILL.md", "sha256": "{{ComputeSha256(skillPath)}}" } ] @@ -162,7 +234,7 @@ public async Task GetSkillFilesAsync_TreatsMissingOptionalPathArraysAsEmpty() await File.WriteAllTextAsync(Path.Combine(bundleDirectory, "skill-manifest.json"), manifestJson); var bundle = await AspireSkillsBundle.LoadAsync(new DirectoryInfo(bundleDirectory), CancellationToken.None); - var files = await bundle.GetSkillFilesAsync(SkillDefinition.Aspireify, CancellationToken.None); + var files = await bundle.GetSkillFilesAsync(AspireifySkillDefinition, CancellationToken.None); var skillFile = Assert.Single(files); Assert.Equal("SKILL.md", skillFile.RelativePath); @@ -178,7 +250,7 @@ public async Task GetSkillFilesAsync_TreatsMissingOptionalPathArraysAsEmpty() public async Task LoadAsync_ThrowsWhenSupportsAreMissing() { var bundleDirectory = CreateTempDirectory(); - var skillDirectory = Path.Combine(bundleDirectory, "skills", SkillDefinition.Aspire.Name); + var skillDirectory = Path.Combine(bundleDirectory, "skills", CommonAgentApplicators.AspireSkillName); Directory.CreateDirectory(skillDirectory); var skillPath = Path.Combine(skillDirectory, "SKILL.md"); await File.WriteAllTextAsync(skillPath, CreateSkillFileContent()); @@ -192,9 +264,8 @@ public async Task LoadAsync_ThrowsWhenSupportsAreMissing() [ new SkillBundleSkill { - Name = SkillDefinition.Aspire.Name, - Description = SkillDefinition.Aspire.Description, - IsDefault = true, + Name = CommonAgentApplicators.AspireSkillName, + Description = AspireSkillDescription, Files = [ new SkillBundleFile @@ -271,13 +342,69 @@ await CreateBundleAsync( } } + [Fact] + public async Task LoadAsync_SkipCompatibilityCheck_AllowsBundleOutsideSupportsRange() + { + var bundleDirectory = CreateTempDirectory(); + + try + { + await CreateBundleAsync( + bundleDirectory, + new Dictionary { ["SKILL.md"] = CreateSkillFileContent() }, + supports: new SkillBundleSupports { AspireCli = ">=13.4.0 <13.5.0" }); + + var bundle = await AspireSkillsBundle.LoadAsync( + new DirectoryInfo(bundleDirectory), + currentCliVersion: "13.5.0-pr.17553.gca8e5ace", + currentSdkVersion: "13.5.0", + skipCompatibilityCheck: true, + CancellationToken.None); + + Assert.Equal(AspireSkillsInstaller.Version, bundle.Version); + } + finally + { + Directory.Delete(bundleDirectory, recursive: true); + } + } + + [Fact] + public async Task LoadAsync_SkipCompatibilityCheck_StillRejectsOtherInvariants() + { + var bundleDirectory = CreateTempDirectory(); + + try + { + await CreateBundleAsync( + bundleDirectory, + new Dictionary { ["SKILL.md"] = CreateSkillFileContent() }); + + // Truncate the bundled SKILL.md so the SHA-256 in the manifest no longer matches. + // The compatibility skip must not bypass content verification. + var skillPath = Path.Combine(bundleDirectory, "skills", CommonAgentApplicators.AspireSkillName, "SKILL.md"); + await File.WriteAllTextAsync(skillPath, "tampered"); + + await Assert.ThrowsAsync(() => AspireSkillsBundle.LoadAsync( + new DirectoryInfo(bundleDirectory), + currentCliVersion: "13.5.0", + currentSdkVersion: "13.5.0", + skipCompatibilityCheck: true, + CancellationToken.None)); + } + finally + { + Directory.Delete(bundleDirectory, recursive: true); + } + } + private static async Task CreateBundleAsync( string bundleDirectory, Dictionary files, string? hashOverride = null, SkillBundleSupports? supports = null) { - var skillDirectory = Path.Combine(bundleDirectory, "skills", SkillDefinition.Aspire.Name); + var skillDirectory = Path.Combine(bundleDirectory, "skills", CommonAgentApplicators.AspireSkillName); Directory.CreateDirectory(skillDirectory); foreach (var (relativePath, content) in files) @@ -295,9 +422,8 @@ private static async Task CreateBundleAsync( [ new SkillBundleSkill { - Name = SkillDefinition.Aspire.Name, - Description = SkillDefinition.Aspire.Description, - IsDefault = true, + Name = CommonAgentApplicators.AspireSkillName, + Description = AspireSkillDescription, InstallExcludedRelativePaths = ["evals"], Files = files .Select(file => new SkillBundleFile @@ -322,6 +448,30 @@ private static SkillBundleSupports CreateSupports() }; } + private static async Task WriteSkillAsync(string bundleDirectory, string skillName, string content) + { + var skillDirectory = Path.Combine(bundleDirectory, "skills", skillName); + Directory.CreateDirectory(skillDirectory); + await File.WriteAllTextAsync(Path.Combine(skillDirectory, "SKILL.md"), content); + } + + private static SkillBundleSkill CreateManifestSkill(string bundleDirectory, string skillName, string description) + { + return new SkillBundleSkill + { + Name = skillName, + Description = description, + Files = + [ + new SkillBundleFile + { + RelativePath = "SKILL.md", + Sha256 = ComputeSha256(Path.Combine(bundleDirectory, "skills", skillName, "SKILL.md")) + } + ] + }; + } + private static Task WriteManifestAsync(string bundleDirectory, SkillBundleManifest manifest) { var manifestJson = JsonSerializer.Serialize(manifest, AspireSkillsJsonSerializerContext.Default.SkillBundleManifest); diff --git a/tests/Aspire.Cli.Tests/Agents/AspireSkillsInstallerTests.cs b/tests/Aspire.Cli.Tests/Agents/AspireSkillsInstallerTests.cs index 3b418f7cc99..95b1f0da049 100644 --- a/tests/Aspire.Cli.Tests/Agents/AspireSkillsInstallerTests.cs +++ b/tests/Aspire.Cli.Tests/Agents/AspireSkillsInstallerTests.cs @@ -8,6 +8,7 @@ using System.Text.Json; using Aspire.Cli.Agents; using Aspire.Cli.Agents.AspireSkills; +using Aspire.Cli.Configuration; using Aspire.Cli.Npm; using Aspire.Cli.Tests.Telemetry; using Aspire.Cli.Tests.TestServices; @@ -19,6 +20,8 @@ namespace Aspire.Cli.Tests.Agents; public class AspireSkillsInstallerTests { + private const string AspireSkillDescription = "Aspire CLI commands and workflows for distributed apps"; + private const string GitHubReleaseAssetBuildType = "https://actions.github.io/buildtypes/workflow/v1"; [Fact] @@ -114,6 +117,70 @@ public async Task InstallAsync_WhenGitHubReleaseIsUnavailableAndEmbeddedBundleMa } } + [Fact] + public async Task InstallAsync_WhenRemoteFetchFeatureIsDisabled_SkipsGitHubAndUsesEmbedded() + { + var rootDirectory = CreateTempDirectory(); + + try + { + var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory)); + var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync(); + var attestationVerifier = new TestGitHubArtifactAttestationVerifier(); + // Throw on any HTTP call so we can prove the GitHub path was never invoked. + var handler = new MockHttpMessageHandler(_ => throw new InvalidOperationException("HTTP must not be called when remote fetch is disabled.")); + var features = new TestFeatures().SetFeature(KnownFeatures.AspireSkillsRemoteFetchEnabled, false); + var installer = CreateInstaller( + executionContext, + httpMessageHandler: handler, + githubArtifactAttestationVerifier: attestationVerifier, + embeddedBundleProvider: embeddedBundleProvider, + features: features); + + var result = await installer.InstallAsync(CancellationToken.None); + + Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status); + Assert.NotNull(result.Bundle); + Assert.True(embeddedBundleProvider.OpenArchiveCalled); + Assert.False(attestationVerifier.VerifyCalled); + } + finally + { + Directory.Delete(rootDirectory, recursive: true); + } + } + + [Fact] + public async Task InstallAsync_WhenRemoteFetchFeatureIsDisabledAndCacheExists_UsesCacheWithoutNetwork() + { + var rootDirectory = CreateTempDirectory(); + + try + { + var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory)); + var cachedBundleDirectory = Path.Combine(executionContext.CacheDirectory.FullName, "aspire-skills", AspireSkillsInstaller.Version); + await CreateCachedBundleAsync(cachedBundleDirectory); + var attestationVerifier = new TestGitHubArtifactAttestationVerifier(); + var handler = new MockHttpMessageHandler(_ => throw new InvalidOperationException("HTTP must not be called when cache is used.")); + var features = new TestFeatures().SetFeature(KnownFeatures.AspireSkillsRemoteFetchEnabled, false); + var installer = CreateInstaller( + executionContext, + httpMessageHandler: handler, + githubArtifactAttestationVerifier: attestationVerifier, + features: features); + + var result = await installer.InstallAsync(CancellationToken.None); + + Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status); + Assert.NotNull(result.Bundle); + Assert.False(attestationVerifier.VerifyCalled); + } + finally + { + Directory.Delete(rootDirectory, recursive: true); + } + } + [Fact] public void EmbeddedAspireSkillsBundleProvider_OpensSnapshotResource() { @@ -298,12 +365,82 @@ public async Task InstallAsync_WhenEmbeddedArchiveHashDoesNotMatch_ReturnsFailur } } + [Fact] + public async Task InstallAsync_WhenEmbeddedBundleSupportsRangeExcludesCurrentCli_StillUsesEmbeddedBundle() + { + var rootDirectory = CreateTempDirectory(); + + try + { + // Simulate a CLI prerelease whose version falls outside the embedded snapshot's + // declared `supports` range (e.g., a 13.5.x dogfood build paired with a snapshot + // stamped ">=13.4.0 <13.5.0"). The embedded path must still install the bundle — + // otherwise an offline user with a version-mismatched embedded snapshot would lose + // access to all bundled skills. + var staleSupports = new SkillBundleSupports + { + AspireCli = ">=0.0.1 <0.0.2", + AspireSdk = ">=0.0.1 <0.0.2" + }; + var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory)); + var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync(supports: staleSupports); + var installer = CreateInstaller(executionContext, embeddedBundleProvider: embeddedBundleProvider); + + var result = await installer.InstallAsync(CancellationToken.None); + + Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status); + Assert.NotNull(result.Bundle); + Assert.True(embeddedBundleProvider.OpenArchiveCalled); + } + finally + { + Directory.Delete(rootDirectory, recursive: true); + } + } + + [Fact] + public async Task InstallAsync_WhenCachedBundleSupportsRangeExcludesCurrentCli_StillUsesCache() + { + var rootDirectory = CreateTempDirectory(); + + try + { + var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory)); + var cachedBundleDirectory = Path.Combine(executionContext.CacheDirectory.FullName, "aspire-skills", AspireSkillsInstaller.Version); + + // The cache is written by the installer itself (either from GitHub or from the + // embedded snapshot), so the bundle's `supports` range is not the right + // invalidation signal — bundle version is. A stale `supports` on a cached entry + // must not force a re-install on every invocation. + await CreateCachedBundleAsync( + cachedBundleDirectory, + supports: new SkillBundleSupports + { + AspireCli = ">=0.0.1 <0.0.2", + AspireSdk = ">=0.0.1 <0.0.2" + }); + var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync(); + var installer = CreateInstaller(executionContext, embeddedBundleProvider: embeddedBundleProvider); + + var result = await installer.InstallAsync(CancellationToken.None); + + Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status); + Assert.NotNull(result.Bundle); + Assert.False(embeddedBundleProvider.OpenArchiveCalled); + } + finally + { + Directory.Delete(rootDirectory, recursive: true); + } + } + private static AspireSkillsInstaller CreateInstaller( CliExecutionContext executionContext, HttpMessageHandler? httpMessageHandler = null, TestGitHubArtifactAttestationVerifier? githubArtifactAttestationVerifier = null, IConfiguration? configuration = null, - IEmbeddedAspireSkillsBundleProvider? embeddedBundleProvider = null) + IEmbeddedAspireSkillsBundleProvider? embeddedBundleProvider = null, + IFeatures? features = null) { return new AspireSkillsInstaller( githubArtifactAttestationVerifier ?? new TestGitHubArtifactAttestationVerifier(), @@ -312,13 +449,17 @@ private static AspireSkillsInstaller CreateInstaller( new TestInteractionService(), executionContext, configuration ?? new ConfigurationBuilder().Build(), + // Default existing tests to the remote-fetch-enabled path so they continue to + // exercise the GitHub flow without per-test boilerplate. Tests that want to + // exercise the production default (flag off) pass an empty TestFeatures. + features ?? new TestFeatures().SetFeature(KnownFeatures.AspireSkillsRemoteFetchEnabled, true), TestTelemetryHelper.CreateInitializedTelemetry(), NullLogger.Instance); } - private static async Task CreateCachedBundleAsync(string bundleDirectory) + private static async Task CreateCachedBundleAsync(string bundleDirectory, SkillBundleSupports? supports = null) { - var skillDirectory = Path.Combine(bundleDirectory, "skills", SkillDefinition.Aspire.Name); + var skillDirectory = Path.Combine(bundleDirectory, "skills", CommonAgentApplicators.AspireSkillName); Directory.CreateDirectory(skillDirectory); var skillPath = Path.Combine(skillDirectory, "SKILL.md"); @@ -335,14 +476,13 @@ await File.WriteAllTextAsync(skillPath, var manifest = new SkillBundleManifest { Version = AspireSkillsInstaller.Version, - Supports = CreateSupports(), + Supports = supports ?? CreateSupports(), Skills = [ new SkillBundleSkill { - Name = SkillDefinition.Aspire.Name, - Description = SkillDefinition.Aspire.Description, - IsDefault = true, + Name = CommonAgentApplicators.AspireSkillName, + Description = AspireSkillDescription, Files = [ new SkillBundleFile @@ -368,14 +508,14 @@ private static SkillBundleSupports CreateSupports() }; } - private static async Task CreateBundleArchiveBytesAsync() + private static async Task CreateBundleArchiveBytesAsync(SkillBundleSupports? supports = null) { var rootDirectory = CreateTempDirectory(); try { var bundleDirectory = Path.Combine(rootDirectory, $"aspire-skills-v{AspireSkillsInstaller.Version}"); - await CreateCachedBundleAsync(bundleDirectory); + await CreateCachedBundleAsync(bundleDirectory, supports); await using var archiveStream = new MemoryStream(); await using (var gzipStream = new GZipStream(archiveStream, CompressionLevel.SmallestSize, leaveOpen: true)) @@ -402,9 +542,9 @@ private static string ComputeSha256(byte[] bytes) return Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); } - private static async Task CreateEmbeddedBundleProviderAsync() + private static async Task CreateEmbeddedBundleProviderAsync(SkillBundleSupports? supports = null) { - var archiveBytes = await CreateBundleArchiveBytesAsync(); + var archiveBytes = await CreateBundleArchiveBytesAsync(supports); return new TestEmbeddedAspireSkillsBundleProvider { Metadata = new EmbeddedAspireSkillsBundleMetadata diff --git a/tests/Aspire.Cli.Tests/Agents/CommonAgentApplicatorsTests.cs b/tests/Aspire.Cli.Tests/Agents/CommonAgentApplicatorsTests.cs index 36a46c10e5e..3c0fea948d3 100644 --- a/tests/Aspire.Cli.Tests/Agents/CommonAgentApplicatorsTests.cs +++ b/tests/Aspire.Cli.Tests/Agents/CommonAgentApplicatorsTests.cs @@ -46,45 +46,36 @@ public void SkillLocation_OnlyStandardIsDefault() } [Fact] - public void SkillDefinition_All_ContainsExpectedSkills() + public void SkillDefinition_CliDefined_ContainsExpectedSkills() { - Assert.Equal(5, SkillDefinition.All.Count); - Assert.Contains(SkillDefinition.All, s => s == SkillDefinition.Aspire); - Assert.Contains(SkillDefinition.All, s => s == SkillDefinition.AspireDeployment); - Assert.Contains(SkillDefinition.All, s => s == SkillDefinition.Aspireify); - Assert.Contains(SkillDefinition.All, s => s == SkillDefinition.PlaywrightCli); - Assert.Contains(SkillDefinition.All, s => s == SkillDefinition.DotnetInspect); + Assert.Equal(2, SkillDefinition.CliDefined.Count); + Assert.Contains(SkillDefinition.CliDefined, s => s == SkillDefinition.PlaywrightCli); + Assert.Contains(SkillDefinition.CliDefined, s => s == SkillDefinition.DotnetInspect); } [Fact] - public void SkillDefinition_DefaultSkills() + public void SkillDefinition_CliDefinedSkills_AreNotDefault() { - Assert.True(SkillDefinition.Aspire.IsDefault); - Assert.True(SkillDefinition.AspireDeployment.IsDefault); - Assert.True(SkillDefinition.Aspireify.IsDefault); - Assert.False(SkillDefinition.PlaywrightCli.IsDefault); - Assert.False(SkillDefinition.DotnetInspect.IsDefault); + Assert.All(SkillDefinition.CliDefined, static skill => Assert.False(skill.IsDefault)); } [Fact] public void SkillDefinition_DotnetInspect_IsRestrictedToCSharp() { Assert.Equal([KnownLanguageId.CSharp], SkillDefinition.DotnetInspect.ApplicableLanguages); - Assert.Empty(SkillDefinition.Aspire.ApplicableLanguages); - Assert.Empty(SkillDefinition.AspireDeployment.ApplicableLanguages); - Assert.Empty(SkillDefinition.Aspireify.ApplicableLanguages); Assert.Empty(SkillDefinition.PlaywrightCli.ApplicableLanguages); } [Fact] public void SkillDefinition_IsApplicableToLanguage_EmptyApplicableLanguages_AlwaysTrue() { - Assert.True(SkillDefinition.Aspire.IsApplicableToLanguage(null)); - Assert.True(SkillDefinition.Aspire.IsApplicableToLanguage(new LanguageId(KnownLanguageId.CSharp))); - Assert.True(SkillDefinition.Aspire.IsApplicableToLanguage(new LanguageId(KnownLanguageId.TypeScript))); - Assert.True(SkillDefinition.AspireDeployment.IsApplicableToLanguage(null)); - Assert.True(SkillDefinition.AspireDeployment.IsApplicableToLanguage(new LanguageId(KnownLanguageId.CSharp))); - Assert.True(SkillDefinition.AspireDeployment.IsApplicableToLanguage(new LanguageId(KnownLanguageId.TypeScript))); + var bundleSkill = SkillDefinition.CreateAspireSkillsBundle( + "aspire-monitoring", + "Observe Aspire apps with logs, traces, metrics, and resource state"); + + Assert.True(bundleSkill.IsApplicableToLanguage(null)); + Assert.True(bundleSkill.IsApplicableToLanguage(new LanguageId(KnownLanguageId.CSharp))); + Assert.True(bundleSkill.IsApplicableToLanguage(new LanguageId(KnownLanguageId.TypeScript))); } [Fact] @@ -106,10 +97,14 @@ public void SkillDefinition_PlaywrightCli_HasNoSkillContent() } [Fact] - public void SkillDefinition_AspireWorkflowSkills_AreExternallySourced() + public void SkillDefinition_BundleSkills_AreExternallySourced() { Assert.All( - [SkillDefinition.Aspire, SkillDefinition.Aspireify, SkillDefinition.AspireDeployment], + [ + SkillDefinition.CreateAspireSkillsBundle(CommonAgentApplicators.AspireSkillName, "Aspire CLI commands and workflows for distributed apps"), + SkillDefinition.CreateAspireSkillsBundle(CommonAgentApplicators.AspireifySkillName, "One-time setup: wire up AppHost with discovered projects"), + SkillDefinition.CreateAspireSkillsBundle(CommonAgentApplicators.AspireDeploymentSkillName, "Aspire deployment target selection, preflight, publish, and deploy workflows") + ], skill => { Assert.Null(skill.SkillContent); @@ -121,7 +116,7 @@ public void SkillDefinition_AspireWorkflowSkills_AreExternallySourced() [Fact] public async Task SkillDefinition_StaticInstallableSkillDescriptionsFitAgentHostLimits() { - var installableSkills = SkillDefinition.All + var installableSkills = SkillDefinition.CliDefined .Where(static skill => skill.SkillContent is not null); foreach (var skill in installableSkills) @@ -139,11 +134,16 @@ public async Task SkillDefinition_StaticInstallableSkillDescriptionsFitAgentHost } [Fact] - public void SkillDefinition_Aspire_ExcludesEvalsFromInstall() + public void SkillDefinition_BundleSkill_ExcludesManifestPathsFromInstall() { - Assert.Contains(SkillDefinition.Aspire.InstallExcludedRelativePaths, path => path == Path.Combine("evals")); - Assert.False(SkillDefinition.Aspire.ShouldInstallFile(Path.Combine("evals", "evals.json"))); - Assert.True(SkillDefinition.Aspire.ShouldInstallFile("SKILL.md")); + var bundleSkill = SkillDefinition.CreateAspireSkillsBundle( + CommonAgentApplicators.AspireSkillName, + "Aspire CLI commands and workflows for distributed apps", + installExcludedRelativePaths: [Path.Combine("evals")]); + + Assert.Contains(bundleSkill.InstallExcludedRelativePaths, path => path == Path.Combine("evals")); + Assert.False(bundleSkill.ShouldInstallFile(Path.Combine("evals", "evals.json"))); + Assert.True(bundleSkill.ShouldInstallFile("SKILL.md")); } [Fact] diff --git a/tests/Aspire.Cli.Tests/Commands/AgentInitCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/AgentInitCommandTests.cs index ace28bcd5c4..02dd72d738a 100644 --- a/tests/Aspire.Cli.Tests/Commands/AgentInitCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/AgentInitCommandTests.cs @@ -2,6 +2,8 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Globalization; +using System.Security.Cryptography; +using System.Text.Json; using Aspire.Cli.Agents; using Aspire.Cli.Agents.AspireSkills; using Aspire.Cli.Commands; @@ -27,7 +29,7 @@ public async Task AgentInitCommand_SummarizesNormalizedDisplayPath_WhenInstallin .Where(choice => choice switch { SkillLocation location => location == SkillLocation.Standard, - SkillDefinition skill => skill == SkillDefinition.Aspire, + SkillDefinition skill => skill.HasName(CommonAgentApplicators.AspireSkillName), _ => false }) .ToList(); @@ -46,7 +48,7 @@ public async Task AgentInitCommand_SummarizesNormalizedDisplayPath_WhenInstallin Assert.Equal(0, exitCode); var expectedSummary = string.Join(Environment.NewLine, AgentCommandStrings.InitCommand_InstalledSkillsSummary, - $" {string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.InitCommand_InstalledSkillsSummarySkills, SkillDefinition.Aspire.Name)}", + $" {string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.InitCommand_InstalledSkillsSummarySkills, CommonAgentApplicators.AspireSkillName)}", $" {string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.InitCommand_InstalledSkillsSummaryLocations, ".agents/skills, ~/.agents/skills")}"); Assert.Contains( @@ -64,14 +66,6 @@ public async Task AgentInitCommand_SummarizesDefaultSkillsOnce() var homeDirectory = workspace.CreateDirectory("fake-home"); var interactionService = new TestInteractionService(); interactionService.SetupStringPromptResponse(workspace.WorkspaceRoot.FullName); - interactionService.PromptForSelectionsCallback = (_, choices, _, _) => choices.Cast() - .Where(choice => choice switch - { - SkillLocation location => location == SkillLocation.Standard, - SkillDefinition skill => skill.IsDefault, - _ => false - }) - .ToList(); var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => { options.InteractionServiceFactory = _ => interactionService; @@ -91,7 +85,7 @@ public async Task AgentInitCommand_SummarizesDefaultSkillsOnce() $" {string.Format( CultureInfo.CurrentCulture, AgentCommandStrings.InitCommand_InstalledSkillsSummarySkills, - string.Join(", ", SkillDefinition.All.Where(static skill => skill.IsDefault).Select(static skill => skill.Name)))}", + $"{CommonAgentApplicators.AspireSkillName}, {CommonAgentApplicators.AspireDeploymentSkillName}, {FakeAspireSkillsInstaller.AspireInitSkillName}, {FakeAspireSkillsInstaller.AspireMonitoringSkillName}, {FakeAspireSkillsInstaller.AspireOrchestrationSkillName}")}", $" {string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.InitCommand_InstalledSkillsSummaryLocations, ".agents/skills, ~/.agents/skills")}"); var message = Assert.Single(interactionService.DisplayedMessages, displayedMessage => displayedMessage.Emoji.Equals(KnownEmojis.Robot)); Assert.Equal(expectedSummary, message.Message); @@ -113,7 +107,7 @@ public async Task AgentInitCommand_IncludesSpecificSkillDirectory_WhenInstallFai .Where(choice => choice switch { SkillLocation location => location == SkillLocation.Standard, - SkillDefinition skill => skill == SkillDefinition.Aspire, + SkillDefinition skill => skill.HasName(CommonAgentApplicators.AspireSkillName), _ => false }) .ToList(); @@ -130,7 +124,7 @@ public async Task AgentInitCommand_IncludesSpecificSkillDirectory_WhenInstallFai Assert.Equal(CliExitCodes.InvalidCommand, exitCode); - var expectedSkillDirectoryPath = Path.Combine(invalidRootFilePath, ".agents", "skills", SkillDefinition.Aspire.Name); + var expectedSkillDirectoryPath = Path.Combine(invalidRootFilePath, ".agents", "skills", CommonAgentApplicators.AspireSkillName); Assert.Contains( interactionService.DisplayedErrors, message => message.Contains(expectedSkillDirectoryPath, StringComparison.Ordinal)); @@ -152,19 +146,312 @@ public async Task AgentInitCommand_NonInteractive_WithAllLocationsAndSkills_Inst // Exit code is InvalidCommand because FakeNpmRunner cannot resolve Playwright CLI in tests. Assert.Equal(CliExitCodes.InvalidCommand, exitCode); - // Verify that the Aspire skills were installed to all locations - Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, ".agents", "skills", "aspire", "SKILL.md"))); - Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, ".agents", "skills", "aspireify", "SKILL.md"))); - Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, ".agents", "skills", "aspire-deployment", "SKILL.md"))); - Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, ".claude", "skills", "aspire", "SKILL.md"))); - Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, ".claude", "skills", "aspireify", "SKILL.md"))); - Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, ".claude", "skills", "aspire-deployment", "SKILL.md"))); - Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, ".github", "skills", "aspire", "SKILL.md"))); - Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, ".github", "skills", "aspireify", "SKILL.md"))); - Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, ".github", "skills", "aspire-deployment", "SKILL.md"))); - Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, ".opencode", "skill", "aspire", "SKILL.md"))); - Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, ".opencode", "skill", "aspireify", "SKILL.md"))); - Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, ".opencode", "skill", "aspire-deployment", "SKILL.md"))); + var expectedSkillNames = new[] + { + CommonAgentApplicators.AspireSkillName, + CommonAgentApplicators.AspireifySkillName, + CommonAgentApplicators.AspireDeploymentSkillName, + FakeAspireSkillsInstaller.AspireInitSkillName, + FakeAspireSkillsInstaller.AspireMonitoringSkillName, + FakeAspireSkillsInstaller.AspireOrchestrationSkillName + }; + var expectedSkillDirectories = new[] + { + Path.Combine(".agents", "skills"), + Path.Combine(".claude", "skills"), + Path.Combine(".github", "skills"), + Path.Combine(".opencode", "skill") + }; + + foreach (var relativeSkillDirectory in expectedSkillDirectories) + { + foreach (var skillName in expectedSkillNames) + { + AssertSkillFileExists(workspace.WorkspaceRoot, relativeSkillDirectory, skillName); + } + } + } + + [Fact] + public async Task AgentInitCommand_InteractiveSkillPrompt_IncludesAllBundleSkills() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var promptedSkillNames = new List(); + var interactionService = new TestInteractionService(); + interactionService.SetupStringPromptResponse(workspace.WorkspaceRoot.FullName); + interactionService.PromptForSelectionsCallback = (_, choices, _, _) => + { + var items = choices.Cast().ToList(); + if (items.FirstOrDefault() is SkillLocation) + { + return [SkillLocation.Standard]; + } + + promptedSkillNames.AddRange(items.OfType().Select(static skill => skill.Name)); + return []; + }; + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.InteractionServiceFactory = _ => interactionService; + }); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("agent init"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + Assert.Contains(CommonAgentApplicators.AspireSkillName, promptedSkillNames); + Assert.Contains(CommonAgentApplicators.AspireifySkillName, promptedSkillNames); + Assert.Contains(CommonAgentApplicators.AspireDeploymentSkillName, promptedSkillNames); + Assert.Contains(FakeAspireSkillsInstaller.AspireInitSkillName, promptedSkillNames); + Assert.Contains(FakeAspireSkillsInstaller.AspireMonitoringSkillName, promptedSkillNames); + Assert.Contains(FakeAspireSkillsInstaller.AspireOrchestrationSkillName, promptedSkillNames); + } + + [Fact] + public async Task AgentInitCommand_InteractiveSkillPrompt_EscapesBundleDescriptions() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var bundle = await CreateBundleAsync( + workspace.WorkspaceRoot, + (FakeAspireSkillsInstaller.AspireMonitoringSkillName, "Observe [danger] apps")); + string? formattedSkill = null; + var interactionService = new TestInteractionService(); + interactionService.SetupStringPromptResponse(workspace.WorkspaceRoot.FullName); + interactionService.PromptForSelectionsCallback = (_, choices, formatter, _) => + { + var items = choices.Cast().ToList(); + if (items.FirstOrDefault() is SkillLocation) + { + return [SkillLocation.Standard]; + } + + var skill = Assert.Single(items.OfType(), static skill => skill.HasName(FakeAspireSkillsInstaller.AspireMonitoringSkillName)); + formattedSkill = formatter(skill); + return []; + }; + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.InteractionServiceFactory = _ => interactionService; + options.AspireSkillsInstallerFactory = serviceProvider => new FakeAspireSkillsInstaller( + serviceProvider.GetRequiredService(), + AspireSkillsInstallResult.Installed(bundle)); + }); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("agent init"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + Assert.NotNull(formattedSkill); + Assert.Contains("Observe [[danger]] apps", formattedSkill); + } + + [Theory] + [InlineData("", "")] + [InlineData(" ", " ")] + [InlineData("Short description", "Short description")] + [InlineData("Short description.", "Short description.")] + [InlineData("First sentence. Second sentence.", "First sentence.")] + [InlineData("**WORKFLOW SKILL** - Top-level router for Aspire 13.4 distributed apps. Detects the AppHost.", "Top-level router for Aspire 13.4 distributed apps.")] + [InlineData("**ANALYSIS SKILL** — Observe Aspire apps. USE FOR: aspire logs.", "Observe Aspire apps.")] + [InlineData("**SETUP SKILL**: One-time setup of resources. INVOKES: aspire add.", "One-time setup of resources.")] + [InlineData("Visit github.com for docs. Then run the tool.", "Visit github.com for docs.")] + [InlineData("**TYPE** -", "")] + // Fix 1 regression: a leading separator that does NOT follow a "**TYPE**" prefix must be preserved. + // The earlier implementation unconditionally trimmed leading separators after the bold-prefix + // branch, which silently mutated bundle descriptions that happened to start with '-' or ':'. + [InlineData("-Quickly do X.", "-Quickly do X.")] + [InlineData(":memo notes", ":memo notes")] + public void SimplifyDescription_ProducesExpectedSummary(string input, string expected) + { + Assert.Equal(expected, AgentInitCommand.SimplifyDescription(input)); + } + + [Fact] + public async Task AgentInitCommand_InteractiveSkillPrompt_StripsVerboseBundleDescription() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var bundle = await CreateBundleAsync( + workspace.WorkspaceRoot, + (FakeAspireSkillsInstaller.AspireMonitoringSkillName, + "**ANALYSIS SKILL** - Observe Aspire apps. USE FOR: aspire logs, aspire traces. INVOKES: aspire CLI.")); + string? formattedSkill = null; + var interactionService = new TestInteractionService(); + interactionService.SetupStringPromptResponse(workspace.WorkspaceRoot.FullName); + interactionService.PromptForSelectionsCallback = (_, choices, formatter, _) => + { + var items = choices.Cast().ToList(); + if (items.FirstOrDefault() is SkillLocation) + { + return [SkillLocation.Standard]; + } + + var skill = Assert.Single(items.OfType(), static skill => skill.HasName(FakeAspireSkillsInstaller.AspireMonitoringSkillName)); + formattedSkill = formatter(skill); + return []; + }; + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.InteractionServiceFactory = _ => interactionService; + options.AspireSkillsInstallerFactory = serviceProvider => new FakeAspireSkillsInstaller( + serviceProvider.GetRequiredService(), + AspireSkillsInstallResult.Installed(bundle)); + }); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("agent init"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + Assert.NotNull(formattedSkill); + Assert.Contains("Observe Aspire apps.", formattedSkill); + Assert.DoesNotContain("ANALYSIS SKILL", formattedSkill); + Assert.DoesNotContain("USE FOR", formattedSkill); + Assert.DoesNotContain("INVOKES", formattedSkill); + } + + [Fact] + public async Task AgentInitCommand_InteractiveSkillPrompt_OrdersSkillsAlphabetically() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + // Intentionally pass bundle skills in non-alphabetical order to confirm the prompt sorts deterministically. + var bundle = await CreateBundleAsync( + workspace.WorkspaceRoot, + ("zeta-bundle-skill", "Zeta skill"), + ("alpha-bundle-skill", "Alpha skill"), + ("middle-bundle-skill", "Middle skill")); + var promptedSkillNames = new List(); + var interactionService = new TestInteractionService(); + interactionService.SetupStringPromptResponse(workspace.WorkspaceRoot.FullName); + interactionService.PromptForSelectionsCallback = (_, choices, _, _) => + { + var items = choices.Cast().ToList(); + if (items.FirstOrDefault() is SkillLocation) + { + return [SkillLocation.Standard]; + } + + promptedSkillNames.AddRange(items.OfType().Select(static skill => skill.Name)); + return []; + }; + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.InteractionServiceFactory = _ => interactionService; + options.AspireSkillsInstallerFactory = serviceProvider => new FakeAspireSkillsInstaller( + serviceProvider.GetRequiredService(), + AspireSkillsInstallResult.Installed(bundle)); + }); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("agent init"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + Assert.NotEmpty(promptedSkillNames); + var sorted = promptedSkillNames.OrderBy(static name => name, StringComparer.OrdinalIgnoreCase).ToList(); + Assert.Equal(sorted, promptedSkillNames); + } + + [Fact] + public async Task AgentInitCommand_NonInteractive_WithSpecificBundleSkill_InstallsSkillFiles() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse($"agent init --workspace-root {workspace.WorkspaceRoot.FullName} --skill-locations all --skills {FakeAspireSkillsInstaller.AspireMonitoringSkillName}"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + AssertSkillFileExists(workspace.WorkspaceRoot, Path.Combine(".agents", "skills"), FakeAspireSkillsInstaller.AspireMonitoringSkillName); + AssertSkillFileExists(workspace.WorkspaceRoot, Path.Combine(".claude", "skills"), FakeAspireSkillsInstaller.AspireMonitoringSkillName); + AssertSkillFileExists(workspace.WorkspaceRoot, Path.Combine(".github", "skills"), FakeAspireSkillsInstaller.AspireMonitoringSkillName); + AssertSkillFileExists(workspace.WorkspaceRoot, Path.Combine(".opencode", "skill"), FakeAspireSkillsInstaller.AspireMonitoringSkillName); + } + + [Fact] + public async Task AgentInitCommand_NonInteractive_WithCliDefinedSkillDifferentCasing_DoesNotResolveBundle() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + const string installFailureMessage = "Aspire skills bundle is unavailable."; + var interactionService = new TestInteractionService(); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.InteractionServiceFactory = _ => interactionService; + options.AspireSkillsInstallerFactory = serviceProvider => new FakeAspireSkillsInstaller( + serviceProvider.GetRequiredService(), + AspireSkillsInstallResult.Failed(installFailureMessage)); + }); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse($"agent init --workspace-root {workspace.WorkspaceRoot.FullName} --skill-locations all --skills PLAYWRIGHT-CLI"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + Assert.DoesNotContain( + interactionService.DisplayedMessages, + message => message.Emoji.Equals(KnownEmojis.Warning) && message.Message == installFailureMessage); + } + + [Fact] + public async Task AgentInitCommand_InteractiveSkillPrompt_CliDefinedSkillsWinBundleNameCollisions() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var bundle = await CreateBundleAsync( + workspace.WorkspaceRoot, + (CommonAgentApplicators.AspireSkillName, "Aspire CLI commands and workflows for distributed apps"), + (SkillDefinition.PlaywrightCli.Name, "Bundle-provided Playwright collision")); + var promptedSkills = new List(); + var interactionService = new TestInteractionService(); + interactionService.SetupStringPromptResponse(workspace.WorkspaceRoot.FullName); + interactionService.PromptForSelectionsCallback = (_, choices, _, _) => + { + var items = choices.Cast().ToList(); + if (items.FirstOrDefault() is SkillLocation) + { + return [SkillLocation.Standard]; + } + + promptedSkills.AddRange(items.OfType()); + return []; + }; + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.InteractionServiceFactory = _ => interactionService; + options.AspireSkillsInstallerFactory = serviceProvider => new FakeAspireSkillsInstaller( + serviceProvider.GetRequiredService(), + AspireSkillsInstallResult.Installed(bundle)); + }); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("agent init"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + var playwrightSkill = Assert.Single(promptedSkills, static skill => skill.HasName(SkillDefinition.PlaywrightCli.Name, StringComparison.OrdinalIgnoreCase)); + Assert.Same(SkillDefinition.PlaywrightCli, playwrightSkill); } [Fact] @@ -216,16 +503,81 @@ public async Task AgentInitCommand_NonInteractive_WithoutSkills_UsesDefaultSkill var exitCode = await result.InvokeAsync().DefaultTimeout(); - // Default Aspire skills are installed. Playwright is not default so it is not selected. + // Default Aspire skills are installed (all bundle skills except the one-time setup skill). + // Aspireify is filtered out by ExcludeOneTimeSetupSkillsFromDefaults; Playwright is + // a CLI-defined skill that is not default. Assert.Equal(CliExitCodes.Success, exitCode); - // Verify the default Aspire skills were installed - var aspireSkillPath = Path.Combine(workspace.WorkspaceRoot.FullName, ".agents", "skills", "aspire", "SKILL.md"); - Assert.True(File.Exists(aspireSkillPath), $"Expected skill file at {aspireSkillPath}"); - var aspireifySkillPath = Path.Combine(workspace.WorkspaceRoot.FullName, ".agents", "skills", "aspireify", "SKILL.md"); - Assert.True(File.Exists(aspireifySkillPath), $"Expected skill file at {aspireifySkillPath}"); - var deploymentSkillPath = Path.Combine(workspace.WorkspaceRoot.FullName, ".agents", "skills", "aspire-deployment", "SKILL.md"); - Assert.True(File.Exists(deploymentSkillPath), $"Expected skill file at {deploymentSkillPath}"); + AssertSkillFileExists(workspace.WorkspaceRoot, Path.Combine(".agents", "skills"), CommonAgentApplicators.AspireSkillName); + AssertSkillFileExists(workspace.WorkspaceRoot, Path.Combine(".agents", "skills"), CommonAgentApplicators.AspireDeploymentSkillName); + AssertSkillFileExists(workspace.WorkspaceRoot, Path.Combine(".agents", "skills"), FakeAspireSkillsInstaller.AspireInitSkillName); + AssertSkillFileExists(workspace.WorkspaceRoot, Path.Combine(".agents", "skills"), FakeAspireSkillsInstaller.AspireMonitoringSkillName); + AssertSkillFileExists(workspace.WorkspaceRoot, Path.Combine(".agents", "skills"), FakeAspireSkillsInstaller.AspireOrchestrationSkillName); + var aspireifySkillPath = Path.Combine(workspace.WorkspaceRoot.FullName, ".agents", "skills", CommonAgentApplicators.AspireifySkillName); + Assert.False(Directory.Exists(aspireifySkillPath), $"Expected no aspireify skill directory but found {aspireifySkillPath}"); + } + + [Fact] + public async Task AgentInitCommand_NonInteractive_AllBundleSkills_AreInstallableByName() + { + // Regression guard for the original issue: the bundle ships skills (aspire-init, + // aspire-monitoring, aspire-orchestration) that were not surfaced by the CLI because the + // install prompt was driven by a hardcoded list. After the refactor, the catalog comes + // from the bundle manifest. + // + // The assertion is data-driven against the bundle's own manifest so this test stays + // accurate as the fixture (or, one day, the real bundle) evolves — adding or removing + // a skill in FakeAspireSkillsInstaller doesn't require updating the test body. + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + using var provider = services.BuildServiceProvider(); + + // Prime the bundle and read the list of skills it actually surfaces. The fake + // installer's InstallAsync is idempotent, so the subsequent CLI invocation will reuse + // this same bundle directory. + var installer = provider.GetRequiredService(); + var installResult = await installer.InstallAsync(TestContext.Current.CancellationToken).DefaultTimeout(); + Assert.NotNull(installResult.Bundle); + var bundleSkillNames = installResult.Bundle.GetSkillDefinitions().Select(static s => s.Name).ToList(); + Assert.NotEmpty(bundleSkillNames); + + // Explicit names instead of `all` keeps the assertion focused on bundle skills and + // avoids dragging in Playwright/dotnet-inspect, which would attempt real network calls. + var skillsArg = string.Join(",", bundleSkillNames); + var command = provider.GetRequiredService(); + var result = command.Parse($"agent init --workspace-root {workspace.WorkspaceRoot.FullName} --skill-locations all --skills {skillsArg}"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + foreach (var skillName in bundleSkillNames) + { + AssertSkillFileExists(workspace.WorkspaceRoot, Path.Combine(".agents", "skills"), skillName); + } + } + + [Fact] + public async Task AgentInitCommand_NonInteractive_WithExplicitBundleSkillName_InstallsBundleSkill() + { + // Regression guard: bundle-only skill names (e.g. aspire-orchestration) must be selectable + // via --skills by name now that the catalog comes from the manifest rather than the + // hardcoded CLI list. + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse($"agent init --workspace-root {workspace.WorkspaceRoot.FullName} --skill-locations all --skills {FakeAspireSkillsInstaller.AspireOrchestrationSkillName}"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + + AssertSkillFileExists(workspace.WorkspaceRoot, Path.Combine(".agents", "skills"), FakeAspireSkillsInstaller.AspireOrchestrationSkillName); + var aspireSkillPath = Path.Combine(workspace.WorkspaceRoot.FullName, ".agents", "skills", CommonAgentApplicators.AspireSkillName); + Assert.False(Directory.Exists(aspireSkillPath), $"Expected only the selected skill but found {aspireSkillPath}"); } [Fact] @@ -259,17 +611,17 @@ public async Task AgentInitCommand_NonInteractive_WithoutWorkspaceRoot_UsesWorki Assert.Equal(CliExitCodes.Success, exitCode); - // Verify that the default Aspire skills were installed under the working directory - var aspireSkillPath = Path.Combine(workspace.WorkspaceRoot.FullName, ".agents", "skills", "aspire", "SKILL.md"); - Assert.True(File.Exists(aspireSkillPath), $"Expected skill file at {aspireSkillPath}"); - var aspireifySkillPath = Path.Combine(workspace.WorkspaceRoot.FullName, ".agents", "skills", "aspireify", "SKILL.md"); - Assert.True(File.Exists(aspireifySkillPath), $"Expected skill file at {aspireifySkillPath}"); - var deploymentSkillPath = Path.Combine(workspace.WorkspaceRoot.FullName, ".agents", "skills", "aspire-deployment", "SKILL.md"); - Assert.True(File.Exists(deploymentSkillPath), $"Expected skill file at {deploymentSkillPath}"); + AssertSkillFileExists(workspace.WorkspaceRoot, Path.Combine(".agents", "skills"), CommonAgentApplicators.AspireSkillName); + AssertSkillFileExists(workspace.WorkspaceRoot, Path.Combine(".agents", "skills"), CommonAgentApplicators.AspireDeploymentSkillName); + AssertSkillFileExists(workspace.WorkspaceRoot, Path.Combine(".agents", "skills"), FakeAspireSkillsInstaller.AspireInitSkillName); + AssertSkillFileExists(workspace.WorkspaceRoot, Path.Combine(".agents", "skills"), FakeAspireSkillsInstaller.AspireMonitoringSkillName); + AssertSkillFileExists(workspace.WorkspaceRoot, Path.Combine(".agents", "skills"), FakeAspireSkillsInstaller.AspireOrchestrationSkillName); + var aspireifySkillPath = Path.Combine(workspace.WorkspaceRoot.FullName, ".agents", "skills", CommonAgentApplicators.AspireifySkillName); + Assert.False(Directory.Exists(aspireifySkillPath), $"Expected no aspireify skill directory but found {aspireifySkillPath}"); } [Fact] - public async Task AgentInitCommand_NonInteractive_WithUnavailableAspireSkillsBundle_Fails() + public async Task AgentInitCommand_NonInteractive_WithUnavailableAspireSkillsBundle_SucceedsWithoutWarningOrSelectedAspireSkills() { using var workspace = TemporaryWorkspace.Create(outputHelper); const string installFailureMessage = "Aspire skills bundle is unavailable."; @@ -289,13 +641,16 @@ public async Task AgentInitCommand_NonInteractive_WithUnavailableAspireSkillsBun var exitCode = await result.InvokeAsync().DefaultTimeout(); - Assert.Equal(CliExitCodes.InvalidCommand, exitCode); - Assert.Contains(installFailureMessage, interactionService.DisplayedErrors); - Assert.Empty(interactionService.DisplayedSuccess); + Assert.Equal(CliExitCodes.Success, exitCode); + Assert.DoesNotContain(installFailureMessage, interactionService.DisplayedErrors); + Assert.DoesNotContain( + interactionService.DisplayedMessages, + message => message.Emoji.Equals(KnownEmojis.Warning) && message.Message == installFailureMessage); + Assert.Contains(McpCommandStrings.InitCommand_ConfigurationComplete, interactionService.DisplayedSuccess); } [Fact] - public async Task PromptAndChainAsync_WithUnavailableAspireSkillsBundle_SucceedsWithoutSelectedAspireSkills() + public async Task PromptAndChainAsync_WithUnavailableAspireSkillsBundle_SucceedsWithoutWarningOrSelectedAspireSkills() { using var workspace = TemporaryWorkspace.Create(outputHelper); const string installFailureMessage = "Aspire skills bundle is unavailable."; @@ -321,12 +676,71 @@ public async Task PromptAndChainAsync_WithUnavailableAspireSkillsBundle_Succeeds Assert.Equal(CliExitCodes.Success, result.ExitCode); Assert.DoesNotContain(result.SelectedSkills, static skill => skill.SourceKind is SkillSourceKind.AspireSkillsBundle); - Assert.Contains( + Assert.DoesNotContain( interactionService.DisplayedMessages, message => message.Emoji.Equals(KnownEmojis.Warning) && message.Message == installFailureMessage); Assert.Contains(McpCommandStrings.InitCommand_ConfigurationComplete, interactionService.DisplayedSuccess); } + [Fact] + public async Task PromptAndChainAsync_WithoutPredicateOverride_PreSelectsBundleDefaultsIncludingAspireify() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var interactionService = new TestInteractionService(); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.InteractionServiceFactory = _ => interactionService; + }); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + + // Passing no predicate pre-selects every bundle-sourced skill, which is the semantic + // `aspire init` relies on so the one-time wiring skill chains into the flow. + var result = await command.PromptAndChainAsync( + interactionService, + CliExitCodes.Success, + workspace.WorkspaceRoot, + PromptBinding.CreateDefault(true), + CancellationToken.None).DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, result.ExitCode); + Assert.Contains(result.SelectedSkills, static skill => skill.HasName(CommonAgentApplicators.AspireifySkillName)); + AssertSkillFileExists(workspace.WorkspaceRoot, Path.Combine(".agents", "skills"), CommonAgentApplicators.AspireifySkillName); + } + + [Fact] + public async Task PromptAndChainAsync_WithExcludeAspireifyPredicate_DoesNotPreSelectAspireify() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var interactionService = new TestInteractionService(); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.InteractionServiceFactory = _ => interactionService; + }); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + + // Callers that just created a new AppHost (aspire new) or are running standalone agent + // init pass a predicate that strips aspireify from the default selection. The skill + // remains in the prompt — it's just not pre-checked. + var result = await command.PromptAndChainAsync( + interactionService, + CliExitCodes.Success, + workspace.WorkspaceRoot, + PromptBinding.CreateDefault(true), + CancellationToken.None, + AgentInitCommand.ExcludeOneTimeSetupSkillsFromDefaults).DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, result.ExitCode); + Assert.DoesNotContain(result.SelectedSkills, static skill => skill.HasName(CommonAgentApplicators.AspireifySkillName)); + var aspireifySkillPath = Path.Combine(workspace.WorkspaceRoot.FullName, ".agents", "skills", CommonAgentApplicators.AspireifySkillName); + Assert.False(Directory.Exists(aspireifySkillPath), $"Expected no aspireify skill directory but found {aspireifySkillPath}"); + } + [Fact] public async Task AgentInitCommand_NonInteractive_WithNoneSkills_SucceedsWithNoSkillsInstalled() { @@ -343,7 +757,7 @@ public async Task AgentInitCommand_NonInteractive_WithNoneSkills_SucceedsWithNoS Assert.Equal(CliExitCodes.Success, exitCode); // No skills selected, so no skill files should be created - var aspireSkillPath = Path.Combine(workspace.WorkspaceRoot.FullName, ".agents", "skills", "aspire"); + var aspireSkillPath = Path.Combine(workspace.WorkspaceRoot.FullName, ".agents", "skills", CommonAgentApplicators.AspireSkillName); Assert.False(Directory.Exists(aspireSkillPath), $"Expected no aspire skill directory but found {aspireSkillPath}"); } @@ -364,6 +778,69 @@ public async Task AgentInitCommand_NonInteractive_ConfigureMcpDefaultsToFalse() Assert.Equal(CliExitCodes.Success, exitCode); } + private static void AssertSkillFileExists(DirectoryInfo workspaceRoot, string relativeSkillDirectory, string skillName) + { + var skillPath = Path.Combine(workspaceRoot.FullName, relativeSkillDirectory, skillName, "SKILL.md"); + Assert.True(File.Exists(skillPath), $"Expected skill file at {skillPath}"); + } + + private static async Task CreateBundleAsync(DirectoryInfo workspaceRoot, params (string Name, string Description)[] skills) + { + var bundleDirectory = new DirectoryInfo(Path.Combine(workspaceRoot.FullName, $".test-aspire-skills-bundle-{Guid.NewGuid():N}")); + Directory.CreateDirectory(bundleDirectory.FullName); + + var manifestSkills = new List(); + foreach (var (name, description) in skills) + { + var skillDirectory = Path.Combine(bundleDirectory.FullName, "skills", name); + Directory.CreateDirectory(skillDirectory); + var skillPath = Path.Combine(skillDirectory, "SKILL.md"); + await File.WriteAllTextAsync(skillPath, $$""" + --- + name: {{name}} + description: "{{description}}" + --- + + # {{name}} + """); + + manifestSkills.Add(new SkillBundleSkill + { + Name = name, + Description = description, + Files = + [ + new SkillBundleFile + { + RelativePath = "SKILL.md", + Sha256 = ComputeSha256(skillPath) + } + ] + }); + } + + var manifest = new SkillBundleManifest + { + Version = AspireSkillsInstaller.Version, + Supports = new SkillBundleSupports + { + AspireCli = ">=0.0.0 <999.0.0", + AspireSdk = ">=0.0.0 <999.0.0" + }, + Skills = [.. manifestSkills] + }; + + var manifestJson = JsonSerializer.Serialize(manifest, AspireSkillsJsonSerializerContext.Default.SkillBundleManifest); + await File.WriteAllTextAsync(Path.Combine(bundleDirectory.FullName, "skill-manifest.json"), manifestJson); + return await AspireSkillsBundle.LoadAsync(bundleDirectory, CancellationToken.None); + } + + private static string ComputeSha256(string path) + { + using var stream = File.OpenRead(path); + return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant(); + } + private static CliExecutionContext CreateExecutionContext(DirectoryInfo workingDirectory, DirectoryInfo homeDirectory) { return TestExecutionContextHelper.CreateExecutionContext( diff --git a/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs index f4838101818..b8408f1ac47 100644 --- a/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs @@ -496,7 +496,11 @@ public async Task InitCommand_WhenAspireifySkillSelected_PrintsToolSpecificFollo return [SkillLocation.Standard, SkillLocation.ClaudeCode, SkillLocation.OpenCode]; } - return [SkillDefinition.Aspireify]; + return items + .OfType() + .Where(static skill => skill.HasName(CommonAgentApplicators.AspireifySkillName)) + .Cast() + .ToList(); }; var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => @@ -540,7 +544,11 @@ public async Task InitCommand_WhenAspireifySkillNotSelected_DoesNotPrintFollowUp return [SkillLocation.Standard]; } - return [SkillDefinition.Aspire]; + return items + .OfType() + .Where(static skill => skill.HasName(CommonAgentApplicators.AspireSkillName)) + .Cast() + .ToList(); }; var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => diff --git a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs index 8baa0175b71..633bdd9ee82 100644 --- a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs @@ -3,6 +3,7 @@ using System.Globalization; using System.Xml.Linq; +using Aspire.Cli.Agents; using Aspire.Cli.Utils; using Aspire.Cli.Certificates; using Aspire.Cli.Commands; @@ -2891,7 +2892,7 @@ public async Task NewCommandNonInteractive_SuppressAgentInitTrue_SkipsAgentInit( Assert.Equal(CliExitCodes.Success, exitCode); // Agent init should not have run — no skill files should exist - var skillPath = Path.Combine(workspace.WorkspaceRoot.FullName, "output", ".agents", "skills", "aspire", "SKILL.md"); + var skillPath = Path.Combine(workspace.WorkspaceRoot.FullName, "output", ".agents", "skills", CommonAgentApplicators.AspireSkillName, "SKILL.md"); Assert.False(File.Exists(skillPath)); } @@ -2917,8 +2918,10 @@ public async Task NewCommandNonInteractive_SuppressAgentInitFalse_RunsAgentInit( Assert.Equal(CliExitCodes.Success, exitCode); // Agent init should have run — default skill files should exist - var skillPath = Path.Combine(workspace.WorkspaceRoot.FullName, "output", ".agents", "skills", "aspire", "SKILL.md"); + var skillPath = Path.Combine(workspace.WorkspaceRoot.FullName, "output", ".agents", "skills", CommonAgentApplicators.AspireSkillName, "SKILL.md"); Assert.True(File.Exists(skillPath)); + var aspireifySkillPath = Path.Combine(workspace.WorkspaceRoot.FullName, "output", ".agents", "skills", CommonAgentApplicators.AspireifySkillName, "SKILL.md"); + Assert.False(File.Exists(aspireifySkillPath)); } [Fact] @@ -2943,8 +2946,10 @@ public async Task NewCommandNonInteractive_NoSuppressAgentInitOption_DefaultsToR Assert.Equal(CliExitCodes.Success, exitCode); // Default is to run agent init - var skillPath = Path.Combine(workspace.WorkspaceRoot.FullName, "output", ".agents", "skills", "aspire", "SKILL.md"); + var skillPath = Path.Combine(workspace.WorkspaceRoot.FullName, "output", ".agents", "skills", CommonAgentApplicators.AspireSkillName, "SKILL.md"); Assert.True(File.Exists(skillPath)); + var aspireifySkillPath = Path.Combine(workspace.WorkspaceRoot.FullName, "output", ".agents", "skills", CommonAgentApplicators.AspireifySkillName, "SKILL.md"); + Assert.False(File.Exists(aspireifySkillPath)); } [Fact] diff --git a/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs b/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs index 3e554a7f97b..0e2ad82a64a 100644 --- a/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs @@ -910,7 +910,7 @@ public Task PromptForSelectionAsync(string promptText, IEnumerable choi return Task.FromResult(choices.First()); } - public Task> PromptForSelectionsAsync(string promptText, IEnumerable choices, Func choiceFormatter, IEnumerable? preSelected = null, bool optional = false, PromptBinding? binding = null, bool echoSelected = true, CancellationToken cancellationToken = default) where T : notnull + public Task> PromptForSelectionsAsync(string promptText, IEnumerable choices, Func choiceFormatter, IEnumerable? preSelected = null, bool optional = false, PromptBinding? binding = null, bool echoSelected = true, IEnumerable? bindingChoices = null, CancellationToken cancellationToken = default) where T : notnull { if (_shouldCancel || cancellationToken.IsCancellationRequested) { diff --git a/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs index 1ca9bb6be7c..17d26e209e7 100644 --- a/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs @@ -2639,17 +2639,17 @@ public CancellationTrackingInteractionService(IInteractionService innerService) public Task ShowStatusAsync(string statusText, Func> action, KnownEmoji? emoji = null, bool allowMarkup = false) => _innerService.ShowStatusAsync(statusText, action, emoji, allowMarkup); public Task ShowDynamicStatusAsync(string initialStatusText, Func, Task> action, KnownEmoji? emoji = null) => _innerService.ShowDynamicStatusAsync(initialStatusText, action, emoji); public void ShowStatus(string statusText, Action action, KnownEmoji? emoji = null, bool allowMarkup = false) => _innerService.ShowStatus(statusText, action, emoji, allowMarkup); - public Task PromptForStringAsync(string promptText, Func? validator = null, bool isSecret = false, bool required = false, PromptBinding? binding = null, CancellationToken cancellationToken = default) + public Task PromptForStringAsync(string promptText, Func? validator = null, bool isSecret = false, bool required = false, PromptBinding? binding = null, CancellationToken cancellationToken = default) => _innerService.PromptForStringAsync(promptText, validator, isSecret, required, binding, cancellationToken); public Task PromptForFilePathAsync(string promptText, Func? validator = null, bool directory = false, bool required = false, PromptBinding? binding = null, CancellationToken cancellationToken = default) => _innerService.PromptForFilePathAsync(promptText, validator, directory, required, binding, cancellationToken); - public Task PromptConfirmAsync(string promptText, PromptBinding? binding = null, CancellationToken cancellationToken = default) + public Task PromptConfirmAsync(string promptText, PromptBinding? binding = null, CancellationToken cancellationToken = default) => _innerService.PromptConfirmAsync(promptText, binding, cancellationToken); - public Task PromptForSelectionAsync(string promptText, IEnumerable choices, Func choiceFormatter, PromptBinding? binding = null, bool echoSelected = true, CancellationToken cancellationToken = default) where T : notnull + public Task PromptForSelectionAsync(string promptText, IEnumerable choices, Func choiceFormatter, PromptBinding? binding = null, bool echoSelected = true, CancellationToken cancellationToken = default) where T : notnull => _innerService.PromptForSelectionAsync(promptText, choices, choiceFormatter, binding, echoSelected, cancellationToken); - public Task> PromptForSelectionsAsync(string promptText, IEnumerable choices, Func choiceFormatter, IEnumerable? preSelected = null, bool optional = false, PromptBinding? binding = null, bool echoSelected = true, CancellationToken cancellationToken = default) where T : notnull - => _innerService.PromptForSelectionsAsync(promptText, choices, choiceFormatter, preSelected, optional, binding, echoSelected, cancellationToken); - public int DisplayIncompatibleVersionError(AppHostIncompatibleException ex, string appHostHostingVersion) + public Task> PromptForSelectionsAsync(string promptText, IEnumerable choices, Func choiceFormatter, IEnumerable? preSelected = null, bool optional = false, PromptBinding? binding = null, bool echoSelected = true, IEnumerable? bindingChoices = null, CancellationToken cancellationToken = default) where T : notnull + => _innerService.PromptForSelectionsAsync(promptText, choices, choiceFormatter, preSelected, optional, binding, echoSelected, bindingChoices, cancellationToken); + public int DisplayIncompatibleVersionError(AppHostIncompatibleException ex, string appHostHostingVersion) => _innerService.DisplayIncompatibleVersionError(ex, appHostHostingVersion); public void DisplayError(string errorMessage, bool allowMarkup = false) => _innerService.DisplayError(errorMessage, allowMarkup); public void DisplayMessage(KnownEmoji emoji, string message, bool allowMarkup = false, ConsoleOutput? consoleOverride = null) => _innerService.DisplayMessage(emoji, message, allowMarkup, consoleOverride); diff --git a/tests/Aspire.Cli.Tests/Interaction/ConsoleInteractionServiceTests.cs b/tests/Aspire.Cli.Tests/Interaction/ConsoleInteractionServiceTests.cs index 391dfe65ae2..ff4ec94bb11 100644 --- a/tests/Aspire.Cli.Tests/Interaction/ConsoleInteractionServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Interaction/ConsoleInteractionServiceTests.cs @@ -1456,6 +1456,67 @@ public async Task PromptForSelectionsAsync_NonInteractive_CliProvidedInvalidValu Assert.Contains("gamma", outputString); } + [Fact] + public async Task PromptForSelectionsAsync_NonInteractive_CliProvidedInvalidValue_OmitsItemsOutsideBindingChoices() + { + // The visible multi-select prompt may include UX-only entries (e.g. a "Configure MCP server" + // applicator) that share the prompt with the real catalog but must not be addressable from + // the --option value. Callers narrow non-interactive validation via the bindingChoices subset; + // entries outside that subset must not leak into the "Available values" rejection message. + var output = new StringBuilder(); + var console = CreateInteractiveConsoleWithInput(output, ""); + var interactionService = CreateInteractionService(console, hostEnvironment: TestHelpers.CreateNonInteractiveHostEnvironment()); + var visibleChoices = new[] { "alpha", "beta", "ux-only-entry" }; + var bindingChoices = new[] { "alpha", "beta" }; + + var option = new System.CommandLine.Option("--items"); + var command = new System.CommandLine.RootCommand { option }; + var parseResult = command.Parse("--items invalid"); + var binding = PromptBinding.Create(parseResult, option); + + await Assert.ThrowsAsync(() => + interactionService.PromptForSelectionsAsync("Select:", visibleChoices, x => x, binding: binding, bindingChoices: bindingChoices, cancellationToken: CancellationToken.None)); + + var outputString = output.ToString(); + Assert.Contains("alpha", outputString); + Assert.Contains("beta", outputString); + Assert.DoesNotContain("ux-only-entry", outputString); + } + + [Fact] + public async Task PromptForSelectionsAsync_NonInteractive_CliProvidedInvalidValue_StripsSpectreMarkupFromChoiceLabels() + { + // Choice formatters sometimes return Spectre.Console markup (e.g. "[bold]Label[/]") so the + // interactive multi-select can render styled text. The non-interactive rejection message is + // plain text, so those tokens must be stripped rather than printed verbatim — otherwise a + // user who mistypes --option sees `[bold]Label[/]` in the "Available values" list. + var output = new StringBuilder(); + var console = CreateInteractiveConsoleWithInput(output, ""); + var interactionService = CreateInteractionService(console, hostEnvironment: TestHelpers.CreateNonInteractiveHostEnvironment()); + var choices = new[] { "alpha", "beta" }; + + var option = new System.CommandLine.Option("--items"); + var command = new System.CommandLine.RootCommand { option }; + var parseResult = command.Parse("--items invalid"); + var binding = PromptBinding.Create(parseResult, option); + + await Assert.ThrowsAsync(() => + interactionService.PromptForSelectionsAsync( + "Select:", + choices, + x => $"[bold]{x}[/] [dim](styled)[/]", + binding: binding, + cancellationToken: CancellationToken.None)); + + var outputString = output.ToString(); + Assert.Contains("alpha", outputString); + Assert.Contains("beta", outputString); + Assert.Contains("(styled)", outputString); + Assert.DoesNotContain("[bold]", outputString); + Assert.DoesNotContain("[/]", outputString); + Assert.DoesNotContain("[dim]", outputString); + } + [Fact] public async Task PromptForSelectionAsync_NonInteractive_WithDefaultValue_ReturnsMatch() { diff --git a/tests/Aspire.Cli.Tests/Projects/ExtensionGuestLauncherTests.cs b/tests/Aspire.Cli.Tests/Projects/ExtensionGuestLauncherTests.cs index ce2f8fa3853..323cb019c52 100644 --- a/tests/Aspire.Cli.Tests/Projects/ExtensionGuestLauncherTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/ExtensionGuestLauncherTests.cs @@ -163,7 +163,7 @@ public Task LaunchAppHostAsync(string projectFile, List arguments, List< public Task PromptForStringAsync(string promptText, Func? validator = null, bool isSecret = false, bool required = false, PromptBinding? binding = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); public Task PromptForFilePathAsync(string promptText, Func? validator = null, bool directory = false, bool required = false, PromptBinding? binding = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); public Task PromptForSelectionAsync(string promptText, IEnumerable choices, Func choiceFormatter, PromptBinding? binding = null, bool echoSelected = true, CancellationToken cancellationToken = default) where T : notnull => throw new NotImplementedException(); - public Task> PromptForSelectionsAsync(string promptText, IEnumerable choices, Func choiceFormatter, IEnumerable? preSelected = null, bool optional = false, PromptBinding? binding = null, bool echoSelected = true, CancellationToken cancellationToken = default) where T : notnull => throw new NotImplementedException(); + public Task> PromptForSelectionsAsync(string promptText, IEnumerable choices, Func choiceFormatter, IEnumerable? preSelected = null, bool optional = false, PromptBinding? binding = null, bool echoSelected = true, IEnumerable? bindingChoices = null, CancellationToken cancellationToken = default) where T : notnull => throw new NotImplementedException(); public int DisplayIncompatibleVersionError(AppHostIncompatibleException ex, string appHostHostingVersion) => throw new NotImplementedException(); public void DisplayError(string errorMessage, bool allowMarkup = false) => throw new NotImplementedException(); public void DisplayMessage(KnownEmoji emoji, string message, bool allowMarkup = false, ConsoleOutput? consoleOverride = null) => throw new NotImplementedException(); diff --git a/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs b/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs index 854334b1a2f..6c8c9b46fdb 100644 --- a/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs +++ b/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs @@ -374,7 +374,7 @@ private sealed class TestInteractionService : IInteractionService public Task PromptForSelectionAsync(string prompt, IEnumerable choices, Func displaySelector, PromptBinding? binding = null, bool echoSelected = true, CancellationToken cancellationToken = default) where T : notnull => throw new NotImplementedException(); - public Task> PromptForSelectionsAsync(string promptText, IEnumerable choices, Func choiceFormatter, IEnumerable? preSelected = null, bool optional = false, PromptBinding? binding = null, bool echoSelected = true, CancellationToken cancellationToken = default) where T : notnull + public Task> PromptForSelectionsAsync(string promptText, IEnumerable choices, Func choiceFormatter, IEnumerable? preSelected = null, bool optional = false, PromptBinding? binding = null, bool echoSelected = true, IEnumerable? bindingChoices = null, CancellationToken cancellationToken = default) where T : notnull => throw new NotImplementedException(); public Task PromptForStringAsync(string promptText, Func? validator = null, bool isSecret = false, bool required = false, PromptBinding? binding = null, CancellationToken cancellationToken = default) diff --git a/tests/Aspire.Cli.Tests/TestServices/FakePlaywrightServices.cs b/tests/Aspire.Cli.Tests/TestServices/FakePlaywrightServices.cs index f87c5d29ce2..19dff3db7df 100644 --- a/tests/Aspire.Cli.Tests/TestServices/FakePlaywrightServices.cs +++ b/tests/Aspire.Cli.Tests/TestServices/FakePlaywrightServices.cs @@ -49,6 +49,10 @@ public Task VerifyProvenanceAsync(string packageNa /// internal sealed class FakeAspireSkillsInstaller : IAspireSkillsInstaller { + internal const string AspireInitSkillName = "aspire-init"; + internal const string AspireMonitoringSkillName = "aspire-monitoring"; + internal const string AspireOrchestrationSkillName = "aspire-orchestration"; + private readonly DirectoryInfo _bundleDirectory; private readonly AspireSkillsInstallResult? _result; @@ -113,7 +117,34 @@ private async Task EnsureBundleAsync(CancellationToken cancellationToken) # Aspire Deployment """, - [(CommonAgentApplicators.AspireDeploymentSkillName, Path.Combine("references", "preflight.md"))] = "# Preflight" + [(CommonAgentApplicators.AspireDeploymentSkillName, Path.Combine("references", "preflight.md"))] = "# Preflight", + [(AspireInitSkillName, "SKILL.md")] = + """ + --- + name: aspire-init + description: "First-run flow for adding Aspire to a repo" + --- + + # Aspire Init + """, + [(AspireMonitoringSkillName, "SKILL.md")] = + """ + --- + name: aspire-monitoring + description: "Observe Aspire apps with logs, traces, metrics, and resource state" + --- + + # Aspire Monitoring + """, + [(AspireOrchestrationSkillName, "SKILL.md")] = + """ + --- + name: aspire-orchestration + description: "Manage Aspire AppHost lifecycle and resource commands" + --- + + # Aspire Orchestration + """ }; foreach (var ((skillName, relativePath), content) in files) @@ -133,9 +164,12 @@ private async Task EnsureBundleAsync(CancellationToken cancellationToken) }, Skills = [ - CreateSkill(CommonAgentApplicators.AspireSkillName, isDefault: true, ["evals"], files), - CreateSkill(CommonAgentApplicators.AspireifySkillName, isDefault: true, [], files), - CreateSkill(CommonAgentApplicators.AspireDeploymentSkillName, isDefault: true, [], files) + CreateSkill(CommonAgentApplicators.AspireSkillName, ["evals"], files), + CreateSkill(CommonAgentApplicators.AspireifySkillName, ["evals"], files), + CreateSkill(CommonAgentApplicators.AspireDeploymentSkillName, ["evals"], files), + CreateSkill(AspireInitSkillName, ["evals"], files), + CreateSkill(AspireMonitoringSkillName, ["evals"], files), + CreateSkill(AspireOrchestrationSkillName, ["evals"], files) ] }; @@ -143,13 +177,12 @@ private async Task EnsureBundleAsync(CancellationToken cancellationToken) await File.WriteAllTextAsync(Path.Combine(_bundleDirectory.FullName, "skill-manifest.json"), manifestJson, cancellationToken); } - private SkillBundleSkill CreateSkill(string skillName, bool isDefault, string[] installExcludedRelativePaths, Dictionary<(string SkillName, string RelativePath), string> files) + private SkillBundleSkill CreateSkill(string skillName, string[] installExcludedRelativePaths, Dictionary<(string SkillName, string RelativePath), string> files) { return new SkillBundleSkill { Name = skillName, Description = $"{skillName} skill", - IsDefault = isDefault, InstallExcludedRelativePaths = installExcludedRelativePaths, Files = files .Where(entry => string.Equals(entry.Key.SkillName, skillName, StringComparison.Ordinal)) diff --git a/tests/Aspire.Cli.Tests/TestServices/TestExtensionInteractionService.cs b/tests/Aspire.Cli.Tests/TestServices/TestExtensionInteractionService.cs index 8fab1ba4e5e..9cc3950292b 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestExtensionInteractionService.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestExtensionInteractionService.cs @@ -75,7 +75,7 @@ public Task PromptForSelectionAsync(string promptText, IEnumerable choi return Task.FromResult(choicesArray.First()); } - public Task> PromptForSelectionsAsync(string promptText, IEnumerable choices, Func choiceFormatter, IEnumerable? preSelected = null, bool optional = false, PromptBinding? binding = null, bool echoSelected = true, CancellationToken cancellationToken = default) where T : notnull + public Task> PromptForSelectionsAsync(string promptText, IEnumerable choices, Func choiceFormatter, IEnumerable? preSelected = null, bool optional = false, PromptBinding? binding = null, bool echoSelected = true, IEnumerable? bindingChoices = null, CancellationToken cancellationToken = default) where T : notnull { if (!choices.Any()) { diff --git a/tests/Aspire.Cli.Tests/TestServices/TestInteractionService.cs b/tests/Aspire.Cli.Tests/TestServices/TestInteractionService.cs index f5a6b1f3d0f..c4212033722 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestInteractionService.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestInteractionService.cs @@ -180,7 +180,7 @@ public Task PromptForSelectionAsync(string promptText, IEnumerable choi return Task.FromResult(choices.First()); } - public Task> PromptForSelectionsAsync(string promptText, IEnumerable choices, Func choiceFormatter, IEnumerable? preSelected = null, bool optional = false, PromptBinding? binding = null, bool echoSelected = true, CancellationToken cancellationToken = default) where T : notnull + public Task> PromptForSelectionsAsync(string promptText, IEnumerable choices, Func choiceFormatter, IEnumerable? preSelected = null, bool optional = false, PromptBinding? binding = null, bool echoSelected = true, IEnumerable? bindingChoices = null, CancellationToken cancellationToken = default) where T : notnull { if (_shouldCancel || cancellationToken.IsCancellationRequested) { From 0cbaf82dab7d2dec5cf2fbda7577e7eaca6e86b1 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Fri, 29 May 2026 13:20:41 -0700 Subject: [PATCH 29/43] [release/13.4] Update Foundry hosted agent builder APIs (#17669) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update Foundry hosted agent builder APIs * Update Foundry hosted agent API surface * Update Foundry hosted agent tests * Remove commented-out health check URLs from hosted agent configuration * Adjust Foundry hosted agent behavior Consolidate AsHostedAgent usage, update Foundry icons and command rendering, and refresh playgrounds, samples, and tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix Foundry hosted-agent run and command icon behavior Conditionally require/provision Foundry project ACR only for hosted-agent publish or explicit registry override, add regression tests for run/publish paths, and align PromptAgent Send Message icon with ChatSparkle. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Avoid default Foundry ACR in run mode Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Cache JsonSerializerOptions in HostedAgentResourceBuilderExtensions Avoid allocating a fresh JsonSerializerOptions per Send Message command invocation, which also defeats JsonSerializer's per-options metadata cache. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Make project positional and add HostedAgentOptions for polyglot - AsHostedAgent(project, HostedAgentOptions?) is now the polyglot-exported overload - Action overload kept as .NET-only for advanced use - HostedAgentOptions exposes Description, Cpu, Memory, Metadata, EnvironmentVariables - Polyglot users now get .asHostedAgent(project, { ... }) instead of .asHostedAgent({ project, configure: async cfg => ... }) - Updated TypeScript, Go, and Java polyglot apphost fixtures to new shape Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Make HostedAgentOptions and its overload internal (ATS-only) HostedAgentOptions exists only to give polyglot SDK generators a clean options-bag shape. .NET callers should keep using the richer Action overload, so both the DTO and the overload that takes it are now internal. To avoid C# overload ambiguity between '.AsHostedAgent(project)' (which should bind to the public Action overload) and the internal options overload, the internal method is renamed to AsHostedAgentForExport. The polyglot-facing name stays as 'asHostedAgent' via [AspireExport(MethodName)]. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Clean up polyglot JSDoc for asHostedAgent Remove blocks from HostedAgentOptions and AsHostedAgentForExport that were flowing into the generated TypeScript/Go/Java SDK JSDoc and including C#-only implementation notes plus broken renderings (e.g. \`AsHostedAgent\`\`1). The polyglot codegen concatenates +, so any C#-implementation chatter pollutes the generated SDK docs. Replace with plain // source comments that stay in C#. Also fix the TypeScript polyglot fixture to match the actual generated signature: when an extension method takes a single optional DTO parameter, the codegen wraps it in an options bag, so the call shape is asHostedAgent(project, { options: { ... } }) — not the flat asHostedAgent(project, { ... }) shape. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix Foundry hosted agent options DTO generation Mark HostedAgentOptions as an ATS DTO so polyglot SDK generation treats it as a JSON value object instead of a live exported handle. Update the TypeScript validation fixture to use the generated flat options shape. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix Foundry polyglot hosted agent fixtures Update Go and Java Foundry polyglot AppHosts to match the generated HostedAgentOptions DTO and AsHostedAgent optional-parameter shapes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix Foundry Java hosted agent fixture Use Double literals for hosted agent CPU and memory options so the generated Java DTO setters compile.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix Foundry API baselines Regenerate the Foundry ATS and API baselines so the release branch backport exposes the hosted-agent options export and drops the stale withComputeEnvironmentExecutable surface. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: tommasodotnet Co-authored-by: Maddy Montaquila Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../FoundryAgentEnterprise.AppHost/AppHost.cs | 3 +- .../FoundryAgents.AppHost/AppHost.cs | 8 +- .../FoundryExtensions.cs | 3 +- .../HostedAgentBuilderExtension.cs | 479 ++++++++++-------- .../HostedAgent/HostedAgentOptions.cs | 77 +++ .../Project/ProjectBuilderExtension.cs | 85 ++-- .../Project/ProjectResource.cs | 12 +- .../PromptAgentBuilderExtensions.cs | 3 +- src/Aspire.Hosting.Foundry/README.md | 2 +- .../api/Aspire.Hosting.Foundry.ats.txt | 8 +- .../api/Aspire.Hosting.Foundry.cs | 134 +++-- .../FoundryHostedAgentDeploymentTests.cs | 2 +- .../AzureDeployerTests.cs | 2 +- .../FoundryExtensionsTests.cs | 20 +- ...romParentFoundryApiEndpoint.verified.bicep | 22 - .../HostedAgentExtensionTests.cs | 160 ++++-- .../ProjectResourceTests.cs | 31 +- .../PromptAgentTests.cs | 20 + .../Aspire.Hosting.Foundry/Go/apphost.go | 19 +- .../Aspire.Hosting.Foundry/Java/AppHost.java | 17 +- .../Aspire.Hosting.Foundry/Python/apphost.py | 2 +- .../TypeScript/apphost.mts | 17 +- 22 files changed, 699 insertions(+), 427 deletions(-) create mode 100644 src/Aspire.Hosting.Foundry/HostedAgent/HostedAgentOptions.cs diff --git a/playground/FoundryAgentEnterprise/FoundryAgentEnterprise.AppHost/AppHost.cs b/playground/FoundryAgentEnterprise/FoundryAgentEnterprise.AppHost/AppHost.cs index 3455e4179ff..8c13a616bf5 100644 --- a/playground/FoundryAgentEnterprise/FoundryAgentEnterprise.AppHost/AppHost.cs +++ b/playground/FoundryAgentEnterprise/FoundryAgentEnterprise.AppHost/AppHost.cs @@ -23,10 +23,9 @@ .WithHttpEndpoint() .WithExternalHttpEndpoints() .WithHttpHealthCheck("/health") - .WithReference(project) .WithReference(deployment) .WaitFor(deployment) - .WithComputeEnvironment(project, (opts) => + .AsHostedAgent(project, (opts) => { opts.Description = "Foundry Agent Basic Example"; opts.Metadata["managed-by"] = "aspire-foundry"; diff --git a/playground/FoundryAgents/FoundryAgents.AppHost/AppHost.cs b/playground/FoundryAgents/FoundryAgents.AppHost/AppHost.cs index ea51a7217a2..5f50030f93d 100644 --- a/playground/FoundryAgents/FoundryAgents.AppHost/AppHost.cs +++ b/playground/FoundryAgents/FoundryAgents.AppHost/AppHost.cs @@ -43,13 +43,13 @@ builder.AddPythonApp("weather-hosted-agent", "../app", "main.py") .WithUv() - .WithReference(project).WithReference(chat).WaitFor(chat) - .WithComputeEnvironment(project); + .WithReference(chat).WaitFor(chat) + .AsHostedAgent(project); builder.AddProject("proj-dotnet-hosted-agent") .WithHttpEndpoint(targetPort: 9000) - .WithReference(project).WithReference(chat).WaitFor(chat) - .WithComputeEnvironment(project); + .WithReference(chat).WaitFor(chat) + .AsHostedAgent(project); // --- Prompt Agents --- diff --git a/src/Aspire.Hosting.Foundry/FoundryExtensions.cs b/src/Aspire.Hosting.Foundry/FoundryExtensions.cs index cc4c537a51d..26d8eb009d2 100644 --- a/src/Aspire.Hosting.Foundry/FoundryExtensions.cs +++ b/src/Aspire.Hosting.Foundry/FoundryExtensions.cs @@ -41,6 +41,7 @@ public static IResourceBuilder AddFoundry(this IDistributedAppl var resource = new FoundryResource(name, ConfigureInfrastructure); return builder.AddResource(resource) + .WithIconName("AgentsAdd") .WithDefaultRoleAssignments(CognitiveServicesBuiltInRole.GetBuiltInRoleName, CognitiveServicesBuiltInRole.CognitiveServicesUser, CognitiveServicesBuiltInRole.CognitiveServicesOpenAIUser); } @@ -77,7 +78,7 @@ public static IResourceBuilder AddDeployment(this IRe deploymentBuilder.AsLocalDeployment(deployment); } - return deploymentBuilder; + return deploymentBuilder.WithIconName("BoxMultiple"); } /// diff --git a/src/Aspire.Hosting.Foundry/HostedAgent/HostedAgentBuilderExtension.cs b/src/Aspire.Hosting.Foundry/HostedAgent/HostedAgentBuilderExtension.cs index d187e221a27..4e11d6e3099 100644 --- a/src/Aspire.Hosting.Foundry/HostedAgent/HostedAgentBuilderExtension.cs +++ b/src/Aspire.Hosting.Foundry/HostedAgent/HostedAgentBuilderExtension.cs @@ -15,277 +15,332 @@ namespace Aspire.Hosting; /// public static class HostedAgentResourceBuilderExtensions { + private static readonly JsonSerializerOptions s_indentedJsonOptions = new() { WriteIndented = true }; /// - /// Configures the resource to run as a hosted agent in Microsoft Foundry. - /// - /// If a project resource is not provided, the method will attempt to find an existing - /// Microsoft Foundry project resource in the application model. If none exists, - /// a new project resource (and its parent account resource) will be created automatically. + /// Configures the resource to run locally as a Microsoft Foundry hosted agent. /// + /// Configures the resource to run locally as a Microsoft Foundry hosted agent. + /// The type of resource being configured. + /// The resource builder for the compute resource. + /// A reference to the for chaining. /// - /// In run mode, this configures the resource with hosted agent endpoints, health checks, - /// and OpenTelemetry settings. In publish mode, the resource is deployed as a hosted agent - /// in Microsoft Foundry. + /// This method applies in run mode. It configures the resource with the hosted agent responses endpoint, + /// a dashboard command for sending messages to the agent, and OpenTelemetry environment variables expected + /// by the Microsoft Foundry agent server SDK. /// - [AspireExportIgnore(Reason = "Subset of the full WithComputeEnvironment overload which is exported.")] - public static IResourceBuilder WithComputeEnvironment( - this IResourceBuilder builder, Action configure) + /// + /// + /// var agent = builder.AddProject<Projects.AgentService>("agent") + /// .AsHostedAgent(); + /// + /// + /// The resource builder. + [AspireExportIgnore(Reason = "Subset of the full AsHostedAgent(project) overload which is exported.")] + public static IResourceBuilder AsHostedAgent(this IResourceBuilder builder) where T : IResourceWithEndpoints, IResourceWithEnvironment, IComputeResource { - return WithComputeEnvironment(builder, project: null, configure: configure); + return AsHostedAgent(builder, project: null, configure: null); } + // The internal AsHostedAgentForExport overload below is the polyglot-exported version of AsHostedAgent. + // The method name differs from AsHostedAgent to avoid C# overload ambiguity with the Action-based + // overload; the polyglot-facing name is set back to "asHostedAgent" via [AspireExport(MethodName)]. + // .NET callers should keep using the Action overload above, which exposes + // the full HostedAgentConfiguration surface (tools, content filters, container protocol versions, etc.). + /// - /// Configures the resource to run as a hosted agent in Microsoft Foundry. - /// - /// If a project resource is not provided, the method will attempt to find an existing - /// Microsoft Foundry project resource in the application model. If none exists, - /// a new project resource (and its parent account resource) will be created automatically. + /// Configures the resource to run and publish as a hosted agent in Microsoft Foundry, targeting the specified Foundry project. /// - /// - /// In run mode, this configures the resource with hosted agent endpoints, health checks, - /// and OpenTelemetry settings. In publish mode, the resource is deployed as a hosted agent - /// in Microsoft Foundry. - /// - [AspireExport("withComputeEnvironmentExecutable", MethodName = "withComputeEnvironment")] - public static IResourceBuilder WithComputeEnvironment( - this IResourceBuilder builder, IResourceBuilder? project = null, Action? configure = null) + /// 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. + /// A reference to the for chaining. + /// The resource builder. + /// Thrown when or is . + [AspireExport("asHostedAgentExecutable", MethodName = "asHostedAgent")] + internal static IResourceBuilder AsHostedAgentForExport( + this IResourceBuilder builder, + IResourceBuilder project, + HostedAgentOptions? options = null) where T : IResourceWithEndpoints, IResourceWithEnvironment, IComputeResource { - /* - * Much of the logic here is similar to ExecutableResourceBuilderExtensions.PublishAsDockerFile(). - * - * That is, in Publish mode, we swap the original resource with a hosted agent resource. - */ - ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(project); - var resource = builder.Resource; + Action? configure = options is null ? null : options.ApplyTo; + return AsHostedAgent(builder, project: project, configure: configure); + } + + /// + /// Configures the resource to run and publish as a hosted agent in Microsoft Foundry, with full programmatic + /// access to the underlying (including Azure SDK-specific options + /// such as tools and content filters). + /// + /// 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 reference to the for chaining. + [AspireExportIgnore(Reason = "Action callback shape is awkward for polyglot hosts; the HostedAgentOptions overload is exported instead.")] + public static IResourceBuilder AsHostedAgent( + this IResourceBuilder builder, + IResourceBuilder? project, + Action? configure = null) + where T : IResourceWithEndpoints, IResourceWithEnvironment, IComputeResource + { + ArgumentNullException.ThrowIfNull(builder); if (builder.ApplicationBuilder.ExecutionContext.IsRunMode) { - // 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 = resource.Annotations.OfType().FirstOrDefault(e => e.Name == "http"); - var targetPort = existingHttpEndpoint?.TargetPort ?? 8088; - - builder - .WithHttpEndpoint(name: "http", env: "DEFAULT_AD_PORT", targetPort: targetPort, isProxied: true) - .WithUrls((ctx) => + ConfigureRunMode(builder); + + if (project is not null) + { + AddProjectReferenceForRunMode(builder, project); + } + + return builder; + } + + var publishProject = project ?? ResolveProjectBuilderForPublish(builder); + ConfigurePublishMode(builder, publishProject, configure); + + return builder; + } + + /// + /// Configures the resource to run and publish as a hosted agent in Microsoft Foundry, with full programmatic + /// access to the underlying . The Foundry project is resolved automatically + /// in publish mode. + /// + /// 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 reference to the for chaining. + [AspireExportIgnore(Reason = "Subset of the full AsHostedAgent overload.")] + public static IResourceBuilder AsHostedAgent( + this IResourceBuilder builder, + Action configure) + where T : IResourceWithEndpoints, IResourceWithEnvironment, IComputeResource + { + ArgumentNullException.ThrowIfNull(configure); + return AsHostedAgent(builder, project: null, configure: configure); + } + + private static void AddProjectReferenceForRunMode( + IResourceBuilder builder, + IResourceBuilder project) + where T : IResourceWithEndpoints, IResourceWithEnvironment, IComputeResource + { + builder.WithReference(project); + + // The default ACR is required for publish-time image push, but in run mode it adds noise to the dashboard. + // When a hosted agent references a Foundry project for local execution, remove the default registry resource. + if (project.Resource.DefaultContainerRegistry is { } defaultRegistry) + { + builder.ApplicationBuilder.Resources.Remove(defaultRegistry); + project.Resource.DefaultContainerRegistry = null; + } + } + + private static IResourceBuilder ResolveProjectBuilderForPublish(IResourceBuilder builder) + where T : IResourceWithEndpoints, IResourceWithEnvironment, IComputeResource + { + if (builder.ApplicationBuilder.Resources.OfType().FirstOrDefault() is { } existingProject) + { + return builder.ApplicationBuilder.CreateResourceBuilder(existingProject); + } + + return builder.ApplicationBuilder + .AddFoundry($"{builder.Resource.Name}-proj-foundry") + .AddProject($"{builder.Resource.Name}-proj"); + } + + private static void ConfigureRunMode(IResourceBuilder builder) + where T : IResourceWithEndpoints, IResourceWithEnvironment, IComputeResource + { + // 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"); + var targetPort = existingHttpEndpoint?.TargetPort ?? 8088; + + builder + .WithIconName("Agents") + .WithHttpEndpoint(name: "http", env: "DEFAULT_AD_PORT", targetPort: targetPort, isProxied: true) + .WithUrls((ctx) => + { + var http = ctx.Urls.FirstOrDefault(u => u.Endpoint?.EndpointName == "http" || u.Endpoint?.EndpointName == "https"); + if (http is null) { - var http = ctx.Urls.FirstOrDefault(u => u.Endpoint?.EndpointName == "http" || u.Endpoint?.EndpointName == "https"); - if (http is null) - { - return; - } - http.DisplayText = "Responses Endpoint"; - http.Url = new UriBuilder(http.Url) - { - Path = "/responses" - }.ToString(); - ctx.Urls.Add(new() - { - DisplayText = "Liveness probe", - Url = new UriBuilder(http.Url) - { - Path = "/liveness" - }.ToString(), - Endpoint = http.Endpoint, - DisplayLocation = UrlDisplayLocation.DetailsOnly - }); - ctx.Urls.Add(new() + return; + } + http.DisplayText = "Responses Endpoint"; + http.Url = new UriBuilder(http.Url) + { + Path = "/responses" + }.ToString(); + }) + .WithHttpCommand( + path: "/responses", + displayName: "Send Message", + endpointName: "http", + commandOptions: new() + { + Method = HttpMethod.Post, + IconName = "ChatSparkle", + IconVariant = IconVariant.Regular, + IsHighlighted = true, + PrepareRequest = async ctx => { - DisplayText = "Readiness probe", - Url = new UriBuilder(http.Url) + var interactionService = ctx.ServiceProvider.GetRequiredService(); + var result = await interactionService.PromptInputAsync( + title: "Responses API", + message: "Enter a message to send to the agent.", + inputLabel: "Message", + placeHolder: "I would like to know the weather today.", + cancellationToken: ctx.CancellationToken + ).ConfigureAwait(true); + if (result.Canceled || string.IsNullOrWhiteSpace(result.Data.Value)) { - Path = "/readiness" - }.ToString(), - Endpoint = http.Endpoint, - DisplayLocation = UrlDisplayLocation.DetailsOnly - }); - }) - .WithHttpHealthCheck("/liveness") - .WithHttpCommand( - path: "/responses", - displayName: "Send Message", - endpointName: "http", - commandOptions: new() + ctx.HttpClient.CancelPendingRequests(); + throw new OperationCanceledException("User canceled the input prompt."); + } + var request = ctx.Request; + var input = result.Data.Value; + request.Content = new StringContent(new JsonObject() { ["input"] = input }.ToString(), System.Text.Encoding.UTF8, "application/json"); + }, + GetCommandResult = async ctx => { - Method = HttpMethod.Post, - IconName = "Agents", - IconVariant = IconVariant.Regular, - IsHighlighted = true, - PrepareRequest = async ctx => + ctx.CancellationToken.ThrowIfCancellationRequested(); + + var response = ctx.Response; + if (!response.IsSuccessStatusCode) { - var interactionService = ctx.ServiceProvider.GetRequiredService(); - var result = await interactionService.PromptInputAsync( - title: "Responses API", - message: "Enter a message to send to the agent.", - inputLabel: "Message", - placeHolder: "I would like to know the weather today.", - cancellationToken: ctx.CancellationToken - ).ConfigureAwait(true); - if (result.Canceled || string.IsNullOrWhiteSpace(result.Data.Value)) - { - ctx.HttpClient.CancelPendingRequests(); - throw new OperationCanceledException("User canceled the input prompt."); - } - var request = ctx.Request; - var input = result.Data.Value; - request.Content = new StringContent(new JsonObject() { ["input"] = input }.ToString(), System.Text.Encoding.UTF8, "application/json"); - }, - GetCommandResult = async ctx => + var errorPayload = await response.Content.ReadAsStringAsync(ctx.CancellationToken).ConfigureAwait(true); + return CommandResults.Failure( + $"Agent request failed with status code {(int)response.StatusCode} ({response.StatusCode}).", + errorPayload, + CommandResultFormat.Text); + } + + var responseJson = await response.Content.ReadFromJsonAsync(cancellationToken: ctx.CancellationToken).ConfigureAwait(true); + if (responseJson is null) { - ctx.CancellationToken.ThrowIfCancellationRequested(); - try - { - var response = await ctx.Response - .EnsureSuccessStatusCode() - .Content - .ReadFromJsonAsync(cancellationToken: ctx.CancellationToken) - .ConfigureAwait(true); - var formattedResponse = $"```\n{JsonSerializer.Serialize(response, new JsonSerializerOptions { WriteIndented = true })}\n```"; - var interactionService = ctx.ServiceProvider.GetRequiredService(); - await interactionService.PromptMessageBoxAsync( - title: "Agent Response", - message: formattedResponse, - options: new() - { - Intent = MessageIntent.Success, - EnableMessageMarkdown = true, - PrimaryButtonText = "Thanks!" - }, - cancellationToken: ctx.CancellationToken - ).ConfigureAwait(true); - return new() { Success = true }; - } - catch (Exception ex) - { - var interactionService = ctx.ServiceProvider.GetRequiredService(); - await interactionService.PromptMessageBoxAsync( - title: "Error", - message: $"An error occurred while processing the agent's response: {ex.Message}", - options: new() - { - Intent = MessageIntent.Error, - PrimaryButtonText = "OK" - }, - cancellationToken: ctx.CancellationToken - ).ConfigureAwait(true); - Console.Error.Write($"Error processing agent response: {ex}"); - return new() { Success = false }; - } - }, - } - ) - .WithOtlpExporter() - .WithEnvironment((ctx) => + 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); + }, + } + ) + .WithOtlpExporter() + .WithEnvironment((ctx) => + { + ctx.EnvironmentVariables.Add("OTEL_INSTRUMENTATION_OPENAI_AGENTS_ENABLED", "true"); + ctx.EnvironmentVariables.Add("OTEL_INSTRUMENTATION_OPENAI_AGENTS_CAPTURE_CONTENT", "true"); + ctx.EnvironmentVariables.Add("OTEL_INSTRUMENTATION_OPENAI_AGENTS_CAPTURE_METRICS", "true"); + ctx.EnvironmentVariables.Add("OTEL_GENAI_CAPTURE_MESSAGES", "true"); + ctx.EnvironmentVariables.Add("OTEL_GENAI_CAPTURE_SYSTEM_INSTRUCTIONS", "true"); + ctx.EnvironmentVariables.Add("OTEL_GENAI_CAPTURE_TOOL_DEFINITIONS", "true"); + ctx.EnvironmentVariables.Add("OTEL_GENAI_EMIT_OPERATION_DETAILS", "true"); + ctx.EnvironmentVariables.Add("OTEL_GENAI_AGENT_NAME", ctx.Resource.Name); + ctx.EnvironmentVariables.Add("OTEL_GENAI_AGENT_ID", ctx.Resource.Name); + var endpointVar = ctx.EnvironmentVariables.FirstOrDefault((item) => item.Key == "OTEL_EXPORTER_OTLP_ENDPOINT"); + if (endpointVar.Equals(default(KeyValuePair))) { - ctx.EnvironmentVariables.Add("OTEL_INSTRUMENTATION_OPENAI_AGENTS_ENABLED", "true"); - ctx.EnvironmentVariables.Add("OTEL_INSTRUMENTATION_OPENAI_AGENTS_CAPTURE_CONTENT", "true"); - ctx.EnvironmentVariables.Add("OTEL_INSTRUMENTATION_OPENAI_AGENTS_CAPTURE_METRICS", "true"); - ctx.EnvironmentVariables.Add("OTEL_GENAI_CAPTURE_MESSAGES", "true"); - ctx.EnvironmentVariables.Add("OTEL_GENAI_CAPTURE_SYSTEM_INSTRUCTIONS", "true"); - ctx.EnvironmentVariables.Add("OTEL_GENAI_CAPTURE_TOOL_DEFINITIONS", "true"); - ctx.EnvironmentVariables.Add("OTEL_GENAI_EMIT_OPERATION_DETAILS", "true"); - ctx.EnvironmentVariables.Add("OTEL_GENAI_AGENT_NAME", ctx.Resource.Name); - ctx.EnvironmentVariables.Add("OTEL_GENAI_AGENT_ID", ctx.Resource.Name); - var endpointVar = ctx.EnvironmentVariables.FirstOrDefault((item) => item.Key == "OTEL_EXPORTER_OTLP_ENDPOINT"); - if (endpointVar.Equals(default(KeyValuePair))) - { - return; - } - // The Microsoft Foundry agentserver SDK expects the exporter to be at OTEL_EXPORTER_ENDPOINT instead. - ctx.EnvironmentVariables["OTEL_EXPORTER_ENDPOINT"] = endpointVar.Value; - }); - return builder; - } - AzureCognitiveServicesProjectResource? projResource; - if (project is not null) - { - projResource = project.Resource; - } - else + return; + } + // The Microsoft Foundry agentserver SDK expects the exporter to be at OTEL_EXPORTER_ENDPOINT instead. + ctx.EnvironmentVariables["OTEL_EXPORTER_ENDPOINT"] = endpointVar.Value; + }); + } + + private static void ConfigurePublishMode( + IResourceBuilder builder, + IResourceBuilder project, + Action? configure) + where T : IResourceWithEndpoints, IResourceWithEnvironment, IComputeResource + { + /* + * Much of the logic here is similar to ExecutableResourceBuilderExtensions.PublishAsDockerFile(). + * + * That is, in Publish mode, we swap the original resource with a hosted agent resource. + */ + var resource = builder.Resource; + var projectResource = project.Resource; + + if (!projectResource.HasAnnotationOfType()) { - projResource = builder.ApplicationBuilder.Resources.OfType().FirstOrDefault(); - if (projResource is null) - { - project = builder.ApplicationBuilder - .AddFoundry($"{resource.Name}-proj-foundry") - .AddProject($"{resource.Name}-proj"); - projResource = project.Resource; - } - else - { - project = builder.ApplicationBuilder.CreateResourceBuilder(projResource); - } + projectResource.Annotations.Add(new RequiresHostedAgentRegistryAnnotation()); } - ResourceBuilderExtensions.WithComputeEnvironment(builder, project!); + ResourceBuilderExtensions.WithComputeEnvironment(builder, project); // Hosted Agent resource name var agentName = $"{resource.Name}-ha"; - if (builder.ApplicationBuilder.TryCreateResourceBuilder(agentName, out var rb)) + if (builder.ApplicationBuilder.TryCreateResourceBuilder(agentName, out var existingHostedAgent)) { // We already have a hosted agent for this resource if (configure is not null) { - rb.Resource.Configure = configure; + existingHostedAgent.Resource.Configure = configure; } - return builder; + return; } + // Get the corresponding ContainerResource for ExecutableResources. Usually this is swapped in at publish time for ExecutableResources. IResource target; if (resource is ContainerResource containerResource) { target = containerResource; } - else if (builder.ApplicationBuilder.TryCreateResourceBuilder(resource.Name, out var crb)) + else if (builder.ApplicationBuilder.TryCreateResourceBuilder(resource.Name, out var containerResourceBuilder)) { - target = crb.Resource; + target = containerResourceBuilder.Resource; } - else + else if (resource is ExecutableResource executableResource) { // Ensure we have a container resource to deploy. - // ExecutableResource needs PublishAsDockerFile() - // to convert them into container resources at this stage. - if (resource is ExecutableResource) - { - builder.ApplicationBuilder.CreateResourceBuilder((ExecutableResource)(object)resource).PublishAsDockerFile(); + // ExecutableResource needs PublishAsDockerFile() to convert it into a container resource at this stage. + builder.ApplicationBuilder.CreateResourceBuilder(executableResource).PublishAsDockerFile(); - if (builder.ApplicationBuilder.TryCreateResourceBuilder(resource.Name, out crb)) - { - target = crb.Resource; - } - else - { - throw new InvalidOperationException($"Unable to create hosted agent for resource '{resource.Name}' because it could not be converted to a container resource."); - } - } - else if (resource is not ProjectResource) + if (builder.ApplicationBuilder.TryCreateResourceBuilder(resource.Name, out containerResourceBuilder)) { - throw new InvalidOperationException($"Unable to create hosted agent for resource '{resource.Name}' because it is not a container, executable, or project resource."); + target = containerResourceBuilder.Resource; } else { - target = resource; + throw new InvalidOperationException($"Unable to create hosted agent for resource '{resource.Name}' because it could not be converted to a container resource."); } } + else if (resource is ProjectResource) + { + target = resource; + } + else + { + throw new InvalidOperationException($"Unable to create hosted agent for resource '{resource.Name}' because it is not a container, executable, or project resource."); + } - // Create a separate agent resource to host the deployment - var agent = new AzureHostedAgentResource(agentName, target, configure); + // Create a separate agent resource to host the deployment. + var hostedAgent = new AzureHostedAgentResource(agentName, target, configure); - // Ensure image gets pushed properly - target.Annotations.Add(new DeploymentTargetAnnotation(agent) + // Ensure image gets pushed properly. + target.Annotations.Add(new DeploymentTargetAnnotation(hostedAgent) { - ComputeEnvironment = projResource, - ContainerRegistry = projResource.ContainerRegistry + ComputeEnvironment = projectResource, + ContainerRegistry = projectResource.ContainerRegistry }); - builder.ApplicationBuilder.AddResource(agent) + builder.ApplicationBuilder.AddResource(hostedAgent) + .WithIconName("Agents") .WithReferenceRelationship(target) .WithReference(project); - - return builder; } } diff --git a/src/Aspire.Hosting.Foundry/HostedAgent/HostedAgentOptions.cs b/src/Aspire.Hosting.Foundry/HostedAgent/HostedAgentOptions.cs new file mode 100644 index 00000000000..7cf11d3c78c --- /dev/null +++ b/src/Aspire.Hosting.Foundry/HostedAgent/HostedAgentOptions.cs @@ -0,0 +1,77 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +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.). + +/// +/// Options that control how a compute resource is deployed as a Microsoft Foundry hosted agent. +/// All properties are optional; unset properties fall back to the Foundry hosted agent defaults. +/// +[AspireDto] +internal sealed class HostedAgentOptions +{ + /// + /// Human-readable description of the hosted agent surfaced in the Microsoft Foundry portal. + /// When not set, the hosted agent default description is used. + /// + public string? Description { get; set; } + + /// + /// CPU allocation for each hosted agent instance, in vCPU cores. Must be between 0.5 and 3.5 + /// in increments of 0.25. When not set, the hosted agent default CPU allocation is used. + /// + public decimal? Cpu { get; set; } + + /// + /// Memory allocation for each hosted agent instance, in GiB. Must be between 1 and 7 in + /// increments of 0.5 and equal to twice the CPU value. When not set, the hosted agent + /// default memory allocation is used. + /// + public decimal? Memory { get; set; } + + /// + /// Additional metadata key/value pairs to attach to the hosted agent definition. + /// Entries with the same key as an existing metadata entry overwrite it. + /// + public IDictionary Metadata { get; init; } = new Dictionary(); + + /// + /// Environment variables to set on the hosted agent container at runtime. + /// Entries with the same key as an existing environment variable overwrite it. + /// + public IDictionary EnvironmentVariables { get; init; } = new Dictionary(); + + internal void ApplyTo(HostedAgentConfiguration configuration) + { + if (Description is not null) + { + configuration.Description = Description; + } + + // Cpu and Memory have a coupled invariant on HostedAgentConfiguration (Memory = Cpu * 2 with validation). + // Apply Cpu first so a subsequent Memory assignment can still override the derived value. + if (Cpu is { } cpu) + { + configuration.Cpu = cpu; + } + + if (Memory is { } memory) + { + configuration.Memory = memory; + } + + foreach (var kvp in Metadata) + { + configuration.Metadata[kvp.Key] = kvp.Value; + } + + foreach (var kvp in EnvironmentVariables) + { + configuration.EnvironmentVariables[kvp.Key] = kvp.Value; + } + } +} diff --git a/src/Aspire.Hosting.Foundry/Project/ProjectBuilderExtension.cs b/src/Aspire.Hosting.Foundry/Project/ProjectBuilderExtension.cs index 2c064f9edf7..457ed9b6239 100644 --- a/src/Aspire.Hosting.Foundry/Project/ProjectBuilderExtension.cs +++ b/src/Aspire.Hosting.Foundry/Project/ProjectBuilderExtension.cs @@ -50,7 +50,11 @@ public static IResourceBuilder AddProject builder.ApplicationBuilder.Services.Configure(o => o.SupportsTargetedRoleAssignments = true); var project = builder.ApplicationBuilder.AddResource(new AzureCognitiveServicesProjectResource(name, ConfigureInfrastructure, builder.Resource)); - project.Resource.DefaultContainerRegistry = CreateDefaultRegistry(builder.ApplicationBuilder, $"{name}-acr"); + if (builder.ApplicationBuilder.ExecutionContext.IsPublishMode) + { + project.Resource.DefaultContainerRegistry = CreateDefaultRegistry(builder.ApplicationBuilder, $"{name}-acr"); + } + return project; } @@ -324,6 +328,12 @@ public static IResourceBuilder AddModelDeployment( return builder.ApplicationBuilder.CreateResourceBuilder(builder.Resource.Parent).AddDeployment(name, modelName, modelVersion, format); } + private static bool RequiresContainerRegistryProvisioning(AzureCognitiveServicesProjectResource project) + { + return project.HasAnnotationOfType() + || project.HasAnnotationOfType(); + } + internal static void ConfigureInfrastructure(AzureResourceInfrastructure infra) { var prefix = infra.AspireResource.Name; @@ -411,44 +421,47 @@ internal static void ConfigureInfrastructure(AzureResourceInfrastructure infra) /* * Container registry for hosted agents * - * TODO: only provision if we need to create a Hosted Agent + * Only provision registry dependencies when the project will publish a hosted agent + * or when the user has explicitly supplied a registry override. */ - - AzureProvisioningResource? registry = null; - if (aspireResource.TryGetLastAnnotation(out var registryReferenceAnnotation) && registryReferenceAnnotation.Registry is AzureProvisioningResource r) - { - registry = r; - } - else if (aspireResource.DefaultContainerRegistry is not null) + if (RequiresContainerRegistryProvisioning(aspireResource)) { - registry = aspireResource.DefaultContainerRegistry; - } - else - { - throw new InvalidOperationException($"No container registry configured for Azure Cognitive Services project resource '{aspireResource.Name}'. A container registry is required to publish and run hosted agents."); + AzureProvisioningResource? registry = null; + if (aspireResource.TryGetLastAnnotation(out var registryReferenceAnnotation) && registryReferenceAnnotation.Registry is AzureProvisioningResource r) + { + registry = r; + } + else if (aspireResource.DefaultContainerRegistry is not null) + { + registry = aspireResource.DefaultContainerRegistry; + } + else + { + throw new InvalidOperationException($"No container registry configured for Azure Cognitive Services project resource '{aspireResource.Name}'. A container registry is required to publish hosted agents."); + } + + var containerRegistry = (ContainerRegistryService)registry.AddAsExistingResource(infra); + infra.Add(containerRegistry); + + // Project needs this to pull hosted agent images during hosted-agent deployment. + var pullRa = containerRegistry.CreateRoleAssignment(ContainerRegistryBuiltInRole.AcrPull, RoleManagementPrincipalType.ServicePrincipal, projectPrincipalId); + // There's a bug in the CDK, see https://github.com/Azure/azure-sdk-for-net/issues/47265 + pullRa.Name = BicepFunction.CreateGuid(containerRegistry.Id, project.Id, pullRa.RoleDefinitionId); + infra.Add(pullRa); + infra.Add(containerRegistry); + infra.Add(new ProvisioningOutput("AZURE_CONTAINER_REGISTRY_ENDPOINT", typeof(string)) + { + Value = containerRegistry.LoginServer + }); + infra.Add(new ProvisioningOutput("AZURE_CONTAINER_REGISTRY_NAME", typeof(string)) + { + Value = containerRegistry.Name + }); + infra.Add(new ProvisioningOutput("AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID", typeof(string)) + { + Value = projectPrincipalId + }); } - var containerRegistry = (ContainerRegistryService)registry.AddAsExistingResource(infra); - // Why do we need this? - infra.Add(containerRegistry); - - // Project needs this to pull hosted agent images and run them - var pullRa = containerRegistry.CreateRoleAssignment(ContainerRegistryBuiltInRole.AcrPull, RoleManagementPrincipalType.ServicePrincipal, projectPrincipalId); - // There's a bug in the CDK, see https://github.com/Azure/azure-sdk-for-net/issues/47265 - pullRa.Name = BicepFunction.CreateGuid(containerRegistry.Id, project.Id, pullRa.RoleDefinitionId); - infra.Add(pullRa); - infra.Add(containerRegistry); - infra.Add(new ProvisioningOutput("AZURE_CONTAINER_REGISTRY_ENDPOINT", typeof(string)) - { - Value = containerRegistry.LoginServer - }); - infra.Add(new ProvisioningOutput("AZURE_CONTAINER_REGISTRY_NAME", typeof(string)) - { - Value = containerRegistry.Name - }); - infra.Add(new ProvisioningOutput("AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID", typeof(string)) - { - Value = projectPrincipalId - }); // Implicit dependencies for capability hosts List capHostDeps = []; diff --git a/src/Aspire.Hosting.Foundry/Project/ProjectResource.cs b/src/Aspire.Hosting.Foundry/Project/ProjectResource.cs index 697b7cafbd8..8fcabc67d52 100644 --- a/src/Aspire.Hosting.Foundry/Project/ProjectResource.cs +++ b/src/Aspire.Hosting.Foundry/Project/ProjectResource.cs @@ -49,8 +49,9 @@ public AzureCognitiveServicesProjectResource([ResourceName] string name, Action< Description = $"Prepares Microsoft Foundry project {name} for deployment.", Action = context => { - if (this.HasAnnotationOfType() && - DefaultContainerRegistry is not null) + if (DefaultContainerRegistry is not null && + (this.HasAnnotationOfType() || + !this.HasAnnotationOfType())) { context.Model.Resources.Remove(DefaultContainerRegistry); DefaultContainerRegistry = null; @@ -248,6 +249,13 @@ public bool TryGetAppIdentityResource([NotNullWhen(true)] out IAppIdentityResour } } +/// +/// Marks a Foundry project as needing container registry provisioning for hosted agent deployment. +/// +internal sealed class RequiresHostedAgentRegistryAnnotation : IResourceAnnotation +{ +} + /// /// Configuration for a Microsoft Foundry capability host. /// diff --git a/src/Aspire.Hosting.Foundry/PromptAgent/PromptAgentBuilderExtensions.cs b/src/Aspire.Hosting.Foundry/PromptAgent/PromptAgentBuilderExtensions.cs index 0c0dc39d4ca..0ed090630ba 100644 --- a/src/Aspire.Hosting.Foundry/PromptAgent/PromptAgentBuilderExtensions.cs +++ b/src/Aspire.Hosting.Foundry/PromptAgent/PromptAgentBuilderExtensions.cs @@ -70,6 +70,7 @@ public static IResourceBuilder AddPromptAgent( var agent = new AzurePromptAgentResource(name, model.Resource.DeploymentName, project.Resource, instructions); var agentBuilder = project.ApplicationBuilder.AddResource(agent) + .WithIconName("Agents") .WithReferenceRelationship(project) .WithReference(project); @@ -103,7 +104,7 @@ public static IResourceBuilder AddPromptAgent( }, commandOptions: new() { - IconName = "Agents", + IconName = "ChatSparkle", IconVariant = IconVariant.Regular, IsHighlighted = true, Arguments = diff --git a/src/Aspire.Hosting.Foundry/README.md b/src/Aspire.Hosting.Foundry/README.md index bfe758c1b71..bb0a19056f5 100644 --- a/src/Aspire.Hosting.Foundry/README.md +++ b/src/Aspire.Hosting.Foundry/README.md @@ -163,7 +163,7 @@ var foundry = builder.AddFoundry("foundry"); var project = foundry.AddProject("my-project"); builder.AddPythonApp("agent", "./app", "main:app") - .WithComputeEnvironment(project); + .AsHostedAgent(project); ``` In run mode, the agent runs locally with health check endpoints and OpenTelemetry instrumentation. In publish mode, the agent is deployed as a hosted agent in Microsoft Foundry. diff --git a/src/Aspire.Hosting.Foundry/api/Aspire.Hosting.Foundry.ats.txt b/src/Aspire.Hosting.Foundry/api/Aspire.Hosting.Foundry.ats.txt index 2176f9296d9..5120af20ebf 100644 --- a/src/Aspire.Hosting.Foundry/api/Aspire.Hosting.Foundry.ats.txt +++ b/src/Aspire.Hosting.Foundry/api/Aspire.Hosting.Foundry.ats.txt @@ -27,6 +27,12 @@ Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.FoundryModel # Describes a model t Format: string # The format or provider of the model (e.g., OpenAI, Microsoft, xAi, Deepseek). Name: string # The name of the model. Version: string # The version of the model. +Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.HostedAgentOptions # Options that control how a compute resource is deployed as a Microsoft Foundry hosted agent. All properties are optional; unset properties fall back to the Foundry hosted agent defaults. + Cpu?: number # CPU allocation for each hosted agent instance, in vCPU cores. Must be between 0.5 and 3.5 in increments of 0.25. When not set, the hosted agent default CPU allocation is used. + Description: string # Human-readable description of the hosted agent surfaced in the Microsoft Foundry portal. When not set, the hosted agent default description is used. + EnvironmentVariables?: Aspire.Hosting/Dict # Environment variables to set on the hosted agent container at runtime. Entries with the same key as an existing environment variable overwrite it. + Memory?: number # Memory allocation for each hosted agent instance, in GiB. Must be between 1 and 7 in increments of 0.5 and equal to twice the CPU value. When not set, the hosted agent default memory allocation is used. + Metadata?: Aspire.Hosting/Dict # Additional metadata key/value pairs to attach to the hosted agent definition. Entries with the same key as an existing metadata entry overwrite it. # Enum Types enum:Aspire.Hosting.FoundryRole = CognitiveServicesOpenAIContributor | CognitiveServicesOpenAIUser | CognitiveServicesUser @@ -262,6 +268,7 @@ Aspire.Hosting.Foundry/addSearchConnection(search: Aspire.Hosting.Azure.Search/A Aspire.Hosting.Foundry/addSharePointTool(name: string, projectConnectionIds: string[]) -> Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.SharePointToolResource Aspire.Hosting.Foundry/addStorageConnection(storage: Aspire.Hosting.Azure.Storage/Aspire.Hosting.Azure.AzureStorageResource) -> Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.AzureCognitiveServicesProjectConnectionResource Aspire.Hosting.Foundry/addWebSearchTool(name: string) -> Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.WebSearchToolResource +Aspire.Hosting.Foundry/asHostedAgentExecutable(project: Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.AzureCognitiveServicesProjectResource, options?: Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.HostedAgentOptions) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithEndpoints Aspire.Hosting.Foundry/AzurePromptAgentResource.connectionStringExpression(context: Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.AzurePromptAgentResource) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.ReferenceExpression Aspire.Hosting.Foundry/AzurePromptAgentResource.description(context: Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.AzurePromptAgentResource) -> string Aspire.Hosting.Foundry/AzurePromptAgentResource.instructions(context: Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.AzurePromptAgentResource) -> string @@ -297,7 +304,6 @@ Aspire.Hosting.Foundry/runAsFoundryLocal() -> Aspire.Hosting.Foundry/Aspire.Host Aspire.Hosting.Foundry/withAppInsights(appInsights: Aspire.Hosting.Azure.ApplicationInsights/Aspire.Hosting.Azure.AzureApplicationInsightsResource) -> Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.AzureCognitiveServicesProjectResource Aspire.Hosting.Foundry/withBingReference(bingReference: Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.BingGroundingConnectionResource|string|Aspire.Hosting/Aspire.Hosting.ApplicationModel.ParameterResource) -> Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.BingGroundingToolResource Aspire.Hosting.Foundry/withCapabilityHost(resource: Aspire.Hosting.Azure.CosmosDB/Aspire.Hosting.AzureCosmosDBResource|Aspire.Hosting.Azure.Storage/Aspire.Hosting.Azure.AzureStorageResource|Aspire.Hosting.Azure.Search/Aspire.Hosting.Azure.AzureSearchResource|Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.FoundryResource) -> Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.AzureCognitiveServicesProjectResource -Aspire.Hosting.Foundry/withComputeEnvironmentExecutable(project?: Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.AzureCognitiveServicesProjectResource, configure?: callback) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithEndpoints Aspire.Hosting.Foundry/withFoundryDeploymentProperties(configure: callback) -> Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.FoundryDeploymentResource Aspire.Hosting.Foundry/withFoundryRoleAssignments(target: Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.FoundryResource, roles: enum:Aspire.Hosting.FoundryRole[]) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResource Aspire.Hosting.Foundry/withKeyVault(keyVault: Aspire.Hosting.Azure.KeyVault/Aspire.Hosting.Azure.AzureKeyVaultResource) -> Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.AzureCognitiveServicesProjectResource diff --git a/src/Aspire.Hosting.Foundry/api/Aspire.Hosting.Foundry.cs b/src/Aspire.Hosting.Foundry/api/Aspire.Hosting.Foundry.cs index 750fde8134a..430e6376e72 100644 --- a/src/Aspire.Hosting.Foundry/api/Aspire.Hosting.Foundry.cs +++ b/src/Aspire.Hosting.Foundry/api/Aspire.Hosting.Foundry.cs @@ -10,31 +10,31 @@ namespace Aspire.Hosting { public static partial class AzureCognitiveServicesProjectConnectionsBuilderExtensions { - [AspireExport("addBingGroundingConnectionFromParameter", Description = "Adds a Grounding with Bing Search connection to a Microsoft Foundry project using a parameter.")] + [AspireExport("addBingGroundingConnectionFromParameter")] public static ApplicationModel.IResourceBuilder AddBingGroundingConnection(this ApplicationModel.IResourceBuilder builder, string name, ApplicationModel.IResourceBuilder bingResourceId) { throw null; } - [AspireExport(Description = "Adds a Grounding with Bing Search connection to a Microsoft Foundry project.")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddBingGroundingConnection(this ApplicationModel.IResourceBuilder builder, string name, string bingResourceId) { throw null; } - [AspireExport("addContainerRegistryConnection", Description = "Adds an Azure Container Registry connection to a Microsoft Foundry project.")] + [AspireExport("addContainerRegistryConnection")] public static ApplicationModel.IResourceBuilder AddConnection(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder registry) { throw null; } - [AspireExport("addKeyVaultConnection", Description = "Adds an Azure Key Vault connection to a Microsoft Foundry project.")] + [AspireExport("addKeyVaultConnection")] public static ApplicationModel.IResourceBuilder AddConnection(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder keyVault) { throw null; } - [AspireExport("addSearchConnection", Description = "Adds an Azure AI Search connection to a Microsoft Foundry project.")] + [AspireExport("addSearchConnection")] public static ApplicationModel.IResourceBuilder AddConnection(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder search) { throw null; } - [AspireExport("addStorageConnection", Description = "Adds an Azure Storage connection to a Microsoft Foundry project.")] + [AspireExport("addStorageConnection")] public static ApplicationModel.IResourceBuilder AddConnection(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder storage) { throw null; } - [AspireExport("addCosmosConnection", Description = "Adds an Azure Cosmos DB connection to a Microsoft Foundry project.")] + [AspireExport("addCosmosConnection")] public static ApplicationModel.IResourceBuilder AddConnection(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder db) { throw null; } [AspireExportIgnore(Reason = "Raw AzureContainerRegistryResource parameters are not ATS-compatible. Use the resource-builder overload instead.")] public static ApplicationModel.IResourceBuilder AddConnection(this ApplicationModel.IResourceBuilder builder, Azure.AzureContainerRegistryResource registry) { throw null; } - [AspireExport("addSearchConnectionFromResource", Description = "Adds an Azure AI Search connection to a Microsoft Foundry project.")] + [AspireExportIgnore(Reason = "Raw AzureSearchResource parameters are not ATS-compatible. Use the resource-builder overload instead.")] public static ApplicationModel.IResourceBuilder AddConnection(this ApplicationModel.IResourceBuilder builder, Azure.AzureSearchResource search) { throw null; } [AspireExportIgnore(Reason = "Raw AzureStorageResource parameters are not ATS-compatible. Use the resource-builder overload instead.")] @@ -58,13 +58,13 @@ public static partial class AzureCognitiveServicesProjectExtensions [AspireExportIgnore(Reason = "Polyglot app hosts use the internal addModelDeployment dispatcher export.")] public static ApplicationModel.IResourceBuilder AddModelDeployment(this ApplicationModel.IResourceBuilder builder, string name, string modelName, string modelVersion, string format) { throw null; } - [AspireExport(Description = "Adds a Microsoft Foundry project resource to a Microsoft Foundry resource.")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddProject(this ApplicationModel.IResourceBuilder builder, string name) { throw null; } - [AspireExport(Description = "Associates an Azure Application Insights resource with a Microsoft Foundry project.")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithAppInsights(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder appInsights) { throw null; } - [AspireExport(Description = "Associates an Azure Key Vault resource with a Microsoft Foundry project.")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithKeyVault(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder keyVault) { throw null; } [AspireExportIgnore(Reason = "The standard WithReference export already covers this polyglot scenario.")] @@ -80,13 +80,13 @@ public static partial class FoundryExtensions [AspireExportIgnore(Reason = "Polyglot app hosts use the internal addDeployment dispatcher export.")] public static ApplicationModel.IResourceBuilder AddDeployment(this ApplicationModel.IResourceBuilder builder, string name, string modelName, string modelVersion, string format) { throw null; } - [AspireExport(Description = "Adds a Microsoft Foundry resource to the distributed application model.")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddFoundry(this IDistributedApplicationBuilder builder, string name) { throw null; } - [AspireExport(Description = "Configures the Microsoft Foundry resource to run by using Foundry Local.")] + [AspireExport] public static ApplicationModel.IResourceBuilder RunAsFoundryLocal(this ApplicationModel.IResourceBuilder builder) { throw null; } - [AspireExport("withFoundryDeploymentProperties", MethodName = "withProperties", Description = "Configures properties of a Microsoft Foundry deployment resource.", RunSyncOnBackgroundThread = true)] + [AspireExport("withFoundryDeploymentProperties", MethodName = "withProperties", RunSyncOnBackgroundThread = true)] public static ApplicationModel.IResourceBuilder WithProperties(this ApplicationModel.IResourceBuilder builder, System.Action configure) { throw null; } [AspireExportIgnore(Reason = "CognitiveServicesBuiltInRole is an Azure.Provisioning type not compatible with ATS. Use the FoundryRole-based overload instead.")] @@ -96,60 +96,64 @@ public static ApplicationModel.IResourceBuilder WithRoleAssignments(this A public static partial class HostedAgentResourceBuilderExtensions { - [AspireExport("withComputeEnvironmentExecutable", MethodName = "withComputeEnvironment")] - public static ApplicationModel.IResourceBuilder WithComputeEnvironment(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder? project = null, System.Action? configure = null) + [AspireExportIgnore(Reason = "Action callback shape is awkward for polyglot hosts; the HostedAgentOptions overload is exported instead.")] + public static ApplicationModel.IResourceBuilder AsHostedAgent(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder? project, System.Action? configure = null) where T : ApplicationModel.IResourceWithEndpoints, ApplicationModel.IResourceWithEnvironment, ApplicationModel.IComputeResource { throw null; } - [AspireExportIgnore(Reason = "Subset of the full WithComputeEnvironment overload which is exported.")] - public static ApplicationModel.IResourceBuilder WithComputeEnvironment(this ApplicationModel.IResourceBuilder builder, System.Action configure) + [AspireExportIgnore(Reason = "Subset of the full AsHostedAgent overload.")] + public static ApplicationModel.IResourceBuilder AsHostedAgent(this ApplicationModel.IResourceBuilder builder, System.Action configure) + where T : ApplicationModel.IResourceWithEndpoints, ApplicationModel.IResourceWithEnvironment, ApplicationModel.IComputeResource { throw null; } + + [AspireExportIgnore(Reason = "Subset of the full AsHostedAgent(project) overload which is exported.")] + public static ApplicationModel.IResourceBuilder AsHostedAgent(this ApplicationModel.IResourceBuilder builder) where T : ApplicationModel.IResourceWithEndpoints, ApplicationModel.IResourceWithEnvironment, ApplicationModel.IComputeResource { throw null; } } public static partial class PromptAgentBuilderExtensions { - [AspireExport(Description = "Adds an Azure AI Search tool to a Microsoft Foundry project.")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddAISearchTool(this ApplicationModel.IResourceBuilder project, string name, string? indexName = null) { throw null; } [AspireExportIgnore(Reason = "BinaryData parameter is not ATS-compatible. Use the string overload instead.")] public static ApplicationModel.IResourceBuilder AddAzureFunctionTool(this ApplicationModel.IResourceBuilder project, string name, string functionName, string description, System.BinaryData parameters, string inputQueueEndpoint, string inputQueueName, string outputQueueEndpoint, string outputQueueName) { throw null; } - [AspireExport(Description = "Adds an Azure Function tool to a Microsoft Foundry project.")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddAzureFunctionTool(this ApplicationModel.IResourceBuilder project, string name, string functionName, string description, string parametersJson, string inputQueueEndpoint, string inputQueueName, string outputQueueEndpoint, string outputQueueName) { throw null; } - [AspireExport(Description = "Adds a Bing Grounding tool to a Microsoft Foundry project.")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddBingGroundingTool(this ApplicationModel.IResourceBuilder project, string name) { throw null; } - [AspireExport(Description = "Adds a Code Interpreter tool to a Microsoft Foundry project.")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddCodeInterpreterTool(this ApplicationModel.IResourceBuilder project, string name) { throw null; } - [AspireExport(Description = "Adds a Computer Use tool to a Microsoft Foundry project.")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddComputerUseTool(this ApplicationModel.IResourceBuilder project, string name, int displayWidth = 1024, int displayHeight = 768, string environment = "browser") { throw null; } - [AspireExport(Description = "Adds a Microsoft Fabric data agent tool to a Microsoft Foundry project.")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddFabricTool(this ApplicationModel.IResourceBuilder project, string name, params string[] projectConnectionIds) { throw null; } - [AspireExport(Description = "Adds a File Search tool to a Microsoft Foundry project.")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddFileSearchTool(this ApplicationModel.IResourceBuilder project, string name, params string[] vectorStoreIds) { throw null; } [AspireExportIgnore(Reason = "BinaryData parameter is not ATS-compatible. Use the string overload instead.")] public static ApplicationModel.IResourceBuilder AddFunctionTool(this ApplicationModel.IResourceBuilder project, string name, string functionName, System.BinaryData parameters, string? description = null, bool? strictModeEnabled = null) { throw null; } - [AspireExport(Description = "Adds an Image Generation tool to a Microsoft Foundry project.")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddImageGenerationTool(this ApplicationModel.IResourceBuilder project, string name) { throw null; } - [AspireExport(Description = "Adds a prompt agent to a Microsoft Foundry project.")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddPromptAgent(this ApplicationModel.IResourceBuilder project, string name, ApplicationModel.IResourceBuilder model, string? instructions = null) { throw null; } - [AspireExport(Description = "Adds a SharePoint grounding tool to a Microsoft Foundry project.")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddSharePointTool(this ApplicationModel.IResourceBuilder project, string name, params string[] projectConnectionIds) { throw null; } - [AspireExport(Description = "Adds a Web Search tool to a Microsoft Foundry project.")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddWebSearchTool(this ApplicationModel.IResourceBuilder project, string name) { throw null; } [AspireExportIgnore(Reason = "IFoundryTool is not ATS-compatible.")] public static ApplicationModel.IResourceBuilder WithCustomTool(this ApplicationModel.IResourceBuilder builder, Foundry.IFoundryTool tool) { throw null; } - [AspireExport(Description = "Links an Azure AI Search tool to a backing search resource.")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithReference(this ApplicationModel.IResourceBuilder tool, ApplicationModel.IResourceBuilder search) { throw null; } [AspireExportIgnore(Reason = "Covered by the internal AspireUnion overload.")] @@ -161,7 +165,7 @@ public static partial class PromptAgentBuilderExtensions [AspireExportIgnore(Reason = "Covered by the internal AspireUnion overload.")] public static ApplicationModel.IResourceBuilder WithReference(this ApplicationModel.IResourceBuilder tool, string bingResourceId) { throw null; } - [AspireExport(Description = "Adds a tool to a prompt agent.")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithTool(this ApplicationModel.IResourceBuilder agent, ApplicationModel.IResourceBuilder tool) { throw null; } } } @@ -435,14 +439,6 @@ public partial class FoundryModel public required string Version { get { throw null; } init { } } - public static partial class AI21Labs - { - [AspireValue("FoundryModels")] - public static readonly FoundryModel AI21Jamba15Large; - [AspireValue("FoundryModels")] - public static readonly FoundryModel AI21Jamba15Mini; - } - public static partial class Anthropic { [AspireValue("FoundryModels")] @@ -480,12 +476,8 @@ public static partial class Cohere [AspireValue("FoundryModels")] public static readonly FoundryModel CohereCommandA; [AspireValue("FoundryModels")] - public static readonly FoundryModel CohereCommandR; - [AspireValue("FoundryModels")] public static readonly FoundryModel CohereCommandR082024; [AspireValue("FoundryModels")] - public static readonly FoundryModel CohereCommandRPlus; - [AspireValue("FoundryModels")] public static readonly FoundryModel CohereCommandRPlus082024; [AspireValue("FoundryModels")] public static readonly FoundryModel CohereEmbedV3English; @@ -517,11 +509,13 @@ public static partial class DeepSeek [AspireValue("FoundryModels")] public static readonly FoundryModel DeepSeekV30324; [AspireValue("FoundryModels")] - public static readonly FoundryModel DeepSeekV31; - [AspireValue("FoundryModels")] public static readonly FoundryModel DeepSeekV32; [AspireValue("FoundryModels")] public static readonly FoundryModel DeepSeekV32Speciale; + [AspireValue("FoundryModels")] + public static readonly FoundryModel DeepSeekV4Flash; + [AspireValue("FoundryModels")] + public static readonly FoundryModel DeepSeekV4Pro; } public static partial class Local @@ -537,8 +531,14 @@ public static partial class Local [AspireValue("FoundryModels")] public static readonly FoundryModel Mistral7bV02; [AspireValue("FoundryModels")] + public static readonly FoundryModel MistralNemo12bInstruct; + [AspireValue("FoundryModels")] public static readonly FoundryModel NemotronSpeechStreamingEn06b; [AspireValue("FoundryModels")] + public static readonly FoundryModel NemotronSpeechStreamingEs06b; + [AspireValue("FoundryModels")] + public static readonly FoundryModel Olmo37bInstruct; + [AspireValue("FoundryModels")] public static readonly FoundryModel Phi35Mini; [AspireValue("FoundryModels")] public static readonly FoundryModel Phi3Mini128k; @@ -582,6 +582,8 @@ public static partial class Local [AspireValue("FoundryModels")] public static readonly FoundryModel Qwen352b; [AspireValue("FoundryModels")] + public static readonly FoundryModel Qwen352bText; + [AspireValue("FoundryModels")] public static readonly FoundryModel Qwen354b; [AspireValue("FoundryModels")] public static readonly FoundryModel Qwen359b; @@ -597,6 +599,8 @@ public static partial class Local public static readonly FoundryModel Qwen3Vl4bInstruct; [AspireValue("FoundryModels")] public static readonly FoundryModel Qwen3Vl8bInstruct; + [AspireValue("FoundryModels")] + public static readonly FoundryModel Smollm33b; } public static partial class Meta @@ -606,21 +610,13 @@ public static partial class Meta [AspireValue("FoundryModels")] public static readonly FoundryModel Llama3290BVisionInstruct; [AspireValue("FoundryModels")] - public static readonly FoundryModel Llama3370BInstruct; - [AspireValue("FoundryModels")] public static readonly FoundryModel Llama4Maverick17B128EInstructFP8; [AspireValue("FoundryModels")] public static readonly FoundryModel Llama4Scout17B16EInstruct; [AspireValue("FoundryModels")] public static readonly FoundryModel MetaLlama31405BInstruct; [AspireValue("FoundryModels")] - public static readonly FoundryModel MetaLlama3170BInstruct; - [AspireValue("FoundryModels")] public static readonly FoundryModel MetaLlama318BInstruct; - [AspireValue("FoundryModels")] - public static readonly FoundryModel MetaLlama370BInstruct; - [AspireValue("FoundryModels")] - public static readonly FoundryModel MetaLlama38BInstruct; } public static partial class Microsoft @@ -646,8 +642,12 @@ public static partial class Microsoft [AspireValue("FoundryModels")] public static readonly FoundryModel AzureLanguageConversationalPiiRedaction; [AspireValue("FoundryModels")] + public static readonly FoundryModel AzureLanguageDocumentPiiRedaction; + [AspireValue("FoundryModels")] public static readonly FoundryModel AzureLanguageLanguageDetection; [AspireValue("FoundryModels")] + public static readonly FoundryModel AzureLanguageTextAnalyticsForHealth; + [AspireValue("FoundryModels")] public static readonly FoundryModel AzureLanguageTextPiiRedaction; [AspireValue("FoundryModels")] public static readonly FoundryModel AzureSpeechSpeechToText; @@ -693,6 +693,8 @@ public static partial class Microsoft [AspireValue("FoundryModels")] public static readonly FoundryModel Phi3Small8kInstruct; [AspireValue("FoundryModels")] + public static readonly FoundryModel Phi3Vision128kInstruct; + [AspireValue("FoundryModels")] public static readonly FoundryModel Phi4; [AspireValue("FoundryModels")] public static readonly FoundryModel Phi4MiniInstruct; @@ -718,18 +720,10 @@ public static partial class MistralAI [AspireValue("FoundryModels")] public static readonly FoundryModel MistralDocumentAi2512; [AspireValue("FoundryModels")] - public static readonly FoundryModel MistralLarge2407; - [AspireValue("FoundryModels")] - public static readonly FoundryModel MistralLarge2411; - [AspireValue("FoundryModels")] public static readonly FoundryModel MistralLarge3; [AspireValue("FoundryModels")] public static readonly FoundryModel MistralMedium2505; [AspireValue("FoundryModels")] - public static readonly FoundryModel MistralNemo; - [AspireValue("FoundryModels")] - public static readonly FoundryModel MistralSmall; - [AspireValue("FoundryModels")] public static readonly FoundryModel MistralSmall2503; } @@ -830,6 +824,8 @@ public static partial class OpenAI [AspireValue("FoundryModels")] public static readonly FoundryModel GptAudioMini; [AspireValue("FoundryModels")] + public static readonly FoundryModel GptChatLatest; + [AspireValue("FoundryModels")] public static readonly FoundryModel GptImage1; [AspireValue("FoundryModels")] public static readonly FoundryModel GptImage15; @@ -840,14 +836,18 @@ public static partial class OpenAI [AspireValue("FoundryModels")] public static readonly FoundryModel GptOss120b; [AspireValue("FoundryModels")] - public static readonly FoundryModel GptOss20b; - [AspireValue("FoundryModels")] public static readonly FoundryModel GptRealtime; [AspireValue("FoundryModels")] public static readonly FoundryModel GptRealtime15; [AspireValue("FoundryModels")] + public static readonly FoundryModel GptRealtime2; + [AspireValue("FoundryModels")] public static readonly FoundryModel GptRealtimeMini; [AspireValue("FoundryModels")] + public static readonly FoundryModel GptRealtimeTranslate; + [AspireValue("FoundryModels")] + public static readonly FoundryModel GptRealtimeWhisper; + [AspireValue("FoundryModels")] public static readonly FoundryModel O1; [AspireValue("FoundryModels")] public static readonly FoundryModel O1Mini; @@ -891,10 +891,6 @@ public static partial class StabilityAI public static partial class XAI { - [AspireValue("FoundryModels")] - public static readonly FoundryModel Grok3; - [AspireValue("FoundryModels")] - public static readonly FoundryModel Grok3Mini; [AspireValue("FoundryModels")] public static readonly FoundryModel Grok4; [AspireValue("FoundryModels")] @@ -906,6 +902,8 @@ public static partial class XAI [AspireValue("FoundryModels")] public static readonly FoundryModel Grok420Reasoning; [AspireValue("FoundryModels")] + public static readonly FoundryModel Grok43; + [AspireValue("FoundryModels")] public static readonly FoundryModel Grok4FastNonReasoning; [AspireValue("FoundryModels")] public static readonly FoundryModel Grok4FastReasoning; @@ -1007,8 +1005,6 @@ public HostedAgentConfiguration(string image) { } [AspireExportIgnore(Reason = "Azure SDK-specific type not usable from polyglot hosts.")] public System.Collections.Generic.IList Tools { get { throw null; } init { } } - - public global::Azure.AI.Projects.Agents.ProjectsAgentVersionCreationOptions ToProjectsAgentVersionCreationOptions() { throw null; } } public partial interface IFoundryTool diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/FoundryHostedAgentDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/FoundryHostedAgentDeploymentTests.cs index 99f58098d1f..a961dda924b 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/FoundryHostedAgentDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/FoundryHostedAgentDeploymentTests.cs @@ -210,7 +210,7 @@ private async Task DeployFoundryHostedAgentToAzureCore(CancellationToken cancell builder.AddProject("dotnet-hosted-agent") .WithReference(chat).WaitFor(chat) - .WithComputeEnvironment(foundryProject); + .AsHostedAgent(foundryProject); builder.Build().Run(); """); diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs index ac9bb7c738e..ae26f6a456e 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs @@ -1284,7 +1284,7 @@ public async Task DeployAsync_WithFoundryAndAzureContainerApps_CreatesCorrectDep var acaEnv = builder.AddAzureContainerAppEnvironment("aca-env"); builder.AddProject("agent", launchProfileName: null) - .WithComputeEnvironment(foundryProject); + .AsHostedAgent(foundryProject); builder.AddProject("api", launchProfileName: null) .WithExternalHttpEndpoints() diff --git a/tests/Aspire.Hosting.Azure.Tests/FoundryExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/FoundryExtensionsTests.cs index 04e464d7bab..60af07d3359 100644 --- a/tests/Aspire.Hosting.Azure.Tests/FoundryExtensionsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/FoundryExtensionsTests.cs @@ -195,17 +195,16 @@ public void AddProject_SetsParentFoundryForProvisioningOrdering() } [Fact] - public void AddProject_AddsDefaultContainerRegistryInRunMode() + public void AddProject_DoesNotAddDefaultContainerRegistryInRunMode() { using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); var project = builder.AddFoundry("myAIFoundry") .AddProject("my-project"); - var registry = Assert.Single(builder.Resources.OfType()); - - Assert.Equal("my-project-acr", registry.Name); - Assert.Same(registry, project.Resource.ContainerRegistry); + Assert.DoesNotContain(builder.Resources, r => r.Name == "my-project-acr"); + Assert.Empty(builder.Resources.OfType()); + Assert.Null(project.Resource.ContainerRegistry); } [Fact] @@ -295,7 +294,7 @@ public async Task WithComputeEnvironment_ResolvesExternalContainerAppReference() var advisorAgent = builder.AddProject("advisoragent", launchProfileName: null) .WithReference(weatherAgent) .WaitFor(weatherAgent) - .WithComputeEnvironment(project); + .AsHostedAgent(project); using var app = builder.Build(); await AzureManifestUtils.ExecuteBeforeStartHooksAsync(app, default); @@ -326,7 +325,7 @@ public async Task WithComputeEnvironment_DoesNotSetReservedFoundryProjectEndpoin .AddProject("my-project"); var advisorAgent = builder.AddProject("advisor-agent", launchProfileName: null) - .WithComputeEnvironment(project); + .AsHostedAgent(project); using var app = builder.Build(); await AzureManifestUtils.ExecuteBeforeStartHooksAsync(app, default); @@ -363,7 +362,7 @@ public async Task WithComputeEnvironment_ResolvesReferenceExpressionEnvironmentV { context.EnvironmentVariables["WEATHER_HEALTH_URL"] = ReferenceExpression.Create($"{weatherAgent.GetEndpoint("http")}/health"); }) - .WithComputeEnvironment(project); + .AsHostedAgent(project); using var app = builder.Build(); await AzureManifestUtils.ExecuteBeforeStartHooksAsync(app, default); @@ -403,7 +402,7 @@ public async Task WithComputeEnvironment_ResolvesEndpointReferenceExpressionEnvi { context.EnvironmentVariables["WEATHER_HOST_AND_PORT"] = weatherAgent.GetEndpoint("http").Property(EndpointProperty.HostAndPort); }) - .WithComputeEnvironment(project); + .AsHostedAgent(project); using var app = builder.Build(); await AzureManifestUtils.ExecuteBeforeStartHooksAsync(app, default); @@ -442,7 +441,7 @@ public async Task WithComputeEnvironment_ThrowsForInternalContainerAppReference( .WithReference(weatherAgent) .WaitFor(weatherAgent); - advisorAgent.WithComputeEnvironment(project); + advisorAgent.AsHostedAgent(project); using var app = builder.Build(); await AzureManifestUtils.ExecuteBeforeStartHooksAsync(app, default); @@ -469,4 +468,3 @@ private sealed class Project : IProjectMetadata } } - diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/FoundryExtensionsTests.AddProject_GeneratesEndpointFromParentFoundryApiEndpoint.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/FoundryExtensionsTests.AddProject_GeneratesEndpointFromParentFoundryApiEndpoint.verified.bicep index 355edccedb0..52fd0a673d9 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/FoundryExtensionsTests.AddProject_GeneratesEndpointFromParentFoundryApiEndpoint.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/FoundryExtensionsTests.AddProject_GeneratesEndpointFromParentFoundryApiEndpoint.verified.bicep @@ -7,8 +7,6 @@ param userPrincipalId string = '' param foundry_outputs_name string -param project_acr_outputs_name string - resource foundry 'Microsoft.CognitiveServices/accounts@2025-09-01' existing = { name: foundry_outputs_name } @@ -28,20 +26,6 @@ resource project 'Microsoft.CognitiveServices/accounts/projects@2025-09-01' = { parent: foundry } -resource project_acr 'Microsoft.ContainerRegistry/registries@2025-04-01' existing = { - name: project_acr_outputs_name -} - -resource project_acr_AcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(project_acr.id, project.id, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')) - properties: { - principalId: project.identity.principalId - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') - principalType: 'ServicePrincipal' - } - scope: project_acr -} - resource project_ai 'Microsoft.Insights/components@2020-02-02' = { name: 'project-ai' kind: 'web' @@ -89,10 +73,4 @@ output endpoint string = project.properties.endpoints['AI Foundry API'] output principalId string = project.identity.principalId -output AZURE_CONTAINER_REGISTRY_ENDPOINT string = project_acr.properties.loginServer - -output AZURE_CONTAINER_REGISTRY_NAME string = project_acr_outputs_name - -output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = project.identity.principalId - output APPLICATION_INSIGHTS_CONNECTION_STRING string = project_ai.properties.ConnectionString \ 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 1fdee732a1a..d298defd34e 100644 --- a/tests/Aspire.Hosting.Foundry.Tests/HostedAgentExtensionTests.cs +++ b/tests/Aspire.Hosting.Foundry.Tests/HostedAgentExtensionTests.cs @@ -3,6 +3,7 @@ #pragma warning disable ASPIRECOMPUTE003 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. +using System.Runtime.CompilerServices; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Utils; using Microsoft.Extensions.DependencyInjection; @@ -12,14 +13,12 @@ namespace Aspire.Hosting.Foundry.Tests; public class HostedAgentExtensionTests { [Fact] - public void WithComputeEnvironment_InRunMode_AddsHttpEndpoint() + public void AsHostedAgent_InRunMode_AddsHttpEndpoint() { using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); - var project = builder.AddFoundry("account") - .AddProject("my-project"); var app = builder.AddPythonApp("agent", "./app.py", "main:app") - .WithComputeEnvironment(project); + .AsHostedAgent(); builder.Build(); @@ -29,15 +28,13 @@ public void WithComputeEnvironment_InRunMode_AddsHttpEndpoint() } [Fact] - public void WithComputeEnvironment_InRunMode_PreservesExistingHttpEndpointTargetPort() + public void AsHostedAgent_InRunMode_PreservesExistingHttpEndpointTargetPort() { using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); - var project = builder.AddFoundry("account") - .AddProject("my-project"); var app = builder.AddPythonApp("agent", "./app.py", "main:app") .WithHttpEndpoint(targetPort: 5000) - .WithComputeEnvironment(project); + .AsHostedAgent(); builder.Build(); @@ -49,14 +46,12 @@ public void WithComputeEnvironment_InRunMode_PreservesExistingHttpEndpointTarget } [Fact] - public void WithComputeEnvironment_InRunMode_DoesNotHardCodePort() + public void AsHostedAgent_InRunMode_DoesNotHardCodePort() { using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); - var project = builder.AddFoundry("account") - .AddProject("my-project"); var app = builder.AddPythonApp("agent", "./app.py", "main:app") - .WithComputeEnvironment(project); + .AsHostedAgent(); builder.Build(); @@ -66,25 +61,25 @@ public void WithComputeEnvironment_InRunMode_DoesNotHardCodePort() } [Fact] - public void WithComputeEnvironment_InRunMode_ConfiguresHealthCheck() + public void AsHostedAgent_InRunMode_ConfiguresSendMessageCommand() { using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); - var project = builder.AddFoundry("account") - .AddProject("my-project"); builder.AddPythonApp("agent", "./app.py", "main:app") - .WithComputeEnvironment(project); + .AsHostedAgent(); builder.Build(); - // The resource should have a health check annotation from WithHttpHealthCheck var resource = builder.Resources.Single(r => r.Name == "agent"); - var healthAnnotation = resource.Annotations.OfType().FirstOrDefault(); - Assert.NotNull(healthAnnotation); + var command = Assert.Single(resource.Annotations.OfType()); + Assert.Equal("Send Message", command.DisplayName); + Assert.Equal("ChatSparkle", command.IconName); + Assert.Equal(IconVariant.Regular, command.IconVariant); + Assert.True(command.IsHighlighted); } [Fact] - public void WithComputeEnvironment_InPublishMode_DoesNotValidateRegion() + public void AsHostedAgent_InPublishMode_DoesNotValidateRegion() { using var builder = TestDistributedApplicationBuilder.Create( DistributedApplicationOperation.Publish); @@ -95,13 +90,13 @@ public void WithComputeEnvironment_InPublishMode_DoesNotValidateRegion() .AddProject("my-project"); var app = builder.AddPythonApp("agent", "./app.py", "main:app") - .WithComputeEnvironment(project); + .AsHostedAgent(project); Assert.NotNull(app); } [Fact] - public void WithComputeEnvironment_InPublishMode_AcceptsValidRegion() + public void AsHostedAgent_InPublishMode_AcceptsValidRegion() { using var builder = TestDistributedApplicationBuilder.Create( DistributedApplicationOperation.Publish); @@ -112,33 +107,33 @@ public void WithComputeEnvironment_InPublishMode_AcceptsValidRegion() .AddProject("my-project"); var app = builder.AddPythonApp("agent", "./app.py", "main:app") - .WithComputeEnvironment(project); + .AsHostedAgent(project); Assert.NotNull(app); } [Fact] - public void WithComputeEnvironment_NoRegionConfig_DoesNotThrow() + public void AsHostedAgent_NoRegionConfig_DoesNotThrow() { using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); var project = builder.AddFoundry("account") .AddProject("my-project"); var app = builder.AddPythonApp("agent", "./app.py", "main:app") - .WithComputeEnvironment(project); + .AsHostedAgent(project); Assert.NotNull(app); } [Fact] - public void WithComputeEnvironment_InPublishMode_CreatesHostedAgentResource() + public void AsHostedAgent_InPublishMode_CreatesHostedAgentResource() { using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); var project = builder.AddFoundry("account") .AddProject("my-project"); builder.AddPythonApp("agent", "./app.py", "main:app") - .WithComputeEnvironment(project); + .AsHostedAgent(project); builder.Build(); @@ -148,12 +143,70 @@ public void WithComputeEnvironment_InPublishMode_CreatesHostedAgentResource() } [Fact] - public void WithComputeEnvironment_WithoutProject_CreatesDefaultProject() + public void AsHostedAgent_WithOptions_AppliesAllPropertiesToConfiguration() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + var project = builder.AddFoundry("account") + .AddProject("my-project"); + + var options = new HostedAgentOptions + { + Description = "test description", + Cpu = 1m, + Memory = 2m, + Metadata = { ["scenario"] = "unit-test" }, + EnvironmentVariables = { ["MY_VAR"] = "my-value" } + }; + + builder.AddPythonApp("agent", "./app.py", "main:app") + .AsHostedAgentForExport(project, options); + + builder.Build(); + + var hostedAgent = Assert.Single(builder.Resources.OfType()); + + var configuration = new HostedAgentConfiguration("test-image"); + hostedAgent.Configure!(configuration); + + Assert.Equal("test description", configuration.Description); + Assert.Equal(1m, configuration.Cpu); + Assert.Equal(2m, configuration.Memory); + Assert.Equal("unit-test", configuration.Metadata["scenario"]); + Assert.Equal("my-value", configuration.EnvironmentVariables["MY_VAR"]); + } + + [Fact] + public void AsHostedAgent_WithNullOptions_DoesNotSetConfigureCallback() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + var project = builder.AddFoundry("account") + .AddProject("my-project"); + + builder.AddPythonApp("agent", "./app.py", "main:app") + .AsHostedAgentForExport(project, options: null); + + builder.Build(); + + var hostedAgent = Assert.Single(builder.Resources.OfType()); + Assert.Null(hostedAgent.Configure); + } + + [Fact] + public void AsHostedAgent_WithNullProject_Throws() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + var app = builder.AddPythonApp("agent", "./app.py", "main:app"); + + Assert.Throws(() => app.AsHostedAgentForExport(project: null!)); + } + + [Fact] + public void AsHostedAgent_WithoutProject_CreatesDefaultProject() { using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); builder.AddPythonApp("agent", "./app.py", "main:app") - .WithComputeEnvironment(); + .AsHostedAgent(); builder.Build(); @@ -161,6 +214,52 @@ public void WithComputeEnvironment_WithoutProject_CreatesDefaultProject() Assert.NotNull(project); } + [Fact] + public void AsHostedAgent_InRunMode_WithProject_AddsProjectDependency() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + var project = builder.AddFoundry("account") + .AddProject("my-project"); + + var app = builder.AddPythonApp("agent", "./app.py", "main:app") + .AsHostedAgent(project); + + builder.Build(); + + Assert.Contains(app.Resource.Annotations.OfType(), w => ReferenceEquals(w.Resource, project.Resource)); + } + + [Fact] + public void AsHostedAgent_InRunMode_WithProject_DoesNotCreateDefaultContainerRegistryResource() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + var project = builder.AddFoundry("account") + .AddProject("my-project"); + + builder.AddPythonApp("agent", "./app.py", "main:app") + .AsHostedAgent(project); + + builder.Build(); + + Assert.Null(project.Resource.DefaultContainerRegistry); + Assert.DoesNotContain(builder.Resources, r => r.Name == "my-project-acr"); + } + + [Fact] + public async Task AsHostedAgent_InRunMode_WithProject_ExecutesBeforeStartHooksWithoutContainerRegistry() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + var project = builder.AddFoundry("account") + .AddProject("my-project"); + + builder.AddPythonApp("agent", "./app.py", "main:app") + .AsHostedAgent(project); + + using var app = builder.Build(); + + await ExecuteBeforeStartHooksAsync(app, default); + } + [Fact] public async Task FoundryProject_DefaultRegistryDoesNotAddGlobalRegistryTargets() { @@ -180,4 +279,7 @@ public async Task FoundryProject_DefaultRegistryDoesNotAddGlobalRegistryTargets( var registryTarget = Assert.Single(registryTargets); Assert.Same(registry.Resource, registryTarget.Registry); } + + [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "ExecuteBeforeStartHooksAsync")] + private static extern Task ExecuteBeforeStartHooksAsync(DistributedApplication app, CancellationToken cancellationToken); } diff --git a/tests/Aspire.Hosting.Foundry.Tests/ProjectResourceTests.cs b/tests/Aspire.Hosting.Foundry.Tests/ProjectResourceTests.cs index 4f98d572b19..b4407274e54 100644 --- a/tests/Aspire.Hosting.Foundry.Tests/ProjectResourceTests.cs +++ b/tests/Aspire.Hosting.Foundry.Tests/ProjectResourceTests.cs @@ -37,17 +37,17 @@ public void AddProject_ReferencesDefaultContainerRegistryForProvisioningOrdering } [Fact] - public void AddProject_InRunMode_ModelsDefaultContainerRegistry() + public void AddProject_InRunMode_DoesNotCreateDefaultContainerRegistry() { using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); var project = builder.AddFoundry("account") .AddProject("my-project"); - var registry = Assert.Single(builder.Resources.OfType()); - Assert.Equal("my-project-acr", registry.Name); - Assert.Same(project.Resource.DefaultContainerRegistry, registry); - Assert.Same(project.Resource.DefaultContainerRegistry, project.Resource.ContainerRegistry); + Assert.DoesNotContain(builder.Resources, r => r.Name == "my-project-acr"); + Assert.Empty(builder.Resources.OfType()); + Assert.Null(project.Resource.DefaultContainerRegistry); + Assert.Null(project.Resource.ContainerRegistry); } [Fact] @@ -89,6 +89,27 @@ public async Task WithAzureContainerRegistry_RemovesDefaultContainerRegistryDuri Assert.DoesNotContain(defaultRegistry, registries); } + [Fact] + public async Task AddProject_WithoutHostedAgents_RemovesDefaultContainerRegistryDuringBeforeStart() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var project = builder.AddFoundry("account") + .AddProject("my-project"); + + var defaultRegistry = project.Resource.DefaultContainerRegistry; + Assert.NotNull(defaultRegistry); + Assert.Contains(defaultRegistry, builder.Resources); + + using var app = builder.Build(); + await ExecuteBeforeStartHooksAsync(app, default); + + var model = app.Services.GetRequiredService(); + var registries = model.Resources.OfType().ToList(); + Assert.DoesNotContain(defaultRegistry, registries); + Assert.Null(project.Resource.DefaultContainerRegistry); + } + [Fact] public void ConnectionStringExpression_HasCorrectFormat() { diff --git a/tests/Aspire.Hosting.Foundry.Tests/PromptAgentTests.cs b/tests/Aspire.Hosting.Foundry.Tests/PromptAgentTests.cs index cc1983bfdb1..d36a4593adc 100644 --- a/tests/Aspire.Hosting.Foundry.Tests/PromptAgentTests.cs +++ b/tests/Aspire.Hosting.Foundry.Tests/PromptAgentTests.cs @@ -55,6 +55,26 @@ public void AddPromptAgent_SetsProjectReference() Assert.Same(project.Resource, agent.Resource.Project); } + [Fact] + public void AddPromptAgent_ConfiguresSendMessageCommand() + { + using var builder = TestDistributedApplicationBuilder.Create(); + var project = builder.AddFoundry("account") + .AddProject("my-project"); + var model = project.AddModelDeployment("gpt41", FoundryModel.OpenAI.Gpt41); + + project.AddPromptAgent("my-agent", model); + + builder.Build(); + + var resource = builder.Resources.Single(r => r.Name == "my-agent"); + var command = Assert.Single(resource.Annotations.OfType()); + Assert.Equal("Send Message", command.DisplayName); + Assert.Equal("ChatSparkle", command.IconName); + Assert.Equal(IconVariant.Regular, command.IconVariant); + Assert.True(command.IsHighlighted); + } + [Fact] public void AddPromptAgent_WithNullName_Throws() { diff --git a/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/Go/apphost.go b/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/Go/apphost.go index b6648a14dd0..c87454c2faf 100644 --- a/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/Go/apphost.go +++ b/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/Go/apphost.go @@ -119,14 +119,17 @@ server.listen(port, '127.0.0.1'); `, }) - hostedAgent.WithComputeEnvironment(&aspire.WithComputeEnvironmentOptions{ - Project: &project, - Configure: func(cfg aspire.HostedAgentConfiguration) { - cfg.SetDescription("Validation hosted agent") - cfg.SetCpu(1) - cfg.SetMemory(2) - _ = cfg.Metadata().Set("scenario", "validation") - _ = cfg.EnvironmentVariables().Set("VALIDATION_MODE", "true") + hostedAgent.AsHostedAgent(project, &aspire.AsHostedAgentOptions{ + Options: &aspire.HostedAgentOptions{ + Description: "Validation hosted agent", + Cpu: aspire.Float64Ptr(1), + Memory: aspire.Float64Ptr(2), + Metadata: map[string]string{ + "scenario": "validation", + }, + EnvironmentVariables: map[string]string{ + "VALIDATION_MODE": "true", + }, }, }) diff --git a/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/Java/AppHost.java b/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/Java/AppHost.java index 0b5fe859414..58171b2d41b 100644 --- a/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/Java/AppHost.java +++ b/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/Java/AppHost.java @@ -1,4 +1,5 @@ import aspire.*; +import java.util.Map; void main() throws Exception { var builder = DistributedApplication.CreateBuilder(); @@ -106,15 +107,13 @@ void main() throws Exception { """ }); - hostedAgent.withComputeEnvironment(new WithComputeEnvironmentOptions() - .project(project) - .configure((configuration) -> { - configuration.setDescription("Validation hosted agent"); - configuration.setCpu(1); - configuration.setMemory(2); - configuration.metadata().put("scenario", "validation"); - configuration.environmentVariables().put("VALIDATION_MODE", "true"); - })); + var hostedAgentOptions = new HostedAgentOptions(); + hostedAgentOptions.setDescription("Validation hosted agent"); + hostedAgentOptions.setCpu(1.0); + hostedAgentOptions.setMemory(2.0); + hostedAgentOptions.setMetadata(Map.of("scenario", "validation")); + hostedAgentOptions.setEnvironmentVariables(Map.of("VALIDATION_MODE", "true")); + hostedAgent.asHostedAgent(project, hostedAgentOptions); var api = builder.addContainer("api", "nginx"); foundry.withContainerRegistryRoleAssignments(registry, new AzureContainerRegistryRole[] { diff --git a/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/Python/apphost.py b/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/Python/apphost.py index 840e9da9d17..5b5d7a61277 100644 --- a/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/Python/apphost.py +++ b/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/Python/apphost.py @@ -123,7 +123,7 @@ """ ]) - hosted_agent.with_compute_environment(project=project) + hosted_agent.as_hosted_agent(project=project) api = builder.add_container("api", "nginx") foundry.with_container_registry_role_assignments(registry) diff --git a/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/TypeScript/apphost.mts b/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/TypeScript/apphost.mts index 80ae398c9f5..2479a1aeba8 100644 --- a/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/TypeScript/apphost.mts +++ b/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/TypeScript/apphost.mts @@ -110,17 +110,12 @@ server.listen(port, '127.0.0.1'); ` ]); -await hostedAgent.withComputeEnvironment({ - project, - configure: async (configuration) => { - await configuration.description.set('Validation hosted agent'); - await configuration.cpu.set(1); - await configuration.memory.set(2); - const metadata = await configuration.metadata(); - await metadata.set('scenario', 'validation'); - const environmentVariables = await configuration.environmentVariables(); - await environmentVariables.set('VALIDATION_MODE', 'true'); - } +await hostedAgent.asHostedAgent(project, { + description: 'Validation hosted agent', + cpu: 1, + memory: 2, + metadata: { scenario: 'validation' }, + environmentVariables: { VALIDATION_MODE: 'true' } }); const api = await builder.addContainer('api', 'nginx'); From e258349fd49dd0fdf08f92468d79f0cfaaf13012 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 30/43] 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> --- 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 6c9c5e8d2769c9cd1626cfacf7b36a887b4d2450 Mon Sep 17 00:00:00 2001 From: "aspire-repo-bot[bot]" <268009190+aspire-repo-bot[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 15:09:55 -0700 Subject: [PATCH 31/43] Fix TypeScript AppHost async callback deadlock (#17689) Run the exported DistributedApplication.RunAsync capability on a background thread so startup callbacks invoked before the first await do not block StreamJsonRpc's non-concurrent synchronization context. Add an end-to-end TypeScript AppHost regression that recreates the lazy IOptions.Configure async callback pattern from #17487. Co-authored-by: IEvangelist <7679720+IEvangelist@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Hosting/DistributedApplication.cs | 2 +- .../TypeScriptEmptyAppHostTemplateTests.cs | 156 ++++++++++++++++++ 2 files changed, 157 insertions(+), 1 deletion(-) diff --git a/src/Aspire.Hosting/DistributedApplication.cs b/src/Aspire.Hosting/DistributedApplication.cs index 1096d7543e6..90873edea70 100644 --- a/src/Aspire.Hosting/DistributedApplication.cs +++ b/src/Aspire.Hosting/DistributedApplication.cs @@ -516,7 +516,7 @@ public virtual async Task StopAsync(CancellationToken cancellationToken = defaul /// in refer to . /// /// - [AspireExport("run")] + [AspireExport("run", RunSyncOnBackgroundThread = true)] public virtual async Task RunAsync(CancellationToken cancellationToken = default) { ProfilingTelemetry.EnsureInitialized(_host.Services); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptEmptyAppHostTemplateTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptEmptyAppHostTemplateTests.cs index 1847a78b052..1d9b45ba6f9 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptEmptyAppHostTemplateTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptEmptyAppHostTemplateTests.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.Text.Json; +using System.Text.Json.Nodes; using Aspire.Cli.EndToEnd.Tests.Helpers; using Aspire.Cli.Tests.Utils; using Hex1b.Automation; @@ -47,4 +49,158 @@ public async Task CreateAndRunTypeScriptEmptyAppHostProject() await auto.AspireStartAsync(counter); await auto.AspireStopAsync(counter); } + + [Fact] + [CaptureWorkspaceOnFailure] + public async Task TypeScriptAppHostRunDoesNotDeadlockWhenLazyOptionsInvokeAsyncCallback() + { + var repoRoot = CliE2ETestHelpers.GetRepoRoot(); + var strategy = CliInstallStrategy.Detect(output.WriteLine); + var workspace = TemporaryWorkspace.Create(output); + + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); + + var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); + + var counter = new SequenceCounter(); + var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + var testBodyFailed = false; + + try + { + await auto.PrepareDockerEnvironmentAsync(counter, workspace, enableDcpDiagnostics: true); + await auto.InstallAspireCliAsync(strategy, counter); + + await auto.AspireNewTypeScriptEmptyAppHostAsync("TsDeadlockRepro", counter); + + var appDirectory = Path.Combine(workspace.WorkspaceRoot.FullName, "TsDeadlockRepro"); + WriteDeadlockReproFiles(appDirectory); + + await auto.RunCommandFailFastAsync("cd TsDeadlockRepro", counter); + await auto.RunCommandFailFastAsync("aspire restore --non-interactive", counter, TimeSpan.FromMinutes(3)); + await auto.RunCommandFailFastAsync("npm run build", counter, TimeSpan.FromMinutes(2)); + + await auto.AspireStartAsync(counter, startTimeout: TimeSpan.FromMinutes(2)); + await auto.AspireStopAsync(counter); + } + catch + { + testBodyFailed = true; + throw; + } + finally + { + try + { + await auto.CaptureAspireDiagnosticsAsync(counter, workspace); + } + catch { } // Best effort + + try + { + await auto.TypeAsync("exit"); + await auto.EnterAsync(); + await pendingRun; + } + catch + { + if (!testBodyFailed) + { + throw; + } + } + } + } + + private static void WriteDeadlockReproFiles(string appDirectory) + { + var sdkVersion = GetSdkVersion(appDirectory); + var extensionDirectory = Directory.CreateDirectory(Path.Combine(appDirectory, "DeadlockExtension")); + + File.WriteAllText(Path.Combine(extensionDirectory.FullName, "DeadlockExtension.csproj"), $$""" + + + net10.0 + enable + enable + $(NoWarn);ASPIREATS001 + + + + + + + """); + + File.WriteAllText(Path.Combine(extensionDirectory.FullName, "DeadlockExtensions.cs"), """ + using Aspire.Hosting; + using Aspire.Hosting.ApplicationModel; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Options; + + namespace DeadlockExtension; + + public static class DeadlockExtensions + { + [AspireExport(RunSyncOnBackgroundThread = true)] + public static IDistributedApplicationBuilder AddLazyOptionsDeadlockRepro( + this IDistributedApplicationBuilder builder, + Action? configure = null) + { + builder.Services.AddOptions() + .Configure(options => configure?.Invoke(options)); + + builder.Eventing.Subscribe((@event, _) => + { + var options = @event.Services.GetRequiredService>().Value; + if (options.SomeProperty is not "value-from-typescript") + { + throw new InvalidOperationException($"Expected TypeScript callback to set SomeProperty, but got '{options.SomeProperty ?? ""}'."); + } + + return Task.CompletedTask; + }); + + return builder; + } + } + + [AspireExport(ExposeProperties = true)] + public sealed class DeadlockOptions + { + public string? SomeProperty { get; set; } + } + """); + + File.WriteAllText(Path.Combine(appDirectory, "apphost.mts"), """ + import { createBuilder } from './.aspire/modules/aspire.mjs'; + + const builder = await createBuilder(); + + await builder.addLazyOptionsDeadlockRepro({ + configure: async (options) => { + await options.someProperty.set("value-from-typescript"); + } + }); + + await builder.build().run(); + """); + + var configPath = Path.Combine(appDirectory, "aspire.config.json"); + var config = JsonNode.Parse(File.ReadAllText(configPath))?.AsObject() + ?? throw new InvalidOperationException($"Unable to read {configPath}."); + config["packages"] = new JsonObject + { + ["DeadlockExtension"] = "DeadlockExtension/DeadlockExtension.csproj" + }; + File.WriteAllText(configPath, config.ToJsonString(new JsonSerializerOptions { WriteIndented = true })); + } + + private static string GetSdkVersion(string appDirectory) + { + var configPath = Path.Combine(appDirectory, "aspire.config.json"); + using var doc = JsonDocument.Parse(File.ReadAllText(configPath)); + return doc.RootElement.GetProperty("sdk").GetProperty("version").GetString() + ?? throw new InvalidOperationException("Expected aspire.config.json to contain sdk.version."); + } } From dfd226a4a2a09cb6c599b06276db1b397ef2813e Mon Sep 17 00:00:00 2001 From: Jose Perez Rodriguez Date: Fri, 29 May 2026 15:11:39 -0700 Subject: [PATCH 32/43] [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> --- 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 565af538808300046a1b7cbe125358661d336a4c Mon Sep 17 00:00:00 2001 From: "aspire-repo-bot[bot]" <268009190+aspire-repo-bot[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 15:56:42 -0700 Subject: [PATCH 33/43] Use TerminalRun in TypeScript deadlock repro E2E test (#17703) Co-authored-by: James Newton-King --- .../TypeScriptEmptyAppHostTemplateTests.cs | 55 ++++--------------- 1 file changed, 11 insertions(+), 44 deletions(-) diff --git a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptEmptyAppHostTemplateTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptEmptyAppHostTemplateTests.cs index 1d9b45ba6f9..c1565c54bfc 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptEmptyAppHostTemplateTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptEmptyAppHostTemplateTests.cs @@ -59,57 +59,24 @@ public async Task TypeScriptAppHostRunDoesNotDeadlockWhenLazyOptionsInvokeAsyncC var workspace = TemporaryWorkspace.Create(output); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); - - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); - var testBodyFailed = false; - - try - { - await auto.PrepareDockerEnvironmentAsync(counter, workspace, enableDcpDiagnostics: true); - await auto.InstallAspireCliAsync(strategy, counter); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); - await auto.AspireNewTypeScriptEmptyAppHostAsync("TsDeadlockRepro", counter); + await auto.PrepareDockerEnvironmentAsync(counter, workspace, enableDcpDiagnostics: true); + await auto.InstallAspireCliAsync(strategy, counter); - var appDirectory = Path.Combine(workspace.WorkspaceRoot.FullName, "TsDeadlockRepro"); - WriteDeadlockReproFiles(appDirectory); + await auto.AspireNewTypeScriptEmptyAppHostAsync("TsDeadlockRepro", counter); - await auto.RunCommandFailFastAsync("cd TsDeadlockRepro", counter); - await auto.RunCommandFailFastAsync("aspire restore --non-interactive", counter, TimeSpan.FromMinutes(3)); - await auto.RunCommandFailFastAsync("npm run build", counter, TimeSpan.FromMinutes(2)); + var appDirectory = Path.Combine(workspace.WorkspaceRoot.FullName, "TsDeadlockRepro"); + WriteDeadlockReproFiles(appDirectory); - await auto.AspireStartAsync(counter, startTimeout: TimeSpan.FromMinutes(2)); - await auto.AspireStopAsync(counter); - } - catch - { - testBodyFailed = true; - throw; - } - finally - { - try - { - await auto.CaptureAspireDiagnosticsAsync(counter, workspace); - } - catch { } // Best effort + await auto.RunCommandAsync("cd TsDeadlockRepro", counter); + await auto.RunCommandAsync("aspire restore --non-interactive", counter, TimeSpan.FromMinutes(3)); + await auto.RunCommandAsync("npm run build", counter, TimeSpan.FromMinutes(2)); - try - { - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - await pendingRun; - } - catch - { - if (!testBodyFailed) - { - throw; - } - } - } + await auto.AspireStartAsync(counter, startTimeout: TimeSpan.FromMinutes(2)); + await auto.AspireStopAsync(counter); } private static void WriteDeadlockReproFiles(string appDirectory) From 64efcefb8594b066a1e36075abfa68ee62c8c31a Mon Sep 17 00:00:00 2001 From: "aspire-repo-bot[bot]" <268009190+aspire-repo-bot[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 19:12:17 -0700 Subject: [PATCH 34/43] [release/13.4] Reference Foundry project from hosted agent target (#17704) * Reference Foundry project from hosted agent target * Strengthen hosted agent Foundry reference test Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix Foundry hosted agent CI tests Seed Foundry project outputs in tests that directly invoke hosted-agent environment resolution, matching the provisioning state available during deployment. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Document Foundry project output seeding Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: tommasodotnet Co-authored-by: David Fowler Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../HostedAgentBuilderExtension.cs | 13 +++++-- .../FoundryExtensionsTests.cs | 14 +++++++ .../HostedAgentExtensionTests.cs | 39 +++++++++++++++++++ 3 files changed, 62 insertions(+), 4 deletions(-) diff --git a/src/Aspire.Hosting.Foundry/HostedAgent/HostedAgentBuilderExtension.cs b/src/Aspire.Hosting.Foundry/HostedAgent/HostedAgentBuilderExtension.cs index 4e11d6e3099..d82ad750c79 100644 --- a/src/Aspire.Hosting.Foundry/HostedAgent/HostedAgentBuilderExtension.cs +++ b/src/Aspire.Hosting.Foundry/HostedAgent/HostedAgentBuilderExtension.cs @@ -295,7 +295,7 @@ private static void ConfigurePublishMode( } // Get the corresponding ContainerResource for ExecutableResources. Usually this is swapped in at publish time for ExecutableResources. - IResource target; + IResourceWithEnvironment target; if (resource is ContainerResource containerResource) { target = containerResource; @@ -308,7 +308,8 @@ private static void ConfigurePublishMode( { // Ensure we have a container resource to deploy. // ExecutableResource needs PublishAsDockerFile() to convert it into a container resource at this stage. - builder.ApplicationBuilder.CreateResourceBuilder(executableResource).PublishAsDockerFile(); + builder.ApplicationBuilder.CreateResourceBuilder(executableResource) + .PublishAsDockerFile(); if (builder.ApplicationBuilder.TryCreateResourceBuilder(resource.Name, out containerResourceBuilder)) { @@ -328,6 +329,11 @@ private static void ConfigurePublishMode( throw new InvalidOperationException($"Unable to create hosted agent for resource '{resource.Name}' because it is not a container, executable, or project resource."); } + // The hosted agent wrapper is not the deployed workload. Apply the Foundry + // reference to the target so its connection annotations flow into the deployment. + builder.ApplicationBuilder.CreateResourceBuilder(target) + .WithReference(project); + // Create a separate agent resource to host the deployment. var hostedAgent = new AzureHostedAgentResource(agentName, target, configure); @@ -340,7 +346,6 @@ private static void ConfigurePublishMode( builder.ApplicationBuilder.AddResource(hostedAgent) .WithIconName("Agents") - .WithReferenceRelationship(target) - .WithReference(project); + .WithReferenceRelationship(target); } } diff --git a/tests/Aspire.Hosting.Azure.Tests/FoundryExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/FoundryExtensionsTests.cs index 60af07d3359..c9aa2b7c359 100644 --- a/tests/Aspire.Hosting.Azure.Tests/FoundryExtensionsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/FoundryExtensionsTests.cs @@ -304,6 +304,7 @@ public async Task WithComputeEnvironment_ResolvesExternalContainerAppReference() var environment = Assert.Single(model.Resources.OfType()); environment.Outputs["AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN"] = "example.azurecontainerapps.io"; environment.ProvisioningTaskCompletionSource?.TrySetResult(); + SetFoundryProjectOutputs(project.Resource); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); var environmentVariables = await AzureHostedAgentResource.GetResolvedEnvironmentVariablesAsync( @@ -332,6 +333,7 @@ public async Task WithComputeEnvironment_DoesNotSetReservedFoundryProjectEndpoin var model = app.Services.GetRequiredService(); var hostedAgent = Assert.Single(model.Resources.OfType()); + SetFoundryProjectOutputs(project.Resource); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); var environmentVariables = await AzureHostedAgentResource.GetResolvedEnvironmentVariablesAsync( @@ -372,6 +374,7 @@ public async Task WithComputeEnvironment_ResolvesReferenceExpressionEnvironmentV var environment = Assert.Single(model.Resources.OfType()); environment.Outputs["AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN"] = "example.azurecontainerapps.io"; environment.ProvisioningTaskCompletionSource?.TrySetResult(); + SetFoundryProjectOutputs(project.Resource); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); var environmentVariables = await AzureHostedAgentResource.GetResolvedEnvironmentVariablesAsync( @@ -412,6 +415,7 @@ public async Task WithComputeEnvironment_ResolvesEndpointReferenceExpressionEnvi var environment = Assert.Single(model.Resources.OfType()); environment.Outputs["AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN"] = "example.azurecontainerapps.io"; environment.ProvisioningTaskCompletionSource?.TrySetResult(); + SetFoundryProjectOutputs(project.Resource); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); var environmentVariables = await AzureHostedAgentResource.GetResolvedEnvironmentVariablesAsync( @@ -462,6 +466,16 @@ await AzureHostedAgentResource.GetResolvedEnvironmentVariablesAsync( Assert.Contains("internal", ex.Message); } + private static void SetFoundryProjectOutputs(AzureCognitiveServicesProjectResource project) + { + // These tests call the deployment-time environment resolver directly. In a real publish, + // provisioning populates the Foundry project Bicep outputs before references are resolved. + // Seed the outputs here so BicepOutputReference.GetValueAsync does not wait for provisioning. + project.Outputs["endpoint"] = "https://account.services.ai.azure.com/api/projects/my-project"; + project.Outputs["APPLICATION_INSIGHTS_CONNECTION_STRING"] = ""; + project.ProvisioningTaskCompletionSource?.TrySetResult(); + } + private sealed class Project : IProjectMetadata { public string ProjectPath => "project"; diff --git a/tests/Aspire.Hosting.Foundry.Tests/HostedAgentExtensionTests.cs b/tests/Aspire.Hosting.Foundry.Tests/HostedAgentExtensionTests.cs index d298defd34e..cb1ac78563d 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.Tests.Utils; using Aspire.Hosting.Utils; using Microsoft.Extensions.DependencyInjection; @@ -142,6 +143,39 @@ public void AsHostedAgent_InPublishMode_CreatesHostedAgentResource() Assert.Equal("agent-ha", hostedAgent.Name); } + [Fact] + public async Task AsHostedAgent_InPublishMode_AddsProjectReferenceToDeploymentTarget() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + var project = builder.AddFoundry("account") + .AddProject("my-project"); + + builder.AddProject("agent", launchProfileName: null) + .AsHostedAgent(project); + + builder.Build(); + + var hostedAgent = Assert.Single(builder.Resources.OfType()); + + Assert.True(hostedAgent.Target.TryGetAnnotationsOfType(out var relationships)); + Assert.Contains(relationships, r => + r.Type == "Reference" && + ReferenceEquals(r.Resource, project.Resource)); + + var envVars = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync( + hostedAgent.Target, DistributedApplicationOperation.Publish, TestServiceProvider.Instance); + + Assert.Contains(envVars, kvp => + kvp.Key == "ConnectionStrings__my-project" && + kvp.Value == "{my-project.connectionString}"); + Assert.Contains(envVars, kvp => + kvp.Key == "MY_PROJECT_CONNECTIONSTRING" && + kvp.Value == "Endpoint={my-project.outputs.endpoint}"); + Assert.DoesNotContain(hostedAgent.Annotations.OfType(), r => + r.Type == "Reference" && + ReferenceEquals(r.Resource, project.Resource)); + } + [Fact] public void AsHostedAgent_WithOptions_AppliesAllPropertiesToConfiguration() { @@ -282,4 +316,9 @@ public async Task FoundryProject_DefaultRegistryDoesNotAddGlobalRegistryTargets( [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "ExecuteBeforeStartHooksAsync")] private static extern Task ExecuteBeforeStartHooksAsync(DistributedApplication app, CancellationToken cancellationToken); + + private sealed class Project : IProjectMetadata + { + public string ProjectPath => "project"; + } } From 11bea2eb9fbb655614bcf9814c55c88a3fdd1126 Mon Sep 17 00:00:00 2001 From: "aspire-repo-bot[bot]" <268009190+aspire-repo-bot[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 20:48:06 -0700 Subject: [PATCH 35/43] =?UTF-8?q?[release/13.4]=20Fix=20five=20`aspire=20l?= =?UTF-8?q?s`=20bugs=20from=20#17620=20(L1=E2=80=93L5)=20(#17688)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix five `aspire ls` bugs from #17620 (L1–L5) Fixes #17615, #17620, #17621, #17624, #17626. - L1 (#17615): Remove the eager-migration block in ConfigurationHelper.RegisterSettingsFiles. Read commands like `aspire ls` no longer silently materialize an aspire.config.json next to a user's legacy .aspire/settings.json. Migration now happens lazily/explicitly via the existing write paths. - L2 (#17620): Drop the `silent` parameter from ProjectLocator.GetAppHostProjectFileFromSettingsAsync so the legacy branch unconditionally surfaces the migration warning, and surface the actual user-authored `.aspire/settings.json` path in the warning text rather than the auto-created `aspire.config.json` path. - L3 (#17621): Remove the dead post-emission `appHosts.Sort()` in LsCommand.FindAppHostsWithJsonStreamAsync (--stream emits candidates as they are discovered, so the sort had no effect on already-emitted output). Update the --stream option description and docs/specs/cli-output-formats.md to declare the arrival-ordered contract. - L4 (#17624): Add an IsValidConfiguredAppHostPath helper in ProjectLocator that rejects `\0` and Path.GetInvalidPathChars() before the path is passed to Path.IsPathRooted / Path.Combine. Wired into both the modern `aspire.config.json` (`appHost.path`) branch and the legacy `.aspire/settings.json` (`appHostPath`) branch. Validation is intentionally at the consumption point rather than in AspireConfigFile.Load, which has 12+ unrelated callers. Adds a new ConfiguredAppHostPathHasInvalidCharacters resource string and refreshes the xlf set via UpdateXlf. - L5 (#17626): Add PathNormalizer.ResolveSymlinks in src/Shared, a recursive segment-walker that canonicalizes intermediate symlinks (Directory.ResolveLinkTarget only reads exactly the path it is given, and returns the link target as stored on disk — so a single call on /tmp/x/y.cs does not unwrap /tmp -> /private/tmp, and following a link whose stored target is /var/.../app keeps the un-canonical /var prefix). The recursion has a hard depth limit of 40 and falls back to the un-resolved input on broken or circular links. Use it in AddSettingsAppHostCandidateAsync as a comparison key only — the surfaced AppHostProjectCandidate keeps its original FileInfo so the displayed path matches what the user authored in settings. Tests: 158 of 160 targeted tests pass (2 Windows-only skipped on macOS). New tests cover L1 (no migration on read), L2 (legacy warning references settings.json), L3 (arrival-order under --stream), L4 (NUL byte in modern and legacy branches), L5 (symlink dedupe via a node_modules-hosted link the discovery walk excludes), plus 5 unit tests on PathNormalizer.ResolveSymlinks itself. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address Aspire CLI PR feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Use constants for AppHost config keys Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Deduplicate AppHost config casing on macOS Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Handle duplicate ATS capabilities Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Revert duplicate ATS compatibility fix PR #17671 owns the Foundry ATS baseline update; this branch should only contain the AppHost and CLI fixes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: adamint Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Adam Ratzman --- docs/specs/cli-output-formats.md | 2 + src/Aspire.Cli/Commands/LsCommand.cs | 9 +- src/Aspire.Cli/Program.cs | 2 +- src/Aspire.Cli/Projects/ProjectLocator.cs | 203 ++++++++-- .../Resources/ErrorStrings.Designer.cs | 27 ++ src/Aspire.Cli/Resources/ErrorStrings.resx | 12 + .../Resources/SharedCommandStrings.resx | 2 +- .../Resources/xlf/ErrorStrings.cs.xlf | 15 + .../Resources/xlf/ErrorStrings.de.xlf | 15 + .../Resources/xlf/ErrorStrings.es.xlf | 15 + .../Resources/xlf/ErrorStrings.fr.xlf | 15 + .../Resources/xlf/ErrorStrings.it.xlf | 15 + .../Resources/xlf/ErrorStrings.ja.xlf | 15 + .../Resources/xlf/ErrorStrings.ko.xlf | 15 + .../Resources/xlf/ErrorStrings.pl.xlf | 15 + .../Resources/xlf/ErrorStrings.pt-BR.xlf | 15 + .../Resources/xlf/ErrorStrings.ru.xlf | 15 + .../Resources/xlf/ErrorStrings.tr.xlf | 15 + .../Resources/xlf/ErrorStrings.zh-Hans.xlf | 15 + .../Resources/xlf/ErrorStrings.zh-Hant.xlf | 15 + .../Resources/xlf/SharedCommandStrings.cs.xlf | 4 +- .../Resources/xlf/SharedCommandStrings.de.xlf | 4 +- .../Resources/xlf/SharedCommandStrings.es.xlf | 4 +- .../Resources/xlf/SharedCommandStrings.fr.xlf | 4 +- .../Resources/xlf/SharedCommandStrings.it.xlf | 4 +- .../Resources/xlf/SharedCommandStrings.ja.xlf | 4 +- .../Resources/xlf/SharedCommandStrings.ko.xlf | 4 +- .../Resources/xlf/SharedCommandStrings.pl.xlf | 4 +- .../xlf/SharedCommandStrings.pt-BR.xlf | 4 +- .../Resources/xlf/SharedCommandStrings.ru.xlf | 4 +- .../Resources/xlf/SharedCommandStrings.tr.xlf | 4 +- .../xlf/SharedCommandStrings.zh-Hans.xlf | 4 +- .../xlf/SharedCommandStrings.zh-Hant.xlf | 4 +- src/Aspire.Cli/Utils/ConfigurationHelper.cs | 99 +---- .../api/Aspire.Hosting.Foundry.ats.txt | 1 + src/Shared/PathNormalizer.cs | 114 ++++++ .../Commands/LsCommandTests.cs | 52 +++ .../Configuration/ConfigurationHelperTests.cs | 102 ++--- .../Projects/ProjectLocatorTests.cs | 381 +++++++++++++++++- tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs | 2 +- .../Utils/PathNormalizerTests.cs | 121 ++++++ 41 files changed, 1167 insertions(+), 209 deletions(-) create mode 100644 tests/Aspire.Cli.Tests/Utils/PathNormalizerTests.cs diff --git a/docs/specs/cli-output-formats.md b/docs/specs/cli-output-formats.md index 1872557e34c..3260effc14e 100644 --- a/docs/specs/cli-output-formats.md +++ b/docs/specs/cli-output-formats.md @@ -44,6 +44,8 @@ Use `--format json --stream` to receive discovery results as NDJSON, with one co {"path":"/path/to/ts-app/apphost.ts","language":"TypeScript","status":"possibly-unbuildable"} ``` +Stream output is emitted in arrival order from parallel discovery; lines are not sorted. The non-streaming `--format json` snapshot above is sorted by `path`. If you need a deterministic order for streamed output, pipe through your own sort step (for example `jq -s 'sort_by(.path)'`). + If discovery finds no AppHost candidates, the stream emits no lines. The stream does not emit `started`, `complete`, or `canceled` control records; use the command's exit code and end-of-file to detect stream completion. #### AppHost candidate fields diff --git a/src/Aspire.Cli/Commands/LsCommand.cs b/src/Aspire.Cli/Commands/LsCommand.cs index 542913fca9c..26c35e431c1 100644 --- a/src/Aspire.Cli/Commands/LsCommand.cs +++ b/src/Aspire.Cli/Commands/LsCommand.cs @@ -153,6 +153,13 @@ private async Task> FindAppHostsWithJsonStreamAsyn { var appHosts = new List(); + // `aspire ls --format json --stream` emits each candidate as soon as discovery surfaces + // it (arrival order from parallel discovery). The contract is documented in + // docs/specs/cli-output-formats.md. Do NOT sort here: + // candidates have already been written to stdout via WriteJsonStreamCandidate above, so + // any post-loop sort would only reorder this in-memory list — which the caller does not + // use for stream output. Pipe to `sort` / `jq -s 'sort_by(.path)'` for ordered output. + // See https://github.com/microsoft/aspire/issues/17621. await foreach (var candidate in _projectLocator.FindAppHostProjectsStreamAsync(_executionContext.WorkingDirectory, scope, cancellationToken: cancellationToken).ConfigureAwait(false)) { cancellationToken.ThrowIfCancellationRequested(); @@ -160,8 +167,6 @@ private async Task> FindAppHostsWithJsonStreamAsyn WriteJsonStreamCandidate(CreateDisplayInfo(candidate)); } - appHosts.Sort((x, y) => string.Compare(x.AppHostFile.FullName, y.AppHostFile.FullName, StringComparison.Ordinal)); - return appHosts; } diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index 4cb97e53d8c..997105e0900 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -304,7 +304,7 @@ internal static async Task BuildApplicationAsync(string[] args, CliStartu var globalSettingsFilePath = GetGlobalSettingsPath(startupContext.Logger); var globalSettingsFile = new FileInfo(globalSettingsFilePath); var workingDirectory = new DirectoryInfo(Environment.CurrentDirectory); - ConfigurationHelper.RegisterSettingsFiles(builder.Configuration, workingDirectory, globalSettingsFile, startupContext.Logger); + ConfigurationHelper.RegisterSettingsFiles(builder.Configuration, workingDirectory, globalSettingsFile); TrySetLocaleOverride(LocaleHelpers.GetLocaleOverride(builder.Configuration), startupContext.Logger, startupContext.ErrorWriter); WarnIfGlobalSettingsContainAppHostPath(globalSettingsFile, startupContext.ErrorWriter); diff --git a/src/Aspire.Cli/Projects/ProjectLocator.cs b/src/Aspire.Cli/Projects/ProjectLocator.cs index 095a1f6363d..09976dd40b1 100644 --- a/src/Aspire.Cli/Projects/ProjectLocator.cs +++ b/src/Aspire.Cli/Projects/ProjectLocator.cs @@ -138,6 +138,9 @@ internal sealed class ProjectLocator( IAppHostCandidateFinder appHostCandidateFinder, AspireCliTelemetry telemetry) : IProjectLocator { + private const string AspireConfigAppHostPathKey = "appHost.path"; + private const string LegacySettingsAppHostPathKey = "appHostPath"; + /// /// Finds all candidate AppHost projects in the specified search directory with language metadata. /// @@ -444,17 +447,45 @@ await Parallel.ForEachAsync(candidatesWithHandlers, parallelOptions, async (cand async Task AddSettingsAppHostCandidateAsync() { - var settingsAppHost = await GetAppHostProjectFileFromSettingsAsync(searchDirectory, searchParentDirectories: true, silent: true, cancellationToken).ConfigureAwait(false); + var settingsAppHost = await GetAppHostProjectFileFromSettingsAsync(searchDirectory, searchParentDirectories: true, silent: false, cancellationToken).ConfigureAwait(false); if (settingsAppHost is null) { return; } - var pathComparison = OperatingSystem.IsWindows() + // Windows and default macOS APFS volumes are case-insensitive, so a + // differently-cased settings path can still refer to the same file found + // by the discovery walk. See https://github.com/microsoft/aspire/issues/17635. + var pathComparison = OperatingSystem.IsWindows() || OperatingSystem.IsMacOS() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; - if (appHostProjects.Any(candidate => string.Equals(candidate.AppHostFile.FullName, settingsAppHost.FullName, pathComparison)) - || unbuildableSuspectedAppHostProjects.Any(candidate => string.Equals(candidate.AppHostFile.FullName, settingsAppHost.FullName, pathComparison))) + + // Canonicalize symlinks before comparing so a settings-derived candidate + // like /tmp/L5/x.cs does not produce a duplicate entry next to the + // discovery-walked /private/tmp/L5/x.cs on macOS, where /tmp is a symlink + // to /private/tmp. See https://github.com/microsoft/aspire/issues/17626. + // Resolved paths are used as comparison keys only — the surfaced + // AppHostProjectCandidate keeps the original FileInfo so display paths are + // unchanged from what the user-authored settings file pointed at. + // + // Symlink resolution does ~one syscall per path segment, so we keep it + // off the hot path: the exact-string compare below short-circuits before + // the per-candidate resolve runs at all in the common case (no symlinks + // involved). Pre-materializing canonical paths for every candidate would + // force the resolve even when the cheap compare would have matched. + var settingsCanonicalPath = PathNormalizer.ResolveSymlinks(settingsAppHost.FullName); + bool IsDuplicate(AppHostProjectCandidate candidate) + { + if (string.Equals(candidate.AppHostFile.FullName, settingsAppHost.FullName, pathComparison)) + { + return true; + } + + var candidateCanonicalPath = PathNormalizer.ResolveSymlinks(candidate.AppHostFile.FullName); + return string.Equals(candidateCanonicalPath, settingsCanonicalPath, pathComparison); + } + + if (appHostProjects.Any(IsDuplicate) || unbuildableSuspectedAppHostProjects.Any(IsDuplicate)) { return; } @@ -528,6 +559,10 @@ async Task AddSettingsAppHostCandidateAsync() public async Task GetAppHostFromSettingsAsync(DirectoryInfo searchDirectory, bool searchParentDirectories, CancellationToken cancellationToken = default) { // Intentionally does not call ValidateAppHostAsync. See interface XML docs for rationale. + // Probe-style callers (DotNetSdkCheck, AspireVersionCheck, TypeScriptAppHostToolingCheck, + // UpdateCommand, IntegrationPackageSearchService) drive this path and expect a + // non-interactive answer; the user-facing legacy-migration warning is emitted from the + // discovery walk (AddSettingsAppHostCandidateAsync) instead. var settingsAppHost = await GetAppHostProjectFileFromSettingsAsync(searchDirectory, searchParentDirectories, silent: true, cancellationToken); if (settingsAppHost is null) { @@ -544,9 +579,13 @@ async Task AddSettingsAppHostCandidateAsync() return settingsAppHost; } - private async Task GetValidatedAppHostProjectFileFromSettingsAsync(DirectoryInfo searchDirectory, bool searchParentDirectories, bool silent, CancellationToken cancellationToken) + private async Task GetValidatedAppHostProjectFileFromSettingsAsync(DirectoryInfo searchDirectory, bool searchParentDirectories, CancellationToken cancellationToken) { - var settingsAppHost = await GetAppHostProjectFileFromSettingsAsync(searchDirectory, searchParentDirectories, silent, cancellationToken); + // This is reached from UseOrFindAppHostProjectFileAsync. When the configured + // legacy settings point at a missing file we still want the warning to surface, + // but the discovery walk that runs afterwards (AddSettingsAppHostCandidateAsync) + // will emit the same warning. Stay silent here to avoid a duplicate. + var settingsAppHost = await GetAppHostProjectFileFromSettingsAsync(searchDirectory, searchParentDirectories, silent: true, cancellationToken); if (settingsAppHost is null) { return null; @@ -594,11 +633,24 @@ async Task AddSettingsAppHostCandidateAsync() } catch (JsonException ex) { - interactionService.DisplayError(ex.Message); + ReportInvalidConfigurationFile(ex, ex.Message, silent); return null; } + if (aspireConfig?.AppHost?.Path is { } configAppHostPath) { + var configFilePath = Path.Combine(searchDirectory.FullName, AspireConfigFile.FileName); + + // Validate before Path.Combine / new FileInfo, which throw ArgumentException + // ("Null character in path." / "Illegal characters in path.") on NUL bytes and + // other invalid characters that survive JSON parsing. Without this we surface + // as a generic "An unexpected error occurred" — see + // https://github.com/microsoft/aspire/issues/17624. + if (!IsValidConfiguredAppHostPath(configAppHostPath, configFilePath, fieldName: AspireConfigAppHostPathKey, silent: silent)) + { + return null; + } + var qualifiedPath = Path.IsPathRooted(configAppHostPath) ? configAppHostPath : Path.Combine(searchDirectory.FullName, configAppHostPath); @@ -612,8 +664,10 @@ async Task AddSettingsAppHostCandidateAsync() } else { - var configFilePath = Path.Combine(searchDirectory.FullName, AspireConfigFile.FileName); - interactionService.DisplayMessage(KnownEmojis.Warning, string.Format(CultureInfo.CurrentCulture, ErrorStrings.AppHostWasSpecifiedButDoesntExist, configFilePath, qualifiedPath)); + if (!silent) + { + interactionService.DisplayMessage(KnownEmojis.Warning, string.Format(CultureInfo.CurrentCulture, ErrorStrings.AppHostWasSpecifiedButDoesntExist, configFilePath, qualifiedPath)); + } return null; } } @@ -625,29 +679,66 @@ async Task AddSettingsAppHostCandidateAsync() if (settingsFile.Exists) { - using var stream = settingsFile.OpenRead(); - var json = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken); - - if (json.RootElement.TryGetProperty("appHostPath", out var appHostPathProperty) && appHostPathProperty.GetString() is { } appHostPath) + try { - var qualifiedAppHostPath = Path.IsPathRooted(appHostPath) ? appHostPath : Path.Combine(settingsFile.Directory!.FullName, appHostPath); - qualifiedAppHostPath = PathNormalizer.NormalizePathForCurrentPlatform(qualifiedAppHostPath); - var appHostFile = new FileInfo(qualifiedAppHostPath); + using var stream = settingsFile.OpenRead(); + using var json = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false); - if (appHostFile.Exists) + if (json.RootElement.ValueKind is not JsonValueKind.Object) { - return appHostFile; + ReportInvalidConfigurationFileShape(settingsFile.FullName, silent); + return null; } - else + + if (json.RootElement.TryGetProperty(LegacySettingsAppHostPathKey, out var appHostPathProperty)) { - // AppHost file was specified but doesn't exist, return null to trigger fallback logic - if (!silent) + if (appHostPathProperty.ValueKind is not JsonValueKind.Null and not JsonValueKind.String) { - interactionService.DisplayMessage(KnownEmojis.Warning, string.Format(CultureInfo.CurrentCulture, ErrorStrings.AppHostWasSpecifiedButDoesntExist, settingsFile.FullName, qualifiedAppHostPath)); + ReportInvalidConfiguredAppHostPathType(settingsFile.FullName, LegacySettingsAppHostPathKey, silent); + return null; + } + + if (appHostPathProperty.GetString() is { } appHostPath) + { + // Mirror the validation on the modern path above so the legacy branch also + // cannot reach Path.Combine with a NUL byte or other Path.GetInvalidPathChars + // value (https://github.com/microsoft/aspire/issues/17624). + if (!IsValidConfiguredAppHostPath(appHostPath, settingsFile.FullName, fieldName: LegacySettingsAppHostPathKey, silent: silent)) + { + return null; + } + + var qualifiedAppHostPath = Path.IsPathRooted(appHostPath) ? appHostPath : Path.Combine(settingsFile.Directory!.FullName, appHostPath); + qualifiedAppHostPath = PathNormalizer.NormalizePathForCurrentPlatform(qualifiedAppHostPath); + var appHostFile = new FileInfo(qualifiedAppHostPath); + + if (appHostFile.Exists) + { + return appHostFile; + } + else + { + if (!silent) + { + // Warn against the user-authored file (.aspire/settings.json), not the + // never-authored aspire.config.json. Earlier versions reported + // aspire.config.json because startup eagerly migrated the legacy + // settings (PR #17234); see https://github.com/microsoft/aspire/issues/17620 + // for the user-facing impact of pointing users at a file they did + // not create. + interactionService.DisplayMessage(KnownEmojis.Warning, string.Format(CultureInfo.CurrentCulture, ErrorStrings.AppHostWasSpecifiedButDoesntExist, settingsFile.FullName, qualifiedAppHostPath)); + } + return null; + } } - return null; } } + catch (JsonException ex) + { + var message = string.Format(CultureInfo.CurrentCulture, ErrorStrings.InvalidJsonInConfigFile, settingsFile.FullName, ex.Message); + ReportInvalidConfigurationFile(ex, message, silent); + return null; + } } if (searchParentDirectories && searchDirectory.Parent is not null) @@ -661,6 +752,68 @@ async Task AddSettingsAppHostCandidateAsync() } } + private void ReportInvalidConfigurationFileShape(string configFilePath, bool silent) + { + var message = string.Format(CultureInfo.CurrentCulture, ErrorStrings.ConfigurationFileMustBeJsonObject, configFilePath); + if (!silent) + { + interactionService.DisplayError(message); + } + else + { + logger.LogWarning("Ignoring AppHost settings in '{ConfigFilePath}' because the configuration root is not a JSON object.", configFilePath); + } + } + + private void ReportInvalidConfiguredAppHostPathType(string configFilePath, string fieldName, bool silent) + { + var message = string.Format(CultureInfo.CurrentCulture, ErrorStrings.ConfiguredAppHostPathMustBeString, configFilePath, fieldName); + if (!silent) + { + interactionService.DisplayError(message); + } + else + { + logger.LogWarning("Ignoring configured AppHost path in '{ConfigFilePath}' ('{FieldName}') because it is not a JSON string.", configFilePath, fieldName); + } + } + + private void ReportInvalidConfigurationFile(JsonException ex, string message, bool silent) + { + if (!silent) + { + interactionService.DisplayError(message); + } + else + { + logger.LogWarning(ex, "Unable to load AppHost settings: {Message}", message); + } + } + + // Reject empty paths (Path.Combine("", base) collapses to the base directory and surfaces + // a misleading "directory doesn't exist" warning downstream) and paths that contain + // characters that would crash System.IO APIs. Path.GetInvalidPathChars() includes NUL on + // every platform plus the platform-specific set of disallowed characters (e.g. < > | on + // Windows). Plain Contains('\0') is included explicitly for readability even though it is + // redundant with the IndexOfAny check. + private bool IsValidConfiguredAppHostPath(string path, string configFilePath, string fieldName, bool silent) + { + if (path.Length == 0 || path.Contains('\0') || path.IndexOfAny(Path.GetInvalidPathChars()) >= 0) + { + if (!silent) + { + interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, ErrorStrings.ConfiguredAppHostPathHasInvalidCharacters, configFilePath, fieldName)); + } + else + { + logger.LogWarning("Ignoring configured AppHost path in '{ConfigFilePath}' ('{FieldName}') because it is empty or contains invalid characters.", configFilePath, fieldName); + } + return false; + } + + return true; + } + public async Task UseOrFindAppHostProjectFileAsync(FileInfo? projectFile, MultipleAppHostProjectsFoundBehavior multipleAppHostProjectsFoundBehavior, bool createSettingsFile, CancellationToken cancellationToken = default) { logger.LogDebug("Finding project file in {CurrentDirectory}", executionContext.WorkingDirectory); @@ -782,7 +935,7 @@ public async Task UseOrFindAppHostProjectFileAsync(F } } - var settingsAppHost = await GetValidatedAppHostProjectFileFromSettingsAsync(executionContext.WorkingDirectory, searchParentDirectories: true, silent: true, cancellationToken); + var settingsAppHost = await GetValidatedAppHostProjectFileFromSettingsAsync(executionContext.WorkingDirectory, searchParentDirectories: true, cancellationToken); if (settingsAppHost is not null && multipleAppHostProjectsFoundBehavior is not MultipleAppHostProjectsFoundBehavior.None) { @@ -960,7 +1113,7 @@ private async Task CreateSettingsFileAsync(FileInfo projectFile, CancellationTok var relativePathToProjectFile = Path.GetRelativePath(settingsFile.Directory!.FullName, projectFile.FullName).Replace(Path.DirectorySeparatorChar, '/'); // Use the configuration writer to set the AppHost path, which will merge with any existing settings. - await ConfigurationService.SetConfigurationInFileAsync(settingsFile.FullName, "appHost.path", relativePathToProjectFile, cancellationToken); + await ConfigurationService.SetConfigurationInFileAsync(settingsFile.FullName, AspireConfigAppHostPathKey, relativePathToProjectFile, cancellationToken); // For polyglot projects, also set language and inherit SDK version from parent/global config. var language = languageDiscovery.GetLanguageByFile(projectFile); diff --git a/src/Aspire.Cli/Resources/ErrorStrings.Designer.cs b/src/Aspire.Cli/Resources/ErrorStrings.Designer.cs index 0a1e6964118..f01fa5c5095 100644 --- a/src/Aspire.Cli/Resources/ErrorStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/ErrorStrings.Designer.cs @@ -452,5 +452,32 @@ public static string CodegenDebugHeader { return ResourceManager.GetString("CodegenDebugHeader", resourceCulture); } } + + /// + /// Looks up a localized string similar to The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path.. + /// + public static string ConfiguredAppHostPathHasInvalidCharacters { + get { + return ResourceManager.GetString("ConfiguredAppHostPathHasInvalidCharacters", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The configured AppHost path in '{0}' ('{1}') must be a JSON string.. + /// + public static string ConfiguredAppHostPathMustBeString { + get { + return ResourceManager.GetString("ConfiguredAppHostPathMustBeString", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The configuration file '{0}' must contain a JSON object.. + /// + public static string ConfigurationFileMustBeJsonObject { + get { + return ResourceManager.GetString("ConfigurationFileMustBeJsonObject", resourceCulture); + } + } } } diff --git a/src/Aspire.Cli/Resources/ErrorStrings.resx b/src/Aspire.Cli/Resources/ErrorStrings.resx index aba3b239f19..15cd900dd7f 100644 --- a/src/Aspire.Cli/Resources/ErrorStrings.resx +++ b/src/Aspire.Cli/Resources/ErrorStrings.resx @@ -276,4 +276,16 @@ Diagnostic details: + + The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. + {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + + + The configured AppHost path in '{0}' ('{1}') must be a JSON string. + {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + + + The configuration file '{0}' must contain a JSON object. + {0} is the configuration file path that the user authored. + diff --git a/src/Aspire.Cli/Resources/SharedCommandStrings.resx b/src/Aspire.Cli/Resources/SharedCommandStrings.resx index db64d25f87f..ab8f423998e 100644 --- a/src/Aspire.Cli/Resources/SharedCommandStrings.resx +++ b/src/Aspire.Cli/Resources/SharedCommandStrings.resx @@ -152,7 +152,7 @@ Include all candidate AppHosts, ignoring .gitignore and built-in directory filters - Stream newline-delimited JSON discovery events. Requires --format json + Output discovered AppHosts as newline-delimited JSON. Requires --format json The --stream option requires --format json. diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.cs.xlf index 7c2ddc27688..652575c0303 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.cs.xlf @@ -82,6 +82,11 @@ Tento příkaz zatím není pro funkci AppHost pro jednosouborové scénáře podporován. + + The configuration file '{0}' must contain a JSON object. + The configuration file '{0}' must contain a JSON object. + {0} is the configuration file path that the user authored. + Configuration key {0} not found. Konfigurační klíč {0} se nenašel. @@ -97,6 +102,16 @@ Hodnota konfigurace je povinná. + + The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. + The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. + {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + + + The configured AppHost path in '{0}' ('{1}') must be a JSON string. + The configured AppHost path in '{0}' ('{1}') must be a JSON string. + {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + Could not parse Aspire.Hosting package version. Nepodařilo se analyzovat verzi balíčku Aspire.Hosting. diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.de.xlf index 7ee217e468a..3cd1ff40c3a 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.de.xlf @@ -82,6 +82,11 @@ Dieser Befehl wird für AppHosts mit einer einzelnen Datei noch nicht unterstützt. + + The configuration file '{0}' must contain a JSON object. + The configuration file '{0}' must contain a JSON object. + {0} is the configuration file path that the user authored. + Configuration key {0} not found. Der Konfigurationsschlüssel „{0}“ wurde nicht gefunden. @@ -97,6 +102,16 @@ Der Konfigurationswert ist erforderlich. + + The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. + The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. + {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + + + The configured AppHost path in '{0}' ('{1}') must be a JSON string. + The configured AppHost path in '{0}' ('{1}') must be a JSON string. + {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + Could not parse Aspire.Hosting package version. Die Aspire.Hosting-Paketversion konnte nicht analysiert werden. diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.es.xlf index 00257b66305..cd2a73d1481 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.es.xlf @@ -82,6 +82,11 @@ Este comando aún no es compatible con AppHosts de un solo archivo. + + The configuration file '{0}' must contain a JSON object. + The configuration file '{0}' must contain a JSON object. + {0} is the configuration file path that the user authored. + Configuration key {0} not found. No se encuentra la clave de configuración {0}. @@ -97,6 +102,16 @@ El valor de configuración es obligatorio. + + The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. + The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. + {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + + + The configured AppHost path in '{0}' ('{1}') must be a JSON string. + The configured AppHost path in '{0}' ('{1}') must be a JSON string. + {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + Could not parse Aspire.Hosting package version. No se pudo analizar la versión del paquete Aspire.Hosting. diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.fr.xlf index 280e4d050b7..66ce3faedae 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.fr.xlf @@ -82,6 +82,11 @@ Cette commande n’est pas encore prise en charge avec les hôtes d’application à fichier unique. + + The configuration file '{0}' must contain a JSON object. + The configuration file '{0}' must contain a JSON object. + {0} is the configuration file path that the user authored. + Configuration key {0} not found. Clé de configuration {0} introuvable. @@ -97,6 +102,16 @@ La valeur de la configuration est requise. + + The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. + The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. + {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + + + The configured AppHost path in '{0}' ('{1}') must be a JSON string. + The configured AppHost path in '{0}' ('{1}') must be a JSON string. + {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + Could not parse Aspire.Hosting package version. Impossible d’analyser la version du package Aspire.Hosting. diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.it.xlf index 3f9864622c6..82ec8b8bc0a 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.it.xlf @@ -82,6 +82,11 @@ Questo comando non è ancora supportato con AppHost a file singolo. + + The configuration file '{0}' must contain a JSON object. + The configuration file '{0}' must contain a JSON object. + {0} is the configuration file path that the user authored. + Configuration key {0} not found. Chiave di configurazione {0} non trovata. @@ -97,6 +102,16 @@ Il valore di configurazione è obbligatorio. + + The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. + The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. + {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + + + The configured AppHost path in '{0}' ('{1}') must be a JSON string. + The configured AppHost path in '{0}' ('{1}') must be a JSON string. + {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + Could not parse Aspire.Hosting package version. Non è possibile analizzare la versione del pacchetto Aspire.Hosting. diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.ja.xlf index 84cb684a628..95977bda072 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.ja.xlf @@ -82,6 +82,11 @@ このコマンドは、単一ファイルの AppHost ではまだサポートされていません。 + + The configuration file '{0}' must contain a JSON object. + The configuration file '{0}' must contain a JSON object. + {0} is the configuration file path that the user authored. + Configuration key {0} not found. 構成キー {0} が見つかりません。 @@ -97,6 +102,16 @@ 構成値が必要です。 + + The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. + The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. + {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + + + The configured AppHost path in '{0}' ('{1}') must be a JSON string. + The configured AppHost path in '{0}' ('{1}') must be a JSON string. + {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + Could not parse Aspire.Hosting package version. Aspire.Hosting パッケージのバージョンを解析できませんでした。 diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.ko.xlf index 1cdf0d2b9d3..de17b21cb8a 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.ko.xlf @@ -82,6 +82,11 @@ 이 명령은 단일 파일 AppHosts에서 아직 지원되지 않습니다. + + The configuration file '{0}' must contain a JSON object. + The configuration file '{0}' must contain a JSON object. + {0} is the configuration file path that the user authored. + Configuration key {0} not found. 구성 키 {0}을(를) 찾을 수 없습니다. @@ -97,6 +102,16 @@ 구성 값이 필요합니다. + + The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. + The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. + {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + + + The configured AppHost path in '{0}' ('{1}') must be a JSON string. + The configured AppHost path in '{0}' ('{1}') must be a JSON string. + {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + Could not parse Aspire.Hosting package version. Aspire.Hosting 패키지 버전을 구문 분석할 수 없습니다. diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.pl.xlf index 791f4f7644c..073565cbffb 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.pl.xlf @@ -82,6 +82,11 @@ To polecenie nie jest jeszcze obsługiwane w przypadku hostów AppHost z jednym plikiem. + + The configuration file '{0}' must contain a JSON object. + The configuration file '{0}' must contain a JSON object. + {0} is the configuration file path that the user authored. + Configuration key {0} not found. Nie znaleziono klucza konfiguracji {0}. @@ -97,6 +102,16 @@ Wartość konfiguracji jest wymagana. + + The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. + The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. + {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + + + The configured AppHost path in '{0}' ('{1}') must be a JSON string. + The configured AppHost path in '{0}' ('{1}') must be a JSON string. + {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + Could not parse Aspire.Hosting package version. Nie można przeanalizować wersji pakietu Aspire.Hosting. diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.pt-BR.xlf index 8c867c5648d..504677a5398 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.pt-BR.xlf @@ -82,6 +82,11 @@ Este comando ainda não tem suporte para AppHosts de arquivo único. + + The configuration file '{0}' must contain a JSON object. + The configuration file '{0}' must contain a JSON object. + {0} is the configuration file path that the user authored. + Configuration key {0} not found. Chave de configuração {0} não encontrada. @@ -97,6 +102,16 @@ O valor de configuração é obrigatório. + + The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. + The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. + {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + + + The configured AppHost path in '{0}' ('{1}') must be a JSON string. + The configured AppHost path in '{0}' ('{1}') must be a JSON string. + {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + Could not parse Aspire.Hosting package version. Não foi possível analisar a versão do pacote Aspire.Hosting. diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.ru.xlf index 77cc5494af3..9048d03de96 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.ru.xlf @@ -82,6 +82,11 @@ Эта команда пока не поддерживается для одиночных файлов AppHost. + + The configuration file '{0}' must contain a JSON object. + The configuration file '{0}' must contain a JSON object. + {0} is the configuration file path that the user authored. + Configuration key {0} not found. Ключ конфигурации {0} не найден. @@ -97,6 +102,16 @@ Значение конфигурации обязательно. + + The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. + The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. + {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + + + The configured AppHost path in '{0}' ('{1}') must be a JSON string. + The configured AppHost path in '{0}' ('{1}') must be a JSON string. + {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + Could not parse Aspire.Hosting package version. Не удалось выполнить разбор версии пакета Aspire.Hosting. diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.tr.xlf index 2fb689d9b5a..a00112fa538 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.tr.xlf @@ -82,6 +82,11 @@ Bu komut, tek dosya Uygulama Ana İşlemlerinde henüz desteklenmemektedir. + + The configuration file '{0}' must contain a JSON object. + The configuration file '{0}' must contain a JSON object. + {0} is the configuration file path that the user authored. + Configuration key {0} not found. {0} yapılandırma anahtarı bulunamadı. @@ -97,6 +102,16 @@ Yapılandırma değeri gereklidir. + + The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. + The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. + {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + + + The configured AppHost path in '{0}' ('{1}') must be a JSON string. + The configured AppHost path in '{0}' ('{1}') must be a JSON string. + {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + Could not parse Aspire.Hosting package version. Aspire.Hosting paket sürümü ayrıştırılamadı. diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.zh-Hans.xlf index f46af90c416..1b1034c7424 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.zh-Hans.xlf @@ -82,6 +82,11 @@ 单文件应用主机尚不支持此命令。 + + The configuration file '{0}' must contain a JSON object. + The configuration file '{0}' must contain a JSON object. + {0} is the configuration file path that the user authored. + Configuration key {0} not found. 未找到配置键 {0}。 @@ -97,6 +102,16 @@ 配置值是必需的。 + + The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. + The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. + {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + + + The configured AppHost path in '{0}' ('{1}') must be a JSON string. + The configured AppHost path in '{0}' ('{1}') must be a JSON string. + {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + Could not parse Aspire.Hosting package version. 无法分析 Aspire.Hosting 包版本。 diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.zh-Hant.xlf index be6279bf20b..a4c742cd1d8 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.zh-Hant.xlf @@ -82,6 +82,11 @@ 單一檔案 AppHost 尚不支援此命令。 + + The configuration file '{0}' must contain a JSON object. + The configuration file '{0}' must contain a JSON object. + {0} is the configuration file path that the user authored. + Configuration key {0} not found. 找不到設定金鑰 {0}。 @@ -97,6 +102,16 @@ 設定值為必須。 + + The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. + The configured AppHost path in '{0}' ('{1}') is empty or contains characters that are not allowed in a file path. + {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + + + The configured AppHost path in '{0}' ('{1}') must be a JSON string. + The configured AppHost path in '{0}' ('{1}') must be a JSON string. + {0} is the configuration file path that the user authored, {1} is the field/setting name within that file (for example "appHost.path" or "appHostPath") + Could not parse Aspire.Hosting package version. 無法剖析 Aspire.Hosting 套件版本。 diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.cs.xlf index d80cb544940..c8a32b43bd6 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.cs.xlf @@ -73,8 +73,8 @@ {0} is the number of directories searched so far. {1} is the number of AppHost candidates found so far. - Stream newline-delimited JSON discovery events. Requires --format json - Stream newline-delimited JSON discovery events. Requires --format json + Output discovered AppHosts as newline-delimited JSON. Requires --format json + Output discovered AppHosts as newline-delimited JSON. Requires --format json diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.de.xlf index 31d42f7488b..5e434376353 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.de.xlf @@ -73,8 +73,8 @@ {0} is the number of directories searched so far. {1} is the number of AppHost candidates found so far. - Stream newline-delimited JSON discovery events. Requires --format json - Stream newline-delimited JSON discovery events. Requires --format json + Output discovered AppHosts as newline-delimited JSON. Requires --format json + Output discovered AppHosts as newline-delimited JSON. Requires --format json diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.es.xlf index c730a706dfb..bf202fc6715 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.es.xlf @@ -73,8 +73,8 @@ {0} is the number of directories searched so far. {1} is the number of AppHost candidates found so far. - Stream newline-delimited JSON discovery events. Requires --format json - Stream newline-delimited JSON discovery events. Requires --format json + Output discovered AppHosts as newline-delimited JSON. Requires --format json + Output discovered AppHosts as newline-delimited JSON. Requires --format json diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.fr.xlf index 49d5496493a..6ab0951b25a 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.fr.xlf @@ -73,8 +73,8 @@ {0} is the number of directories searched so far. {1} is the number of AppHost candidates found so far. - Stream newline-delimited JSON discovery events. Requires --format json - Stream newline-delimited JSON discovery events. Requires --format json + Output discovered AppHosts as newline-delimited JSON. Requires --format json + Output discovered AppHosts as newline-delimited JSON. Requires --format json diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.it.xlf index 6767b9fd2c5..9269f235e7e 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.it.xlf @@ -73,8 +73,8 @@ {0} is the number of directories searched so far. {1} is the number of AppHost candidates found so far. - Stream newline-delimited JSON discovery events. Requires --format json - Stream newline-delimited JSON discovery events. Requires --format json + Output discovered AppHosts as newline-delimited JSON. Requires --format json + Output discovered AppHosts as newline-delimited JSON. Requires --format json diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ja.xlf index 548d2b428d7..1d67deacc53 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ja.xlf @@ -73,8 +73,8 @@ {0} is the number of directories searched so far. {1} is the number of AppHost candidates found so far. - Stream newline-delimited JSON discovery events. Requires --format json - Stream newline-delimited JSON discovery events. Requires --format json + Output discovered AppHosts as newline-delimited JSON. Requires --format json + Output discovered AppHosts as newline-delimited JSON. Requires --format json diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ko.xlf index 0b5ae46ed6f..e602e9cfa91 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ko.xlf @@ -73,8 +73,8 @@ {0} is the number of directories searched so far. {1} is the number of AppHost candidates found so far. - Stream newline-delimited JSON discovery events. Requires --format json - Stream newline-delimited JSON discovery events. Requires --format json + Output discovered AppHosts as newline-delimited JSON. Requires --format json + Output discovered AppHosts as newline-delimited JSON. Requires --format json diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pl.xlf index 8774eb35ce6..c7ce26d905e 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pl.xlf @@ -73,8 +73,8 @@ {0} is the number of directories searched so far. {1} is the number of AppHost candidates found so far. - Stream newline-delimited JSON discovery events. Requires --format json - Stream newline-delimited JSON discovery events. Requires --format json + Output discovered AppHosts as newline-delimited JSON. Requires --format json + Output discovered AppHosts as newline-delimited JSON. Requires --format json diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pt-BR.xlf index 60c709da5f5..a4077bf74bd 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pt-BR.xlf @@ -73,8 +73,8 @@ {0} is the number of directories searched so far. {1} is the number of AppHost candidates found so far. - Stream newline-delimited JSON discovery events. Requires --format json - Stream newline-delimited JSON discovery events. Requires --format json + Output discovered AppHosts as newline-delimited JSON. Requires --format json + Output discovered AppHosts as newline-delimited JSON. Requires --format json diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ru.xlf index 9d7383418f3..092d0677494 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ru.xlf @@ -73,8 +73,8 @@ {0} is the number of directories searched so far. {1} is the number of AppHost candidates found so far. - Stream newline-delimited JSON discovery events. Requires --format json - Stream newline-delimited JSON discovery events. Requires --format json + Output discovered AppHosts as newline-delimited JSON. Requires --format json + Output discovered AppHosts as newline-delimited JSON. Requires --format json diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.tr.xlf index 567191b78ac..a5e5b3b9052 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.tr.xlf @@ -73,8 +73,8 @@ {0} is the number of directories searched so far. {1} is the number of AppHost candidates found so far. - Stream newline-delimited JSON discovery events. Requires --format json - Stream newline-delimited JSON discovery events. Requires --format json + Output discovered AppHosts as newline-delimited JSON. Requires --format json + Output discovered AppHosts as newline-delimited JSON. Requires --format json diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hans.xlf index 1f16cae6bc4..6ed4ab62cb5 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hans.xlf @@ -73,8 +73,8 @@ {0} is the number of directories searched so far. {1} is the number of AppHost candidates found so far. - Stream newline-delimited JSON discovery events. Requires --format json - Stream newline-delimited JSON discovery events. Requires --format json + Output discovered AppHosts as newline-delimited JSON. Requires --format json + Output discovered AppHosts as newline-delimited JSON. Requires --format json diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hant.xlf index 16bdb12f075..0d9c9855ef2 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hant.xlf @@ -73,8 +73,8 @@ {0} is the number of directories searched so far. {1} is the number of AppHost candidates found so far. - Stream newline-delimited JSON discovery events. Requires --format json - Stream newline-delimited JSON discovery events. Requires --format json + Output discovered AppHosts as newline-delimited JSON. Requires --format json + Output discovered AppHosts as newline-delimited JSON. Requires --format json diff --git a/src/Aspire.Cli/Utils/ConfigurationHelper.cs b/src/Aspire.Cli/Utils/ConfigurationHelper.cs index 042d20c694e..b83f5273393 100644 --- a/src/Aspire.Cli/Utils/ConfigurationHelper.cs +++ b/src/Aspire.Cli/Utils/ConfigurationHelper.cs @@ -7,7 +7,6 @@ using Aspire.Cli.Configuration; using Aspire.Cli.Resources; using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; namespace Aspire.Cli.Utils; @@ -24,7 +23,7 @@ internal static class ConfigurationHelper AllowTrailingCommas = true }; - internal static void RegisterSettingsFiles(IConfigurationBuilder configuration, DirectoryInfo workingDirectory, FileInfo globalSettingsFile, ILogger logger) + internal static void RegisterSettingsFiles(IConfigurationBuilder configuration, DirectoryInfo workingDirectory, FileInfo globalSettingsFile) { var currentDirectory = workingDirectory; @@ -43,46 +42,27 @@ internal static void RegisterSettingsFiles(IConfigurationBuilder configuration, // TODO: Remove legacy .aspire/settings.json fallback once confident most users have migrated. // Tracked by https://github.com/microsoft/aspire/issues/15239 - // Fall back to .aspire/settings.json (legacy format) + // Fall back to .aspire/settings.json (legacy format). + // + // Startup is shared by every command — including read-only ones (aspire ls, ps, + // doctor, describe, --version). Earlier versions eagerly migrated the legacy file + // to aspire.config.json here so the workspace would move forward on the user's + // first run of a newer CLI (https://github.com/microsoft/aspire/issues/15488), + // but that broke the "read commands don't mutate the working tree" contract: + // running aspire ls in a workspace that only had .aspire/settings.json was + // silently writing aspire.config.json, polluting git status and tripping CI + // dirty-tree checks (https://github.com/microsoft/aspire/issues/17615). + // + // Migration is now deferred to commands that already mutate the workspace + // (aspire run/add/init/update/etc. via ProjectLocator.CreateSettingsFileAsync -> + // AspireConfigFile.LoadOrCreate). Read commands continue to work against the + // legacy file directly: AppHostPathConfigurationPolicy.TryFindAppHostPathKey + // accepts both the legacy flat "appHostPath" key and the modern "appHost:path" + // hierarchical key, and ProjectLocator's settings-file reader has its own legacy + // fallback that does not write. var legacySettingsPath = BuildPathToSettingsJsonFile(currentDirectory.FullName); if (File.Exists(legacySettingsPath)) { - // Eagerly migrate the legacy layout to aspire.config.json on startup so that - // even read-only commands (aspire doctor, aspire ls, aspire --version, etc.) - // move the workspace forward on the user's first run of a newer CLI. Without - // this, migration only happens when a command actively writes settings - // (aspire run/add/update/pipeline), leaving workspaces stuck on the legacy - // layout indefinitely for users who never invoke those commands. - // See https://github.com/microsoft/aspire/issues/15488. - if (LegacySettingsFileHasMigratableData(legacySettingsPath)) - { - try - { - _ = AspireConfigFile.LoadOrCreate(currentDirectory.FullName); - var migratedPath = Path.Combine(currentDirectory.FullName, AspireConfigFile.FileName); - if (File.Exists(migratedPath)) - { - logger.LogInformation( - "Migrated legacy {LegacyPath} to {MigratedPath} on CLI startup.", - legacySettingsPath, - migratedPath); - localSettingsFile = new FileInfo(migratedPath); - break; - } - } - catch (Exception ex) - { - // Migration is best-effort during startup. If it fails (read-only - // directory, IO error, malformed legacy JSON), fall back to using the - // legacy file directly so the CLI still works. The next command that - // writes settings will retry the migration through its normal path. - logger.LogWarning( - ex, - "Failed to migrate legacy {LegacyPath} to aspire.config.json on startup. Falling back to the legacy file.", - legacySettingsPath); - } - } - localSettingsFile = new FileInfo(legacySettingsPath); break; } @@ -108,47 +88,6 @@ internal static string BuildPathToSettingsJsonFile(string workingDirectory) return Path.Combine(workingDirectory, ".aspire", "settings.json"); } - /// - /// Returns true when a legacy .aspire/settings.json file contains an - /// appHostPath entry — the signal that this is a real AppHost workspace worth - /// migrating to aspire.config.json. Files that exist purely as directory-walking - /// stop markers (empty JSON object, whitespace, comment-only, or files that only carry - /// flat colon-keys awaiting in-place normalization) are not migrated because doing so - /// would needlessly materialize an aspire.config.json alongside them with no - /// meaningful content. - /// - private static bool LegacySettingsFileHasMigratableData(string legacySettingsPath) - { - try - { - var content = File.ReadAllText(legacySettingsPath); - if (string.IsNullOrWhiteSpace(content)) - { - return false; - } - - // Read appHostPath directly with JsonDocument to avoid coupling this check to the - // strict-typed AspireJsonConfiguration deserializer, which would fail for files - // that contain loosely-typed values (e.g. string "false" in a bool dictionary). - using var doc = JsonDocument.Parse(content, ParseOptions); - if (doc.RootElement.ValueKind != JsonValueKind.Object) - { - return false; - } - - return doc.RootElement.TryGetProperty("appHostPath", out var appHostPathElement) - && appHostPathElement.ValueKind == JsonValueKind.String - && !string.IsNullOrEmpty(appHostPathElement.GetString()); - } - catch - { - // Treat unreadable or malformed legacy files as not-migratable so startup does - // not throw. The user will see the same JSON parse error from the regular - // configuration registration path below if the file is genuinely broken. - return false; - } - } - internal static DirectoryInfo? GetLegacySettingsRootDirectory(FileInfo settingsFile) { if (!string.Equals(settingsFile.Name, AspireJsonConfiguration.FileName, StringComparison.OrdinalIgnoreCase)) diff --git a/src/Aspire.Hosting.Foundry/api/Aspire.Hosting.Foundry.ats.txt b/src/Aspire.Hosting.Foundry/api/Aspire.Hosting.Foundry.ats.txt index 5120af20ebf..bff2fdcab1a 100644 --- a/src/Aspire.Hosting.Foundry/api/Aspire.Hosting.Foundry.ats.txt +++ b/src/Aspire.Hosting.Foundry/api/Aspire.Hosting.Foundry.ats.txt @@ -304,6 +304,7 @@ Aspire.Hosting.Foundry/runAsFoundryLocal() -> Aspire.Hosting.Foundry/Aspire.Host Aspire.Hosting.Foundry/withAppInsights(appInsights: Aspire.Hosting.Azure.ApplicationInsights/Aspire.Hosting.Azure.AzureApplicationInsightsResource) -> Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.AzureCognitiveServicesProjectResource Aspire.Hosting.Foundry/withBingReference(bingReference: Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.BingGroundingConnectionResource|string|Aspire.Hosting/Aspire.Hosting.ApplicationModel.ParameterResource) -> Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.BingGroundingToolResource Aspire.Hosting.Foundry/withCapabilityHost(resource: Aspire.Hosting.Azure.CosmosDB/Aspire.Hosting.AzureCosmosDBResource|Aspire.Hosting.Azure.Storage/Aspire.Hosting.Azure.AzureStorageResource|Aspire.Hosting.Azure.Search/Aspire.Hosting.Azure.AzureSearchResource|Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.FoundryResource) -> Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.AzureCognitiveServicesProjectResource +Aspire.Hosting.Foundry/withComputeEnvironmentExecutable(project?: Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.AzureCognitiveServicesProjectResource, configure?: callback) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithEndpoints Aspire.Hosting.Foundry/withFoundryDeploymentProperties(configure: callback) -> Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.FoundryDeploymentResource Aspire.Hosting.Foundry/withFoundryRoleAssignments(target: Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.FoundryResource, roles: enum:Aspire.Hosting.FoundryRole[]) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResource Aspire.Hosting.Foundry/withKeyVault(keyVault: Aspire.Hosting.Azure.KeyVault/Aspire.Hosting.Azure.AzureKeyVaultResource) -> Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.AzureCognitiveServicesProjectResource diff --git a/src/Shared/PathNormalizer.cs b/src/Shared/PathNormalizer.cs index 19beaf4a0d0..7b5ea212bd3 100644 --- a/src/Shared/PathNormalizer.cs +++ b/src/Shared/PathNormalizer.cs @@ -84,4 +84,118 @@ public static string ResolveToFilesystemPath(string path) return path; } + + /// + /// Resolves symbolic links along every segment of and returns + /// the filesystem-canonical absolute path. Useful for comparing two user-supplied paths + /// that may differ only because one of them traverses a symlinked directory + /// (for example /tmp/x vs /private/tmp/x on macOS, where /tmp is a + /// symlink to /private/tmp). + /// + /// + /// Walks each segment so that an intermediate directory symlink resolves + /// correctly — only reads the + /// symlink at exactly the path it is given, so a single call on a path like + /// /tmp/x/y.cs would not unwrap /tmp. + /// On any IO failure (broken link, permission denied, missing intermediate + /// segment, circular link), returns the path with as many segments resolved as + /// possible. This is a best-effort canonicalization for comparison — callers should + /// not rely on it for security boundaries. + /// + public static string ResolveSymlinks(string path) + { + return ResolveSymlinksCore(path, depth: 0); + } + + // Hard depth limit on recursive canonicalization to defend against pathological + // symlink chains; well-formed real-world paths resolve in a handful of levels. + private const int MaxResolveSymlinksDepth = 40; + + private static string ResolveSymlinksCore(string path, int depth) + { + if (string.IsNullOrEmpty(path)) + { + return path; + } + + if (depth > MaxResolveSymlinksDepth) + { + // Give up rather than risk a stack overflow on circular/pathological links. + return path; + } + + try + { + var fullPath = Path.GetFullPath(path); + var root = Path.GetPathRoot(fullPath); + if (string.IsNullOrEmpty(root)) + { + return fullPath; + } + + // Walk only the part after the root so segment splitting cannot eat a drive + // letter ("C:") or UNC prefix. + var relative = fullPath[root.Length..]; + var segments = relative.Split( + [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], + StringSplitOptions.RemoveEmptyEntries); + + var current = root; + for (var i = 0; i < segments.Length; i++) + { + current = Path.Combine(current, segments[i]); + + FileSystemInfo? linkTarget = null; + try + { + // For intermediate segments we know they must be directories — files + // cannot have child segments. For the final segment, try file first + // then directory, since either is plausible. + linkTarget = i < segments.Length - 1 + ? Directory.ResolveLinkTarget(current, returnFinalTarget: true) + : File.ResolveLinkTarget(current, returnFinalTarget: true) + ?? Directory.ResolveLinkTarget(current, returnFinalTarget: true); + } + catch (IOException) + { + // Broken or circular symlink. Stop unwrapping and return what we have + // resolved so far combined with the remaining unresolved segments — + // matches the behaviour callers get from FileInfo when the link is bad. + return CombineRemaining(current, segments, i + 1); + } + catch (UnauthorizedAccessException) + { + return CombineRemaining(current, segments, i + 1); + } + + if (linkTarget?.FullName is { Length: > 0 } resolved) + { + // ResolveLinkTarget returns the symlink target exactly as stored on disk, + // which may itself contain unresolved symlinks in intermediate segments + // (for example on macOS a link target "/var/.../app" still has + // "/var -> /private/var" unresolved). Recurse so the canonical form does + // not depend on which side of the comparison reached the file first. + current = ResolveSymlinksCore(resolved, depth + 1); + } + } + + return current; + } + catch (Exception) + { + // Defensive: any unexpected normalization failure preserves caller-visible + // behaviour by falling back to the input path. + return path; + } + + static string CombineRemaining(string current, string[] segments, int startIndex) + { + for (var j = startIndex; j < segments.Length; j++) + { + current = Path.Combine(current, segments[j]); + } + + return current; + } + } } diff --git a/tests/Aspire.Cli.Tests/Commands/LsCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/LsCommandTests.cs index 59a2655e32b..660c40f978a 100644 --- a/tests/Aspire.Cli.Tests/Commands/LsCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/LsCommandTests.cs @@ -356,6 +356,58 @@ public async Task LsCommand_JsonFormat_Stream_WhenNoCandidates_DoesNotWriteStder Assert.Equal(string.Empty, errorWriter.ToString()); } + [Fact] + public async Task LsCommand_JsonFormat_Stream_EmitsCandidatesInArrivalOrder() + { + // Regression test for https://github.com/microsoft/aspire/issues/17621. + // `aspire ls --format json --stream` must emit each candidate in the order it + // arrives from parallel discovery — it must NOT sort the stream output. Without + // this contract, the streaming option offers no latency benefit over the buffered + // snapshot, and consumers that rely on prompt arrival are silently broken. + // + // Use names in non-alphabetical arrival order (Z, A, M) so any incidental + // alphabetical sort would fail this assertion. + using var workspace = TemporaryWorkspace.Create(outputHelper); + var textWriter = new TestOutputTextWriter(outputHelper); + var errorWriter = new StringWriter(); + var appHostPathZ = Path.Combine(workspace.WorkspaceRoot.FullName, "ZApp", "Z.AppHost.csproj"); + var appHostPathA = Path.Combine(workspace.WorkspaceRoot.FullName, "AApp", "A.AppHost.csproj"); + var appHostPathM = Path.Combine(workspace.WorkspaceRoot.FullName, "MApp", "M.AppHost.csproj"); + var appHostZ = new AppHostProjectCandidate(new FileInfo(appHostPathZ), KnownLanguageId.CSharp); + var appHostA = new AppHostProjectCandidate(new FileInfo(appHostPathA), KnownLanguageId.CSharp); + var appHostM = new AppHostProjectCandidate(new FileInfo(appHostPathM), KnownLanguageId.CSharp); + var projectLocator = new TestProjectLocator + { + FindAppHostProjectsStreamAsyncCallback = (_, _, _, _) => ToAsyncEnumerable(appHostZ, appHostA, appHostM) + }; + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.OutputTextWriter = textWriter; + options.ErrorTextWriter = errorWriter; + options.ProjectLocatorFactory = _ => projectLocator; + }); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("ls --format json --stream"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + + var lines = textWriter.Logs.ToArray(); + Assert.Equal(3, lines.Length); + + using var first = JsonDocument.Parse(lines[0]); + using var second = JsonDocument.Parse(lines[1]); + using var third = JsonDocument.Parse(lines[2]); + Assert.Equal(appHostPathZ, first.RootElement.GetProperty("path").GetString()); + Assert.Equal(appHostPathA, second.RootElement.GetProperty("path").GetString()); + Assert.Equal(appHostPathM, third.RootElement.GetProperty("path").GetString()); + Assert.Equal(string.Empty, errorWriter.ToString()); + } + [Fact] public async Task LsCommand_JsonFormat_Stream_FlushesCandidateBeforeDiscoveryCompletes() { diff --git a/tests/Aspire.Cli.Tests/Configuration/ConfigurationHelperTests.cs b/tests/Aspire.Cli.Tests/Configuration/ConfigurationHelperTests.cs index 0f6a189c035..796a2b3bd04 100644 --- a/tests/Aspire.Cli.Tests/Configuration/ConfigurationHelperTests.cs +++ b/tests/Aspire.Cli.Tests/Configuration/ConfigurationHelperTests.cs @@ -7,7 +7,6 @@ using Aspire.Cli.Tests.Utils; using Aspire.Cli.Utils; using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging.Abstractions; namespace Aspire.Cli.Tests.Configuration; @@ -23,7 +22,7 @@ private static IConfiguration BuildConfigurationFromSettingsFile( var globalSettingsFile = new FileInfo(Path.Combine(globalDir.FullName, AspireConfigFile.FileName)); var builder = new ConfigurationBuilder(); - ConfigurationHelper.RegisterSettingsFiles(builder, workspace.WorkspaceRoot, globalSettingsFile, NullLogger.Instance); + ConfigurationHelper.RegisterSettingsFiles(builder, workspace.WorkspaceRoot, globalSettingsFile); return builder.Build(); } @@ -174,13 +173,19 @@ public void GetWorkspaceAspireDirectory_UsesLegacySettingsParentDirectory() } [Fact] - public void RegisterSettingsFiles_MigratesLegacySettingsToAspireConfigJsonOnStartup() + public void RegisterSettingsFiles_DoesNotMigrateLegacySettingsOnStartup() { - // Reproduces https://github.com/microsoft/aspire/issues/15488: a user upgrades the - // CLI and runs it against an existing AppHost workspace that only has the legacy - // .aspire/settings.json. The CLI must eagerly migrate the workspace to - // aspire.config.json on startup, regardless of which command the user runs (even - // read-only commands that never pass createSettingsFile: true to ProjectLocator). + // Read commands (aspire ls, ps, doctor, --version) must not silently write + // aspire.config.json to disk when a workspace only has the legacy + // .aspire/settings.json. Earlier versions migrated eagerly here + // (https://github.com/microsoft/aspire/issues/15488), but that violated the + // "read commands don't mutate the working tree" contract reported in + // https://github.com/microsoft/aspire/issues/17615. + // + // Migration is now deferred to commands that already mutate the workspace + // (aspire run/add/init/update/etc.). Startup must register the legacy file + // directly so legacy settings remain readable from IConfiguration without + // materializing aspire.config.json. using var workspace = TemporaryWorkspace.Create(outputHelper); var legacyDir = workspace.CreateDirectory(AspireJsonConfiguration.SettingsFolder); @@ -199,11 +204,18 @@ public void RegisterSettingsFiles_MigratesLegacySettingsToAspireConfigJsonOnStar var globalSettingsFile = new FileInfo(Path.Combine(globalDir.FullName, AspireConfigFile.FileName)); var builder = new ConfigurationBuilder(); - ConfigurationHelper.RegisterSettingsFiles(builder, workspace.WorkspaceRoot, globalSettingsFile, NullLogger.Instance); + ConfigurationHelper.RegisterSettingsFiles(builder, workspace.WorkspaceRoot, globalSettingsFile); - Assert.True( + Assert.False( File.Exists(aspireConfigPath), - "aspire.config.json should have been created by eager migration during RegisterSettingsFiles."); + "Startup must not materialize aspire.config.json — that would be a silent write from a read command path."); + + // Legacy values must remain readable via their flat key. Consumers reading the + // hierarchical key go through AppHostPathConfigurationPolicy.TryFindAppHostPathKey, + // which falls back to the legacy "appHostPath" key. + var config = builder.Build(); + Assert.Equal("MyApp.csproj", config["appHostPath"]); + Assert.Equal("stable", config["channel"]); } [Fact] @@ -213,8 +225,8 @@ public void RegisterSettingsFiles_DoesNotOverwriteExistingAspireConfigJson() // Both files present: the workspace was already migrated but the legacy file was // retained (this is the documented transition state — see AspireConfigFile.LoadOrCreate - // and https://github.com/microsoft/aspire/issues/15239). Startup migration must not - // clobber the existing aspire.config.json or re-migrate from stale legacy data. + // and https://github.com/microsoft/aspire/issues/15239). Startup must continue to + // prefer the new file and must not touch either file from a read-command path. var aspireConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName); var existingContent = """ { @@ -234,7 +246,7 @@ public void RegisterSettingsFiles_DoesNotOverwriteExistingAspireConfigJson() var globalSettingsFile = new FileInfo(Path.Combine(globalDir.FullName, AspireConfigFile.FileName)); var builder = new ConfigurationBuilder(); - ConfigurationHelper.RegisterSettingsFiles(builder, workspace.WorkspaceRoot, globalSettingsFile, NullLogger.Instance); + ConfigurationHelper.RegisterSettingsFiles(builder, workspace.WorkspaceRoot, globalSettingsFile); Assert.Equal(existingContent, File.ReadAllText(aspireConfigPath)); @@ -244,76 +256,26 @@ public void RegisterSettingsFiles_DoesNotOverwriteExistingAspireConfigJson() } [Fact] - public void RegisterSettingsFiles_GuardRejectsUnparseableLegacyFile() + public void RegisterSettingsFiles_UnparseableLegacyFileDoesNotCreateAspireConfigJson() { using var workspace = TemporaryWorkspace.Create(outputHelper); var legacyDir = workspace.CreateDirectory(AspireJsonConfiguration.SettingsFolder); var legacySettingsPath = Path.Combine(legacyDir.FullName, AspireJsonConfiguration.FileName); - // Unparseable JSON fails JsonDocument.Parse inside LegacySettingsFileHasMigratableData, - // so the guard short-circuits and returns false before migration is attempted. The - // downstream JSON registration via AddSettingsFile is what surfaces the parse error - // to the user, which is the pre-existing "your settings.json is broken" signal. The - // migration step itself must not introduce a new crash path on top of that. File.WriteAllText(legacySettingsPath, "{ this is not valid json"); var globalDir = workspace.CreateDirectory("global-aspire"); var globalSettingsFile = new FileInfo(Path.Combine(globalDir.FullName, AspireConfigFile.FileName)); var builder = new ConfigurationBuilder(); + // AddSettingsFile (JSON configuration provider) may throw on malformed JSON when the + // configuration is built, but RegisterSettingsFiles itself must not produce a + // partially-written aspire.config.json on the way through. var ex = Record.Exception(() => - ConfigurationHelper.RegisterSettingsFiles(builder, workspace.WorkspaceRoot, globalSettingsFile, NullLogger.Instance)); + ConfigurationHelper.RegisterSettingsFiles(builder, workspace.WorkspaceRoot, globalSettingsFile)); - // The guard rejected the file before migration ran, so aspire.config.json must not have - // been materialized at the workspace root. var migratedPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName); - Assert.False(File.Exists(migratedPath), "Unparseable legacy file should not produce a partial aspire.config.json."); - // Either no exception (graceful), or the same InvalidOperationException previously - // thrown by AddSettingsFile for malformed JSON. Both are acceptable; what we're - // proving is that the guard prevented us from crashing inside the new migration step. + Assert.False(File.Exists(migratedPath), "Unparseable legacy file must not produce an aspire.config.json on disk."); Assert.True(ex is null or InvalidOperationException, $"Unexpected exception type: {ex?.GetType().FullName}"); } - - [Fact] - public void RegisterSettingsFiles_FallsBackToLegacyWhenMigrationLoadThrows() - { - using var workspace = TemporaryWorkspace.Create(outputHelper); - - var legacyDir = workspace.CreateDirectory(AspireJsonConfiguration.SettingsFolder); - var legacySettingsPath = Path.Combine(legacyDir.FullName, AspireJsonConfiguration.FileName); - // This file is *parseable* JSON with a valid string appHostPath, so - // LegacySettingsFileHasMigratableData returns true and we enter the migration try - // block. However, "features" is typed Dictionary with a strict - // FlexibleBooleanDictionaryConverter, so passing a string for it causes - // AspireJsonConfiguration.Load (invoked by AspireConfigFile.LoadOrCreate) to throw a - // JsonException. That exception must be caught and we must fall back to registering - // the legacy file directly. - File.WriteAllText(legacySettingsPath, """ - { - "appHostPath": "MyApp.csproj", - "features": "not-an-object" - } - """); - - var globalDir = workspace.CreateDirectory("global-aspire"); - var globalSettingsFile = new FileInfo(Path.Combine(globalDir.FullName, AspireConfigFile.FileName)); - - var builder = new ConfigurationBuilder(); - var ex = Record.Exception(() => - ConfigurationHelper.RegisterSettingsFiles(builder, workspace.WorkspaceRoot, globalSettingsFile, NullLogger.Instance)); - - Assert.Null(ex); - - // Migration failed inside LoadOrCreate, so aspire.config.json must not exist at the - // workspace root. Its absence proves we hit the catch block and continued past the - // failed migration rather than half-writing a new config file. - var migratedPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName); - Assert.False(File.Exists(migratedPath), "Failed migration should not produce a partial aspire.config.json."); - - // The fallback registered the legacy file directly, so appHostPath remains readable - // from configuration via its flat key (the JSON source flattens nested objects with ':', - // but appHostPath is a root-level scalar so its key is unchanged). - var config = builder.Build(); - Assert.Equal("MyApp.csproj", config["appHostPath"]); - } } diff --git a/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs b/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs index 8fc9e2bdb46..0283dfda9d1 100644 --- a/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs @@ -15,7 +15,9 @@ using Aspire.Cli.Tests.TestServices; using Aspire.Cli.Tests.Utils; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Logging.Testing; namespace Aspire.Cli.Tests.Projects; @@ -2594,6 +2596,379 @@ await projectLocator.UseOrFindAppHostProjectFileAsync( }); } + [Fact] + public async Task GetAppHostFromSettings_LegacyFile_WarningNamesSettingsJsonNotAspireConfigJson() + { + // Regression test for https://github.com/microsoft/aspire/issues/17620. + // When the only user-authored config is the legacy .aspire/settings.json and its + // appHostPath points at a file that no longer exists, the warning must reference + // .aspire/settings.json (the file the user wrote) and must NOT reference + // aspire.config.json (which the user never authored). + // + // The discovery walk (FindAppHostProjectsAsync, the path used by `aspire ls`) is + // the user-facing surface that emits this warning via AddSettingsAppHostCandidateAsync. + // The probe-style GetAppHostFromSettingsAsync API is intentionally silent so background + // feature-detection callers (DotNetSdkCheck, AspireVersionCheck, etc.) don't leak + // user-facing output. + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var workspaceSettingsDirectory = workspace.CreateDirectory(".aspire"); + var aspireSettingsFile = new FileInfo(Path.Combine(workspaceSettingsDirectory.FullName, "settings.json")); + + using (var writer = aspireSettingsFile.OpenWrite()) + { + await JsonSerializer.SerializeAsync(writer, new + { + appHostPath = "DoesNotExist/AppHost.csproj" + }); + } + + var interactionService = new TestInteractionService(); + var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); + var projectLocator = CreateProjectLocator(executionContext, interactionService: interactionService); + + await projectLocator.FindAppHostProjectsAsync(workspace.WorkspaceRoot, AppHostDiscoveryScope.DefaultFiltered, CancellationToken.None).DefaultTimeout(); + + var warning = Assert.Single(interactionService.DisplayedMessages); + Assert.Equal(KnownEmojis.Warning, warning.Emoji); + Assert.Contains(aspireSettingsFile.FullName, warning.Message); + Assert.DoesNotContain(AspireConfigFile.FileName, warning.Message); + } + + [Fact] + public async Task GetAppHostFromSettings_AspireConfigJson_WithNulByteInPath_ReportsValidationErrorAndDoesNotCrash() + { + // Regression test for https://github.com/microsoft/aspire/issues/17624. + // A NUL byte in appHost.path survives JSON deserialization, then would crash + // inside Path.Combine / new FileInfo with "Null character in path." surfaced as + // a generic "unexpected error". Discovery must instead return null and display + // a clear validation error naming the user-authored config file. + // + // Driven through FindAppHostProjectsAsync (the user-facing `aspire ls` path) so + // the validation error surfaces via AddSettingsAppHostCandidateAsync. + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var configPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName); + // \u0000 is the escaped NUL byte; the JSON parser preserves it as a literal \0 + // inside the deserialized string. Writing a raw NUL would test JSON parsing, + // not the post-deserialization validation we want to exercise. + await File.WriteAllTextAsync(configPath, """ + { "appHost": { "path": "a\u0000b.csproj" } } + """); + + var sink = new TestSink(); + var logger = new TestLogger(new TestLoggerFactory(sink, enabled: true)); + var interactionService = new TestInteractionService(); + var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); + var projectLocator = CreateProjectLocator(executionContext, interactionService: interactionService, logger: logger); + + await projectLocator.FindAppHostProjectsAsync(workspace.WorkspaceRoot, AppHostDiscoveryScope.DefaultFiltered, CancellationToken.None).DefaultTimeout(); + + var error = Assert.Single(interactionService.DisplayedErrors); + Assert.Contains(configPath, error); + Assert.Contains("appHost.path", error); + Assert.DoesNotContain(sink.Writes, w => w.LogLevel == LogLevel.Warning && w.Message?.Contains("Ignoring configured AppHost path", StringComparison.Ordinal) == true); + } + + [Fact] + public async Task GetAppHostFromSettings_LegacySettings_WithNulByteInPath_ReportsValidationErrorAndDoesNotCrash() + { + // Same scenario as the modern-config case above, but for the legacy + // .aspire/settings.json reader. Both code paths consume a user-supplied path + // string and must validate it before reaching System.IO APIs. + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var workspaceSettingsDirectory = workspace.CreateDirectory(".aspire"); + var aspireSettingsFile = new FileInfo(Path.Combine(workspaceSettingsDirectory.FullName, "settings.json")); + await File.WriteAllTextAsync(aspireSettingsFile.FullName, """ + { "appHostPath": "a\u0000b.csproj" } + """); + + var interactionService = new TestInteractionService(); + var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); + var projectLocator = CreateProjectLocator(executionContext, interactionService: interactionService); + + await projectLocator.FindAppHostProjectsAsync(workspace.WorkspaceRoot, AppHostDiscoveryScope.DefaultFiltered, CancellationToken.None).DefaultTimeout(); + + var error = Assert.Single(interactionService.DisplayedErrors); + Assert.Contains(aspireSettingsFile.FullName, error); + Assert.Contains("appHostPath", error); + } + + [Fact] + public async Task GetAppHostFromSettings_AspireConfigJson_WithEmptyPath_ReportsValidationErrorAndDoesNotCrash() + { + // Adversarial regression: an empty string for appHost.path is technically valid + // JSON but used to fall through the L4 validation with the misleading + // "contains characters that are not allowed" message even though it has no + // characters at all. Path.Combine(directory, "") collapses to the directory, + // which would then surface a "directory is not a file" warning downstream. + // The validator now rejects empty paths up front with the corrected message. + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var configPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName); + await File.WriteAllTextAsync(configPath, """ + { "appHost": { "path": "" } } + """); + + var interactionService = new TestInteractionService(); + var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); + var projectLocator = CreateProjectLocator(executionContext, interactionService: interactionService); + + await projectLocator.FindAppHostProjectsAsync(workspace.WorkspaceRoot, AppHostDiscoveryScope.DefaultFiltered, CancellationToken.None).DefaultTimeout(); + + var error = Assert.Single(interactionService.DisplayedErrors); + Assert.Contains(configPath, error); + Assert.Contains("appHost.path", error); + Assert.Contains("is empty or contains", error); + } + + [Fact] + public async Task GetAppHostFromSettings_LegacySettings_WithEmptyPath_ReportsValidationErrorAndDoesNotCrash() + { + // Companion to the modern-config empty-path case above; the legacy reader has + // the same gap and must surface the same corrected validation error. + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var workspaceSettingsDirectory = workspace.CreateDirectory(".aspire"); + var aspireSettingsFile = new FileInfo(Path.Combine(workspaceSettingsDirectory.FullName, "settings.json")); + await File.WriteAllTextAsync(aspireSettingsFile.FullName, """ + { "appHostPath": "" } + """); + + var interactionService = new TestInteractionService(); + var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); + var projectLocator = CreateProjectLocator(executionContext, interactionService: interactionService); + + await projectLocator.FindAppHostProjectsAsync(workspace.WorkspaceRoot, AppHostDiscoveryScope.DefaultFiltered, CancellationToken.None).DefaultTimeout(); + + var error = Assert.Single(interactionService.DisplayedErrors); + Assert.Contains(aspireSettingsFile.FullName, error); + Assert.Contains("appHostPath", error); + Assert.Contains("is empty or contains", error); + } + + [Fact] + public async Task GetAppHostFromSettings_MalformedConfig_SilentProbeLogsWarning() + { + // Probe-style callers intentionally don't write to the user-facing interaction + // service, but malformed config still needs diagnostics so background checks are + // debuggable. + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var configPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName); + await File.WriteAllTextAsync(configPath, """ + { "appHost": { + """); + + var sink = new TestSink(); + var logger = new TestLogger(new TestLoggerFactory(sink, enabled: true)); + var interactionService = new TestInteractionService(); + var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); + var projectLocator = CreateProjectLocator(executionContext, interactionService: interactionService, logger: logger); + + var foundAppHost = await projectLocator.GetAppHostFromSettingsAsync(CancellationToken.None).DefaultTimeout(); + + Assert.Null(foundAppHost); + Assert.Empty(interactionService.DisplayedErrors); + + var warning = Assert.Single(sink.Writes, w => w.LogLevel == LogLevel.Warning); + Assert.Contains(configPath, warning.Message); + Assert.IsAssignableFrom(warning.Exception); + } + + [Fact] + public async Task GetAppHostFromSettings_MalformedLegacySettings_SilentProbeLogsWarning() + { + // The legacy settings reader uses JsonDocument directly, so it needs the same + // silent-mode diagnostics as aspire.config.json instead of letting JsonException + // escape from background probes. + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var workspaceSettingsDirectory = workspace.CreateDirectory(".aspire"); + var settingsPath = Path.Combine(workspaceSettingsDirectory.FullName, "settings.json"); + await File.WriteAllTextAsync(settingsPath, """ + { "appHostPath": + """); + + var sink = new TestSink(); + var logger = new TestLogger(new TestLoggerFactory(sink, enabled: true)); + var interactionService = new TestInteractionService(); + var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); + var projectLocator = CreateProjectLocator(executionContext, interactionService: interactionService, logger: logger); + + var foundAppHost = await projectLocator.GetAppHostFromSettingsAsync(CancellationToken.None).DefaultTimeout(); + + Assert.Null(foundAppHost); + Assert.Empty(interactionService.DisplayedErrors); + + var warning = Assert.Single(sink.Writes, w => w.LogLevel == LogLevel.Warning); + Assert.Contains(settingsPath, warning.Message); + Assert.IsAssignableFrom(warning.Exception); + } + + [Fact] + public async Task GetAppHostFromSettings_LegacySettingsWithNonStringPath_ReportsValidationErrorAndDoesNotCrash() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var workspaceSettingsDirectory = workspace.CreateDirectory(".aspire"); + var settingsPath = Path.Combine(workspaceSettingsDirectory.FullName, "settings.json"); + await File.WriteAllTextAsync(settingsPath, """ + { "appHostPath": 123 } + """); + + var sink = new TestSink(); + var logger = new TestLogger(new TestLoggerFactory(sink, enabled: true)); + var interactionService = new TestInteractionService(); + var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); + var projectLocator = CreateProjectLocator(executionContext, interactionService: interactionService, logger: logger); + + var foundAppHost = await projectLocator.GetAppHostFromSettingsAsync(CancellationToken.None).DefaultTimeout(); + + Assert.Null(foundAppHost); + Assert.Empty(interactionService.DisplayedErrors); + + var warning = Assert.Single(sink.Writes, w => w.LogLevel == LogLevel.Warning); + Assert.Contains(settingsPath, warning.Message); + Assert.Contains("not a JSON string", warning.Message); + } + + [Fact] + public async Task GetAppHostFromSettings_LegacySettingsWithNonObjectRoot_ReportsValidationErrorAndDoesNotCrash() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var workspaceSettingsDirectory = workspace.CreateDirectory(".aspire"); + var settingsPath = Path.Combine(workspaceSettingsDirectory.FullName, "settings.json"); + await File.WriteAllTextAsync(settingsPath, """ + [] + """); + + var sink = new TestSink(); + var logger = new TestLogger(new TestLoggerFactory(sink, enabled: true)); + var interactionService = new TestInteractionService(); + var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); + var projectLocator = CreateProjectLocator(executionContext, interactionService: interactionService, logger: logger); + + var foundAppHost = await projectLocator.GetAppHostFromSettingsAsync(CancellationToken.None).DefaultTimeout(); + + Assert.Null(foundAppHost); + Assert.Empty(interactionService.DisplayedErrors); + + var warning = Assert.Single(sink.Writes, w => w.LogLevel == LogLevel.Warning); + Assert.Contains(settingsPath, warning.Message); + Assert.Contains("not a JSON object", warning.Message); + } + + [Fact] + public async Task FindAppHostProjectsAsync_DeduplicatesSettingsCandidateAcrossSymlink() + { + // Regression test for https://github.com/microsoft/aspire/issues/17626. + // When the user's settings file points at an apphost via a symlinked path + // (for example /tmp/L5/x.cs on macOS while the canonical path is + // /private/tmp/L5/x.cs), the directory walk surfaces the canonical path and the + // settings reader surfaces the user-written symbolic path. Plain string + // comparison between the two used to produce a duplicate entry; dedupe must now + // canonicalize symlinks before comparing. + // + // To isolate the settings-vs-walk dedupe from the walk's own behaviour, the + // symlink target lives in a directory the default discovery filter skips + // (node_modules), so the walk only surfaces the real path. + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var realAppDirectory = workspace.WorkspaceRoot.CreateSubdirectory("real-app"); + var realAppHost = new FileInfo(Path.Combine(realAppDirectory.FullName, "AppHost.csproj")); + await File.WriteAllTextAsync(realAppHost.FullName, "Not a real project file."); + + // node_modules is excluded from DefaultFiltered discovery, so a symlink placed + // there is invisible to the walk. The same symlink path used in settings still + // resolves through the link to the real apphost file. + var nodeModulesDir = workspace.WorkspaceRoot.CreateSubdirectory("node_modules"); + var linkDirectory = Path.Combine(nodeModulesDir.FullName, "link"); + try + { + Directory.CreateSymbolicLink(linkDirectory, realAppDirectory.FullName); + } + catch (UnauthorizedAccessException ex) + { + Assert.Skip($"Cannot create symbolic links in this environment: {ex.Message}"); + } + catch (IOException ex) + { + Assert.Skip($"Symbolic link creation failed in this environment: {ex.Message}"); + } + + var pathThroughLink = Path.Combine(linkDirectory, "AppHost.csproj"); + + // Sanity-check the test setup: the symlink path must resolve to the real file, + // and the two strings must differ — otherwise the test would pass trivially. + Assert.True(File.Exists(pathThroughLink), "Symlinked path should resolve to the real apphost file."); + Assert.NotEqual(realAppHost.FullName, pathThroughLink); + + var workspaceSettingsDirectory = workspace.CreateDirectory(".aspire"); + var aspireSettingsFile = new FileInfo(Path.Combine(workspaceSettingsDirectory.FullName, "settings.json")); + using (var writer = aspireSettingsFile.OpenWrite()) + { + await JsonSerializer.SerializeAsync(writer, new + { + appHostPath = pathThroughLink + }); + } + + var projectFactory = new TestAppHostProjectFactory + { + ValidateAppHostCallback = _ => new AppHostValidationResult(IsValid: true) + }; + + var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); + var projectLocator = CreateProjectLocator(executionContext, projectFactory: projectFactory); + + var found = await projectLocator.FindAppHostProjectsAsync(workspace.WorkspaceRoot, AppHostDiscoveryScope.DefaultFiltered, CancellationToken.None).DefaultTimeout(); + + // Exactly one candidate: the settings-derived path through the symlink must be + // recognized as the same file as the walk-discovered real path. + Assert.Single(found); + } + + [Fact] + public async Task FindAppHostProjectsAsync_DeduplicatesAspireConfigCandidateWithDifferentCasing() + { + // Regression test for https://github.com/microsoft/aspire/issues/17635. + // Case-insensitive file systems can resolve a user-authored config path like + // APPHOST.csproj to the same file that the discovery walk reports as + // AppHost.csproj. The two strings differ, but the paths should still dedupe. + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var appHostProjectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj")); + await File.WriteAllTextAsync(appHostProjectFile.FullName, "Not a real project file."); + + var differentlyCasedPath = Path.Combine(workspace.WorkspaceRoot.FullName, "APPHOST.csproj"); + if (!File.Exists(differentlyCasedPath)) + { + Assert.Skip("The current file system is case-sensitive."); + } + + Assert.NotEqual(appHostProjectFile.FullName, differentlyCasedPath); + + var configPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName); + await File.WriteAllTextAsync(configPath, $$""" + { "appHost": { "path": {{JsonSerializer.Serialize(differentlyCasedPath)}} } } + """); + + var projectFactory = new TestAppHostProjectFactory + { + ValidateAppHostCallback = _ => new AppHostValidationResult(IsValid: true) + }; + + var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); + var projectLocator = CreateProjectLocator(executionContext, projectFactory: projectFactory); + + var found = await projectLocator.FindAppHostProjectsAsync(workspace.WorkspaceRoot, AppHostDiscoveryScope.DefaultFiltered, CancellationToken.None).DefaultTimeout(); + + Assert.Single(found); + } + private static ProjectLocator CreateProjectLocator( CliExecutionContext executionContext, IInteractionService? interactionService = null, @@ -2602,16 +2977,16 @@ private static ProjectLocator CreateProjectLocator( ILanguageDiscovery? languageDiscovery = null, IDotNetSdkInstaller? sdkInstaller = null, IGitRepository? gitRepository = null, - AspireCliTelemetry? telemetry = null) + AspireCliTelemetry? telemetry = null, + ILogger? logger = null) { - var logger = NullLogger.Instance; var appHostCandidateFinder = new AppHostCandidateFinder( gitRepository ?? new TestGitRepository(), new ProfilingTelemetry(new ConfigurationBuilder().Build()), NullLogger.Instance); return new ProjectLocator( - logger, + logger ?? NullLogger.Instance, executionContext, interactionService ?? new TestInteractionService(), configurationService ?? new TestConfigurationService(executionContext), diff --git a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs index c540b425adc..a9a4b318fe4 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs @@ -83,7 +83,7 @@ public static IServiceCollection CreateServiceCollection(TemporaryWorkspace work var globalSettingsFilePath = Path.Combine(options.WorkingDirectory.FullName, ".aspire", "settings.global.json"); var globalSettingsFile = new FileInfo(globalSettingsFilePath); - ConfigurationHelper.RegisterSettingsFiles(configBuilder, options.WorkingDirectory, globalSettingsFile, NullLogger.Instance); + ConfigurationHelper.RegisterSettingsFiles(configBuilder, options.WorkingDirectory, globalSettingsFile); var configuration = configBuilder.Build(); services.AddSingleton(configuration); diff --git a/tests/Aspire.Cli.Tests/Utils/PathNormalizerTests.cs b/tests/Aspire.Cli.Tests/Utils/PathNormalizerTests.cs new file mode 100644 index 00000000000..728f8a3878f --- /dev/null +++ b/tests/Aspire.Cli.Tests/Utils/PathNormalizerTests.cs @@ -0,0 +1,121 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.Utils; + +namespace Aspire.Cli.Tests.Utils; + +public class PathNormalizerTests(ITestOutputHelper outputHelper) +{ + [Fact] + public void ResolveSymlinks_IsIdempotent_WhenPathHasNoSymlinks() + { + // The input itself may sit under a symlinked root (for example /var -> /private/var + // on macOS), so we cannot assert the result equals the input. We can assert + // idempotence: a path with no remaining symlinks must resolve to itself. + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var subdir = workspace.WorkspaceRoot.CreateSubdirectory("App"); + var file = new FileInfo(Path.Combine(subdir.FullName, "app.csproj")); + File.WriteAllText(file.FullName, ""); + + var firstPass = PathNormalizer.ResolveSymlinks(file.FullName); + var secondPass = PathNormalizer.ResolveSymlinks(firstPass); + + Assert.Equal(firstPass, secondPass); + } + + [Fact] + public void ResolveSymlinks_ReturnsInputUnchanged_WhenEmpty() + { + Assert.Equal(string.Empty, PathNormalizer.ResolveSymlinks(string.Empty)); + } + + [Fact] + public void ResolveSymlinks_ResolvesFinalFileSymlink() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var target = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "target.csproj")); + File.WriteAllText(target.FullName, ""); + + var linkPath = Path.Combine(workspace.WorkspaceRoot.FullName, "link.csproj"); + TryCreateSymlink(linkPath, target.FullName, isDirectory: false); + + var resolved = PathNormalizer.ResolveSymlinks(linkPath); + + // The link's final target should be canonical-equal to the real file. We use + // ResolveSymlinks on the target as well to account for the temp directory itself + // sitting under a symlinked root (for example /tmp -> /private/tmp on macOS). + Assert.Equal(PathNormalizer.ResolveSymlinks(target.FullName), resolved); + } + + [Fact] + public void ResolveSymlinks_ResolvesIntermediateDirectorySymlink() + { + // The L5 repro relies on a symlink that is NOT the final segment: on macOS, + // /tmp -> /private/tmp, and the apphost lives at /tmp/L5/x.cs. A single call to + // Directory.ResolveLinkTarget on the full path would not unwrap /tmp, so the + // implementation must walk segments. + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var realDirectory = workspace.WorkspaceRoot.CreateSubdirectory("real"); + var nested = realDirectory.CreateSubdirectory("nested"); + var file = new FileInfo(Path.Combine(nested.FullName, "app.csproj")); + File.WriteAllText(file.FullName, ""); + + var linkDirectory = Path.Combine(workspace.WorkspaceRoot.FullName, "link"); + TryCreateSymlink(linkDirectory, realDirectory.FullName, isDirectory: true); + + // Path through the link should resolve to the same canonical path as the path + // through the real directory. + var pathThroughLink = Path.Combine(linkDirectory, "nested", "app.csproj"); + + var resolvedThroughLink = PathNormalizer.ResolveSymlinks(pathThroughLink); + var resolvedThroughReal = PathNormalizer.ResolveSymlinks(file.FullName); + + Assert.Equal(resolvedThroughReal, resolvedThroughLink); + } + + [Fact] + public void ResolveSymlinks_PreservesPath_WhenLinkIsBroken() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var missingTarget = Path.Combine(workspace.WorkspaceRoot.FullName, "missing.csproj"); + var linkPath = Path.Combine(workspace.WorkspaceRoot.FullName, "broken-link.csproj"); + TryCreateSymlink(linkPath, missingTarget, isDirectory: false); + + // A broken link should not throw — the method must fall back to returning the + // path so callers can still surface a useful "file not found" error. + var resolved = PathNormalizer.ResolveSymlinks(linkPath); + + Assert.False(string.IsNullOrEmpty(resolved)); + } + + private static void TryCreateSymlink(string linkPath, string targetPath, bool isDirectory) + { + try + { + if (isDirectory) + { + Directory.CreateSymbolicLink(linkPath, targetPath); + } + else + { + File.CreateSymbolicLink(linkPath, targetPath); + } + } + catch (UnauthorizedAccessException ex) + { + // Creating symlinks on Windows requires either administrator rights or + // Developer Mode. Skip cleanly on environments that don't allow it rather + // than failing the test for an environment reason. + Assert.Skip($"Cannot create symbolic links in this environment: {ex.Message}"); + } + catch (IOException ex) + { + Assert.Skip($"Symbolic link creation failed in this environment: {ex.Message}"); + } + } +} From 56e88661e4d6026cea423827c62c067e11fdf192 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Sun, 31 May 2026 06:35:46 +1000 Subject: [PATCH 36/43] 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 --- 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 da473d27fe558682bb716214ca154018cbb4b638 Mon Sep 17 00:00:00 2001 From: Jose Perez Rodriguez Date: Sat, 30 May 2026 14:54:42 -0700 Subject: [PATCH 37/43] 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> --- .../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 2574ef57e97fc393aff67592fd442afca6a6d02f Mon Sep 17 00:00:00 2001 From: "aspire-repo-bot[bot]" <268009190+aspire-repo-bot[bot]@users.noreply.github.com> Date: Sat, 30 May 2026 14:55:34 -0700 Subject: [PATCH 38/43] Fix DistributedApplicationTestingBuilder failing when dashboard enabled with dynamic ports (#17731) When users set DisableDashboard = false on DistributedApplicationTestingBuilder, the TransportOptionsValidator rejected the HTTP URLs that the testing builder defaults (http://localhost:8080, http://localhost:4317) because ASPIRE_ALLOW_UNSECURED_TRANSPORT was not set. Fix: Set AllowUnsecuredTransport = true by default in PreConfigureBuilderOptions, since all URLs are already defaulted to HTTP. Add a regression test that enables the dashboard and verifies it becomes healthy. Fixes #17622 Co-authored-by: James Newton-King --- .../DistributedApplicationFactory.cs | 6 ++ .../Dashboard/DashboardServiceHost.cs | 20 +++--- .../Aspire.Hosting.Testing.Tests.csproj | 1 + .../TestingBuilderTests.cs | 64 +++++++++++++++++++ .../Dashboard/DashboardServiceHostTests.cs | 17 ----- 5 files changed, 80 insertions(+), 28 deletions(-) diff --git a/src/Aspire.Hosting.Testing/DistributedApplicationFactory.cs b/src/Aspire.Hosting.Testing/DistributedApplicationFactory.cs index 99ed720f33f..860eb1f8441 100644 --- a/src/Aspire.Hosting.Testing/DistributedApplicationFactory.cs +++ b/src/Aspire.Hosting.Testing/DistributedApplicationFactory.cs @@ -206,6 +206,12 @@ private static void PreConfigureBuilderOptions( SetDefault(KnownConfigNames.AspNetCoreUrls, "http://localhost:8080"); SetDefaultFallback(KnownConfigNames.DashboardOtlpGrpcEndpointUrl, KnownConfigNames.Legacy.DashboardOtlpGrpcEndpointUrl, "http://localhost:4317"); + // Since the testing builder defaults all dashboard/OTLP URLs to HTTP, also allow + // unsecured transport by default. This prevents OptionsValidationException when the + // user enables the dashboard (DisableDashboard = false) without explicitly setting + // ASPIRE_ALLOW_UNSECURED_TRANSPORT. See https://github.com/microsoft/aspire/issues/17622 + SetDefault(KnownConfigNames.AllowUnsecuredTransport, "true"); + var appHostProjectPath = ResolveProjectPath(entryPointAssembly); if (!string.IsNullOrEmpty(appHostProjectPath) && Directory.Exists(appHostProjectPath)) { diff --git a/src/Aspire.Hosting/Dashboard/DashboardServiceHost.cs b/src/Aspire.Hosting/Dashboard/DashboardServiceHost.cs index 9753be2f352..b3efb58fea9 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardServiceHost.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardServiceHost.cs @@ -138,9 +138,15 @@ void ConfigureKestrel(KestrelServerOptions kestrelOptions) kestrelOptions.Listen(IPAddress.Loopback, port: 0, ConfigureListen); _logger.LogDebug("Resource service endpoint not configured. Listening on {Scheme}://127.0.0.1:.", scheme); } - else if (IsLocalResourceServiceEndpoint(uri)) + else if (IPAddress.TryParse(uri.Host, out var ip) && IPAddress.IsLoopback(ip)) { - // Listen on the requested localhost port. + // Bind to the exact loopback address specified (e.g. 127.0.0.1 or [::1]). + kestrelOptions.Listen(ip, uri.Port, ConfigureListen); + _logger.LogDebug("Resource service endpoint configured: {Uri}", uri); + } + else if (uri.IsLoopback || IsLocalhostOrLocalhostTld(uri)) + { + // For "localhost" or *.localhost hosts, bind to both IPv4 and IPv6 loopback. kestrelOptions.ListenLocalhost(uri.Port, ConfigureListen); _logger.LogDebug("Resource service endpoint configured: {Uri}", uri); } @@ -178,16 +184,8 @@ internal static string ResolveScheme(Uri? configuredUri, bool allowUnsecuredTran return allowUnsecuredTransport ? "http" : "https"; } - /// - /// Determines whether the resource service endpoint is scoped to the local machine. - /// - internal static bool IsLocalResourceServiceEndpoint(Uri uri) + private static bool IsLocalhostOrLocalhostTld(Uri uri) { - if (uri.IsLoopback) - { - return true; - } - var host = uri.Host.EndsWith(".", StringComparison.Ordinal) ? uri.Host[..^1] : uri.Host; diff --git a/tests/Aspire.Hosting.Testing.Tests/Aspire.Hosting.Testing.Tests.csproj b/tests/Aspire.Hosting.Testing.Tests/Aspire.Hosting.Testing.Tests.csproj index f9fc3a5b311..898ccdd5257 100644 --- a/tests/Aspire.Hosting.Testing.Tests/Aspire.Hosting.Testing.Tests.csproj +++ b/tests/Aspire.Hosting.Testing.Tests/Aspire.Hosting.Testing.Tests.csproj @@ -24,6 +24,7 @@ + diff --git a/tests/Aspire.Hosting.Testing.Tests/TestingBuilderTests.cs b/tests/Aspire.Hosting.Testing.Tests/TestingBuilderTests.cs index afc3a33b270..da7ea4cc8a9 100644 --- a/tests/Aspire.Hosting.Testing.Tests/TestingBuilderTests.cs +++ b/tests/Aspire.Hosting.Testing.Tests/TestingBuilderTests.cs @@ -8,6 +8,7 @@ using Aspire.Hosting.Utils; using Aspire.TestProject; using Aspire.TestUtilities; +using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -568,6 +569,69 @@ public async Task StartAsyncAbandonedAfterHang() } } + [Fact] + [RequiresFeature(TestFeature.Docker)] + public async Task DashboardEnabledInTestingBuilderShouldWorkWithDynamicPorts() + { + var builder = DistributedApplicationTestingBuilder.Create([], (options, _) => + { + options.DisableDashboard = false; + }); + builder.WithTestAndResourceLogging(output); + + await using var app = await builder.BuildAsync(); + + await app.StartAsync().DefaultTimeout(TestConstants.LongTimeoutTimeSpan); + + // Wait for the dashboard to become healthy, confirming it is actually running. + await app.ResourceNotifications.WaitForResourceHealthyAsync( + "aspire-dashboard", + CancellationToken.None).DefaultTimeout(TestConstants.LongTimeoutTimeSpan); + } + + [Theory] + [RequiresFeature(TestFeature.Docker)] + [InlineData("https://127.0.0.1:0")] + [InlineData("https://[::1]:0")] + public async Task LoopbackWithDynamicPorts(string endpointUrl) + { + var builder = DistributedApplicationTestingBuilder.Create([], (opt, _) => + { + opt.DisableDashboard = false; + }); + builder.WithTestAndResourceLogging(output); + + builder.Configuration["ASPNETCORE_URLS"] = endpointUrl; + builder.Configuration["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"] = endpointUrl; + builder.Configuration["ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL"] = endpointUrl; + + await using var app = await builder.BuildAsync(); + await app.StartAsync().DefaultTimeout(TestConstants.LongTimeoutTimeSpan); + + // Wait for the dashboard to become healthy, confirming it is actually running. + await app.ResourceNotifications.WaitForResourceHealthyAsync( + "aspire-dashboard", + CancellationToken.None).DefaultTimeout(TestConstants.LongTimeoutTimeSpan); + } + + [Fact] + [RequiresFeature(TestFeature.Docker)] + public async Task NonLocalResourceServiceEndpointThrows() + { + var builder = DistributedApplicationTestingBuilder.Create([], (opt, _) => + { + opt.DisableDashboard = false; + }); + builder.WithTestAndResourceLogging(output); + + builder.Configuration["ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL"] = "https://example.com:5001"; + + await using var app = await builder.BuildAsync(); + + var ex = await Assert.ThrowsAsync(() => app.StartAsync()); + Assert.Equal("ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL must contain a local loopback address.", ex.Message); + } + private sealed record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) { public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); diff --git a/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceHostTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceHostTests.cs index 84c00dfa4fa..c1dbe8d9627 100644 --- a/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceHostTests.cs +++ b/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceHostTests.cs @@ -22,21 +22,4 @@ public void ResolveScheme_ReturnsExpectedScheme(string? uriString, bool allowUns Assert.Equal(expectedScheme, scheme); } - - [Theory] - [InlineData("https://localhost:5001", true)] - [InlineData("https://localhost.:5001", true)] - [InlineData("https://127.0.0.1:5001", true)] - [InlineData("https://[::1]:5001", true)] - [InlineData("https://myapp.dev.localhost:5001", true)] - [InlineData("https://myapp.dev.localhost.:5001", true)] - [InlineData("https://example.com:5001", false)] - [InlineData("https://localhost.example.com:5001", false)] - [InlineData("https://example-localhost:5001", false)] - public void IsLocalResourceServiceEndpoint_ReturnsExpectedResult(string uriString, bool expectedResult) - { - var result = DashboardServiceHost.IsLocalResourceServiceEndpoint(new Uri(uriString)); - - Assert.Equal(expectedResult, result); - } } From f2e540e80be0b2f96570570564e56ce82890ab1b Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Sun, 31 May 2026 09:55:56 +0800 Subject: [PATCH 39/43] Assert allocated dashboard service URI has expected host and non-zero port (#17741) --- .../TestingBuilderTests.cs | 29 ++++++++++++------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/tests/Aspire.Hosting.Testing.Tests/TestingBuilderTests.cs b/tests/Aspire.Hosting.Testing.Tests/TestingBuilderTests.cs index da7ea4cc8a9..96ea3c9283a 100644 --- a/tests/Aspire.Hosting.Testing.Tests/TestingBuilderTests.cs +++ b/tests/Aspire.Hosting.Testing.Tests/TestingBuilderTests.cs @@ -3,6 +3,7 @@ using System.Net.Http.Json; using System.Reflection; +using Aspire.Hosting.Dashboard; using Aspire.Hosting.Tests; using Aspire.Hosting.Tests.Utils; using Aspire.Hosting.Utils; @@ -583,17 +584,20 @@ public async Task DashboardEnabledInTestingBuilderShouldWorkWithDynamicPorts() await app.StartAsync().DefaultTimeout(TestConstants.LongTimeoutTimeSpan); - // Wait for the dashboard to become healthy, confirming it is actually running. - await app.ResourceNotifications.WaitForResourceHealthyAsync( - "aspire-dashboard", - CancellationToken.None).DefaultTimeout(TestConstants.LongTimeoutTimeSpan); + // Get the allocated dashboard service URI from the app host to confirm the final endpoint. + var dashboardServiceHost = app.Services.GetRequiredService(); + var resourceServiceUri = await dashboardServiceHost.GetResourceServiceUriAsync().DefaultTimeout(TestConstants.LongTimeoutTimeSpan); + + var uri = new Uri(resourceServiceUri); + Assert.Equal("127.0.0.1", uri.Host); + Assert.NotEqual(0, uri.Port); } [Theory] [RequiresFeature(TestFeature.Docker)] - [InlineData("https://127.0.0.1:0")] - [InlineData("https://[::1]:0")] - public async Task LoopbackWithDynamicPorts(string endpointUrl) + [InlineData("https://127.0.0.1:0", "127.0.0.1")] + [InlineData("https://[::1]:0", "[::1]")] + public async Task LoopbackWithDynamicPorts(string endpointUrl, string expectedHost) { var builder = DistributedApplicationTestingBuilder.Create([], (opt, _) => { @@ -608,10 +612,13 @@ public async Task LoopbackWithDynamicPorts(string endpointUrl) await using var app = await builder.BuildAsync(); await app.StartAsync().DefaultTimeout(TestConstants.LongTimeoutTimeSpan); - // Wait for the dashboard to become healthy, confirming it is actually running. - await app.ResourceNotifications.WaitForResourceHealthyAsync( - "aspire-dashboard", - CancellationToken.None).DefaultTimeout(TestConstants.LongTimeoutTimeSpan); + // Get the allocated dashboard service URI from the app host to confirm the final endpoint. + var dashboardServiceHost = app.Services.GetRequiredService(); + var resourceServiceUri = await dashboardServiceHost.GetResourceServiceUriAsync().DefaultTimeout(TestConstants.LongTimeoutTimeSpan); + + var uri = new Uri(resourceServiceUri); + Assert.Equal(expectedHost, uri.Host); + Assert.NotEqual(0, uri.Port); } [Fact] From 9c260c29a6c3f9a63750a518a56440a3d855fb8f Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sat, 30 May 2026 20:12:40 -0700 Subject: [PATCH 40/43] 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> --- .../HostedAgent/AzureHostedAgentResource.cs | 63 ++++++++ .../HostedAgentBuilderExtension.cs | 145 +++++++++++++++--- .../HostedAgent/HostedAgentConfiguration.cs | 25 +++ .../HostedAgent/HostedAgentOptions.cs | 90 ++++++++++- .../HostedAgentConfigurationTests.cs | 15 ++ .../HostedAgentExtensionTests.cs | 132 +++++++++++++++- .../TypeScript/apphost.mts | 8 +- 7 files changed, 453 insertions(+), 25 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 d82ad750c79..60104ccc4d3 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. @@ -46,8 +47,8 @@ public static IResourceBuilder AsHostedAgent(this IResourceBuilder buil // The internal AsHostedAgentForExport overload below is the polyglot-exported version of AsHostedAgent. // The method name differs from AsHostedAgent to avoid C# overload ambiguity with the Action-based // overload; the polyglot-facing name is set back to "asHostedAgent" via [AspireExport(MethodName)]. - // .NET callers should keep using the Action overload above, which exposes - // the full HostedAgentConfiguration surface (tools, content filters, container protocol versions, etc.). + // .NET callers should keep using the Action overload, which exposes the + // full HostedAgentConfiguration surface (tools, content filters, container protocol versions, etc.). /// /// Configures the resource to run and publish as a hosted agent in Microsoft Foundry, targeting the specified Foundry project. @@ -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 e13850995727767ba26088932630342417745f40 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 1 Jun 2026 04:43:05 +1000 Subject: [PATCH 41/43] 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> --- 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 ad738263df9..362e8a8443c 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -94,6 +94,8 @@ dotnet test --filter-not-trait "quarantined=true" 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). + ## Integrations (Formerly Components) Please check the [Aspire integrations contribution guidelines](/src/Components/README.md) if you intend to make contributions to a new or existing Aspire integration. 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 17d26e209e7..356b30d9664 100644 --- a/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs @@ -1923,6 +1923,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 becb48e2d61099e35ae336d527d3875e928d6594 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 31 May 2026 18:34:38 -0700 Subject: [PATCH 42/43] 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> --- .../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 60104ccc4d3..ce7a545e47f 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 3cbbeb2341c2a9f04c4639d85118cb2a6db402c9 Mon Sep 17 00:00:00 2001 From: "aspire-repo-bot[bot]" <268009190+aspire-repo-bot[bot]@users.noreply.github.com> Date: Mon, 1 Jun 2026 16:20:14 -0700 Subject: [PATCH 43/43] [create-pull-request] automated change (#17700) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .../Aspire.Hosting.Azure.AppConfiguration.cs | 10 +- .../api/Aspire.Hosting.Azure.AppContainers.cs | 27 +- .../api/Aspire.Hosting.Azure.AppService.cs | 13 +- ...spire.Hosting.Azure.ApplicationInsights.cs | 4 +- .../Aspire.Hosting.Azure.CognitiveServices.cs | 6 +- .../Aspire.Hosting.Azure.ContainerRegistry.cs | 8 +- .../api/Aspire.Hosting.Azure.CosmosDB.cs | 18 +- .../api/Aspire.Hosting.Azure.EventHubs.cs | 14 +- .../api/Aspire.Hosting.Azure.FrontDoor.cs | 4 +- .../api/Aspire.Hosting.Azure.Functions.cs | 10 +- .../api/Aspire.Hosting.Azure.KeyVault.cs | 4 +- .../api/Aspire.Hosting.Azure.Kubernetes.cs | 43 +- .../api/Aspire.Hosting.Azure.Kusto.cs | 10 +- .../api/Aspire.Hosting.Azure.Network.cs | 52 +- ...spire.Hosting.Azure.OperationalInsights.cs | 2 +- .../api/Aspire.Hosting.Azure.PostgreSQL.cs | 8 +- .../api/Aspire.Hosting.Azure.Redis.cs | 4 +- .../api/Aspire.Hosting.Azure.Search.cs | 2 +- .../api/Aspire.Hosting.Azure.ServiceBus.cs | 20 +- .../api/Aspire.Hosting.Azure.SignalR.cs | 2 +- .../api/Aspire.Hosting.Azure.Sql.cs | 12 +- .../api/Aspire.Hosting.Azure.Storage.cs | 30 +- .../api/Aspire.Hosting.Azure.WebPubSub.cs | 4 +- .../api/Aspire.Hosting.Azure.cs | 55 +- .../api/Aspire.Hosting.Blazor.cs | 50 ++ .../api/Aspire.Hosting.Browsers.cs | 2 +- .../api/Aspire.Hosting.DevTunnels.cs | 10 +- .../api/Aspire.Hosting.Docker.cs | 23 +- .../api/Aspire.Hosting.EntityFrameworkCore.cs | 10 +- .../api/Aspire.Hosting.Foundry.cs | 4 +- .../api/Aspire.Hosting.Garnet.cs | 6 +- .../api/Aspire.Hosting.GitHub.Models.cs | 4 +- .../api/Aspire.Hosting.Go.cs | 53 ++ .../api/Aspire.Hosting.JavaScript.cs | 41 +- .../api/Aspire.Hosting.Kafka.cs | 10 +- .../api/Aspire.Hosting.Keycloak.cs | 10 +- .../api/Aspire.Hosting.Kubernetes.cs | 176 +++++-- .../api/Aspire.Hosting.Maui.cs | 16 +- .../api/Aspire.Hosting.Milvus.cs | 12 +- .../api/Aspire.Hosting.MongoDB.cs | 14 +- .../api/Aspire.Hosting.MySql.cs | 18 +- .../api/Aspire.Hosting.Nats.cs | 6 +- .../api/Aspire.Hosting.OpenAI.cs | 10 +- .../api/Aspire.Hosting.Oracle.cs | 12 +- .../api/Aspire.Hosting.Orleans.cs | 32 +- .../api/Aspire.Hosting.PostgreSQL.cs | 28 +- .../api/Aspire.Hosting.Python.cs | 18 +- .../api/Aspire.Hosting.Qdrant.cs | 6 +- .../api/Aspire.Hosting.RabbitMQ.cs | 6 +- .../api/Aspire.Hosting.Redis.cs | 24 +- .../api/Aspire.Hosting.Seq.cs | 6 +- .../api/Aspire.Hosting.SqlServer.cs | 14 +- .../api/Aspire.Hosting.Testing.cs | 2 +- .../api/Aspire.Hosting.Valkey.cs | 8 +- .../api/Aspire.Hosting.Yarp.cs | 74 +-- src/Aspire.Hosting/api/Aspire.Hosting.cs | 495 +++++++++++++----- .../api/Aspire.TypeSystem.cs | 52 ++ 57 files changed, 1084 insertions(+), 530 deletions(-) create mode 100644 src/Aspire.Hosting.Blazor/api/Aspire.Hosting.Blazor.cs create mode 100644 src/Aspire.Hosting.Go/api/Aspire.Hosting.Go.cs diff --git a/src/Aspire.Hosting.Azure.AppConfiguration/api/Aspire.Hosting.Azure.AppConfiguration.cs b/src/Aspire.Hosting.Azure.AppConfiguration/api/Aspire.Hosting.Azure.AppConfiguration.cs index f8b14093b9b..3a1dc1508b7 100644 --- a/src/Aspire.Hosting.Azure.AppConfiguration/api/Aspire.Hosting.Azure.AppConfiguration.cs +++ b/src/Aspire.Hosting.Azure.AppConfiguration/api/Aspire.Hosting.Azure.AppConfiguration.cs @@ -10,19 +10,19 @@ namespace Aspire.Hosting { public static partial class AzureAppConfigurationExtensions { - [AspireExport(Description = "Adds an Azure App Configuration resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddAzureAppConfiguration(this IDistributedApplicationBuilder builder, string name) { throw null; } - [AspireExport(Description = "Configures Azure App Configuration to run with the local emulator", RunSyncOnBackgroundThread = true)] + [AspireExport(RunSyncOnBackgroundThread = true)] public static ApplicationModel.IResourceBuilder RunAsEmulator(this ApplicationModel.IResourceBuilder builder, System.Action>? configureEmulator = null) { throw null; } - [AspireExport(Description = "Adds a data bind mount for the App Configuration emulator")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithDataBindMount(this ApplicationModel.IResourceBuilder builder, string? path = null) { throw null; } - [AspireExport(Description = "Adds a data volume for the App Configuration emulator")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithDataVolume(this ApplicationModel.IResourceBuilder builder, string? name = null) { throw null; } - [AspireExport(Description = "Sets the host port for the App Configuration emulator")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithHostPort(this ApplicationModel.IResourceBuilder builder, int? port) { throw null; } [AspireExportIgnore(Reason = "AppConfigurationBuiltInRole is an Azure.Provisioning type not compatible with ATS. Use the AzureAppConfigurationRole-based overload instead.")] diff --git a/src/Aspire.Hosting.Azure.AppContainers/api/Aspire.Hosting.Azure.AppContainers.cs b/src/Aspire.Hosting.Azure.AppContainers/api/Aspire.Hosting.Azure.AppContainers.cs index f2eae891342..3ade723fd4c 100644 --- a/src/Aspire.Hosting.Azure.AppContainers/api/Aspire.Hosting.Azure.AppContainers.cs +++ b/src/Aspire.Hosting.Azure.AppContainers/api/Aspire.Hosting.Azure.AppContainers.cs @@ -10,68 +10,68 @@ namespace Aspire.Hosting { public static partial class AzureContainerAppContainerExtensions { - [AspireExport("publishContainerAsAzureContainerApp", MethodName = "publishAsAzureContainerApp", Description = "Configures the container resource to be published as an Azure Container App")] + [AspireExport("publishContainerAsAzureContainerApp", MethodName = "publishAsAzureContainerApp")] public static ApplicationModel.IResourceBuilder PublishAsAzureContainerApp(this ApplicationModel.IResourceBuilder container, System.Action configure) where T : ApplicationModel.ContainerResource { throw null; } } public static partial class AzureContainerAppExecutableExtensions { - [AspireExport("publishExecutableAsAzureContainerApp", MethodName = "publishAsAzureContainerApp", Description = "Configures the executable resource to be published as an Azure Container App")] + [AspireExport("publishExecutableAsAzureContainerApp", MethodName = "publishAsAzureContainerApp")] public static ApplicationModel.IResourceBuilder PublishAsAzureContainerApp(this ApplicationModel.IResourceBuilder executable, System.Action configure) where T : ApplicationModel.ExecutableResource { throw null; } } public static partial class AzureContainerAppExtensions { - [AspireExport(Description = "Adds an Azure Container App Environment resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddAzureContainerAppEnvironment(this IDistributedApplicationBuilder builder, string name) { throw null; } [System.Obsolete("Use AddAzureContainerAppEnvironment instead. This method will be removed in a future version.")] public static IDistributedApplicationBuilder AddAzureContainerAppsInfrastructure(this IDistributedApplicationBuilder builder) { throw null; } - [AspireExport(Description = "Configures resources to use azd naming conventions")] + [AspireExport] + public static ApplicationModel.IResourceBuilder WithAcrPullIdentity(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder identityBuilder) { throw null; } + + [AspireExport] public static ApplicationModel.IResourceBuilder WithAzdResourceNaming(this ApplicationModel.IResourceBuilder builder) { throw null; } - [AspireExport(Description = "Configures the container app environment to use a specific Log Analytics Workspace")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithAzureLogAnalyticsWorkspace(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder workspaceBuilder) { throw null; } - [AspireExport(Description = "Configures resources to use compact naming for length-constrained Azure resources")] + [AspireExport] [System.Diagnostics.CodeAnalysis.Experimental("ASPIREACANAMING001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] public static ApplicationModel.IResourceBuilder WithCompactResourceNaming(this ApplicationModel.IResourceBuilder builder) { throw null; } - [AspireExport(Description = "Configures whether the Aspire dashboard is included in the container app environment")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithDashboard(this ApplicationModel.IResourceBuilder builder, bool enable = true) { throw null; } - [AspireExport(Description = "Configures whether HTTP endpoints are upgraded to HTTPS")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithHttpsUpgrade(this ApplicationModel.IResourceBuilder builder, bool upgrade = true) { throw null; } } public static partial class AzureContainerAppProjectExtensions { - [AspireExport("publishProjectAsAzureContainerApp", MethodName = "publishAsAzureContainerApp", Description = "Configures the project resource to be published as an Azure Container App")] + [AspireExport("publishProjectAsAzureContainerApp", MethodName = "publishAsAzureContainerApp")] public static ApplicationModel.IResourceBuilder PublishAsAzureContainerApp(this ApplicationModel.IResourceBuilder project, System.Action configure) where T : ApplicationModel.ProjectResource { throw null; } } public static partial class ContainerAppExtensions { - [AspireExport(Description = "Configures the custom domain for the container app")] + [AspireExport] [System.Diagnostics.CodeAnalysis.Experimental("ASPIREACADOMAINS001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] public static void ConfigureCustomDomain(this global::Azure.Provisioning.AppContainers.ContainerApp app, ApplicationModel.IResourceBuilder customDomain, ApplicationModel.IResourceBuilder certificateName) { } [AspireExportIgnore(Reason = "Polyglot app hosts use the internal publishAsAzureContainerAppJob dispatcher export.")] - [System.Diagnostics.CodeAnalysis.Experimental("ASPIREAZURE002", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] public static ApplicationModel.IResourceBuilder PublishAsAzureContainerAppJob(this ApplicationModel.IResourceBuilder resource, System.Action configure) where T : ApplicationModel.IComputeResource { throw null; } [AspireExportIgnore(Reason = "Polyglot app hosts use the internal publishAsAzureContainerAppJob dispatcher export.")] - [System.Diagnostics.CodeAnalysis.Experimental("ASPIREAZURE002", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] public static ApplicationModel.IResourceBuilder PublishAsAzureContainerAppJob(this ApplicationModel.IResourceBuilder resource) where T : ApplicationModel.IComputeResource { throw null; } [AspireExportIgnore(Reason = "Polyglot app hosts use the internal publishAsScheduledAzureContainerAppJob dispatcher export.")] - [System.Diagnostics.CodeAnalysis.Experimental("ASPIREAZURE002", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] public static ApplicationModel.IResourceBuilder PublishAsScheduledAzureContainerAppJob(this ApplicationModel.IResourceBuilder resource, string cronExpression, System.Action? configure = null) where T : ApplicationModel.IComputeResource { throw null; } } @@ -86,7 +86,6 @@ public AzureContainerAppCustomizationAnnotation(System.Action Configure { get { throw null; } } } - [System.Diagnostics.CodeAnalysis.Experimental("ASPIREAZURE002", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] public sealed partial class AzureContainerAppJobCustomizationAnnotation : ApplicationModel.IResourceAnnotation { public AzureContainerAppJobCustomizationAnnotation(System.Action configure) { } diff --git a/src/Aspire.Hosting.Azure.AppService/api/Aspire.Hosting.Azure.AppService.cs b/src/Aspire.Hosting.Azure.AppService/api/Aspire.Hosting.Azure.AppService.cs index 4887236cfc0..58df7af3523 100644 --- a/src/Aspire.Hosting.Azure.AppService/api/Aspire.Hosting.Azure.AppService.cs +++ b/src/Aspire.Hosting.Azure.AppService/api/Aspire.Hosting.Azure.AppService.cs @@ -10,20 +10,23 @@ namespace Aspire.Hosting { public static partial class AzureAppServiceComputeResourceExtensions { - [AspireExport(Description = "Publishes the compute resource as an Azure App Service website or deployment slot")] + [AspireExport] public static ApplicationModel.IResourceBuilder PublishAsAzureAppServiceWebsite(this ApplicationModel.IResourceBuilder builder, System.Action? configure = null, System.Action? configureSlot = null) where T : ApplicationModel.IComputeResource { throw null; } - [AspireExport(Description = "Skips Azure App Service environment variable name validation for the compute resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder SkipEnvironmentVariableNameChecks(this ApplicationModel.IResourceBuilder builder) where T : ApplicationModel.IComputeResource { throw null; } } public static partial class AzureAppServiceEnvironmentExtensions { - [AspireExport(Description = "Adds an Azure App Service environment resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddAzureAppServiceEnvironment(this IDistributedApplicationBuilder builder, string name) { throw null; } + [AspireExport] + public static ApplicationModel.IResourceBuilder WithAcrPullIdentity(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder identityBuilder) { throw null; } + [AspireExportIgnore(Reason = "Polyglot app hosts use the internal withAzureApplicationInsights dispatcher export.")] public static ApplicationModel.IResourceBuilder WithAzureApplicationInsights(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder applicationInsightsLocation) { throw null; } @@ -36,7 +39,7 @@ public static partial class AzureAppServiceEnvironmentExtensions [AspireExportIgnore(Reason = "Polyglot app hosts use the internal withAzureApplicationInsights dispatcher export.")] public static ApplicationModel.IResourceBuilder WithAzureApplicationInsights(this ApplicationModel.IResourceBuilder builder) { throw null; } - [AspireExport(Description = "Configures whether the Aspire dashboard is included in the Azure App Service environment")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithDashboard(this ApplicationModel.IResourceBuilder builder, bool enable = true) { throw null; } [AspireExportIgnore(Reason = "Polyglot app hosts use the internal withDeploymentSlot dispatcher export.")] @@ -45,7 +48,7 @@ public static partial class AzureAppServiceEnvironmentExtensions [AspireExportIgnore(Reason = "Polyglot app hosts use the internal withDeploymentSlot dispatcher export.")] public static ApplicationModel.IResourceBuilder WithDeploymentSlot(this ApplicationModel.IResourceBuilder builder, string deploymentSlot) { throw null; } - [AspireExport(Description = "Configures whether HTTP endpoints are automatically upgraded to HTTPS in Azure App Service")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithHttpsUpgrade(this ApplicationModel.IResourceBuilder builder, bool upgrade = true) { throw null; } } } diff --git a/src/Aspire.Hosting.Azure.ApplicationInsights/api/Aspire.Hosting.Azure.ApplicationInsights.cs b/src/Aspire.Hosting.Azure.ApplicationInsights/api/Aspire.Hosting.Azure.ApplicationInsights.cs index bb7c10c034a..f25c5548e7c 100644 --- a/src/Aspire.Hosting.Azure.ApplicationInsights/api/Aspire.Hosting.Azure.ApplicationInsights.cs +++ b/src/Aspire.Hosting.Azure.ApplicationInsights/api/Aspire.Hosting.Azure.ApplicationInsights.cs @@ -13,10 +13,10 @@ public static partial class AzureApplicationInsightsExtensions [AspireExportIgnore(Reason = "logAnalyticsWorkspace parameter cannot be made optional in ATS. Use the single-parameter overload with WithLogAnalyticsWorkspace instead.")] public static ApplicationModel.IResourceBuilder AddAzureApplicationInsights(this IDistributedApplicationBuilder builder, string name, ApplicationModel.IResourceBuilder? logAnalyticsWorkspace) { throw null; } - [AspireExport(Description = "Adds an Azure Application Insights resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddAzureApplicationInsights(this IDistributedApplicationBuilder builder, string name) { throw null; } - [AspireExport(Description = "Configures the Application Insights resource to use a Log Analytics Workspace")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithLogAnalyticsWorkspace(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder logAnalyticsWorkspace) { throw null; } [AspireExportIgnore(Reason = "BicepOutputReference is not ATS-compatible. Use the IResourceBuilder overload instead.")] diff --git a/src/Aspire.Hosting.Azure.CognitiveServices/api/Aspire.Hosting.Azure.CognitiveServices.cs b/src/Aspire.Hosting.Azure.CognitiveServices/api/Aspire.Hosting.Azure.CognitiveServices.cs index 76d3034a168..fb30e7c7d01 100644 --- a/src/Aspire.Hosting.Azure.CognitiveServices/api/Aspire.Hosting.Azure.CognitiveServices.cs +++ b/src/Aspire.Hosting.Azure.CognitiveServices/api/Aspire.Hosting.Azure.CognitiveServices.cs @@ -10,17 +10,17 @@ namespace Aspire.Hosting { public static partial class AzureOpenAIExtensions { - [AspireExport(Description = "Adds an Azure OpenAI resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddAzureOpenAI(this IDistributedApplicationBuilder builder, string name) { throw null; } [AspireExportIgnore(Reason = "Obsolete API that accepts AzureOpenAIDeployment which is not ATS-compatible.")] [System.Obsolete("AddDeployment taking an AzureOpenAIDeployment is deprecated. Please the AddDeployment overload that returns an AzureOpenAIDeploymentResource instead.")] public static ApplicationModel.IResourceBuilder AddDeployment(this ApplicationModel.IResourceBuilder builder, ApplicationModel.AzureOpenAIDeployment deployment) { throw null; } - [AspireExport(Description = "Adds an Azure OpenAI deployment resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddDeployment(this ApplicationModel.IResourceBuilder builder, string name, string modelName, string modelVersion) { throw null; } - [AspireExport(Description = "Configures properties of an Azure OpenAI deployment", RunSyncOnBackgroundThread = true)] + [AspireExport(RunSyncOnBackgroundThread = true)] public static ApplicationModel.IResourceBuilder WithProperties(this ApplicationModel.IResourceBuilder builder, System.Action configure) { throw null; } [AspireExportIgnore(Reason = "CognitiveServicesBuiltInRole is an Azure.Provisioning type not compatible with ATS. Use the enum-based overload instead.")] diff --git a/src/Aspire.Hosting.Azure.ContainerRegistry/api/Aspire.Hosting.Azure.ContainerRegistry.cs b/src/Aspire.Hosting.Azure.ContainerRegistry/api/Aspire.Hosting.Azure.ContainerRegistry.cs index 07f6901c4d4..8275f1daf27 100644 --- a/src/Aspire.Hosting.Azure.ContainerRegistry/api/Aspire.Hosting.Azure.ContainerRegistry.cs +++ b/src/Aspire.Hosting.Azure.ContainerRegistry/api/Aspire.Hosting.Azure.ContainerRegistry.cs @@ -10,18 +10,18 @@ namespace Aspire.Hosting { public static partial class AzureContainerRegistryExtensions { - [AspireExport(Description = "Adds an Azure Container Registry resource to the distributed application model.")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddAzureContainerRegistry(this IDistributedApplicationBuilder builder, string name) { throw null; } - [AspireExport(Description = "Gets the Azure Container Registry associated with a compute environment resource.")] + [AspireExport] public static ApplicationModel.IResourceBuilder GetAzureContainerRegistry(this ApplicationModel.IResourceBuilder builder) where T : ApplicationModel.IResource, Azure.IAzureComputeEnvironmentResource { throw null; } - [AspireExport("withContainerRegistryAzureContainerRegistry", MethodName = "withAzureContainerRegistry", Description = "Configures a compute environment resource to use an Azure Container Registry.")] + [AspireExport("withContainerRegistryAzureContainerRegistry", MethodName = "withAzureContainerRegistry")] public static ApplicationModel.IResourceBuilder WithAzureContainerRegistry(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder registryBuilder) where T : ApplicationModel.IResource, ApplicationModel.IComputeEnvironmentResource { throw null; } - [AspireExport(Description = "Configures a purge task for the Azure Container Registry resource.")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithPurgeTask(this ApplicationModel.IResourceBuilder builder, string schedule, string? filter = null, System.TimeSpan? ago = null, int keep = 3, string? taskName = null) { throw null; } [AspireExportIgnore(Reason = "ContainerRegistryBuiltInRole is an Azure.Provisioning type not compatible with ATS. Use the AzureContainerRegistryRole-based overload instead.")] diff --git a/src/Aspire.Hosting.Azure.CosmosDB/api/Aspire.Hosting.Azure.CosmosDB.cs b/src/Aspire.Hosting.Azure.CosmosDB/api/Aspire.Hosting.Azure.CosmosDB.cs index ea286c206be..fd3ab1614de 100644 --- a/src/Aspire.Hosting.Azure.CosmosDB/api/Aspire.Hosting.Azure.CosmosDB.cs +++ b/src/Aspire.Hosting.Azure.CosmosDB/api/Aspire.Hosting.Azure.CosmosDB.cs @@ -48,7 +48,7 @@ void Azure.IResourceWithAzureFunctionsConfig.ApplyAzureFunctionsConfiguration(Sy public static partial class AzureCosmosExtensions { - [AspireExport(Description = "Adds an Azure Cosmos DB resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddAzureCosmosDB(this IDistributedApplicationBuilder builder, string name) { throw null; } [AspireExportIgnore(Reason = "Polyglot app hosts use the internal addContainer dispatcher export.")] @@ -57,17 +57,17 @@ public static partial class AzureCosmosExtensions [AspireExportIgnore(Reason = "Polyglot app hosts use the internal addContainer dispatcher export.")] public static ApplicationModel.IResourceBuilder AddContainer(this ApplicationModel.IResourceBuilder builder, string name, string partitionKeyPath, string? containerName = null) { throw null; } - [AspireExport(Description = "Adds an Azure Cosmos DB database resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddCosmosDatabase(this ApplicationModel.IResourceBuilder builder, string name, string? databaseName = null) { throw null; } [AspireExportIgnore(Reason = "Obsolete API with incorrect return type. Use AddCosmosDatabase instead.")] [System.Obsolete("This method is obsolete because it has the wrong return type and will be removed in a future version. Use AddCosmosDatabase instead to add a Cosmos DB database.")] public static ApplicationModel.IResourceBuilder AddDatabase(this ApplicationModel.IResourceBuilder builder, string databaseName) { throw null; } - [AspireExport(Description = "Configures the Azure Cosmos DB resource to run using the local emulator", RunSyncOnBackgroundThread = true)] + [AspireExport(RunSyncOnBackgroundThread = true)] public static ApplicationModel.IResourceBuilder RunAsEmulator(this ApplicationModel.IResourceBuilder builder, System.Action>? configureContainer = null) { throw null; } - [AspireExport(Description = "Configures the Azure Cosmos DB resource to run using the preview emulator", RunSyncOnBackgroundThread = true)] + [AspireExport(RunSyncOnBackgroundThread = true)] [System.Diagnostics.CodeAnalysis.Experimental("ASPIRECOSMOSDB001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] public static ApplicationModel.IResourceBuilder RunAsPreviewEmulator(this ApplicationModel.IResourceBuilder builder, System.Action>? configureContainer = null) { throw null; } @@ -77,20 +77,20 @@ public static partial class AzureCosmosExtensions [AspireExportIgnore(Reason = "Polyglot app hosts use the internal withAccessKeyAuthentication dispatcher export.")] public static ApplicationModel.IResourceBuilder WithAccessKeyAuthentication(this ApplicationModel.IResourceBuilder builder) { throw null; } - [AspireExport(Description = "Exposes the Data Explorer endpoint for the preview emulator")] + [AspireExport] [System.Diagnostics.CodeAnalysis.Experimental("ASPIRECOSMOSDB001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] public static ApplicationModel.IResourceBuilder WithDataExplorer(this ApplicationModel.IResourceBuilder builder, int? port = null) { throw null; } - [AspireExport(Description = "Adds a named volume for the data folder to an Azure Cosmos DB emulator resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithDataVolume(this ApplicationModel.IResourceBuilder builder, string? name = null) { throw null; } - [AspireExport(Description = "Configures Azure Cosmos DB to use the default Azure SKU")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithDefaultAzureSku(this ApplicationModel.IResourceBuilder builder) { throw null; } - [AspireExport(Description = "Sets the host port for the Cosmos DB emulator gateway endpoint")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithGatewayPort(this ApplicationModel.IResourceBuilder builder, int? port) { throw null; } - [AspireExport(Description = "Sets the partition count for the Azure Cosmos DB emulator")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithPartitionCount(this ApplicationModel.IResourceBuilder builder, int count) { throw null; } } } diff --git a/src/Aspire.Hosting.Azure.EventHubs/api/Aspire.Hosting.Azure.EventHubs.cs b/src/Aspire.Hosting.Azure.EventHubs/api/Aspire.Hosting.Azure.EventHubs.cs index 7cdf5fd6d4a..56327bdeea7 100644 --- a/src/Aspire.Hosting.Azure.EventHubs/api/Aspire.Hosting.Azure.EventHubs.cs +++ b/src/Aspire.Hosting.Azure.EventHubs/api/Aspire.Hosting.Azure.EventHubs.cs @@ -10,25 +10,25 @@ namespace Aspire.Hosting { public static partial class AzureEventHubsExtensions { - [AspireExport(Description = "Adds an Azure Event Hubs namespace resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddAzureEventHubs(this IDistributedApplicationBuilder builder, string name) { throw null; } - [AspireExport(Description = "Adds an Azure Event Hub consumer group resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddConsumerGroup(this ApplicationModel.IResourceBuilder builder, string name, string? groupName = null) { throw null; } [System.Obsolete("This method is obsolete because it has the wrong return type and will be removed in a future version. Use AddHub instead to add an Azure Event Hub.")] public static ApplicationModel.IResourceBuilder AddEventHub(this ApplicationModel.IResourceBuilder builder, string name) { throw null; } - [AspireExport(Description = "Adds an Azure Event Hub resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddHub(this ApplicationModel.IResourceBuilder builder, string name, string? hubName = null) { throw null; } - [AspireExport(Description = "Configures the Azure Event Hubs resource to run with the local emulator", RunSyncOnBackgroundThread = true)] + [AspireExport(RunSyncOnBackgroundThread = true)] public static ApplicationModel.IResourceBuilder RunAsEmulator(this ApplicationModel.IResourceBuilder builder, System.Action>? configureContainer = null) { throw null; } [AspireExportIgnore(Reason = "Action callbacks are not ATS-compatible.")] public static ApplicationModel.IResourceBuilder WithConfiguration(this ApplicationModel.IResourceBuilder builder, System.Action configJson) { throw null; } - [AspireExport(Description = "Sets the emulator configuration file path")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithConfigurationFile(this ApplicationModel.IResourceBuilder builder, string path) { throw null; } [System.Obsolete("This method is obsolete because it doesn't work as intended and will be removed in a future version.")] @@ -40,10 +40,10 @@ public static partial class AzureEventHubsExtensions [System.Obsolete("Use WithHostPort instead.")] public static ApplicationModel.IResourceBuilder WithGatewayPort(this ApplicationModel.IResourceBuilder builder, int? port) { throw null; } - [AspireExport(Description = "Sets the host port for the Event Hubs emulator endpoint")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithHostPort(this ApplicationModel.IResourceBuilder builder, int? port) { throw null; } - [AspireExport(Description = "Configures properties of an Azure Event Hub", RunSyncOnBackgroundThread = true)] + [AspireExport(RunSyncOnBackgroundThread = true)] public static ApplicationModel.IResourceBuilder WithProperties(this ApplicationModel.IResourceBuilder builder, System.Action configure) { throw null; } [AspireExportIgnore(Reason = "EventHubsBuiltInRole is an Azure.Provisioning type not compatible with ATS. Use the AzureEventHubsRole-based overload instead.")] diff --git a/src/Aspire.Hosting.Azure.FrontDoor/api/Aspire.Hosting.Azure.FrontDoor.cs b/src/Aspire.Hosting.Azure.FrontDoor/api/Aspire.Hosting.Azure.FrontDoor.cs index 1a15edd0751..874da433745 100644 --- a/src/Aspire.Hosting.Azure.FrontDoor/api/Aspire.Hosting.Azure.FrontDoor.cs +++ b/src/Aspire.Hosting.Azure.FrontDoor/api/Aspire.Hosting.Azure.FrontDoor.cs @@ -10,10 +10,10 @@ namespace Aspire.Hosting { public static partial class AzureFrontDoorExtensions { - [AspireExport(Description = "Adds an Azure Front Door resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddAzureFrontDoor(this IDistributedApplicationBuilder builder, string name) { throw null; } - [AspireExport(Description = "Adds an origin (backend) to the Azure Front Door resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithOrigin(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder resource) where T : ApplicationModel.IComputeResource, ApplicationModel.IResourceWithEndpoints { throw null; } } diff --git a/src/Aspire.Hosting.Azure.Functions/api/Aspire.Hosting.Azure.Functions.cs b/src/Aspire.Hosting.Azure.Functions/api/Aspire.Hosting.Azure.Functions.cs index 5c69bc50633..c46fb91dbd1 100644 --- a/src/Aspire.Hosting.Azure.Functions/api/Aspire.Hosting.Azure.Functions.cs +++ b/src/Aspire.Hosting.Azure.Functions/api/Aspire.Hosting.Azure.Functions.cs @@ -10,14 +10,14 @@ namespace Aspire.Hosting { public static partial class AzureFunctionsProjectResourceExtensions { - [AspireExport(Description = "Adds an Azure Functions project to the distributed application")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddAzureFunctionsProject(this IDistributedApplicationBuilder builder, string name, string projectPath) { throw null; } [AspireExportIgnore(Reason = "TProject : IProjectMetadata is a .NET-specific generic constraint not compatible with ATS. Use the project path overload instead.")] public static ApplicationModel.IResourceBuilder AddAzureFunctionsProject(this IDistributedApplicationBuilder builder, string name) where TProject : IProjectMetadata, new() { throw null; } - [AspireExport(Description = "Configures the Azure Functions project to use specified Azure Storage as host storage")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithHostStorage(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder storage) { throw null; } [AspireExportIgnore(Reason = "IResourceWithAzureFunctionsConfig is an internal interface constraint not compatible with ATS.")] @@ -27,15 +27,15 @@ public static partial class AzureFunctionsProjectResourceExtensions public static partial class DurableTaskResourceExtensions { - [AspireExport(Description = "Adds a Durable Task scheduler resource to the distributed application.")] + [AspireExport] [System.Diagnostics.CodeAnalysis.Experimental("ASPIREDURABLETASK001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] public static ApplicationModel.IResourceBuilder AddDurableTaskScheduler(this IDistributedApplicationBuilder builder, string name) { throw null; } - [AspireExport(Description = "Adds a Durable Task hub resource associated with the scheduler.")] + [AspireExport] [System.Diagnostics.CodeAnalysis.Experimental("ASPIREDURABLETASK001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] public static ApplicationModel.IResourceBuilder AddTaskHub(this ApplicationModel.IResourceBuilder builder, string name) { throw null; } - [AspireExport(Description = "Configures the Durable Task scheduler to run using the local emulator.", RunSyncOnBackgroundThread = true)] + [AspireExport(RunSyncOnBackgroundThread = true)] [System.Diagnostics.CodeAnalysis.Experimental("ASPIREDURABLETASK001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] public static ApplicationModel.IResourceBuilder RunAsEmulator(this ApplicationModel.IResourceBuilder builder, System.Action>? configureContainer = null) { throw null; } diff --git a/src/Aspire.Hosting.Azure.KeyVault/api/Aspire.Hosting.Azure.KeyVault.cs b/src/Aspire.Hosting.Azure.KeyVault/api/Aspire.Hosting.Azure.KeyVault.cs index 8b2928d3438..b77693808ef 100644 --- a/src/Aspire.Hosting.Azure.KeyVault/api/Aspire.Hosting.Azure.KeyVault.cs +++ b/src/Aspire.Hosting.Azure.KeyVault/api/Aspire.Hosting.Azure.KeyVault.cs @@ -10,7 +10,7 @@ namespace Aspire.Hosting { public static partial class AzureKeyVaultResourceExtensions { - [AspireExport(Description = "Adds an Azure Key Vault resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddAzureKeyVault(this IDistributedApplicationBuilder builder, string name) { throw null; } [AspireExportIgnore(Reason = "Polyglot app hosts use the internal addSecret dispatcher export.")] @@ -31,7 +31,7 @@ public static partial class AzureKeyVaultResourceExtensions [AspireExportIgnore(Reason = "Polyglot app hosts use the internal addSecret dispatcher export.")] public static ApplicationModel.IResourceBuilder AddSecret(this ApplicationModel.IResourceBuilder builder, string name, string secretName, ApplicationModel.ReferenceExpression value) { throw null; } - [AspireExport(Description = "Gets a secret reference from the Azure Key Vault")] + [AspireExport] public static Azure.IAzureKeyVaultSecretReference GetSecret(this ApplicationModel.IResourceBuilder builder, string secretName) { throw null; } [AspireExportIgnore(Reason = "KeyVaultBuiltInRole is an Azure.Provisioning type not compatible with ATS. Use the AzureKeyVaultRole-based overload instead.")] diff --git a/src/Aspire.Hosting.Azure.Kubernetes/api/Aspire.Hosting.Azure.Kubernetes.cs b/src/Aspire.Hosting.Azure.Kubernetes/api/Aspire.Hosting.Azure.Kubernetes.cs index 09d396f850a..8b91b2ba433 100644 --- a/src/Aspire.Hosting.Azure.Kubernetes/api/Aspire.Hosting.Azure.Kubernetes.cs +++ b/src/Aspire.Hosting.Azure.Kubernetes/api/Aspire.Hosting.Azure.Kubernetes.cs @@ -8,37 +8,55 @@ //------------------------------------------------------------------------------ namespace Aspire.Hosting { + public static partial class AzureCertManagerExtensions + { + [AspireExport] + public static ApplicationModel.IResourceBuilder AddCertManager(this ApplicationModel.IResourceBuilder builder, string name, string? chartVersion = null) { throw null; } + } + public static partial class AzureKubernetesEnvironmentExtensions { - [AspireExport(Description = "Adds an Azure Kubernetes Service environment resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddAzureKubernetesEnvironment(this IDistributedApplicationBuilder builder, string name) { throw null; } - [AspireExport(Description = "Adds a node pool to the AKS cluster")] + [AspireExport] + public static ApplicationModel.IResourceBuilder AddLoadBalancer(this ApplicationModel.IResourceBuilder builder, string name, ApplicationModel.IResourceBuilder subnet) { throw null; } + + [AspireExport] public static ApplicationModel.IResourceBuilder AddNodePool(this ApplicationModel.IResourceBuilder builder, string name, string vmSize = "Standard_D2s_v5", int minCount = 1, int maxCount = 3) { throw null; } - [AspireExport(Description = "Configures the AKS environment to use a specific container registry")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithContainerRegistry(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder registry) { throw null; } - [AspireExport("withNodePoolSubnet", MethodName = "withSubnet", Description = "Configures an AKS node pool to use a specific VNet subnet")] + [AspireExport("withNodePoolSubnet", MethodName = "withSubnet")] public static ApplicationModel.IResourceBuilder WithSubnet(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder subnet) { throw null; } - [AspireExport(Description = "Configures the AKS cluster to use a VNet subnet")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithSubnet(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder subnet) { throw null; } - [AspireExport(Description = "Replaces the default system node pool with a customized configuration")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithSystemNodePool(this ApplicationModel.IResourceBuilder builder, string vmSize = "Standard_D2s_v5", int minCount = 1, int maxCount = 3) { throw null; } - [AspireExport(Description = "Enables workload identity on the AKS cluster")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithWorkloadIdentity(this ApplicationModel.IResourceBuilder builder, bool enabled = true) { throw null; } } public static partial class AzureKubernetesIngressExtensions { - [AspireExport(Description = "Adds a Kubernetes Gateway API Gateway to an AKS environment")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddGateway(this ApplicationModel.IResourceBuilder builder, string name) { throw null; } - [AspireExport(Description = "Adds a Kubernetes Ingress resource to an AKS environment")] + [AspireExport] + public static ApplicationModel.IResourceBuilder AddHelmChart(this ApplicationModel.IResourceBuilder builder, string name, string chartReference, string chartVersion) { throw null; } + + [AspireExport] public static ApplicationModel.IResourceBuilder AddIngress(this ApplicationModel.IResourceBuilder builder, string name) { throw null; } + + [AspireExport] + public static ApplicationModel.IResourceBuilder WithLoadBalancer(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder loadBalancer) { throw null; } + + [AspireExport("withLoadBalancerOnIngress", MethodName = "withLoadBalancer")] + public static ApplicationModel.IResourceBuilder WithLoadBalancer(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder loadBalancer) { throw null; } } } @@ -3500,4 +3518,11 @@ public AzureKubernetesEnvironmentResource(string name, System.Action, ApplicationModel.IResourceWithParent, ApplicationModel.IResource + { + internal AzureKubernetesLoadBalancerResource() : base(default!) { } + + public AzureKubernetesEnvironmentResource Parent { get { throw null; } } + } } \ No newline at end of file diff --git a/src/Aspire.Hosting.Azure.Kusto/api/Aspire.Hosting.Azure.Kusto.cs b/src/Aspire.Hosting.Azure.Kusto/api/Aspire.Hosting.Azure.Kusto.cs index 78f9b62d21d..2198fc50a93 100644 --- a/src/Aspire.Hosting.Azure.Kusto/api/Aspire.Hosting.Azure.Kusto.cs +++ b/src/Aspire.Hosting.Azure.Kusto/api/Aspire.Hosting.Azure.Kusto.cs @@ -10,19 +10,19 @@ namespace Aspire.Hosting { public static partial class AzureKustoBuilderExtensions { - [AspireExport(Description = "Adds an Azure Data Explorer (Kusto) cluster resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddAzureKustoCluster(this IDistributedApplicationBuilder builder, string name) { throw null; } - [AspireExport(Description = "Adds a Kusto read-write database resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddReadWriteDatabase(this ApplicationModel.IResourceBuilder builder, string name, string? databaseName = null) { throw null; } - [AspireExport(Description = "Configures the Kusto cluster to run using the local emulator", RunSyncOnBackgroundThread = true)] + [AspireExport(RunSyncOnBackgroundThread = true)] public static ApplicationModel.IResourceBuilder RunAsEmulator(this ApplicationModel.IResourceBuilder builder, System.Action>? configureContainer = null) { throw null; } - [AspireExport(Description = "Defines the KQL script used to create the database")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithCreationScript(this ApplicationModel.IResourceBuilder builder, string script) { throw null; } - [AspireExport(Description = "Sets the host port for the Kusto emulator endpoint")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithHostPort(this ApplicationModel.IResourceBuilder builder, int port) { throw null; } } } diff --git a/src/Aspire.Hosting.Azure.Network/api/Aspire.Hosting.Azure.Network.cs b/src/Aspire.Hosting.Azure.Network/api/Aspire.Hosting.Azure.Network.cs index 7ef1291d6b9..93a75a686d5 100644 --- a/src/Aspire.Hosting.Azure.Network/api/Aspire.Hosting.Azure.Network.cs +++ b/src/Aspire.Hosting.Azure.Network/api/Aspire.Hosting.Azure.Network.cs @@ -10,81 +10,81 @@ namespace Aspire.Hosting { public static partial class AzureNatGatewayExtensions { - [AspireExport(Description = "Adds an Azure NAT Gateway resource to the application model.")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddNatGateway(this IDistributedApplicationBuilder builder, string name) { throw null; } - [AspireExport(Description = "Associates an Azure Public IP Address resource with an Azure NAT Gateway resource.")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithPublicIPAddress(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder publicIPAddress) { throw null; } } public static partial class AzureNetworkSecurityGroupExtensions { - [AspireExport(Description = "Adds an Azure Network Security Group resource to the application model.")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddNetworkSecurityGroup(this IDistributedApplicationBuilder builder, string name) { throw null; } - [AspireExport(Description = "Adds a security rule to an Azure Network Security Group resource.")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithSecurityRule(this ApplicationModel.IResourceBuilder builder, Azure.AzureSecurityRule rule) { throw null; } } public static partial class AzureNetworkSecurityPerimeterExtensions { - [AspireExport(Description = "Adds an Azure Network Security Perimeter resource to the application model.")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddNetworkSecurityPerimeter(this IDistributedApplicationBuilder builder, string name) { throw null; } - [AspireExport(Description = "Adds an access rule to an Azure Network Security Perimeter resource.")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithAccessRule(this ApplicationModel.IResourceBuilder builder, Azure.AzureNspAccessRule rule) { throw null; } - [AspireExport("associateWithNetworkSecurityPerimeter", MethodName = "withNetworkSecurityPerimeter", Description = "Associates an Azure PaaS resource with a Network Security Perimeter.")] + [AspireExport("associateWithNetworkSecurityPerimeter", MethodName = "withNetworkSecurityPerimeter")] public static ApplicationModel.IResourceBuilder WithNetworkSecurityPerimeter(this ApplicationModel.IResourceBuilder target, ApplicationModel.IResourceBuilder nsp, global::Azure.Provisioning.Network.NetworkSecurityPerimeterAssociationAccessMode accessMode = global::Azure.Provisioning.Network.NetworkSecurityPerimeterAssociationAccessMode.Enforced, string? associationName = null) where T : ApplicationModel.IResource, Azure.IAzureNspAssociationTarget { throw null; } } public static partial class AzurePrivateEndpointExtensions { - [AspireExport(Description = "Adds an Azure Private Endpoint resource to an Azure subnet resource.")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddPrivateEndpoint(this ApplicationModel.IResourceBuilder subnet, ApplicationModel.IResourceBuilder target) { throw null; } } public static partial class AzurePublicIPAddressExtensions { - [AspireExport(Description = "Adds an Azure Public IP Address resource to the application model.")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddPublicIPAddress(this IDistributedApplicationBuilder builder, string name) { throw null; } } public static partial class AzureVirtualNetworkExtensions { - [AspireExport("addAzureVirtualNetworkFromParameter", MethodName = "addAzureVirtualNetwork", Description = "Adds an Azure Virtual Network resource to the application model with a parameterized address prefix.")] + [AspireExportIgnore(Reason = "Polyglot app hosts use the internal addAzureVirtualNetwork dispatcher export.")] public static ApplicationModel.IResourceBuilder AddAzureVirtualNetwork(this IDistributedApplicationBuilder builder, string name, ApplicationModel.IResourceBuilder addressPrefix) { throw null; } - [AspireExport(Description = "Adds an Azure Virtual Network resource to the application model.")] + [AspireExportIgnore(Reason = "Polyglot app hosts use the internal addAzureVirtualNetwork dispatcher export.")] public static ApplicationModel.IResourceBuilder AddAzureVirtualNetwork(this IDistributedApplicationBuilder builder, string name, string? addressPrefix = null) { throw null; } - [AspireExport("addSubnetFromParameter", MethodName = "addSubnet", Description = "Adds an Azure subnet resource with a parameterized address prefix to an Azure Virtual Network resource.")] + [AspireExportIgnore(Reason = "Polyglot app hosts use the internal addSubnet dispatcher export.")] public static ApplicationModel.IResourceBuilder AddSubnet(this ApplicationModel.IResourceBuilder builder, string name, ApplicationModel.IResourceBuilder addressPrefix, string? subnetName = null) { throw null; } - [AspireExport(Description = "Adds an Azure subnet resource to an Azure Virtual Network resource.")] + [AspireExportIgnore(Reason = "Polyglot app hosts use the internal addSubnet dispatcher export.")] public static ApplicationModel.IResourceBuilder AddSubnet(this ApplicationModel.IResourceBuilder builder, string name, string addressPrefix, string? subnetName = null) { throw null; } - [AspireExport(Description = "Adds an inbound allow rule to the Azure subnet resource's Network Security Group.")] + [AspireExport] public static ApplicationModel.IResourceBuilder AllowInbound(this ApplicationModel.IResourceBuilder builder, string? port = null, string? from = null, string? to = null, global::Azure.Provisioning.Network.SecurityRuleProtocol? protocol = null, int? priority = null, string? name = null) { throw null; } - [AspireExport(Description = "Adds an outbound allow rule to the Azure subnet resource's Network Security Group.")] + [AspireExport] public static ApplicationModel.IResourceBuilder AllowOutbound(this ApplicationModel.IResourceBuilder builder, string? port = null, string? from = null, string? to = null, global::Azure.Provisioning.Network.SecurityRuleProtocol? protocol = null, int? priority = null, string? name = null) { throw null; } - [AspireExport(Description = "Adds an inbound deny rule to the Azure subnet resource's Network Security Group.")] + [AspireExport] public static ApplicationModel.IResourceBuilder DenyInbound(this ApplicationModel.IResourceBuilder builder, string? port = null, string? from = null, string? to = null, global::Azure.Provisioning.Network.SecurityRuleProtocol? protocol = null, int? priority = null, string? name = null) { throw null; } - [AspireExport(Description = "Adds an outbound deny rule to the Azure subnet resource's Network Security Group.")] + [AspireExport] public static ApplicationModel.IResourceBuilder DenyOutbound(this ApplicationModel.IResourceBuilder builder, string? port = null, string? from = null, string? to = null, global::Azure.Provisioning.Network.SecurityRuleProtocol? protocol = null, int? priority = null, string? name = null) { throw null; } - [AspireExport("withSubnetDelegatedSubnet", MethodName = "withDelegatedSubnet", Description = "Associates a delegated Azure subnet resource with an Azure resource that supports subnet delegation.")] + [AspireExport("withSubnetDelegatedSubnet", MethodName = "withDelegatedSubnet")] public static ApplicationModel.IResourceBuilder WithDelegatedSubnet(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder subnet) where T : Azure.IAzureDelegatedSubnetResource { throw null; } - [AspireExport(Description = "Associates an Azure NAT Gateway resource with an Azure subnet resource.")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithNatGateway(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder natGateway) { throw null; } - [AspireExport(Description = "Associates an Azure Network Security Group resource with an Azure subnet resource.")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithNetworkSecurityGroup(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder nsg) { throw null; } } } @@ -127,21 +127,21 @@ public AzureNetworkSecurityPerimeterResource(string name, System.Action AddressPrefixes { get { throw null; } } + public System.Collections.Generic.List AddressPrefixes { get { throw null; } init { } } - public System.Collections.Generic.List AddressPrefixReferences { get { throw null; } } + public System.Collections.Generic.List AddressPrefixReferences { get { throw null; } init { } } public required global::Azure.Provisioning.Network.NetworkSecurityPerimeterAccessRuleDirection Direction { get { throw null; } set { } } - public System.Collections.Generic.List FullyQualifiedDomainNameReferences { get { throw null; } } + public System.Collections.Generic.List FullyQualifiedDomainNameReferences { get { throw null; } init { } } - public System.Collections.Generic.List FullyQualifiedDomainNames { get { throw null; } } + public System.Collections.Generic.List FullyQualifiedDomainNames { get { throw null; } init { } } public required string Name { get { throw null; } set { } } - public System.Collections.Generic.List SubscriptionReferences { get { throw null; } } + public System.Collections.Generic.List SubscriptionReferences { get { throw null; } init { } } - public System.Collections.Generic.List Subscriptions { get { throw null; } } + public System.Collections.Generic.List Subscriptions { get { throw null; } init { } } } public partial class AzurePrivateEndpointResource : AzureProvisioningResource diff --git a/src/Aspire.Hosting.Azure.OperationalInsights/api/Aspire.Hosting.Azure.OperationalInsights.cs b/src/Aspire.Hosting.Azure.OperationalInsights/api/Aspire.Hosting.Azure.OperationalInsights.cs index 148525d0cd5..b2df43c02ab 100644 --- a/src/Aspire.Hosting.Azure.OperationalInsights/api/Aspire.Hosting.Azure.OperationalInsights.cs +++ b/src/Aspire.Hosting.Azure.OperationalInsights/api/Aspire.Hosting.Azure.OperationalInsights.cs @@ -10,7 +10,7 @@ namespace Aspire.Hosting { public static partial class AzureLogAnalyticsWorkspaceExtensions { - [AspireExport(Description = "Adds an Azure Log Analytics Workspace resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddAzureLogAnalyticsWorkspace(this IDistributedApplicationBuilder builder, string name) { throw null; } } } diff --git a/src/Aspire.Hosting.Azure.PostgreSQL/api/Aspire.Hosting.Azure.PostgreSQL.cs b/src/Aspire.Hosting.Azure.PostgreSQL/api/Aspire.Hosting.Azure.PostgreSQL.cs index 6e8a1830a60..afdd7ecf8a3 100644 --- a/src/Aspire.Hosting.Azure.PostgreSQL/api/Aspire.Hosting.Azure.PostgreSQL.cs +++ b/src/Aspire.Hosting.Azure.PostgreSQL/api/Aspire.Hosting.Azure.PostgreSQL.cs @@ -10,10 +10,10 @@ namespace Aspire.Hosting { public static partial class AzurePostgresExtensions { - [AspireExport(Description = "Adds an Azure PostgreSQL Flexible Server resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddAzurePostgresFlexibleServer(this IDistributedApplicationBuilder builder, string name) { throw null; } - [AspireExport(Description = "Adds an Azure PostgreSQL database")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddDatabase(this ApplicationModel.IResourceBuilder builder, string name, string? databaseName = null) { throw null; } [System.Obsolete("This method is obsolete and will be removed in a future version. Use AddAzurePostgresFlexibleServer instead to add an Azure PostgreSQL Flexible Server resource.")] @@ -22,7 +22,7 @@ public static partial class AzurePostgresExtensions [System.Obsolete("This method is obsolete and will be removed in a future version. Use AddAzurePostgresFlexibleServer instead to add an Azure PostgreSQL Flexible Server resource.")] public static ApplicationModel.IResourceBuilder PublishAsAzurePostgresFlexibleServer(this ApplicationModel.IResourceBuilder builder) { throw null; } - [AspireExport(Description = "Configures the Azure PostgreSQL Flexible Server resource to run locally in a container", RunSyncOnBackgroundThread = true)] + [AspireExport(RunSyncOnBackgroundThread = true)] public static ApplicationModel.IResourceBuilder RunAsContainer(this ApplicationModel.IResourceBuilder builder, System.Action>? configureContainer = null) { throw null; } [AspireExportIgnore(Reason = "Polyglot app hosts use the internal withPasswordAuthentication dispatcher export.")] @@ -31,7 +31,7 @@ public static partial class AzurePostgresExtensions [AspireExportIgnore(Reason = "Polyglot app hosts use the internal withPasswordAuthentication dispatcher export.")] public static ApplicationModel.IResourceBuilder WithPasswordAuthentication(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder keyVaultBuilder, ApplicationModel.IResourceBuilder? userName = null, ApplicationModel.IResourceBuilder? password = null) { throw null; } - [AspireExport(Description = "Adds a Postgres MCP server container", RunSyncOnBackgroundThread = true)] + [AspireExport(RunSyncOnBackgroundThread = true)] [System.Diagnostics.CodeAnalysis.Experimental("ASPIREPOSTGRES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] public static ApplicationModel.IResourceBuilder WithPostgresMcp(this ApplicationModel.IResourceBuilder builder, System.Action>? configureContainer = null, string? containerName = null) { throw null; } } diff --git a/src/Aspire.Hosting.Azure.Redis/api/Aspire.Hosting.Azure.Redis.cs b/src/Aspire.Hosting.Azure.Redis/api/Aspire.Hosting.Azure.Redis.cs index 1ad9efd53c4..af8ae400835 100644 --- a/src/Aspire.Hosting.Azure.Redis/api/Aspire.Hosting.Azure.Redis.cs +++ b/src/Aspire.Hosting.Azure.Redis/api/Aspire.Hosting.Azure.Redis.cs @@ -10,10 +10,10 @@ namespace Aspire.Hosting { public static partial class AzureManagedRedisExtensions { - [AspireExport(Description = "Adds an Azure Managed Redis resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddAzureManagedRedis(this IDistributedApplicationBuilder builder, string name) { throw null; } - [AspireExport(Description = "Configures Azure Managed Redis to run in a local container", RunSyncOnBackgroundThread = true)] + [AspireExport(RunSyncOnBackgroundThread = true)] public static ApplicationModel.IResourceBuilder RunAsContainer(this ApplicationModel.IResourceBuilder builder, System.Action>? configureContainer = null) { throw null; } [AspireExportIgnore(Reason = "Polyglot app hosts use the internal withAccessKeyAuthentication dispatcher export.")] diff --git a/src/Aspire.Hosting.Azure.Search/api/Aspire.Hosting.Azure.Search.cs b/src/Aspire.Hosting.Azure.Search/api/Aspire.Hosting.Azure.Search.cs index 92f283f9b60..d63887cc614 100644 --- a/src/Aspire.Hosting.Azure.Search/api/Aspire.Hosting.Azure.Search.cs +++ b/src/Aspire.Hosting.Azure.Search/api/Aspire.Hosting.Azure.Search.cs @@ -10,7 +10,7 @@ namespace Aspire.Hosting { public static partial class AzureSearchExtensions { - [AspireExport(Description = "Adds an Azure AI Search service resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddAzureSearch(this IDistributedApplicationBuilder builder, string name) { throw null; } [AspireExportIgnore(Reason = "SearchBuiltInRole is an Azure.Provisioning type not compatible with ATS. Use the AzureSearchRole-based overload instead.")] diff --git a/src/Aspire.Hosting.Azure.ServiceBus/api/Aspire.Hosting.Azure.ServiceBus.cs b/src/Aspire.Hosting.Azure.ServiceBus/api/Aspire.Hosting.Azure.ServiceBus.cs index fcc0b60819e..20b89239037 100644 --- a/src/Aspire.Hosting.Azure.ServiceBus/api/Aspire.Hosting.Azure.ServiceBus.cs +++ b/src/Aspire.Hosting.Azure.ServiceBus/api/Aspire.Hosting.Azure.ServiceBus.cs @@ -10,20 +10,20 @@ namespace Aspire.Hosting { public static partial class AzureServiceBusExtensions { - [AspireExport(Description = "Adds an Azure Service Bus namespace resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddAzureServiceBus(this IDistributedApplicationBuilder builder, string name) { throw null; } [AspireExportIgnore(Reason = "Obsolete API with incorrect return type. Use AddServiceBusQueue instead.")] [System.Obsolete("This method is obsolete because it has the wrong return type and will be removed in a future version. Use AddServiceBusQueue instead to add an Azure Service Bus Queue.")] public static ApplicationModel.IResourceBuilder AddQueue(this ApplicationModel.IResourceBuilder builder, string name) { throw null; } - [AspireExport(Description = "Adds an Azure Service Bus queue resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddServiceBusQueue(this ApplicationModel.IResourceBuilder builder, string name, string? queueName = null) { throw null; } - [AspireExport(Description = "Adds an Azure Service Bus subscription resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddServiceBusSubscription(this ApplicationModel.IResourceBuilder builder, string name, string? subscriptionName = null) { throw null; } - [AspireExport(Description = "Adds an Azure Service Bus topic resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddServiceBusTopic(this ApplicationModel.IResourceBuilder builder, string name, string? topicName = null) { throw null; } [AspireExportIgnore(Reason = "Obsolete API. Use AddServiceBusSubscription instead.")] @@ -38,25 +38,25 @@ public static partial class AzureServiceBusExtensions [System.Obsolete("This method is obsolete because it has the wrong return type and will be removed in a future version. Use AddServiceBusTopic instead to add an Azure Service Bus Topic.")] public static ApplicationModel.IResourceBuilder AddTopic(this ApplicationModel.IResourceBuilder builder, string name) { throw null; } - [AspireExport(Description = "Configures the Azure Service Bus resource to run with the local emulator", RunSyncOnBackgroundThread = true)] + [AspireExport(RunSyncOnBackgroundThread = true)] public static ApplicationModel.IResourceBuilder RunAsEmulator(this ApplicationModel.IResourceBuilder builder, System.Action>? configureContainer = null) { throw null; } [AspireExportIgnore(Reason = "Action callbacks are not ATS-compatible.")] public static ApplicationModel.IResourceBuilder WithConfiguration(this ApplicationModel.IResourceBuilder builder, System.Action configJson) { throw null; } - [AspireExport(Description = "Sets the emulator configuration file path")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithConfigurationFile(this ApplicationModel.IResourceBuilder builder, string path) { throw null; } - [AspireExport(Description = "Sets the host port for the Service Bus emulator endpoint")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithHostPort(this ApplicationModel.IResourceBuilder builder, int? port) { throw null; } - [AspireExport("withQueueProperties", MethodName = "withProperties", Description = "Configures properties of an Azure Service Bus queue", RunSyncOnBackgroundThread = true)] + [AspireExport("withQueueProperties", MethodName = "withProperties", RunSyncOnBackgroundThread = true)] public static ApplicationModel.IResourceBuilder WithProperties(this ApplicationModel.IResourceBuilder builder, System.Action configure) { throw null; } - [AspireExport("withSubscriptionProperties", MethodName = "withProperties", Description = "Configures properties of an Azure Service Bus subscription", RunSyncOnBackgroundThread = true)] + [AspireExport("withSubscriptionProperties", MethodName = "withProperties", RunSyncOnBackgroundThread = true)] public static ApplicationModel.IResourceBuilder WithProperties(this ApplicationModel.IResourceBuilder builder, System.Action configure) { throw null; } - [AspireExport("withTopicProperties", MethodName = "withProperties", Description = "Configures properties of an Azure Service Bus topic", RunSyncOnBackgroundThread = true)] + [AspireExport("withTopicProperties", MethodName = "withProperties", RunSyncOnBackgroundThread = true)] public static ApplicationModel.IResourceBuilder WithProperties(this ApplicationModel.IResourceBuilder builder, System.Action configure) { throw null; } [AspireExportIgnore(Reason = "ServiceBusBuiltInRole is an Azure.Provisioning type not compatible with ATS. Use the AzureServiceBusRole-based overload instead.")] diff --git a/src/Aspire.Hosting.Azure.SignalR/api/Aspire.Hosting.Azure.SignalR.cs b/src/Aspire.Hosting.Azure.SignalR/api/Aspire.Hosting.Azure.SignalR.cs index c5d8171dc55..308d9899030 100644 --- a/src/Aspire.Hosting.Azure.SignalR/api/Aspire.Hosting.Azure.SignalR.cs +++ b/src/Aspire.Hosting.Azure.SignalR/api/Aspire.Hosting.Azure.SignalR.cs @@ -16,7 +16,7 @@ public static partial class AzureSignalRExtensions [AspireExportIgnore(Reason = "Use the dedicated polyglot overload instead.")] public static ApplicationModel.IResourceBuilder AddAzureSignalR(this IDistributedApplicationBuilder builder, string name) { throw null; } - [AspireExport(Description = "Configures an Azure SignalR resource to be emulated. This resource requires an Azure SignalR resource to be added to the application model. Please note that the resource will be emulated in Serverless mode.", RunSyncOnBackgroundThread = true)] + [AspireExport(RunSyncOnBackgroundThread = true)] public static ApplicationModel.IResourceBuilder RunAsEmulator(this ApplicationModel.IResourceBuilder builder, System.Action>? configureContainer = null) { throw null; } [AspireExportIgnore(Reason = "SignalRBuiltInRole is an Azure.Provisioning type not compatible with ATS. Use the AzureSignalRRole-based overload instead.")] diff --git a/src/Aspire.Hosting.Azure.Sql/api/Aspire.Hosting.Azure.Sql.cs b/src/Aspire.Hosting.Azure.Sql/api/Aspire.Hosting.Azure.Sql.cs index d8dd9b40955..e267fe2b194 100644 --- a/src/Aspire.Hosting.Azure.Sql/api/Aspire.Hosting.Azure.Sql.cs +++ b/src/Aspire.Hosting.Azure.Sql/api/Aspire.Hosting.Azure.Sql.cs @@ -10,10 +10,10 @@ namespace Aspire.Hosting { public static partial class AzureSqlExtensions { - [AspireExport(Description = "Adds an Azure SQL Database server resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddAzureSqlServer(this IDistributedApplicationBuilder builder, string name) { throw null; } - [AspireExport(Description = "Adds an Azure SQL database resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddDatabase(this ApplicationModel.IResourceBuilder builder, string name, string? databaseName = null) { throw null; } [System.Obsolete("This method is obsolete and will be removed in a future version. Use AddAzureSqlServer instead to add an Azure SQL server resource.")] @@ -22,18 +22,18 @@ public static partial class AzureSqlExtensions [System.Obsolete("This method is obsolete and will be removed in a future version. Use AddAzureSqlServer instead to add an Azure SQL server resource.")] public static ApplicationModel.IResourceBuilder PublishAsAzureSqlDatabase(this ApplicationModel.IResourceBuilder builder) { throw null; } - [AspireExport(Description = "Configures the Azure SQL server to run locally in a SQL Server container", RunSyncOnBackgroundThread = true)] + [AspireExport(RunSyncOnBackgroundThread = true)] public static ApplicationModel.IResourceBuilder RunAsContainer(this ApplicationModel.IResourceBuilder builder, System.Action>? configureContainer = null) { throw null; } - [AspireExport(Description = "Configures the Azure SQL server to use a specific storage account for deployment scripts")] + [AspireExport] [System.Diagnostics.CodeAnalysis.Experimental("ASPIREAZURE003", UrlFormat = "https://aka.ms/aspire/diagnostics#{0}")] public static ApplicationModel.IResourceBuilder WithAdminDeploymentScriptStorage(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder storage) { throw null; } - [AspireExport(Description = "Configures the Azure SQL server to use a specific subnet for deployment scripts")] + [AspireExport] [System.Diagnostics.CodeAnalysis.Experimental("ASPIREAZURE003", UrlFormat = "https://aka.ms/aspire/diagnostics#{0}")] public static ApplicationModel.IResourceBuilder WithAdminDeploymentScriptSubnet(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder subnet) { throw null; } - [AspireExport(Description = "Configures the Azure SQL database to use the default Azure SKU")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithDefaultAzureSku(this ApplicationModel.IResourceBuilder builder) { throw null; } } } diff --git a/src/Aspire.Hosting.Azure.Storage/api/Aspire.Hosting.Azure.Storage.cs b/src/Aspire.Hosting.Azure.Storage/api/Aspire.Hosting.Azure.Storage.cs index da2d56b6f10..00238d1d6de 100644 --- a/src/Aspire.Hosting.Azure.Storage/api/Aspire.Hosting.Azure.Storage.cs +++ b/src/Aspire.Hosting.Azure.Storage/api/Aspire.Hosting.Azure.Storage.cs @@ -10,56 +10,56 @@ namespace Aspire.Hosting { public static partial class AzureStorageExtensions { - [AspireExport(Description = "Adds an Azure Storage resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddAzureStorage(this IDistributedApplicationBuilder builder, string name) { throw null; } [System.Obsolete("Use AddBlobContainer on IResourceBuilder instead.")] public static ApplicationModel.IResourceBuilder AddBlobContainer(this ApplicationModel.IResourceBuilder builder, string name, string? blobContainerName = null) { throw null; } - [AspireExport(Description = "Adds an Azure Blob Storage container resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddBlobContainer(this ApplicationModel.IResourceBuilder builder, string name, string? blobContainerName = null) { throw null; } - [AspireExport(Description = "Adds an Azure Blob Storage resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddBlobs(this ApplicationModel.IResourceBuilder builder, string name) { throw null; } - [AspireExport(Description = "Adds an Azure Data Lake Storage resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddDataLake(this ApplicationModel.IResourceBuilder builder, string name) { throw null; } - [AspireExport(Description = "Adds an Azure Data Lake Storage file system resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddDataLakeFileSystem(this ApplicationModel.IResourceBuilder builder, string name, string? dataLakeFileSystemName = null) { throw null; } - [AspireExport(Description = "Adds an Azure Storage queue resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddQueue(this ApplicationModel.IResourceBuilder builder, string name, string? queueName = null) { throw null; } - [AspireExport(Description = "Adds an Azure Queue Storage resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddQueues(this ApplicationModel.IResourceBuilder builder, string name) { throw null; } - [AspireExport(Description = "Adds an Azure Table Storage resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddTables(this ApplicationModel.IResourceBuilder builder, string name) { throw null; } - [AspireExport(Description = "Configures the Azure Storage resource to be emulated using Azurite", RunSyncOnBackgroundThread = true)] + [AspireExport(RunSyncOnBackgroundThread = true)] public static ApplicationModel.IResourceBuilder RunAsEmulator(this ApplicationModel.IResourceBuilder builder, System.Action>? configureContainer = null) { throw null; } - [AspireExport(Description = "Configures whether the emulator checks API version validity")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithApiVersionCheck(this ApplicationModel.IResourceBuilder builder, bool enable = true) { throw null; } - [AspireExport(Description = "Sets the host port for blob requests on the storage emulator")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithBlobPort(this ApplicationModel.IResourceBuilder builder, int port) { throw null; } - [AspireExport(Description = "Adds a bind mount for the data folder to an Azure Storage emulator resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithDataBindMount(this ApplicationModel.IResourceBuilder builder, string? path = null, bool isReadOnly = false) { throw null; } - [AspireExport(Description = "Adds a named volume for the data folder to an Azure Storage emulator resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithDataVolume(this ApplicationModel.IResourceBuilder builder, string? name = null, bool isReadOnly = false) { throw null; } - [AspireExport(Description = "Sets the host port for queue requests on the storage emulator")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithQueuePort(this ApplicationModel.IResourceBuilder builder, int port) { throw null; } [AspireExportIgnore(Reason = "StorageBuiltInRole is an Azure.Provisioning type not compatible with ATS. Use the AzureStorageRole-based overload instead.")] public static ApplicationModel.IResourceBuilder WithRoleAssignments(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder target, params global::Azure.Provisioning.Storage.StorageBuiltInRole[] roles) where T : ApplicationModel.IResource { throw null; } - [AspireExport(Description = "Sets the host port for table requests on the storage emulator")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithTablePort(this ApplicationModel.IResourceBuilder builder, int port) { throw null; } } } diff --git a/src/Aspire.Hosting.Azure.WebPubSub/api/Aspire.Hosting.Azure.WebPubSub.cs b/src/Aspire.Hosting.Azure.WebPubSub/api/Aspire.Hosting.Azure.WebPubSub.cs index 1c9b87a5e55..e83aaf9228b 100644 --- a/src/Aspire.Hosting.Azure.WebPubSub/api/Aspire.Hosting.Azure.WebPubSub.cs +++ b/src/Aspire.Hosting.Azure.WebPubSub/api/Aspire.Hosting.Azure.WebPubSub.cs @@ -10,7 +10,7 @@ namespace Aspire.Hosting { public static partial class AzureWebPubSubExtensions { - [AspireExport(Description = "Adds an Azure Web PubSub resource to the distributed application model.")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddAzureWebPubSub(this IDistributedApplicationBuilder builder, string name) { throw null; } [AspireExportIgnore(Reason = "UpstreamAuthSettings is not ATS-compatible. Use the polyglot overload without auth settings instead.")] @@ -19,7 +19,7 @@ public static partial class AzureWebPubSubExtensions [AspireExportIgnore(Reason = "ExpressionInterpolatedStringHandler and UpstreamAuthSettings are not ATS-compatible. Use the polyglot overload without auth settings instead.")] public static ApplicationModel.IResourceBuilder AddEventHandler(this ApplicationModel.IResourceBuilder builder, ApplicationModel.ReferenceExpression.ExpressionInterpolatedStringHandler urlTemplateExpression, string userEventPattern = "*", string[]? systemEvents = null, global::Azure.Provisioning.WebPubSub.UpstreamAuthSettings? authSettings = null) { throw null; } - [AspireExport(Description = "Adds a hub to the Azure Web PubSub resource.")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddHub(this ApplicationModel.IResourceBuilder builder, string name, string? hubName = null) { throw null; } [AspireExportIgnore(Reason = "Use the AddHub overload with the optional hubName parameter instead.")] diff --git a/src/Aspire.Hosting.Azure/api/Aspire.Hosting.Azure.cs b/src/Aspire.Hosting.Azure/api/Aspire.Hosting.Azure.cs index 6040ea3b4fd..426d3ea70b4 100644 --- a/src/Aspire.Hosting.Azure/api/Aspire.Hosting.Azure.cs +++ b/src/Aspire.Hosting.Azure/api/Aspire.Hosting.Azure.cs @@ -10,13 +10,13 @@ namespace Aspire.Hosting { public static partial class AzureBicepResourceExtensions { - [AspireExport(Description = "Adds an Azure Bicep template resource from a file")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddBicepTemplate(this IDistributedApplicationBuilder builder, string name, string bicepFile) { throw null; } - [AspireExport(Description = "Adds an Azure Bicep template resource from inline Bicep content")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddBicepTemplateString(this IDistributedApplicationBuilder builder, string name, string bicepContent) { throw null; } - [AspireExport(Description = "Gets an output reference from an Azure Bicep template resource")] + [AspireExport] public static Azure.BicepOutputReference GetOutput(this ApplicationModel.IResourceBuilder builder, string name) { throw null; } [System.Obsolete("GetSecretOutput is obsolete. Use IAzureKeyVaultResource.GetSecret instead.")] @@ -81,13 +81,13 @@ public static ApplicationModel.IResourceBuilder WithParameter(this Applica public static partial class AzureProvisionerExtensions { - [AspireExport(Description = "Adds Azure provisioning services to the distributed application builder")] + [AspireExport] public static IDistributedApplicationBuilder AddAzureProvisioning(this IDistributedApplicationBuilder builder) { throw null; } } public static partial class AzureProvisioningResourceExtensions { - [AspireExport(Description = "Adds an Azure provisioning resource to the application model")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddAzureInfrastructure(this IDistributedApplicationBuilder builder, string name, System.Action configureInfrastructure) { throw null; } [AspireExportIgnore(Reason = "KeyVaultSecret is an Azure.Provisioning type not compatible with ATS.")] @@ -111,21 +111,21 @@ public static partial class AzureProvisioningResourceExtensions [AspireExportIgnore(Reason = "ProvisioningParameter is an Azure.Provisioning type not compatible with ATS.")] public static global::Azure.Provisioning.ProvisioningParameter AsProvisioningParameter(this Azure.BicepOutputReference outputReference, Azure.AzureResourceInfrastructure infrastructure, string? parameterName = null) { throw null; } - [AspireExport(Description = "Configures the Azure provisioning infrastructure callback")] + [AspireExport] public static ApplicationModel.IResourceBuilder ConfigureInfrastructure(this ApplicationModel.IResourceBuilder builder, System.Action configure) where T : Azure.AzureProvisioningResource { throw null; } } public static partial class AzureResourceExtensions { - [AspireExport(Description = "Clears the default Azure role assignments from a resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder ClearDefaultRoleAssignments(this ApplicationModel.IResourceBuilder builder) where T : ApplicationModel.IAzureResource { throw null; } - [AspireExport(Description = "Gets the normalized Bicep identifier for an Azure resource")] + [AspireExport] public static string GetBicepIdentifier(this ApplicationModel.IAzureResource resource) { throw null; } - [AspireExport(Description = "Publishes an Azure resource to the manifest as a connection string")] + [AspireExport] public static ApplicationModel.IResourceBuilder PublishAsConnectionString(this ApplicationModel.IResourceBuilder builder) where T : ApplicationModel.IAzureResource, ApplicationModel.IResourceWithConnectionString { throw null; } } @@ -252,15 +252,15 @@ public AzureEnvironmentResource(string name, ApplicationModel.ParameterResource public static partial class AzureEnvironmentResourceExtensions { - [AspireExport(Description = "Adds the shared Azure environment resource to the application model")] + [AspireExport] [System.Diagnostics.CodeAnalysis.Experimental("ASPIREAZURE001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] public static ApplicationModel.IResourceBuilder AddAzureEnvironment(this IDistributedApplicationBuilder builder) { throw null; } - [AspireExport(Description = "Sets the Azure location for the shared Azure environment resource")] + [AspireExport] [System.Diagnostics.CodeAnalysis.Experimental("ASPIREAZURE001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] public static ApplicationModel.IResourceBuilder WithLocation(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder location) { throw null; } - [AspireExport(Description = "Sets the Azure resource group for the shared Azure environment resource")] + [AspireExport] [System.Diagnostics.CodeAnalysis.Experimental("ASPIREAZURE001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] public static ApplicationModel.IResourceBuilder WithResourceGroup(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder resourceGroup) { throw null; } } @@ -320,12 +320,24 @@ internal AzureResourceInfrastructure() : base(default!) { } public AzureProvisioningResource AspireResource { get { throw null; } } } + [System.Diagnostics.CodeAnalysis.Experimental("ASPIREAZURE003", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + public sealed partial class AzureRoleAssignmentResource : AzureProvisioningResource + { + public AzureRoleAssignmentResource(string name, AzureProvisioningResource targetAzureResource, ApplicationModel.IResource? ownerResource, AzureUserAssignedIdentityResource? identityResource, System.Action configureInfrastructure) : base(default!, default!) { } + + public AzureUserAssignedIdentityResource? IdentityResource { get { throw null; } } + + public ApplicationModel.IResource? OwnerResource { get { throw null; } } + + public AzureProvisioningResource TargetAzureResource { get { throw null; } } + } + public static partial class AzureUserAssignedIdentityExtensions { - [AspireExport(Description = "Adds an Azure user-assigned identity resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddAzureUserAssignedIdentity(this IDistributedApplicationBuilder builder, string name) { throw null; } - [AspireExport("withUserAssignedIdentityAzureUserAssignedIdentity", MethodName = "withAzureUserAssignedIdentity", Description = "Associates an Azure user-assigned identity with a compute resource")] + [AspireExport("withUserAssignedIdentityAzureUserAssignedIdentity", MethodName = "withAzureUserAssignedIdentity")] public static ApplicationModel.IResourceBuilder WithAzureUserAssignedIdentity(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder identityResourceBuilder) where T : ApplicationModel.IComputeResource { throw null; } } @@ -520,6 +532,11 @@ public partial interface IResourceWithAzureFunctionsConfig : ApplicationModel.IR void ApplyAzureFunctionsConfiguration(System.Collections.Generic.IDictionary target, string connectionName); } + public partial interface ITokenCredentialProvider + { + global::Azure.Core.TokenCredential TokenCredential { get; } + } + [System.Diagnostics.CodeAnalysis.Experimental("ASPIREAZURE003", UrlFormat = "https://aka.ms/aspire/diagnostics#{0}")] public sealed partial class PrivateEndpointTargetAnnotation : ApplicationModel.IResourceAnnotation { @@ -528,6 +545,16 @@ public PrivateEndpointTargetAnnotation(AzureProvisioningResource privateEndpoint public AzureProvisioningResource PrivateEndpointResource { get { throw null; } } } + [System.Diagnostics.CodeAnalysis.Experimental("ASPIREAZURE003", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + public sealed partial class ReferenceRoleAssignmentAnnotation : ApplicationModel.IResourceAnnotation + { + public ReferenceRoleAssignmentAnnotation(AzureProvisioningResource target, System.Collections.Generic.IReadOnlySet roles) { } + + public System.Collections.Generic.IReadOnlySet Roles { get { throw null; } } + + public AzureProvisioningResource Target { get { throw null; } } + } + public partial class RoleAssignmentAnnotation : ApplicationModel.IResourceAnnotation { public RoleAssignmentAnnotation(AzureProvisioningResource target, System.Collections.Generic.IReadOnlySet roles) { } diff --git a/src/Aspire.Hosting.Blazor/api/Aspire.Hosting.Blazor.cs b/src/Aspire.Hosting.Blazor/api/Aspire.Hosting.Blazor.cs new file mode 100644 index 00000000000..8cf802af91f --- /dev/null +++ b/src/Aspire.Hosting.Blazor/api/Aspire.Hosting.Blazor.cs @@ -0,0 +1,50 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ +namespace Aspire.Hosting +{ + [System.Diagnostics.CodeAnalysis.Experimental("ASPIREBLAZOR001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + public static partial class BlazorGatewayExtensions + { + [AspireExport] + public static ApplicationModel.IResourceBuilder AddBlazorGateway(this IDistributedApplicationBuilder builder, string name) { throw null; } + + [AspireExport("addBlazorWasmProject")] + public static ApplicationModel.IResourceBuilder AddBlazorWasmApp(this IDistributedApplicationBuilder builder, string name, string projectPath) { throw null; } + + [AspireExportIgnore(Reason = "Open generic type parameter TProject is not ATS-compatible.")] + public static ApplicationModel.IResourceBuilder AddBlazorWasmProject(this IDistributedApplicationBuilder builder, string name) + where TProject : IProjectMetadata, new() { throw null; } + + [AspireExport] + public static ApplicationModel.IResourceBuilder WithBlazorClientApp(this ApplicationModel.IResourceBuilder gateway, ApplicationModel.IResourceBuilder wasmApp, string apiPrefix = "_api", string otlpPrefix = "_otlp", bool proxyTelemetry = true) { throw null; } + } + + [System.Diagnostics.CodeAnalysis.Experimental("ASPIREBLAZOR001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + public static partial class BlazorHostedExtensions + { + [AspireExportIgnore(Reason = "Blazor hosted APIs are not yet stable for ATS export.")] + public static ApplicationModel.IResourceBuilder ProxyBlazorService(this ApplicationModel.IResourceBuilder host, ApplicationModel.IResourceBuilder service, string apiPrefix = "_api") { throw null; } + + [AspireExportIgnore(Reason = "Blazor hosted APIs are not yet stable for ATS export.")] + public static ApplicationModel.IResourceBuilder ProxyBlazorTelemetry(this ApplicationModel.IResourceBuilder host, string otlpPrefix = "_otlp") { throw null; } + } + + [System.Diagnostics.CodeAnalysis.Experimental("ASPIREBLAZOR001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + [AspireExport] + public sealed partial class BlazorWasmAppResource : ApplicationModel.Resource, ApplicationModel.IResourceWithEnvironment, ApplicationModel.IResource, ApplicationModel.IResourceWithParent + { + public BlazorWasmAppResource(string name, string projectPath) : base(default!) { } + + public ApplicationModel.IResource Parent { get { throw null; } } + + public string ProjectDirectory { get { throw null; } } + + public string ProjectPath { get { throw null; } } + } +} \ No newline at end of file diff --git a/src/Aspire.Hosting.Browsers/api/Aspire.Hosting.Browsers.cs b/src/Aspire.Hosting.Browsers/api/Aspire.Hosting.Browsers.cs index 1a408ddd69a..898757bf54f 100644 --- a/src/Aspire.Hosting.Browsers/api/Aspire.Hosting.Browsers.cs +++ b/src/Aspire.Hosting.Browsers/api/Aspire.Hosting.Browsers.cs @@ -12,7 +12,7 @@ namespace Aspire.Hosting public static partial class BrowserLogsBuilderExtensions { [System.Diagnostics.CodeAnalysis.Experimental("ASPIREBROWSERLOGS001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] - [AspireExport(Description = "Adds a child browser logs resource that opens tracked browser sessions, captures browser logs, and captures screenshots.")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithBrowserLogs(this ApplicationModel.IResourceBuilder builder, string? browser = null, string? profile = null, BrowserUserDataMode? userDataMode = null) where T : ApplicationModel.IResourceWithEndpoints { throw null; } } diff --git a/src/Aspire.Hosting.DevTunnels/api/Aspire.Hosting.DevTunnels.cs b/src/Aspire.Hosting.DevTunnels/api/Aspire.Hosting.DevTunnels.cs index e6f16237871..1823b48e113 100644 --- a/src/Aspire.Hosting.DevTunnels/api/Aspire.Hosting.DevTunnels.cs +++ b/src/Aspire.Hosting.DevTunnels/api/Aspire.Hosting.DevTunnels.cs @@ -13,7 +13,7 @@ public static partial class DevTunnelsResourceBuilderExtensions [AspireExportIgnore(Reason = "Use the dedicated polyglot overload instead.")] public static ApplicationModel.IResourceBuilder AddDevTunnel(this IDistributedApplicationBuilder builder, string name, string? tunnelId = null, DevTunnels.DevTunnelOptions? options = null) { throw null; } - [AspireExport("getEndpointByEndpointReference", MethodName = "getTunnelEndpoint", Description = "Gets the public endpoint exposed by the dev tunnel.")] + [AspireExport("getEndpointByEndpointReference", MethodName = "getTunnelEndpoint")] public static ApplicationModel.EndpointReference GetEndpoint(this ApplicationModel.IResourceBuilder tunnelBuilder, ApplicationModel.EndpointReference targetEndpointReference) { throw null; } [AspireExportIgnore(Reason = "IResource parameter type is not ATS-compatible. Use the EndpointReference-based overload instead.")] @@ -23,16 +23,16 @@ public static partial class DevTunnelsResourceBuilderExtensions public static ApplicationModel.EndpointReference GetEndpoint(this ApplicationModel.IResourceBuilder tunnelBuilder, ApplicationModel.IResourceBuilder resourceBuilder, string endpointName) where TResource : ApplicationModel.IResourceWithEndpoints { throw null; } - [AspireExport(Description = "Configures the dev tunnel to allow anonymous access.")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithAnonymousAccess(this ApplicationModel.IResourceBuilder tunnelBuilder) { throw null; } [AspireExportIgnore(Reason = "DevTunnelPortOptions is not ATS-compatible. Use the overload with EndpointReference or EndpointReference + bool instead.")] public static ApplicationModel.IResourceBuilder WithReference(this ApplicationModel.IResourceBuilder tunnelBuilder, ApplicationModel.EndpointReference targetEndpoint, DevTunnels.DevTunnelPortOptions? portOptions) { throw null; } - [AspireExport("withReferenceEndpointAnonymous", MethodName = "withTunnelReferenceAnonymous", Description = "Configures the dev tunnel to expose a target endpoint with access control.")] + [AspireExport("withReferenceEndpointAnonymous", MethodName = "withTunnelReferenceAnonymous")] public static ApplicationModel.IResourceBuilder WithReference(this ApplicationModel.IResourceBuilder tunnelBuilder, ApplicationModel.EndpointReference targetEndpoint, bool allowAnonymous) { throw null; } - [AspireExport("withReferenceEndpoint", MethodName = "withTunnelReference", Description = "Configures the dev tunnel to expose a target endpoint.")] + [AspireExport("withReferenceEndpoint", MethodName = "withTunnelReference")] public static ApplicationModel.IResourceBuilder WithReference(this ApplicationModel.IResourceBuilder tunnelBuilder, ApplicationModel.EndpointReference targetEndpoint) { throw null; } [AspireExportIgnore(Reason = "This method extends generic IResourceBuilder and injects dev tunnel service discovery. It requires two IResourceBuilder parameters which makes the polyglot API confusing. Use WithReference on the DevTunnelResource builder instead.")] @@ -43,7 +43,7 @@ public static ApplicationModel.IResourceBuilder WithReference WithReference(this ApplicationModel.IResourceBuilder tunnelBuilder, ApplicationModel.IResourceBuilder resourceBuilder, DevTunnels.DevTunnelPortOptions? portOptions = null) where TResource : ApplicationModel.IResourceWithEndpoints { throw null; } - [AspireExport("withReferenceResourceAnonymous", MethodName = "withTunnelReferenceAll", Description = "Configures the dev tunnel to expose all endpoints on the referenced resource.")] + [AspireExport("withReferenceResourceAnonymous", MethodName = "withTunnelReferenceAll")] public static ApplicationModel.IResourceBuilder WithReference(this ApplicationModel.IResourceBuilder tunnelBuilder, ApplicationModel.IResourceBuilder resourceBuilder, bool allowAnonymous) where TResource : ApplicationModel.IResourceWithEndpoints { throw null; } } diff --git a/src/Aspire.Hosting.Docker/api/Aspire.Hosting.Docker.cs b/src/Aspire.Hosting.Docker/api/Aspire.Hosting.Docker.cs index afb1d90ed6f..ccc4ef07125 100644 --- a/src/Aspire.Hosting.Docker/api/Aspire.Hosting.Docker.cs +++ b/src/Aspire.Hosting.Docker/api/Aspire.Hosting.Docker.cs @@ -10,31 +10,31 @@ namespace Aspire.Hosting { public static partial class DockerComposeAspireDashboardResourceBuilderExtensions { - [AspireExport(Description = "Enables or disables forwarded headers support for the Aspire dashboard")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithForwardedHeaders(this ApplicationModel.IResourceBuilder builder, bool enabled = true) { throw null; } - [AspireExport(Description = "Sets the host port for the Aspire dashboard")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithHostPort(this ApplicationModel.IResourceBuilder builder, int? port = null) { throw null; } } public static partial class DockerComposeEnvironmentExtensions { - [AspireExport(Description = "Adds a Docker Compose publishing environment")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddDockerComposeEnvironment(this IDistributedApplicationBuilder builder, string name) { throw null; } - [AspireExport(Description = "Configures the generated Docker Compose file before it is written to disk")] + [AspireExport] public static ApplicationModel.IResourceBuilder ConfigureComposeFile(this ApplicationModel.IResourceBuilder builder, System.Action configure) { throw null; } - [AspireExport(Description = "Configures the captured environment variables written to the Docker Compose .env file")] + [AspireExport] public static ApplicationModel.IResourceBuilder ConfigureEnvFile(this ApplicationModel.IResourceBuilder builder, System.Action> configure) { throw null; } - [AspireExport("configureDashboard", MethodName = "configureDashboard", Description = "Configures the Aspire dashboard resource for the Docker Compose environment", RunSyncOnBackgroundThread = true)] + [AspireExport("configureDashboard", MethodName = "configureDashboard", RunSyncOnBackgroundThread = true)] public static ApplicationModel.IResourceBuilder WithDashboard(this ApplicationModel.IResourceBuilder builder, System.Action> configure) { throw null; } - [AspireExport(Description = "Enables or disables the Aspire dashboard for the Docker Compose environment")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithDashboard(this ApplicationModel.IResourceBuilder builder, bool enabled = true) { throw null; } - [AspireExport(Description = "Configures properties of the Docker Compose environment", RunSyncOnBackgroundThread = true)] + [AspireExport(RunSyncOnBackgroundThread = true)] public static ApplicationModel.IResourceBuilder WithProperties(this ApplicationModel.IResourceBuilder builder, System.Action configure) { throw null; } } @@ -43,13 +43,13 @@ public static partial class DockerComposeServiceExtensions [AspireExportIgnore(Reason = "IManifestExpressionProvider parameters are not ATS-compatible. Use the parameter-builder overload in polyglot app hosts.")] public static string AsEnvironmentPlaceholder(this ApplicationModel.IManifestExpressionProvider manifestExpressionProvider, Docker.DockerComposeServiceResource dockerComposeService) { throw null; } - [AspireExport(Description = "Creates a Docker Compose environment variable placeholder from a parameter builder")] + [AspireExport] public static string AsEnvironmentPlaceholder(this ApplicationModel.IResourceBuilder builder, Docker.DockerComposeServiceResource dockerComposeService) { throw null; } [AspireExportIgnore(Reason = "Prefer the builder or IManifestExpressionProvider overloads in polyglot app hosts to avoid duplicate asEnvironmentPlaceholder projections on ParameterResource.")] public static string AsEnvironmentPlaceholder(this ApplicationModel.ParameterResource parameter, Docker.DockerComposeServiceResource dockerComposeService) { throw null; } - [AspireExport(Description = "Publishes the resource as a Docker Compose service with custom service configuration")] + [AspireExport] public static ApplicationModel.IResourceBuilder PublishAsDockerComposeService(this ApplicationModel.IResourceBuilder builder, System.Action configure) where T : ApplicationModel.IComputeResource { throw null; } } @@ -425,6 +425,7 @@ public sealed partial class Build public string? Target { get { throw null; } set { } } } + [AspireExport(ExposeProperties = true)] [YamlDotNet.Serialization.YamlSerializable] public sealed partial class ConfigReference { @@ -486,6 +487,7 @@ public sealed partial class Logging public System.Collections.Generic.Dictionary Options { get { throw null; } set { } } } + [AspireExport(ExposeProperties = true)] [YamlDotNet.Serialization.YamlSerializable] public sealed partial class SecretReference { @@ -505,6 +507,7 @@ public sealed partial class SecretReference public int? Uid { get { throw null; } set { } } } + [AspireExport(ExposeProperties = true)] [YamlDotNet.Serialization.YamlSerializable] public sealed partial class Ulimit { diff --git a/src/Aspire.Hosting.EntityFrameworkCore/api/Aspire.Hosting.EntityFrameworkCore.cs b/src/Aspire.Hosting.EntityFrameworkCore/api/Aspire.Hosting.EntityFrameworkCore.cs index 2d62bdb47b3..6690fe7a8e2 100644 --- a/src/Aspire.Hosting.EntityFrameworkCore/api/Aspire.Hosting.EntityFrameworkCore.cs +++ b/src/Aspire.Hosting.EntityFrameworkCore/api/Aspire.Hosting.EntityFrameworkCore.cs @@ -25,10 +25,10 @@ public static partial class EFMigrationResourceBuilderExtensions [AspireExport] public static ApplicationModel.IResourceBuilder WithMigrationOutputDirectory(this ApplicationModel.IResourceBuilder builder, string outputDirectory) { throw null; } - [AspireExport("withMigrationsProjectFromPath", MethodName = "withMigrationsProject", Description = "Configures a separate project containing the migrations using a path")] + [AspireExportIgnore(Reason = "Polyglot app hosts use the internal withMigrationsProject dispatcher export.")] public static ApplicationModel.IResourceBuilder WithMigrationsProject(this ApplicationModel.IResourceBuilder builder, string projectPath) { throw null; } - [AspireExport] + [AspireExportIgnore(Reason = "Uses IProjectMetadata generic constraint which is a .NET-specific type. Polyglot app hosts use the internal withMigrationsProject dispatcher export.")] public static ApplicationModel.IResourceBuilder WithMigrationsProject(this ApplicationModel.IResourceBuilder builder) where TProject : IProjectMetadata, new() { throw null; } } @@ -41,10 +41,10 @@ public static partial class EFResourceBuilderExtensions [AspireExportIgnore(Reason = "Action> callbacks are not ATS-compatible.")] public static ApplicationModel.IResourceBuilder AddEFMigrations(this ApplicationModel.IResourceBuilder builder, string name, string dbContextTypeName, System.Action>? configureToolResource) { throw null; } - [AspireExport("addEFMigrationsWithContextType", MethodName = "addEFMigrations", Description = "Adds EF Core migration management for a specific DbContext type identified by name")] + [AspireExportIgnore(Reason = "Polyglot app hosts use the internal addEFMigrations dispatcher export.")] public static ApplicationModel.IResourceBuilder AddEFMigrations(this ApplicationModel.IResourceBuilder builder, string name, string dbContextTypeName) { throw null; } - [AspireExport] + [AspireExportIgnore(Reason = "Polyglot app hosts use the internal addEFMigrations dispatcher export.")] public static ApplicationModel.IResourceBuilder AddEFMigrations(this ApplicationModel.IResourceBuilder builder, string name) { throw null; } } } @@ -72,8 +72,10 @@ public EFMigrationResource(string name, ApplicationModel.ProjectResource project public ApplicationModel.ProjectResource ProjectResource { get { throw null; } } + [AspireExportIgnore(Reason = "Conflicts with the publishAsMigrationBundle builder method export.")] public bool PublishAsMigrationBundle { get { throw null; } set { } } + [AspireExportIgnore(Reason = "Conflicts with the publishAsMigrationScript builder method export.")] public bool PublishAsMigrationScript { get { throw null; } set { } } public bool PublishBundleContainer { get { throw null; } set { } } diff --git a/src/Aspire.Hosting.Foundry/api/Aspire.Hosting.Foundry.cs b/src/Aspire.Hosting.Foundry/api/Aspire.Hosting.Foundry.cs index 430e6376e72..5c410ca70c3 100644 --- a/src/Aspire.Hosting.Foundry/api/Aspire.Hosting.Foundry.cs +++ b/src/Aspire.Hosting.Foundry/api/Aspire.Hosting.Foundry.cs @@ -96,7 +96,7 @@ public static ApplicationModel.IResourceBuilder WithRoleAssignments(this A public static partial class HostedAgentResourceBuilderExtensions { - [AspireExportIgnore(Reason = "Action callback shape is awkward for polyglot hosts; the HostedAgentOptions overload is exported instead.")] + [AspireExportIgnore(Reason = "Action callback shape is awkward for polyglot hosts; the HostedAgentOptions DTO shape is exported instead.")] public static ApplicationModel.IResourceBuilder AsHostedAgent(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder? project, System.Action? configure = null) where T : ApplicationModel.IResourceWithEndpoints, ApplicationModel.IResourceWithEnvironment, ApplicationModel.IComputeResource { throw null; } @@ -217,6 +217,8 @@ public AzureCognitiveServicesProjectResource(string name, System.Action> ApplicationModel.IResourceWithConnectionString.GetConnectionProperties() { throw null; } diff --git a/src/Aspire.Hosting.Garnet/api/Aspire.Hosting.Garnet.cs b/src/Aspire.Hosting.Garnet/api/Aspire.Hosting.Garnet.cs index e3c252d060a..589cdf1e6f2 100644 --- a/src/Aspire.Hosting.Garnet/api/Aspire.Hosting.Garnet.cs +++ b/src/Aspire.Hosting.Garnet/api/Aspire.Hosting.Garnet.cs @@ -16,16 +16,16 @@ public static partial class GarnetBuilderExtensions [AspireExportIgnore(Reason = "Use the dedicated polyglot overload instead.")] public static ApplicationModel.IResourceBuilder AddGarnet(this IDistributedApplicationBuilder builder, string name, int? port) { throw null; } - [AspireExport(Description = "Mounts a host directory as the Garnet data directory.")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithDataBindMount(this ApplicationModel.IResourceBuilder builder, string source, bool isReadOnly = false) { throw null; } - [AspireExport(Description = "Adds a persistent data volume to the Garnet resource.")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithDataVolume(this ApplicationModel.IResourceBuilder builder, string? name = null, bool isReadOnly = false) { throw null; } [System.Obsolete("This method is obsolete and will be removed in a future version. Use the overload without the keysChangedThreshold parameter.")] public static ApplicationModel.IResourceBuilder WithPersistence(this ApplicationModel.IResourceBuilder builder, System.TimeSpan? interval, long keysChangedThreshold) { throw null; } - [AspireExport(Description = "Configures snapshot persistence for the Garnet resource.")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithPersistence(this ApplicationModel.IResourceBuilder builder, System.TimeSpan? interval = null) { throw null; } } } diff --git a/src/Aspire.Hosting.GitHub.Models/api/Aspire.Hosting.GitHub.Models.cs b/src/Aspire.Hosting.GitHub.Models/api/Aspire.Hosting.GitHub.Models.cs index 8cf0abde572..b0719d64bf2 100644 --- a/src/Aspire.Hosting.GitHub.Models/api/Aspire.Hosting.GitHub.Models.cs +++ b/src/Aspire.Hosting.GitHub.Models/api/Aspire.Hosting.GitHub.Models.cs @@ -16,10 +16,10 @@ public static partial class GitHubModelsExtensions [AspireExportIgnore(Reason = "The polyglot overload uses the GitHubModelName enum instead. See the internal AddGitHubModel(GitHubModelName) overload.")] public static ApplicationModel.IResourceBuilder AddGitHubModel(this IDistributedApplicationBuilder builder, string name, string model, ApplicationModel.IResourceBuilder? organization = null) { throw null; } - [AspireExport(Description = "Configures the API key for the GitHub Model resource.")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithApiKey(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder apiKey) { throw null; } - [AspireExport("enableHealthCheck", MethodName = "withHealthCheck", Description = "Adds a health check for the GitHub Model resource.")] + [AspireExport("enableHealthCheck")] public static ApplicationModel.IResourceBuilder WithHealthCheck(this ApplicationModel.IResourceBuilder builder) { throw null; } } } diff --git a/src/Aspire.Hosting.Go/api/Aspire.Hosting.Go.cs b/src/Aspire.Hosting.Go/api/Aspire.Hosting.Go.cs new file mode 100644 index 00000000000..3866b33624f --- /dev/null +++ b/src/Aspire.Hosting.Go/api/Aspire.Hosting.Go.cs @@ -0,0 +1,53 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ +namespace Aspire.Hosting +{ + public static partial class GoHostingExtensions + { + [AspireExport] + public static ApplicationModel.IResourceBuilder AddGoApp(this IDistributedApplicationBuilder builder, string name, string appDirectory, string packagePath = ".", string[]? buildTags = null, string? ldFlags = null, string? gcFlags = null, bool raceDetector = false) { throw null; } + + [AspireExport] + public static ApplicationModel.IResourceBuilder WithAppArgs(this ApplicationModel.IResourceBuilder builder, params object[] args) + where T : Go.GoAppResource { throw null; } + + [AspireExport] + public static ApplicationModel.IResourceBuilder WithDelveServer(this ApplicationModel.IResourceBuilder builder, int port = 2345) + where T : Go.GoAppResource { throw null; } + + [AspireExport] + public static ApplicationModel.IResourceBuilder WithGoPrivate(this ApplicationModel.IResourceBuilder builder, string[] privatePatterns, string authHost, string usernameArgName = "GIT_USER", string tokenSecretId = "gittoken") + where T : Go.GoAppResource { throw null; } + + [AspireExport] + public static ApplicationModel.IResourceBuilder WithModDownload(this ApplicationModel.IResourceBuilder builder) + where T : Go.GoAppResource { throw null; } + + [AspireExport] + public static ApplicationModel.IResourceBuilder WithModTidy(this ApplicationModel.IResourceBuilder builder) + where T : Go.GoAppResource { throw null; } + + [AspireExport] + public static ApplicationModel.IResourceBuilder WithModVendor(this ApplicationModel.IResourceBuilder builder) + where T : Go.GoAppResource { throw null; } + + [AspireExport] + public static ApplicationModel.IResourceBuilder WithVetTool(this ApplicationModel.IResourceBuilder builder) + where T : Go.GoAppResource { throw null; } + } +} + +namespace Aspire.Hosting.Go +{ + [AspireExport(ExposeProperties = true)] + public partial class GoAppResource : ApplicationModel.ExecutableResource, IResourceWithServiceDiscovery, ApplicationModel.IResourceWithEndpoints, ApplicationModel.IResource, ApplicationModel.IContainerFilesDestinationResource + { + public GoAppResource(string name, string workingDirectory) : base(default!, default!, default!) { } + } +} \ No newline at end of file diff --git a/src/Aspire.Hosting.JavaScript/api/Aspire.Hosting.JavaScript.cs b/src/Aspire.Hosting.JavaScript/api/Aspire.Hosting.JavaScript.cs index b966c771f94..62369ff88ca 100644 --- a/src/Aspire.Hosting.JavaScript/api/Aspire.Hosting.JavaScript.cs +++ b/src/Aspire.Hosting.JavaScript/api/Aspire.Hosting.JavaScript.cs @@ -10,31 +10,34 @@ namespace Aspire.Hosting { public static partial class JavaScriptHostingExtensions { - [AspireExport(Description = "Adds a JavaScript application resource")] + [AspireExport] + public static ApplicationModel.IResourceBuilder AddBunApp(this IDistributedApplicationBuilder builder, string name, string appDirectory, string scriptPath) { throw null; } + + [AspireExport] public static ApplicationModel.IResourceBuilder AddJavaScriptApp(this IDistributedApplicationBuilder builder, string name, string appDirectory, string runScriptName = "dev") { throw null; } [System.Diagnostics.CodeAnalysis.Experimental("ASPIREJAVASCRIPT001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] - [AspireExport(Description = "Adds a Next.js application resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddNextJsApp(this IDistributedApplicationBuilder builder, string name, string appDirectory, string runScriptName = "dev") { throw null; } - [AspireExport(Description = "Adds a Node.js application resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddNodeApp(this IDistributedApplicationBuilder builder, string name, string appDirectory, string scriptPath) { throw null; } - [AspireExport(Description = "Adds a Vite application resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddViteApp(this IDistributedApplicationBuilder builder, string name, string appDirectory, string runScriptName = "dev") { throw null; } [System.Diagnostics.CodeAnalysis.Experimental("ASPIREJAVASCRIPT001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] - [AspireExport(Description = "Disables deploy-time build validation checks for the Next.js application.")] + [AspireExport] public static ApplicationModel.IResourceBuilder DisableBuildValidation(this ApplicationModel.IResourceBuilder builder) { throw null; } [System.Diagnostics.CodeAnalysis.Experimental("ASPIREJAVASCRIPT001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] - [AspireExport(Description = "Publishes the JavaScript application as a standalone Node.js server that runs a built artifact directly.")] + [AspireExport] public static ApplicationModel.IResourceBuilder PublishAsNodeServer(this ApplicationModel.IResourceBuilder builder, string entryPoint, string outputPath = ".") where TResource : JavaScript.JavaScriptAppResource { throw null; } [System.Diagnostics.CodeAnalysis.Experimental("ASPIREJAVASCRIPT001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] - [AspireExport(Description = "Publishes the JavaScript application as a Node.js server that uses a package manager script at runtime.")] - public static ApplicationModel.IResourceBuilder PublishAsNpmScript(this ApplicationModel.IResourceBuilder builder, string startScriptName = "start", string? runScriptArguments = null) + [AspireExport] + public static ApplicationModel.IResourceBuilder PublishAsPackageScript(this ApplicationModel.IResourceBuilder builder, string scriptName = "start", string? runScriptArguments = null) where TResource : JavaScript.JavaScriptAppResource { throw null; } [System.Diagnostics.CodeAnalysis.Experimental("ASPIREJAVASCRIPT001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] @@ -48,34 +51,34 @@ public static ApplicationModel.IResourceBuilder PublishAsStaticWebsit where TResource : JavaScript.JavaScriptAppResource { throw null; } [System.Diagnostics.CodeAnalysis.Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] - [AspireExport(Description = "Configures a browser debugger for the JavaScript application")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithBrowserDebugger(this ApplicationModel.IResourceBuilder builder, string browser = "msedge") where T : JavaScript.JavaScriptAppResource { throw null; } - [AspireExport(Description = "Specifies an npm script to run before starting the application")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithBuildScript(this ApplicationModel.IResourceBuilder resource, string scriptName, string[]? args = null) where TResource : JavaScript.JavaScriptAppResource { throw null; } - [AspireExport(Description = "Configures Bun as the package manager")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithBun(this ApplicationModel.IResourceBuilder resource, bool install = true, string[]? installArgs = null) where TResource : JavaScript.JavaScriptAppResource { throw null; } - [AspireExport(Description = "Configures npm as the package manager")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithNpm(this ApplicationModel.IResourceBuilder resource, bool install = true, string? installCommand = null, string[]? installArgs = null) where TResource : JavaScript.JavaScriptAppResource { throw null; } - [AspireExport(Description = "Configures pnpm as the package manager")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithPnpm(this ApplicationModel.IResourceBuilder resource, bool install = true, string[]? installArgs = null) where TResource : JavaScript.JavaScriptAppResource { throw null; } - [AspireExport(Description = "Specifies an npm script to run during development")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithRunScript(this ApplicationModel.IResourceBuilder resource, string scriptName, string[]? args = null) where TResource : JavaScript.JavaScriptAppResource { throw null; } - [AspireExport(Description = "Configures a custom Vite configuration file")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithViteConfig(this ApplicationModel.IResourceBuilder builder, string configPath) { throw null; } - [AspireExport(Description = "Configures yarn as the package manager")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithYarn(this ApplicationModel.IResourceBuilder resource, bool install = true, string[]? installArgs = null) where TResource : JavaScript.JavaScriptAppResource { throw null; } } @@ -83,6 +86,12 @@ public static ApplicationModel.IResourceBuilder WithYarn(t namespace Aspire.Hosting.JavaScript { + [AspireExport(ExposeProperties = true)] + public sealed partial class BunAppResource : JavaScriptAppResource, IResourceWithServiceDiscovery, ApplicationModel.IResourceWithEndpoints, ApplicationModel.IResource, ApplicationModel.IContainerFilesDestinationResource + { + public BunAppResource(string name, string command, string workingDirectory) : base(default!, default!, default!) { } + } + public sealed partial record CopyFilePattern(string Source, string Destination) { } diff --git a/src/Aspire.Hosting.Kafka/api/Aspire.Hosting.Kafka.cs b/src/Aspire.Hosting.Kafka/api/Aspire.Hosting.Kafka.cs index cc304a35ae2..cb56ab59ee3 100644 --- a/src/Aspire.Hosting.Kafka/api/Aspire.Hosting.Kafka.cs +++ b/src/Aspire.Hosting.Kafka/api/Aspire.Hosting.Kafka.cs @@ -10,19 +10,19 @@ namespace Aspire.Hosting { public static partial class KafkaBuilderExtensions { - [AspireExport(Description = "Adds a Kafka container resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddKafka(this IDistributedApplicationBuilder builder, string name, int? port = null) { throw null; } - [AspireExport(Description = "Adds a data bind mount to the Kafka container")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithDataBindMount(this ApplicationModel.IResourceBuilder builder, string source, bool isReadOnly = false) { throw null; } - [AspireExport(Description = "Adds a data volume to the Kafka container")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithDataVolume(this ApplicationModel.IResourceBuilder builder, string? name = null, bool isReadOnly = false) { throw null; } - [AspireExport(Description = "Sets the host port for the Kafka UI container")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithHostPort(this ApplicationModel.IResourceBuilder builder, int? port) { throw null; } - [AspireExport(Description = "Adds a Kafka UI container to manage the Kafka resource", RunSyncOnBackgroundThread = true)] + [AspireExport(RunSyncOnBackgroundThread = true)] public static ApplicationModel.IResourceBuilder WithKafkaUI(this ApplicationModel.IResourceBuilder builder, System.Action>? configureContainer = null, string? containerName = null) { throw null; } } diff --git a/src/Aspire.Hosting.Keycloak/api/Aspire.Hosting.Keycloak.cs b/src/Aspire.Hosting.Keycloak/api/Aspire.Hosting.Keycloak.cs index 47a8b8e7fd4..ff5e58de580 100644 --- a/src/Aspire.Hosting.Keycloak/api/Aspire.Hosting.Keycloak.cs +++ b/src/Aspire.Hosting.Keycloak/api/Aspire.Hosting.Keycloak.cs @@ -10,19 +10,19 @@ namespace Aspire.Hosting { public static partial class KeycloakResourceBuilderExtensions { - [AspireExport(Description = "Adds a Keycloak container resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddKeycloak(this IDistributedApplicationBuilder builder, string name, int? port = null, ApplicationModel.IResourceBuilder? adminUsername = null, ApplicationModel.IResourceBuilder? adminPassword = null) { throw null; } - [AspireExport(Description = "Adds a data bind mount for Keycloak")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithDataBindMount(this ApplicationModel.IResourceBuilder builder, string source) { throw null; } - [AspireExport(Description = "Adds a data volume for Keycloak")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithDataVolume(this ApplicationModel.IResourceBuilder builder, string? name = null) { throw null; } - [AspireExport(Description = "Disables Keycloak features")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithDisabledFeatures(this ApplicationModel.IResourceBuilder builder, params string[] features) { throw null; } - [AspireExport(Description = "Enables Keycloak features")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithEnabledFeatures(this ApplicationModel.IResourceBuilder builder, params string[] features) { throw null; } [AspireExportIgnore(Reason = "Polyglot app hosts use the internal withOtlpExporter dispatcher export.")] diff --git a/src/Aspire.Hosting.Kubernetes/api/Aspire.Hosting.Kubernetes.cs b/src/Aspire.Hosting.Kubernetes/api/Aspire.Hosting.Kubernetes.cs index 6390ad8e59d..47c4e43d0ca 100644 --- a/src/Aspire.Hosting.Kubernetes/api/Aspire.Hosting.Kubernetes.cs +++ b/src/Aspire.Hosting.Kubernetes/api/Aspire.Hosting.Kubernetes.cs @@ -8,127 +8,181 @@ //------------------------------------------------------------------------------ namespace Aspire.Hosting { + public static partial class CertManagerExtensions + { + [AspireExport] + public static ApplicationModel.IResourceBuilder AddCertManager(this ApplicationModel.IResourceBuilder builder, string name, string? chartVersion = null) { throw null; } + + [AspireExport] + public static ApplicationModel.IResourceBuilder AddIssuer(this ApplicationModel.IResourceBuilder builder, string name) { throw null; } + + [AspireExport("withAcmeServerParam")] + public static ApplicationModel.IResourceBuilder WithAcmeServer(this ApplicationModel.IResourceBuilder builder, string serverUrl, ApplicationModel.IResourceBuilder email) { throw null; } + + [AspireExport] + public static ApplicationModel.IResourceBuilder WithAcmeServer(this ApplicationModel.IResourceBuilder builder, string serverUrl, string email) { throw null; } + + [AspireExport] + public static ApplicationModel.IResourceBuilder WithHttp01Solver(this ApplicationModel.IResourceBuilder builder) { throw null; } + + [AspireExport("withLetsEncryptProductionParam")] + public static ApplicationModel.IResourceBuilder WithLetsEncryptProduction(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder email) { throw null; } + + [AspireExport] + public static ApplicationModel.IResourceBuilder WithLetsEncryptProduction(this ApplicationModel.IResourceBuilder builder, string email) { throw null; } + + [AspireExport("withLetsEncryptStagingParam")] + public static ApplicationModel.IResourceBuilder WithLetsEncryptStaging(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder email) { throw null; } + + [AspireExport] + public static ApplicationModel.IResourceBuilder WithLetsEncryptStaging(this ApplicationModel.IResourceBuilder builder, string email) { throw null; } + + [AspireExport("withGatewayTlsIssuer")] + public static ApplicationModel.IResourceBuilder WithTls(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder issuer) { throw null; } + } + public static partial class KubernetesAspireDashboardResourceBuilderExtensions { - [AspireExport(Description = "Enables or disables forwarded headers support for the Aspire dashboard")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithForwardedHeaders(this ApplicationModel.IResourceBuilder builder, bool enabled = true) { throw null; } - [AspireExport(Description = "Sets the Kubernetes Service ports for the OTLP endpoints")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithOtlpServicePort(this ApplicationModel.IResourceBuilder builder, int? grpcPort = null, int? httpPort = null) { throw null; } - [AspireExport(Description = "Sets the Kubernetes Service port for the Aspire dashboard")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithServicePort(this ApplicationModel.IResourceBuilder builder, int? port = null) { throw null; } } public static partial class KubernetesEnvironmentExtensions { - [AspireExport(Description = "Adds a Kubernetes publishing environment")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddKubernetesEnvironment(this IDistributedApplicationBuilder builder, string name) { throw null; } - [AspireExport(Description = "Adds a named node pool to a Kubernetes environment")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddNodePool(this ApplicationModel.IResourceBuilder builder, string name) { throw null; } - [AspireExport("configureDashboard", MethodName = "configureDashboard", Description = "Configures the Aspire dashboard resource for the Kubernetes environment", RunSyncOnBackgroundThread = true)] + [AspireExport("configureDashboard", MethodName = "configureDashboard", RunSyncOnBackgroundThread = true)] public static ApplicationModel.IResourceBuilder WithDashboard(this ApplicationModel.IResourceBuilder builder, System.Action> configure) { throw null; } - [AspireExport(Description = "Enables or disables the Aspire dashboard for the Kubernetes environment")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithDashboard(this ApplicationModel.IResourceBuilder builder, bool enabled = true) { throw null; } - [AspireExport(Description = "Configures Helm chart deployment settings", RunSyncOnBackgroundThread = true)] + [AspireExport(RunSyncOnBackgroundThread = true)] public static ApplicationModel.IResourceBuilder WithHelm(this ApplicationModel.IResourceBuilder builder, System.Action? configure = null) { throw null; } - [AspireExport("withKubernetesNodePool", MethodName = "withNodePool", Description = "Schedules a workload on a specific Kubernetes node pool")] + [AspireExport("withKubernetesNodePool", MethodName = "withNodePool")] public static ApplicationModel.IResourceBuilder WithNodePool(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder nodePool) where T : ApplicationModel.IResource { throw null; } - [AspireExport(Description = "Configures properties of a Kubernetes environment", RunSyncOnBackgroundThread = true)] + [AspireExport(RunSyncOnBackgroundThread = true)] public static ApplicationModel.IResourceBuilder WithProperties(this ApplicationModel.IResourceBuilder builder, System.Action configure) { throw null; } } public static partial class KubernetesGatewayExtensions { - [AspireExport(Description = "Adds a Kubernetes Gateway API Gateway resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddGateway(this ApplicationModel.IResourceBuilder builder, string name) { throw null; } - [AspireExport("withGatewayAnnotationParam", Description = "Adds a parameterized Kubernetes metadata annotation to a Gateway")] + [AspireExport("withGatewayAnnotationParam")] public static ApplicationModel.IResourceBuilder WithGatewayAnnotation(this ApplicationModel.IResourceBuilder builder, string key, ApplicationModel.IResourceBuilder value) { throw null; } - [AspireExport(Description = "Adds a Kubernetes metadata annotation to a Gateway")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithGatewayAnnotation(this ApplicationModel.IResourceBuilder builder, string key, string value) { throw null; } - [AspireExport("withGatewayClassParam", Description = "Sets a parameterized GatewayClass for a Kubernetes Gateway")] + [AspireExport("withGatewayClassParam")] public static ApplicationModel.IResourceBuilder WithGatewayClass(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder className) { throw null; } - [AspireExport(Description = "Sets the GatewayClass for a Kubernetes Gateway")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithGatewayClass(this ApplicationModel.IResourceBuilder builder, string className) { throw null; } - [AspireExport("withGatewayHostnameParam", Description = "Adds a parameterized hostname to a Kubernetes Gateway")] + [AspireExport("withGatewayHostnameParam")] public static ApplicationModel.IResourceBuilder WithHostname(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder hostname) { throw null; } - [AspireExport("withGatewayHostname", MethodName = "withHostname", Description = "Adds a hostname to a Kubernetes Gateway")] + [AspireExport("withGatewayHostname", MethodName = "withHostname")] public static ApplicationModel.IResourceBuilder WithHostname(this ApplicationModel.IResourceBuilder builder, string hostname) { throw null; } - [AspireExport("withGatewayPathRoute", Description = "Adds a path-based route to a Kubernetes Gateway")] - public static ApplicationModel.IResourceBuilder WithRoute(this ApplicationModel.IResourceBuilder builder, string path, ApplicationModel.EndpointReference endpoint, Kubernetes.IngressPathType pathType = Kubernetes.IngressPathType.Prefix) { throw null; } + [AspireExport("withGatewayPathRoute")] + public static ApplicationModel.IResourceBuilder WithRoute(this ApplicationModel.IResourceBuilder builder, string path, ApplicationModel.EndpointReference endpoint, Kubernetes.GatewayPathMatchType pathType = Kubernetes.GatewayPathMatchType.PathPrefix) { throw null; } - [AspireExport("withGatewayHostRoute", Description = "Adds a host-and-path route to a Kubernetes Gateway")] - public static ApplicationModel.IResourceBuilder WithRoute(this ApplicationModel.IResourceBuilder builder, string host, string path, ApplicationModel.EndpointReference endpoint, Kubernetes.IngressPathType pathType = Kubernetes.IngressPathType.Prefix) { throw null; } + [AspireExport("withGatewayHostRoute")] + public static ApplicationModel.IResourceBuilder WithRoute(this ApplicationModel.IResourceBuilder builder, string host, string path, ApplicationModel.EndpointReference endpoint, Kubernetes.GatewayPathMatchType pathType = Kubernetes.GatewayPathMatchType.PathPrefix) { throw null; } - [AspireExport("withGatewayTlsParam", Description = "Configures TLS on a Kubernetes Gateway with a parameterized secret")] + [AspireExport("withGatewayTlsParam")] public static ApplicationModel.IResourceBuilder WithTls(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder secretName) { throw null; } - [AspireExport("withGatewayTls", MethodName = "withTls", Description = "Configures TLS on a Kubernetes Gateway listener")] + [AspireExport("withGatewayTls", MethodName = "withTls")] public static ApplicationModel.IResourceBuilder WithTls(this ApplicationModel.IResourceBuilder builder, string secretName) { throw null; } - [AspireExport("withGatewayTlsAuto", Description = "Configures TLS on a Kubernetes Gateway with an auto-generated secret")] + [AspireExport("withGatewayTlsAuto")] public static ApplicationModel.IResourceBuilder WithTls(this ApplicationModel.IResourceBuilder builder) { throw null; } } + public static partial class KubernetesHelmChartExtensions + { + [AspireExport] + public static ApplicationModel.IResourceBuilder AddHelmChart(this ApplicationModel.IResourceBuilder builder, string name, string chartReference, string chartVersion) { throw null; } + + [AspireExport("withHelmChartDestroy")] + public static ApplicationModel.IResourceBuilder WithDestroy(this ApplicationModel.IResourceBuilder builder) { throw null; } + + [AspireExport("withHelmChartForceConflicts")] + public static ApplicationModel.IResourceBuilder WithForceConflicts(this ApplicationModel.IResourceBuilder builder) { throw null; } + + [AspireExport] + public static ApplicationModel.IResourceBuilder WithHelmValue(this ApplicationModel.IResourceBuilder builder, string key, string value) { throw null; } + + [AspireExport("withHelmChartNamespace")] + public static ApplicationModel.IResourceBuilder WithNamespace(this ApplicationModel.IResourceBuilder builder, string @namespace) { throw null; } + + [AspireExport("withHelmChartReleaseName")] + public static ApplicationModel.IResourceBuilder WithReleaseName(this ApplicationModel.IResourceBuilder builder, string releaseName) { throw null; } + } + public static partial class KubernetesIngressExtensions { - [AspireExport(Description = "Adds a Kubernetes Ingress resource for HTTP routing")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddIngress(this ApplicationModel.IResourceBuilder builder, string name) { throw null; } - [AspireExport(Description = "Sets the default backend for a Kubernetes Ingress")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithDefaultBackend(this ApplicationModel.IResourceBuilder builder, ApplicationModel.EndpointReference endpoint) { throw null; } - [AspireExport("withIngressHostnameParam", Description = "Adds a parameterized hostname to a Kubernetes Ingress")] + [AspireExport("withIngressHostnameParam")] public static ApplicationModel.IResourceBuilder WithHostname(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder hostname) { throw null; } - [AspireExport("withIngressHostname", MethodName = "withHostname", Description = "Adds a hostname to a Kubernetes Ingress")] + [AspireExport("withIngressHostname", MethodName = "withHostname")] public static ApplicationModel.IResourceBuilder WithHostname(this ApplicationModel.IResourceBuilder builder, string hostname) { throw null; } - [AspireExport("withIngressAnnotationParam", Description = "Adds a parameterized Kubernetes metadata annotation to an Ingress")] + [AspireExport("withIngressAnnotationParam")] public static ApplicationModel.IResourceBuilder WithIngressAnnotation(this ApplicationModel.IResourceBuilder builder, string key, ApplicationModel.IResourceBuilder value) { throw null; } - [AspireExport(Description = "Adds a Kubernetes metadata annotation to a Kubernetes Ingress")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithIngressAnnotation(this ApplicationModel.IResourceBuilder builder, string key, string value) { throw null; } - [AspireExport("withIngressClassParam", Description = "Sets a parameterized ingress class for a Kubernetes Ingress")] + [AspireExport("withIngressClassParam")] public static ApplicationModel.IResourceBuilder WithIngressClass(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder className) { throw null; } - [AspireExport(Description = "Sets the ingress class for a Kubernetes Ingress")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithIngressClass(this ApplicationModel.IResourceBuilder builder, string className) { throw null; } - [AspireExport("withIngressPathRoute", Description = "Adds a path-based route to a Kubernetes Ingress")] - public static ApplicationModel.IResourceBuilder WithRoute(this ApplicationModel.IResourceBuilder builder, string path, ApplicationModel.EndpointReference endpoint, Kubernetes.IngressPathType pathType = Kubernetes.IngressPathType.Prefix) { throw null; } + [AspireExport("withIngressPath")] + public static ApplicationModel.IResourceBuilder WithPath(this ApplicationModel.IResourceBuilder builder, string path, ApplicationModel.EndpointReference endpoint, Kubernetes.IngressPathType pathType = Kubernetes.IngressPathType.Prefix) { throw null; } - [AspireExport("withIngressHostRoute", Description = "Adds a host-and-path route to a Kubernetes Ingress")] - public static ApplicationModel.IResourceBuilder WithRoute(this ApplicationModel.IResourceBuilder builder, string host, string path, ApplicationModel.EndpointReference endpoint, Kubernetes.IngressPathType pathType = Kubernetes.IngressPathType.Prefix) { throw null; } + [AspireExport("withIngressHostAndPath")] + public static ApplicationModel.IResourceBuilder WithPath(this ApplicationModel.IResourceBuilder builder, string host, string path, ApplicationModel.EndpointReference endpoint, Kubernetes.IngressPathType pathType = Kubernetes.IngressPathType.Prefix) { throw null; } - [AspireExport("withIngressTlsParam", Description = "Configures TLS for a Kubernetes Ingress with a parameterized secret")] + [AspireExport("withIngressTlsParam")] public static ApplicationModel.IResourceBuilder WithTls(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder secretName) { throw null; } - [AspireExport("withIngressTls", MethodName = "withTls", Description = "Configures TLS for a Kubernetes Ingress using a K8S secret")] + [AspireExport("withIngressTls", MethodName = "withTls")] public static ApplicationModel.IResourceBuilder WithTls(this ApplicationModel.IResourceBuilder builder, string secretName) { throw null; } - [AspireExport("withIngressTlsAuto", Description = "Configures TLS for a Kubernetes Ingress with an auto-generated secret")] + [AspireExport("withIngressTlsAuto")] public static ApplicationModel.IResourceBuilder WithTls(this ApplicationModel.IResourceBuilder builder) { throw null; } } public static partial class KubernetesServiceExtensions { - [AspireExport(Description = "Publishes the resource as a Kubernetes service")] + [AspireExport] public static ApplicationModel.IResourceBuilder PublishAsKubernetesService(this ApplicationModel.IResourceBuilder builder, System.Action configure) where T : ApplicationModel.IComputeResource { throw null; } } @@ -136,6 +190,31 @@ public static ApplicationModel.IResourceBuilder PublishAsKubernetesService namespace Aspire.Hosting.Kubernetes { + [AspireExport] + public sealed partial class CertManagerIssuerResource : ApplicationModel.Resource, ApplicationModel.IResourceWithParent, ApplicationModel.IResourceWithParent, ApplicationModel.IResource + { + public CertManagerIssuerResource(string name, CertManagerResource parent) : base(default!) { } + + public CertManagerResource Parent { get { throw null; } } + } + + [AspireExport] + public sealed partial class CertManagerResource : ApplicationModel.Resource, ApplicationModel.IResourceWithParent, ApplicationModel.IResourceWithParent, ApplicationModel.IResource + { + public CertManagerResource(string name, KubernetesEnvironmentResource environment, KubernetesHelmChartResource helmChart) : base(default!) { } + + public KubernetesHelmChartResource HelmChart { get { throw null; } } + + public KubernetesEnvironmentResource Parent { get { throw null; } } + } + + public enum GatewayPathMatchType + { + PathPrefix = 0, + Exact = 1, + RegularExpression = 2 + } + public sealed partial class HelmChartDescriptionAnnotation : ApplicationModel.IResourceAnnotation { public HelmChartDescriptionAnnotation(ApplicationModel.ReferenceExpression description) { } @@ -258,6 +337,22 @@ public KubernetesGatewayResource(string name, KubernetesEnvironmentResource envi public KubernetesEnvironmentResource Parent { get { throw null; } } } + [AspireExport] + public sealed partial class KubernetesHelmChartResource : ApplicationModel.Resource, ApplicationModel.IResourceWithParent, ApplicationModel.IResourceWithParent, ApplicationModel.IResource + { + public KubernetesHelmChartResource(string name, KubernetesEnvironmentResource environment, string chartReference, string chartVersion) : base(default!) { } + + public string ChartReference { get { throw null; } } + + public string ChartVersion { get { throw null; } } + + public string? Namespace { get { throw null; } set { } } + + public KubernetesEnvironmentResource Parent { get { throw null; } } + + public string? ReleaseName { get { throw null; } set { } } + } + [AspireExport] public partial class KubernetesIngressResource : ApplicationModel.Resource, ApplicationModel.IResourceWithParent, ApplicationModel.IResourceWithParent, ApplicationModel.IResource { @@ -290,6 +385,7 @@ public partial class KubernetesResource : ApplicationModel.Resource, Application { public KubernetesResource(string name, ApplicationModel.IResource resource, KubernetesEnvironmentResource kubernetesEnvironmentResource) : base(default!) { } + [AspireExportIgnore(Reason = "Kubernetes manifest resource types are C#-only customization objects and are not part of the polyglot SDK surface.")] public System.Collections.Generic.List AdditionalResources { get { throw null; } } public Resources.ConfigMap? ConfigMap { get { throw null; } set { } } diff --git a/src/Aspire.Hosting.Maui/api/Aspire.Hosting.Maui.cs b/src/Aspire.Hosting.Maui/api/Aspire.Hosting.Maui.cs index 4cd29b2cbb1..589b6a572fb 100644 --- a/src/Aspire.Hosting.Maui/api/Aspire.Hosting.Maui.cs +++ b/src/Aspire.Hosting.Maui/api/Aspire.Hosting.Maui.cs @@ -10,7 +10,7 @@ namespace Aspire.Hosting { public static partial class MauiAndroidExtensions { - [AspireExport(Description = "Adds an Android device resource for a .NET MAUI project.")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddAndroidDevice(this ApplicationModel.IResourceBuilder builder, string name, string? deviceId = null) { throw null; } [AspireExportIgnore(Reason = "Convenience overload. Use the overload with the optional device ID parameter instead.")] @@ -19,7 +19,7 @@ public static partial class MauiAndroidExtensions [AspireExportIgnore(Reason = "Convenience overload. Use the overload with name and optional device ID instead.")] public static ApplicationModel.IResourceBuilder AddAndroidDevice(this ApplicationModel.IResourceBuilder builder) { throw null; } - [AspireExport(Description = "Adds an Android emulator resource for a .NET MAUI project.")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddAndroidEmulator(this ApplicationModel.IResourceBuilder builder, string name, string? emulatorId = null) { throw null; } [AspireExportIgnore(Reason = "Convenience overload. Use the overload with the optional emulator ID parameter instead.")] @@ -31,7 +31,7 @@ public static partial class MauiAndroidExtensions public static partial class MauiiOSExtensions { - [AspireExport(Description = "Adds an iOS device resource for a .NET MAUI project.")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddiOSDevice(this ApplicationModel.IResourceBuilder builder, string name, string? deviceId = null) { throw null; } [AspireExportIgnore(Reason = "Convenience overload. Use the overload with the optional device UDID parameter instead.")] @@ -40,7 +40,7 @@ public static partial class MauiiOSExtensions [AspireExportIgnore(Reason = "Convenience overload. Use the overload with name and optional device UDID instead.")] public static ApplicationModel.IResourceBuilder AddiOSDevice(this ApplicationModel.IResourceBuilder builder) { throw null; } - [AspireExport(Description = "Adds an iOS simulator resource for a .NET MAUI project.")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddiOSSimulator(this ApplicationModel.IResourceBuilder builder, string name, string? simulatorId = null) { throw null; } [AspireExportIgnore(Reason = "Convenience overload. Use the overload with the optional simulator UDID parameter instead.")] @@ -52,7 +52,7 @@ public static partial class MauiiOSExtensions public static partial class MauiMacCatalystExtensions { - [AspireExport(Description = "Adds a Mac Catalyst platform resource for a .NET MAUI project.")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddMacCatalystDevice(this ApplicationModel.IResourceBuilder builder, string name) { throw null; } [AspireExportIgnore(Reason = "Convenience overload. Use the named overload instead.")] @@ -61,20 +61,20 @@ public static partial class MauiMacCatalystExtensions public static partial class MauiOtlpExtensions { - [AspireExport(Description = "Configures a .NET MAUI platform resource to send OpenTelemetry data through a development tunnel.")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithOtlpDevTunnel(this ApplicationModel.IResourceBuilder builder) where T : Maui.IMauiPlatformResource, ApplicationModel.IResourceWithEnvironment { throw null; } } public static partial class MauiProjectExtensions { - [AspireExport(Description = "Adds a .NET MAUI project to the application model.")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddMauiProject(this IDistributedApplicationBuilder builder, string name, string projectPath) { throw null; } } public static partial class MauiWindowsExtensions { - [AspireExport(Description = "Adds a Windows platform resource for a .NET MAUI project.")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddWindowsDevice(this ApplicationModel.IResourceBuilder builder, string name) { throw null; } [AspireExportIgnore(Reason = "Convenience overload. Use the named overload instead.")] diff --git a/src/Aspire.Hosting.Milvus/api/Aspire.Hosting.Milvus.cs b/src/Aspire.Hosting.Milvus/api/Aspire.Hosting.Milvus.cs index 0e7d095df08..87c907eb063 100644 --- a/src/Aspire.Hosting.Milvus/api/Aspire.Hosting.Milvus.cs +++ b/src/Aspire.Hosting.Milvus/api/Aspire.Hosting.Milvus.cs @@ -10,26 +10,26 @@ namespace Aspire.Hosting { public static partial class MilvusBuilderExtensions { - [AspireExport(Description = "Adds a Milvus database resource to a Milvus server resource.")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddDatabase(this ApplicationModel.IResourceBuilder builder, string name, string? databaseName = null) { throw null; } - [AspireExport(Description = "Adds a Milvus server resource to the distributed application model.")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddMilvus(this IDistributedApplicationBuilder builder, string name, ApplicationModel.IResourceBuilder? apiKey = null, int? grpcPort = null) { throw null; } - [AspireExport(Description = "Adds the Attu administration tool for Milvus.", RunSyncOnBackgroundThread = true)] + [AspireExport(RunSyncOnBackgroundThread = true)] public static ApplicationModel.IResourceBuilder WithAttu(this ApplicationModel.IResourceBuilder builder, System.Action>? configureContainer = null, string? containerName = null) where T : Milvus.MilvusServerResource { throw null; } [System.Obsolete("Use WithConfigurationFile instead.")] public static ApplicationModel.IResourceBuilder WithConfigurationBindMount(this ApplicationModel.IResourceBuilder builder, string configurationFilePath) { throw null; } - [AspireExport(Description = "Copies a Milvus configuration file into the container.")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithConfigurationFile(this ApplicationModel.IResourceBuilder builder, string configurationFilePath) { throw null; } - [AspireExport(Description = "Mounts a host directory as the Milvus data directory.")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithDataBindMount(this ApplicationModel.IResourceBuilder builder, string source, bool isReadOnly = false) { throw null; } - [AspireExport(Description = "Adds a persistent data volume to the Milvus server resource.")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithDataVolume(this ApplicationModel.IResourceBuilder builder, string? name = null, bool isReadOnly = false) { throw null; } } } diff --git a/src/Aspire.Hosting.MongoDB/api/Aspire.Hosting.MongoDB.cs b/src/Aspire.Hosting.MongoDB/api/Aspire.Hosting.MongoDB.cs index 57e9a02960c..52a48d92ee6 100644 --- a/src/Aspire.Hosting.MongoDB/api/Aspire.Hosting.MongoDB.cs +++ b/src/Aspire.Hosting.MongoDB/api/Aspire.Hosting.MongoDB.cs @@ -10,32 +10,32 @@ namespace Aspire.Hosting { public static partial class MongoDBBuilderExtensions { - [AspireExport(Description = "Adds a MongoDB database resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddDatabase(this ApplicationModel.IResourceBuilder builder, string name, string? databaseName = null) { throw null; } - [AspireExport(Description = "Adds a MongoDB container resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddMongoDB(this IDistributedApplicationBuilder builder, string name, int? port = null, ApplicationModel.IResourceBuilder? userName = null, ApplicationModel.IResourceBuilder? password = null) { throw null; } [AspireExportIgnore(Reason = "Convenience overload. Use the overload with optional userName and password parameters instead.")] public static ApplicationModel.IResourceBuilder AddMongoDB(this IDistributedApplicationBuilder builder, string name, int? port) { throw null; } - [AspireExport(Description = "Adds a bind mount for the MongoDB data folder")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithDataBindMount(this ApplicationModel.IResourceBuilder builder, string source, bool isReadOnly = false) { throw null; } - [AspireExport(Description = "Adds a named volume for the MongoDB data folder")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithDataVolume(this ApplicationModel.IResourceBuilder builder, string? name = null, bool isReadOnly = false) { throw null; } - [AspireExport(Description = "Sets the host port for the Mongo Express resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithHostPort(this ApplicationModel.IResourceBuilder builder, int? port) { throw null; } [System.Obsolete("Use WithInitFiles instead.")] [AspireExportIgnore(Reason = "Obsolete API. Use WithInitFiles instead.")] public static ApplicationModel.IResourceBuilder WithInitBindMount(this ApplicationModel.IResourceBuilder builder, string source, bool isReadOnly = true) { throw null; } - [AspireExport(Description = "Copies init files into a MongoDB container")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithInitFiles(this ApplicationModel.IResourceBuilder builder, string source) { throw null; } - [AspireExport(Description = "Adds a MongoExpress administration platform for MongoDB", RunSyncOnBackgroundThread = true)] + [AspireExport(RunSyncOnBackgroundThread = true)] public static ApplicationModel.IResourceBuilder WithMongoExpress(this ApplicationModel.IResourceBuilder builder, System.Action>? configureContainer = null, string? containerName = null) where T : ApplicationModel.MongoDBServerResource { throw null; } } diff --git a/src/Aspire.Hosting.MySql/api/Aspire.Hosting.MySql.cs b/src/Aspire.Hosting.MySql/api/Aspire.Hosting.MySql.cs index a1ab0d06cfb..79c6c2162b7 100644 --- a/src/Aspire.Hosting.MySql/api/Aspire.Hosting.MySql.cs +++ b/src/Aspire.Hosting.MySql/api/Aspire.Hosting.MySql.cs @@ -10,34 +10,34 @@ namespace Aspire.Hosting { public static partial class MySqlBuilderExtensions { - [AspireExport(Description = "Adds a MySQL database")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddDatabase(this ApplicationModel.IResourceBuilder builder, string name, string? databaseName = null) { throw null; } - [AspireExport(Description = "Adds a MySQL server resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddMySql(this IDistributedApplicationBuilder builder, string name, ApplicationModel.IResourceBuilder? password = null, int? port = null) { throw null; } - [AspireExport(Description = "Defines the SQL script for database creation")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithCreationScript(this ApplicationModel.IResourceBuilder builder, string script) { throw null; } - [AspireExport(Description = "Adds a data bind mount for MySQL")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithDataBindMount(this ApplicationModel.IResourceBuilder builder, string source, bool isReadOnly = false) { throw null; } - [AspireExport(Description = "Adds a data volume for MySQL")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithDataVolume(this ApplicationModel.IResourceBuilder builder, string? name = null, bool isReadOnly = false) { throw null; } - [AspireExport(Description = "Sets the host port for phpMyAdmin")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithHostPort(this ApplicationModel.IResourceBuilder builder, int? port) { throw null; } [System.Obsolete("Use WithInitFiles instead.")] public static ApplicationModel.IResourceBuilder WithInitBindMount(this ApplicationModel.IResourceBuilder builder, string source, bool isReadOnly = true) { throw null; } - [AspireExport(Description = "Copies init files to MySQL")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithInitFiles(this ApplicationModel.IResourceBuilder builder, string source) { throw null; } - [AspireExport(Description = "Configures the MySQL password")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithPassword(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder password) { throw null; } - [AspireExport(Description = "Adds phpMyAdmin management UI", RunSyncOnBackgroundThread = true)] + [AspireExport(RunSyncOnBackgroundThread = true)] public static ApplicationModel.IResourceBuilder WithPhpMyAdmin(this ApplicationModel.IResourceBuilder builder, System.Action>? configureContainer = null, string? containerName = null) where T : ApplicationModel.MySqlServerResource { throw null; } } diff --git a/src/Aspire.Hosting.Nats/api/Aspire.Hosting.Nats.cs b/src/Aspire.Hosting.Nats/api/Aspire.Hosting.Nats.cs index bc0098d1974..98e36d6b124 100644 --- a/src/Aspire.Hosting.Nats/api/Aspire.Hosting.Nats.cs +++ b/src/Aspire.Hosting.Nats/api/Aspire.Hosting.Nats.cs @@ -16,16 +16,16 @@ public static partial class NatsBuilderExtensions [AspireExportIgnore(Reason = "Use the dedicated polyglot overload instead.")] public static ApplicationModel.IResourceBuilder AddNats(this IDistributedApplicationBuilder builder, string name, int? port) { throw null; } - [AspireExport(Description = "Mounts a host directory as the NATS data directory.")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithDataBindMount(this ApplicationModel.IResourceBuilder builder, string source, bool isReadOnly = false) { throw null; } - [AspireExport(Description = "Adds a persistent data volume to the NATS resource.")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithDataVolume(this ApplicationModel.IResourceBuilder builder, string? name = null, bool isReadOnly = false) { throw null; } [System.Obsolete("This method is obsolete and will be removed in a future version. Use the overload without the srcMountPath parameter and WithDataBindMount extension instead if you want to keep data locally.")] public static ApplicationModel.IResourceBuilder WithJetStream(this ApplicationModel.IResourceBuilder builder, string? srcMountPath = null) { throw null; } - [AspireExport(Description = "Configures the NATS resource to enable JetStream.")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithJetStream(this ApplicationModel.IResourceBuilder builder) { throw null; } } } diff --git a/src/Aspire.Hosting.OpenAI/api/Aspire.Hosting.OpenAI.cs b/src/Aspire.Hosting.OpenAI/api/Aspire.Hosting.OpenAI.cs index 45c3b60adbf..d2da6df11dd 100644 --- a/src/Aspire.Hosting.OpenAI/api/Aspire.Hosting.OpenAI.cs +++ b/src/Aspire.Hosting.OpenAI/api/Aspire.Hosting.OpenAI.cs @@ -10,19 +10,19 @@ namespace Aspire.Hosting { public static partial class OpenAIExtensions { - [AspireExport(Description = "Adds an OpenAI model resource.")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddModel(this ApplicationModel.IResourceBuilder builder, string name, string model) { throw null; } - [AspireExport(Description = "Adds an OpenAI resource to the distributed application model.")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddOpenAI(this IDistributedApplicationBuilder builder, string name) { throw null; } - [AspireExport(Description = "Configures the API key for the OpenAI resource.")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithApiKey(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder apiKey) { throw null; } - [AspireExport(Description = "Configures the endpoint URI for the OpenAI resource.")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithEndpoint(this ApplicationModel.IResourceBuilder builder, string endpoint) { throw null; } - [AspireExport(Description = "Adds a health check for the OpenAI model resource.")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithHealthCheck(this ApplicationModel.IResourceBuilder builder) { throw null; } } } diff --git a/src/Aspire.Hosting.Oracle/api/Aspire.Hosting.Oracle.cs b/src/Aspire.Hosting.Oracle/api/Aspire.Hosting.Oracle.cs index 82f1367c8bb..29109aa0b82 100644 --- a/src/Aspire.Hosting.Oracle/api/Aspire.Hosting.Oracle.cs +++ b/src/Aspire.Hosting.Oracle/api/Aspire.Hosting.Oracle.cs @@ -10,25 +10,25 @@ namespace Aspire.Hosting { public static partial class OracleDatabaseBuilderExtensions { - [AspireExport(Description = "Adds an Oracle database resource to an Oracle server resource.")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddDatabase(this ApplicationModel.IResourceBuilder builder, string name, string? databaseName = null) { throw null; } - [AspireExport(Description = "Adds an Oracle server resource to the distributed application model.")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddOracle(this IDistributedApplicationBuilder builder, string name, ApplicationModel.IResourceBuilder? password = null, int? port = null) { throw null; } - [AspireExport(Description = "Mounts a host directory as the Oracle data directory.")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithDataBindMount(this ApplicationModel.IResourceBuilder builder, string source) { throw null; } - [AspireExport(Description = "Adds a persistent data volume to the Oracle server resource.")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithDataVolume(this ApplicationModel.IResourceBuilder builder, string? name = null) { throw null; } - [AspireExport(Description = "Mounts a host directory as the Oracle DB setup directory.")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithDbSetupBindMount(this ApplicationModel.IResourceBuilder builder, string source) { throw null; } [System.Obsolete("Use WithInitFiles instead.")] public static ApplicationModel.IResourceBuilder WithInitBindMount(this ApplicationModel.IResourceBuilder builder, string source) { throw null; } - [AspireExport(Description = "Copies initialization files into the Oracle container.")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithInitFiles(this ApplicationModel.IResourceBuilder builder, string source) { throw null; } } } diff --git a/src/Aspire.Hosting.Orleans/api/Aspire.Hosting.Orleans.cs b/src/Aspire.Hosting.Orleans/api/Aspire.Hosting.Orleans.cs index 4dbd4957c3f..7dabd5c2867 100644 --- a/src/Aspire.Hosting.Orleans/api/Aspire.Hosting.Orleans.cs +++ b/src/Aspire.Hosting.Orleans/api/Aspire.Hosting.Orleans.cs @@ -10,44 +10,44 @@ namespace Aspire.Hosting { public static partial class OrleansServiceClientExtensions { - [AspireExport("withOrleansClientReference", MethodName = "withReference", Description = "Adds an Orleans client reference to a resource")] + [AspireExport("withOrleansClientReference")] public static ApplicationModel.IResourceBuilder WithReference(this ApplicationModel.IResourceBuilder builder, Orleans.OrleansServiceClient orleansServiceClient) where T : ApplicationModel.IResourceWithEnvironment, ApplicationModel.IResourceWithEndpoints { throw null; } } public static partial class OrleansServiceExtensions { - [AspireExport(Description = "Adds an Orleans service configuration")] + [AspireExport] public static Orleans.OrleansService AddOrleans(this IDistributedApplicationBuilder builder, string name) { throw null; } - [AspireExport(Description = "Creates an Orleans client view for the service")] + [AspireExport] public static Orleans.OrleansServiceClient AsClient(this Orleans.OrleansService orleansService) { throw null; } [AspireExportIgnore(Reason = "IProviderConfiguration cannot be created directly by polyglot callers. Use the default provider overload instead.")] public static Orleans.OrleansService WithBroadcastChannel(this Orleans.OrleansService orleansServiceBuilder, string name, Orleans.IProviderConfiguration provider) { throw null; } - [AspireExport(Description = "Adds an Orleans broadcast channel provider")] + [AspireExport] public static Orleans.OrleansService WithBroadcastChannel(this Orleans.OrleansService orleansServiceBuilder, string name) { throw null; } [AspireExportIgnore(Reason = "ParameterResource handle overload is not needed in polyglot. Use the string overload instead.")] public static Orleans.OrleansService WithClusterId(this Orleans.OrleansService orleansServiceBuilder, ApplicationModel.IResourceBuilder clusterId) { throw null; } - [AspireExport(Description = "Sets the Orleans cluster ID")] + [AspireExport] public static Orleans.OrleansService WithClusterId(this Orleans.OrleansService orleansServiceBuilder, string clusterId) { throw null; } - [AspireExport(Description = "Configures Orleans clustering using a resource connection")] + [AspireExport] public static Orleans.OrleansService WithClustering(this Orleans.OrleansService orleansServiceBuilder, ApplicationModel.IResourceBuilder provider) { throw null; } [AspireExportIgnore(Reason = "IProviderConfiguration cannot be created directly by polyglot callers. Use the resource-based overload instead.")] public static Orleans.OrleansService WithClustering(this Orleans.OrleansService orleansServiceBuilder, Orleans.IProviderConfiguration provider) { throw null; } - [AspireExport(Description = "Configures Orleans development clustering")] + [AspireExport] public static Orleans.OrleansService WithDevelopmentClustering(this Orleans.OrleansService orleansServiceBuilder) { throw null; } [AspireExportIgnore(Reason = "Convenience overload. Use the overload with explicit provider name instead.")] public static Orleans.OrleansService WithGrainDirectory(this Orleans.OrleansService orleansServiceBuilder, ApplicationModel.IResourceBuilder provider) { throw null; } - [AspireExport(Description = "Adds an Orleans grain directory provider")] + [AspireExport] public static Orleans.OrleansService WithGrainDirectory(this Orleans.OrleansService orleansServiceBuilder, string name, ApplicationModel.IResourceBuilder provider) { throw null; } [AspireExportIgnore(Reason = "IProviderConfiguration cannot be created directly by polyglot callers. Use the resource-based overload instead.")] @@ -56,26 +56,26 @@ public static partial class OrleansServiceExtensions [AspireExportIgnore(Reason = "Convenience overload. Use the overload with explicit provider name instead.")] public static Orleans.OrleansService WithGrainStorage(this Orleans.OrleansService orleansServiceBuilder, ApplicationModel.IResourceBuilder provider) { throw null; } - [AspireExport(Description = "Adds an Orleans grain storage provider")] + [AspireExport] public static Orleans.OrleansService WithGrainStorage(this Orleans.OrleansService orleansServiceBuilder, string name, ApplicationModel.IResourceBuilder provider) { throw null; } [AspireExportIgnore(Reason = "IProviderConfiguration cannot be created directly by polyglot callers. Use the resource-based overload instead.")] public static Orleans.OrleansService WithGrainStorage(this Orleans.OrleansService orleansServiceBuilder, string name, Orleans.IProviderConfiguration provider) { throw null; } - [AspireExport(Description = "Adds in-memory Orleans grain storage")] + [AspireExport] public static Orleans.OrleansService WithMemoryGrainStorage(this Orleans.OrleansService orleansServiceBuilder, string name) { throw null; } - [AspireExport(Description = "Configures in-memory Orleans reminders")] + [AspireExport] public static Orleans.OrleansService WithMemoryReminders(this Orleans.OrleansService orleansServiceBuilder) { throw null; } - [AspireExport(Description = "Adds in-memory Orleans streaming")] + [AspireExport] public static Orleans.OrleansService WithMemoryStreaming(this Orleans.OrleansService orleansServiceBuilder, string name) { throw null; } - [AspireExport("withOrleansReference", MethodName = "withReference", Description = "Adds an Orleans silo reference to a resource")] + [AspireExport("withOrleansReference")] public static ApplicationModel.IResourceBuilder WithReference(this ApplicationModel.IResourceBuilder builder, Orleans.OrleansService orleansService) where T : ApplicationModel.IResourceWithEnvironment, ApplicationModel.IResourceWithEndpoints { throw null; } - [AspireExport(Description = "Configures Orleans reminder storage")] + [AspireExport] public static Orleans.OrleansService WithReminders(this Orleans.OrleansService orleansServiceBuilder, ApplicationModel.IResourceBuilder provider) { throw null; } [AspireExportIgnore(Reason = "IProviderConfiguration cannot be created directly by polyglot callers. Use the resource-based overload instead.")] @@ -84,13 +84,13 @@ public static ApplicationModel.IResourceBuilder WithReference(this Applica [AspireExportIgnore(Reason = "ParameterResource handle overload is not needed in polyglot. Use the string overload instead.")] public static Orleans.OrleansService WithServiceId(this Orleans.OrleansService orleansServiceBuilder, ApplicationModel.IResourceBuilder serviceId) { throw null; } - [AspireExport(Description = "Sets the Orleans service ID")] + [AspireExport] public static Orleans.OrleansService WithServiceId(this Orleans.OrleansService orleansServiceBuilder, string serviceId) { throw null; } [AspireExportIgnore(Reason = "Convenience overload. Use the overload with explicit provider name instead.")] public static Orleans.OrleansService WithStreaming(this Orleans.OrleansService orleansServiceBuilder, ApplicationModel.IResourceBuilder provider) { throw null; } - [AspireExport(Description = "Adds an Orleans stream provider")] + [AspireExport] public static Orleans.OrleansService WithStreaming(this Orleans.OrleansService orleansServiceBuilder, string name, ApplicationModel.IResourceBuilder provider) { throw null; } [AspireExportIgnore(Reason = "IProviderConfiguration cannot be created directly by polyglot callers. Use the resource-based overload instead.")] diff --git a/src/Aspire.Hosting.PostgreSQL/api/Aspire.Hosting.PostgreSQL.cs b/src/Aspire.Hosting.PostgreSQL/api/Aspire.Hosting.PostgreSQL.cs index 5e903cfa614..03f25781d5d 100644 --- a/src/Aspire.Hosting.PostgreSQL/api/Aspire.Hosting.PostgreSQL.cs +++ b/src/Aspire.Hosting.PostgreSQL/api/Aspire.Hosting.PostgreSQL.cs @@ -10,52 +10,52 @@ namespace Aspire.Hosting { public static partial class PostgresBuilderExtensions { - [AspireExport(Description = "Adds a PostgreSQL database")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddDatabase(this ApplicationModel.IResourceBuilder builder, string name, string? databaseName = null) { throw null; } - [AspireExport(Description = "Adds a PostgreSQL server resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddPostgres(this IDistributedApplicationBuilder builder, string name, ApplicationModel.IResourceBuilder? userName = null, ApplicationModel.IResourceBuilder? password = null, int? port = null) { throw null; } - [AspireExport(Description = "Defines the SQL script for database creation")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithCreationScript(this ApplicationModel.IResourceBuilder builder, string script) { throw null; } - [AspireExport(Description = "Adds a data bind mount for PostgreSQL")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithDataBindMount(this ApplicationModel.IResourceBuilder builder, string source, bool isReadOnly = false) { throw null; } - [AspireExport(Description = "Adds a data volume for PostgreSQL")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithDataVolume(this ApplicationModel.IResourceBuilder builder, string? name = null, bool isReadOnly = false) { throw null; } - [AspireExport("withPostgresHostPort", MethodName = "withHostPort", Description = "Sets the host port for PostgreSQL")] + [AspireExport("withPostgresHostPort", MethodName = "withHostPort")] public static ApplicationModel.IResourceBuilder WithHostPort(this ApplicationModel.IResourceBuilder builder, int? port) { throw null; } - [AspireExport("withPgAdminHostPort", MethodName = "withHostPort", Description = "Sets the host port for pgAdmin")] + [AspireExport("withPgAdminHostPort", MethodName = "withHostPort")] public static ApplicationModel.IResourceBuilder WithHostPort(this ApplicationModel.IResourceBuilder builder, int? port) { throw null; } - [AspireExport("withPgWebHostPort", MethodName = "withHostPort", Description = "Sets the host port for pgweb")] + [AspireExport("withPgWebHostPort", MethodName = "withHostPort")] public static ApplicationModel.IResourceBuilder WithHostPort(this ApplicationModel.IResourceBuilder builder, int? port) { throw null; } [AspireExportIgnore(Reason = "Obsolete. Use WithInitFiles instead.")] [System.Obsolete("Use WithInitFiles instead.")] public static ApplicationModel.IResourceBuilder WithInitBindMount(this ApplicationModel.IResourceBuilder builder, string source, bool isReadOnly = true) { throw null; } - [AspireExport(Description = "Copies init files to PostgreSQL")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithInitFiles(this ApplicationModel.IResourceBuilder builder, string source) { throw null; } - [AspireExport(Description = "Configures the PostgreSQL password")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithPassword(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder password) { throw null; } - [AspireExport(Description = "Adds pgAdmin 4 management UI", RunSyncOnBackgroundThread = true)] + [AspireExport(RunSyncOnBackgroundThread = true)] public static ApplicationModel.IResourceBuilder WithPgAdmin(this ApplicationModel.IResourceBuilder builder, System.Action>? configureContainer = null, string? containerName = null) where T : ApplicationModel.PostgresServerResource { throw null; } - [AspireExport(Description = "Adds pgweb management UI", RunSyncOnBackgroundThread = true)] + [AspireExport(RunSyncOnBackgroundThread = true)] public static ApplicationModel.IResourceBuilder WithPgWeb(this ApplicationModel.IResourceBuilder builder, System.Action>? configureContainer = null, string? containerName = null) { throw null; } - [AspireExport(Description = "Adds Postgres MCP server", RunSyncOnBackgroundThread = true)] + [AspireExport(RunSyncOnBackgroundThread = true)] [System.Diagnostics.CodeAnalysis.Experimental("ASPIREPOSTGRES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] public static ApplicationModel.IResourceBuilder WithPostgresMcp(this ApplicationModel.IResourceBuilder builder, System.Action>? configureContainer = null, string? containerName = null) { throw null; } - [AspireExport(Description = "Configures the PostgreSQL user name")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithUserName(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder userName) { throw null; } } } diff --git a/src/Aspire.Hosting.Python/api/Aspire.Hosting.Python.cs b/src/Aspire.Hosting.Python/api/Aspire.Hosting.Python.cs index e3cf4f4bccc..767271d0ce4 100644 --- a/src/Aspire.Hosting.Python/api/Aspire.Hosting.Python.cs +++ b/src/Aspire.Hosting.Python/api/Aspire.Hosting.Python.cs @@ -18,35 +18,35 @@ public static partial class PythonAppResourceBuilderExtensions [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] public static ApplicationModel.IResourceBuilder AddPythonApp(this IDistributedApplicationBuilder builder, string name, string appDirectory, string scriptPath, params string[] scriptArgs) { throw null; } - [AspireExport(Description = "Adds a Python script application resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddPythonApp(this IDistributedApplicationBuilder builder, string name, string appDirectory, string scriptPath) { throw null; } - [AspireExport(Description = "Adds a Python executable application resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddPythonExecutable(this IDistributedApplicationBuilder builder, string name, string appDirectory, string executableName) { throw null; } - [AspireExport(Description = "Adds a Python module application resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddPythonModule(this IDistributedApplicationBuilder builder, string name, string appDirectory, string moduleName) { throw null; } - [AspireExport(Description = "Adds a Uvicorn-based Python application resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddUvicornApp(this IDistributedApplicationBuilder builder, string name, string appDirectory, string app) { throw null; } - [AspireExport(Description = "Enables debugging support for a Python application")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithDebugging(this ApplicationModel.IResourceBuilder builder) where T : Python.PythonAppResource { throw null; } - [AspireExport(Description = "Configures the entrypoint for a Python application")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithEntrypoint(this ApplicationModel.IResourceBuilder builder, Python.EntrypointType entrypointType, string entrypoint) where T : Python.PythonAppResource { throw null; } - [AspireExport(Description = "Configures pip package installation for a Python application")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithPip(this ApplicationModel.IResourceBuilder builder, bool install = true, string[]? installArgs = null) where T : Python.PythonAppResource { throw null; } - [AspireExport(Description = "Configures uv package management for a Python application")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithUv(this ApplicationModel.IResourceBuilder builder, bool install = true, string[]? args = null) where T : Python.PythonAppResource { throw null; } - [AspireExport(Description = "Configures the virtual environment for a Python application")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithVirtualEnvironment(this ApplicationModel.IResourceBuilder builder, string virtualEnvironmentPath, bool createIfNotExists = true) where T : Python.PythonAppResource { throw null; } } diff --git a/src/Aspire.Hosting.Qdrant/api/Aspire.Hosting.Qdrant.cs b/src/Aspire.Hosting.Qdrant/api/Aspire.Hosting.Qdrant.cs index bd8a78239f6..f57abb9435c 100644 --- a/src/Aspire.Hosting.Qdrant/api/Aspire.Hosting.Qdrant.cs +++ b/src/Aspire.Hosting.Qdrant/api/Aspire.Hosting.Qdrant.cs @@ -10,13 +10,13 @@ namespace Aspire.Hosting { public static partial class QdrantBuilderExtensions { - [AspireExport(Description = "Adds a Qdrant resource to the application. A container is used for local development.")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddQdrant(this IDistributedApplicationBuilder builder, string name, ApplicationModel.IResourceBuilder? apiKey = null, int? grpcPort = null, int? httpPort = null) { throw null; } - [AspireExport(Description = "Adds a bind mount for the data folder to a Qdrant container resource.")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithDataBindMount(this ApplicationModel.IResourceBuilder builder, string source, bool isReadOnly = false) { throw null; } - [AspireExport(Description = "Adds a named volume for the data folder to a Qdrant container resource.")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithDataVolume(this ApplicationModel.IResourceBuilder builder, string? name = null, bool isReadOnly = false) { throw null; } [AspireExportIgnore(Reason = "Polyglot app hosts use the generic withReference export.")] diff --git a/src/Aspire.Hosting.RabbitMQ/api/Aspire.Hosting.RabbitMQ.cs b/src/Aspire.Hosting.RabbitMQ/api/Aspire.Hosting.RabbitMQ.cs index bb749cc0a68..b3584f2490c 100644 --- a/src/Aspire.Hosting.RabbitMQ/api/Aspire.Hosting.RabbitMQ.cs +++ b/src/Aspire.Hosting.RabbitMQ/api/Aspire.Hosting.RabbitMQ.cs @@ -10,13 +10,13 @@ namespace Aspire.Hosting { public static partial class RabbitMQBuilderExtensions { - [AspireExport(Description = "Adds a RabbitMQ container resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddRabbitMQ(this IDistributedApplicationBuilder builder, string name, ApplicationModel.IResourceBuilder? userName = null, ApplicationModel.IResourceBuilder? password = null, int? port = null) { throw null; } - [AspireExport(Description = "Adds a data bind mount to the RabbitMQ container")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithDataBindMount(this ApplicationModel.IResourceBuilder builder, string source, bool isReadOnly = false) { throw null; } - [AspireExport(Description = "Adds a data volume to the RabbitMQ container")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithDataVolume(this ApplicationModel.IResourceBuilder builder, string? name = null, bool isReadOnly = false) { throw null; } [AspireExportIgnore(Reason = "Polyglot app hosts use the internal withManagementPlugin dispatcher export.")] diff --git a/src/Aspire.Hosting.Redis/api/Aspire.Hosting.Redis.cs b/src/Aspire.Hosting.Redis/api/Aspire.Hosting.Redis.cs index ecd75e18c67..169a64d270b 100644 --- a/src/Aspire.Hosting.Redis/api/Aspire.Hosting.Redis.cs +++ b/src/Aspire.Hosting.Redis/api/Aspire.Hosting.Redis.cs @@ -10,43 +10,43 @@ namespace Aspire.Hosting { public static partial class RedisBuilderExtensions { - [AspireExport(Description = "Adds a Redis container resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddRedis(this IDistributedApplicationBuilder builder, string name, int? port = null, ApplicationModel.IResourceBuilder? password = null) { throw null; } [AspireExportIgnore(Reason = "Polyglot app hosts use the canonical addRedis export with options.")] public static ApplicationModel.IResourceBuilder AddRedis(this IDistributedApplicationBuilder builder, string name, int? port) { throw null; } - [AspireExport(Description = "Adds a data bind mount with persistence")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithDataBindMount(this ApplicationModel.IResourceBuilder builder, string source, bool isReadOnly = false) { throw null; } - [AspireExport("withRedisInsightDataBindMount", MethodName = "withDataBindMount", Description = "Adds a data bind mount for Redis Insight")] + [AspireExport("withRedisInsightDataBindMount", MethodName = "withDataBindMount")] public static ApplicationModel.IResourceBuilder WithDataBindMount(this ApplicationModel.IResourceBuilder builder, string source) { throw null; } - [AspireExport(Description = "Adds a data volume with persistence")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithDataVolume(this ApplicationModel.IResourceBuilder builder, string? name = null, bool isReadOnly = false) { throw null; } - [AspireExport("withRedisInsightDataVolume", Description = "Adds a data volume for Redis Insight", MethodName = "withDataVolume")] + [AspireExport("withRedisInsightDataVolume", MethodName = "withDataVolume")] public static ApplicationModel.IResourceBuilder WithDataVolume(this ApplicationModel.IResourceBuilder builder, string? name = null) { throw null; } - [AspireExport(Description = "Sets the host port for Redis")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithHostPort(this ApplicationModel.IResourceBuilder builder, int? port) { throw null; } - [AspireExport("withRedisCommanderHostPort", MethodName = "withHostPort", Description = "Sets the host port for Redis Commander")] + [AspireExport("withRedisCommanderHostPort", MethodName = "withHostPort")] public static ApplicationModel.IResourceBuilder WithHostPort(this ApplicationModel.IResourceBuilder builder, int? port) { throw null; } - [AspireExport("withRedisInsightHostPort", MethodName = "withHostPort", Description = "Sets the host port for Redis Insight")] + [AspireExport("withRedisInsightHostPort", MethodName = "withHostPort")] public static ApplicationModel.IResourceBuilder WithHostPort(this ApplicationModel.IResourceBuilder builder, int? port) { throw null; } - [AspireExport(Description = "Configures the password for Redis")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithPassword(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder? password) { throw null; } - [AspireExport(Description = "Configures Redis persistence")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithPersistence(this ApplicationModel.IResourceBuilder builder, System.TimeSpan? interval = null, long keysChangedThreshold = 1) { throw null; } - [AspireExport(Description = "Adds Redis Commander management UI", RunSyncOnBackgroundThread = true)] + [AspireExport(RunSyncOnBackgroundThread = true)] public static ApplicationModel.IResourceBuilder WithRedisCommander(this ApplicationModel.IResourceBuilder builder, System.Action>? configureContainer = null, string? containerName = null) { throw null; } - [AspireExport(Description = "Adds Redis Insight management UI", RunSyncOnBackgroundThread = true)] + [AspireExport(RunSyncOnBackgroundThread = true)] public static ApplicationModel.IResourceBuilder WithRedisInsight(this ApplicationModel.IResourceBuilder builder, System.Action>? configureContainer = null, string? containerName = null) { throw null; } } } diff --git a/src/Aspire.Hosting.Seq/api/Aspire.Hosting.Seq.cs b/src/Aspire.Hosting.Seq/api/Aspire.Hosting.Seq.cs index 42fbb99955f..79bb56bac94 100644 --- a/src/Aspire.Hosting.Seq/api/Aspire.Hosting.Seq.cs +++ b/src/Aspire.Hosting.Seq/api/Aspire.Hosting.Seq.cs @@ -10,16 +10,16 @@ namespace Aspire.Hosting { public static partial class SeqBuilderExtensions { - [AspireExport(Description = "Adds a Seq server container resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddSeq(this IDistributedApplicationBuilder builder, string name, ApplicationModel.IResourceBuilder? adminPassword, int? port = null) { throw null; } [AspireExportIgnore(Reason = "Convenience overload. Use the overload with optional adminPassword parameter instead.")] public static ApplicationModel.IResourceBuilder AddSeq(this IDistributedApplicationBuilder builder, string name, int? port = null) { throw null; } - [AspireExport(Description = "Adds a data bind mount for Seq")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithDataBindMount(this ApplicationModel.IResourceBuilder builder, string source, bool isReadOnly = false) { throw null; } - [AspireExport(Description = "Adds a data volume for Seq")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithDataVolume(this ApplicationModel.IResourceBuilder builder, string? name = null, bool isReadOnly = false) { throw null; } } } diff --git a/src/Aspire.Hosting.SqlServer/api/Aspire.Hosting.SqlServer.cs b/src/Aspire.Hosting.SqlServer/api/Aspire.Hosting.SqlServer.cs index 6c40c49ce50..f553a7178ff 100644 --- a/src/Aspire.Hosting.SqlServer/api/Aspire.Hosting.SqlServer.cs +++ b/src/Aspire.Hosting.SqlServer/api/Aspire.Hosting.SqlServer.cs @@ -10,25 +10,25 @@ namespace Aspire.Hosting { public static partial class SqlServerBuilderExtensions { - [AspireExport(Description = "Adds a SQL Server database resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddDatabase(this ApplicationModel.IResourceBuilder builder, string name, string? databaseName = null) { throw null; } - [AspireExport(Description = "Adds a SQL Server container resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddSqlServer(this IDistributedApplicationBuilder builder, string name, ApplicationModel.IResourceBuilder? password = null, int? port = null) { throw null; } - [AspireExport(Description = "Defines the SQL script used to create the database")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithCreationScript(this ApplicationModel.IResourceBuilder builder, string script) { throw null; } - [AspireExport(Description = "Adds a bind mount for the SQL Server data folder")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithDataBindMount(this ApplicationModel.IResourceBuilder builder, string source, bool isReadOnly = false) { throw null; } - [AspireExport(Description = "Adds a named volume for the SQL Server data folder")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithDataVolume(this ApplicationModel.IResourceBuilder builder, string? name = null, bool isReadOnly = false) { throw null; } - [AspireExport(Description = "Sets the host port for the SQL Server resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithHostPort(this ApplicationModel.IResourceBuilder builder, int? port) { throw null; } - [AspireExport(Description = "Configures the password for the SQL Server resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithPassword(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder password) { throw null; } } } diff --git a/src/Aspire.Hosting.Testing/api/Aspire.Hosting.Testing.cs b/src/Aspire.Hosting.Testing/api/Aspire.Hosting.Testing.cs index 946eb31a85e..9e25da9ed56 100644 --- a/src/Aspire.Hosting.Testing/api/Aspire.Hosting.Testing.cs +++ b/src/Aspire.Hosting.Testing/api/Aspire.Hosting.Testing.cs @@ -43,7 +43,7 @@ public static partial class DistributedApplicationHostingTestingExtensions [AspireExportIgnore(Reason = "Use the exported getConnectionString overload without a cancellation token.")] public static System.Threading.Tasks.ValueTask GetConnectionStringAsync(this DistributedApplication app, string resourceName, System.Threading.CancellationToken cancellationToken = default) { throw null; } - [AspireExport(Description = "Gets the endpoint for the specified resource.")] + [AspireExport] public static System.Uri GetEndpoint(this DistributedApplication app, string resourceName, string? endpointName = null) { throw null; } [AspireExportIgnore(Reason = "Use the ATS-friendly overload that accepts a network identifier string.")] diff --git a/src/Aspire.Hosting.Valkey/api/Aspire.Hosting.Valkey.cs b/src/Aspire.Hosting.Valkey/api/Aspire.Hosting.Valkey.cs index aa83256845c..eff0f849a14 100644 --- a/src/Aspire.Hosting.Valkey/api/Aspire.Hosting.Valkey.cs +++ b/src/Aspire.Hosting.Valkey/api/Aspire.Hosting.Valkey.cs @@ -10,19 +10,19 @@ namespace Aspire.Hosting { public static partial class ValkeyBuilderExtensions { - [AspireExport(Description = "Adds a Valkey container resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddValkey(this IDistributedApplicationBuilder builder, string name, int? port = null, ApplicationModel.IResourceBuilder? password = null) { throw null; } [AspireExportIgnore(Reason = "Convenience overload. Use the overload with optional password parameter instead.")] public static ApplicationModel.IResourceBuilder AddValkey(this IDistributedApplicationBuilder builder, string name, int? port) { throw null; } - [AspireExport(Description = "Adds a data bind mount for Valkey and enables persistence")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithDataBindMount(this ApplicationModel.IResourceBuilder builder, string source, bool isReadOnly = false) { throw null; } - [AspireExport(Description = "Adds a data volume for Valkey and enables persistence")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithDataVolume(this ApplicationModel.IResourceBuilder builder, string? name = null, bool isReadOnly = false) { throw null; } - [AspireExport(Description = "Configures Valkey persistence")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithPersistence(this ApplicationModel.IResourceBuilder builder, System.TimeSpan? interval = null, long keysChangedThreshold = 1) { throw null; } } } diff --git a/src/Aspire.Hosting.Yarp/api/Aspire.Hosting.Yarp.cs b/src/Aspire.Hosting.Yarp/api/Aspire.Hosting.Yarp.cs index 62fdbbd281a..358d07e94db 100644 --- a/src/Aspire.Hosting.Yarp/api/Aspire.Hosting.Yarp.cs +++ b/src/Aspire.Hosting.Yarp/api/Aspire.Hosting.Yarp.cs @@ -51,25 +51,25 @@ public static partial class YarpConfigurationBuilderExtensions public static partial class YarpResourceExtensions { - [AspireExport(Description = "Adds a YARP container to the application model.")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddYarp(this IDistributedApplicationBuilder builder, string name) { throw null; } - [AspireExport(Description = "In publish mode, generates a Dockerfile that copies static files from the specified resource into /app/wwwroot.")] + [AspireExport] public static ApplicationModel.IResourceBuilder PublishWithStaticFiles(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder resourceWithFiles) { throw null; } - [AspireExport(Description = "Configure the YARP resource.", RunSyncOnBackgroundThread = true)] + [AspireExport(RunSyncOnBackgroundThread = true)] public static ApplicationModel.IResourceBuilder WithConfiguration(this ApplicationModel.IResourceBuilder builder, System.Action configurationBuilder) { throw null; } - [AspireExport(Description = "Configures the host HTTPS port that the YARP resource is exposed on instead of using randomly assigned port.")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithHostHttpsPort(this ApplicationModel.IResourceBuilder builder, int? port) { throw null; } - [AspireExport(Description = "Configures the host port that the YARP resource is exposed on instead of using randomly assigned port.")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithHostPort(this ApplicationModel.IResourceBuilder builder, int? port) { throw null; } - [AspireExport("withStaticFiles2", MethodName = "withStaticFiles", Description = "Enables static file serving. In run mode: bind mounts to /wwwroot.")] + [AspireExportIgnore(Reason = "A single internal export with an optional sourcePath parameter provides the polyglot API without changing the public C# overloads.")] public static ApplicationModel.IResourceBuilder WithStaticFiles(this ApplicationModel.IResourceBuilder builder, string sourcePath) { throw null; } - [AspireExport("withStaticFiles1", MethodName = "withStaticFiles", Description = "Enables static file serving in the YARP resource. Static files are served from the wwwroot folder.")] + [AspireExportIgnore(Reason = "A single internal export with an optional sourcePath parameter provides the polyglot API without changing the public C# overloads.")] public static ApplicationModel.IResourceBuilder WithStaticFiles(this ApplicationModel.IResourceBuilder builder) { throw null; } } } @@ -100,10 +100,10 @@ public static partial class YarpClusterExtensions [AspireExportIgnore(Reason = "HttpClientConfig is not ATS-compatible. Use the DTO-based overload instead.")] public static YarpCluster WithHttpClientConfig(this YarpCluster cluster, global::Yarp.ReverseProxy.Configuration.HttpClientConfig config) { throw null; } - [AspireExport(Description = "Sets the load balancing policy for the cluster.")] + [AspireExport] public static YarpCluster WithLoadBalancingPolicy(this YarpCluster cluster, string policy) { throw null; } - [AspireExport("withClusterMetadata", MethodName = "withMetadata", Description = "Sets metadata for the cluster.")] + [AspireExport("withClusterMetadata", MethodName = "withMetadata")] public static YarpCluster WithMetadata(this YarpCluster cluster, System.Collections.Generic.IReadOnlyDictionary metadata) { throw null; } [AspireExportIgnore(Reason = "SessionAffinityConfig is not ATS-compatible. Use the DTO-based overload instead.")] @@ -129,31 +129,31 @@ public static partial class YarpRouteExtensions [AspireExportIgnore(Reason = "RouteHeader is not ATS-compatible. Use the DTO-based overload instead.")] public static YarpRoute WithMatchHeaders(this YarpRoute route, params global::Yarp.ReverseProxy.Configuration.RouteHeader[] headers) { throw null; } - [AspireExport(Description = "Matches requests that contain the specified host headers.")] + [AspireExport] public static YarpRoute WithMatchHosts(this YarpRoute route, params string[] hosts) { throw null; } - [AspireExport(Description = "Matches requests that use the specified HTTP methods.")] + [AspireExport] public static YarpRoute WithMatchMethods(this YarpRoute route, params string[] methods) { throw null; } - [AspireExport(Description = "Matches requests with the specified path pattern.")] + [AspireExport] public static YarpRoute WithMatchPath(this YarpRoute route, string path) { throw null; } [AspireExportIgnore(Reason = "RouteQueryParameter is not ATS-compatible. Use the DTO-based overload instead.")] public static YarpRoute WithMatchRouteQueryParameter(this YarpRoute route, params global::Yarp.ReverseProxy.Configuration.RouteQueryParameter[] queryParameters) { throw null; } - [AspireExport(Description = "Sets the maximum request body size for the route.")] + [AspireExport] public static YarpRoute WithMaxRequestBodySize(this YarpRoute route, long maxRequestBodySize) { throw null; } - [AspireExport("withRouteMetadata", MethodName = "withMetadata", Description = "Sets metadata for the route.")] + [AspireExport("withRouteMetadata", MethodName = "withMetadata")] public static YarpRoute WithMetadata(this YarpRoute route, System.Collections.Generic.IReadOnlyDictionary? metadata) { throw null; } - [AspireExport(Description = "Sets the route order.")] + [AspireExport] public static YarpRoute WithOrder(this YarpRoute route, int? order) { throw null; } [AspireExportIgnore(Reason = "Action> callbacks are not ATS-compatible.")] public static YarpRoute WithTransform(this YarpRoute route, System.Action> createTransform) { throw null; } - [AspireExport(Description = "Sets the transforms for the route.")] + [AspireExport] public static YarpRoute WithTransforms(this YarpRoute route, System.Collections.Generic.IReadOnlyList>? transforms) { throw null; } } } @@ -162,19 +162,19 @@ namespace Aspire.Hosting.Yarp.Transforms { public static partial class ForwardedTransformExtensions { - [AspireExport(Description = "Adds the transform which will set the given header with the Base64 encoded client certificate.")] + [AspireExport] public static YarpRoute WithTransformClientCertHeader(this YarpRoute route, string headerName) { throw null; } - [AspireExport(Description = "Adds the transform which will add the Forwarded header as defined by [RFC 7239](https://tools.ietf.org/html/rfc7239).")] + [AspireExport] public static YarpRoute WithTransformForwarded(this YarpRoute route, bool useHost = true, bool useProto = true, global::Yarp.ReverseProxy.Transforms.NodeFormat forFormat = global::Yarp.ReverseProxy.Transforms.NodeFormat.Random, global::Yarp.ReverseProxy.Transforms.NodeFormat byFormat = global::Yarp.ReverseProxy.Transforms.NodeFormat.Random, global::Yarp.ReverseProxy.Transforms.ForwardedTransformActions action = global::Yarp.ReverseProxy.Transforms.ForwardedTransformActions.Set) { throw null; } - [AspireExport(Description = "Adds the transform which will add X-Forwarded-* headers.")] + [AspireExport] public static YarpRoute WithTransformXForwarded(this YarpRoute route, string headerPrefix = "X-Forwarded-", global::Yarp.ReverseProxy.Transforms.ForwardedTransformActions xDefault = global::Yarp.ReverseProxy.Transforms.ForwardedTransformActions.Set, global::Yarp.ReverseProxy.Transforms.ForwardedTransformActions? xFor = null, global::Yarp.ReverseProxy.Transforms.ForwardedTransformActions? xHost = null, global::Yarp.ReverseProxy.Transforms.ForwardedTransformActions? xProto = null, global::Yarp.ReverseProxy.Transforms.ForwardedTransformActions? xPrefix = null) { throw null; } } public static partial class HttpMethodTransformExtensions { - [AspireExport(Description = "Adds the transform that will replace the HTTP method if it matches.")] + [AspireExport] public static YarpRoute WithTransformHttpMethodChange(this YarpRoute route, string fromHttpMethod, string toHttpMethod) { throw null; } } @@ -195,61 +195,61 @@ public static partial class PathTransformExtensions public static partial class QueryTransformExtensions { - [AspireExport(Description = "Adds the transform that will remove the given query key.")] + [AspireExport] public static YarpRoute WithTransformQueryRemoveKey(this YarpRoute route, string queryKey) { throw null; } - [AspireExport(Description = "Adds the transform that will append or set the query parameter from a route value.")] + [AspireExport] public static YarpRoute WithTransformQueryRouteValue(this YarpRoute route, string queryKey, string routeValueKey, bool append = true) { throw null; } - [AspireExport(Description = "Adds the transform that will append or set the query parameter from the given value.")] + [AspireExport] public static YarpRoute WithTransformQueryValue(this YarpRoute route, string queryKey, string value, bool append = true) { throw null; } } public static partial class RequestHeadersTransformExtensions { - [AspireExport(Description = "Adds the transform which will enable or suppress copying request headers to the proxy request.")] + [AspireExport] public static YarpRoute WithTransformCopyRequestHeaders(this YarpRoute route, bool copy = true) { throw null; } - [AspireExport(Description = "Adds the transform which will append or set the request header.")] + [AspireExport] public static YarpRoute WithTransformRequestHeader(this YarpRoute route, string headerName, string value, bool append = true) { throw null; } - [AspireExport(Description = "Adds the transform which will remove the request header.")] + [AspireExport] public static YarpRoute WithTransformRequestHeaderRemove(this YarpRoute route, string headerName) { throw null; } - [AspireExport(Description = "Adds the transform which will append or set the request header from a route value.")] + [AspireExport] public static YarpRoute WithTransformRequestHeaderRouteValue(this YarpRoute route, string headerName, string routeValueKey, bool append = true) { throw null; } - [AspireExport(Description = "Adds the transform which will only copy the allowed request headers. Other transforms")] + [AspireExport] public static YarpRoute WithTransformRequestHeadersAllowed(this YarpRoute route, params string[] allowedHeaders) { throw null; } - [AspireExport(Description = "Adds the transform which will copy the incoming request Host header to the proxy request.")] + [AspireExport] public static YarpRoute WithTransformUseOriginalHostHeader(this YarpRoute route, bool useOriginal = true) { throw null; } } public static partial class ResponseTransformExtensions { - [AspireExport(Description = "Adds the transform which will enable or suppress copying response headers to the client response.")] + [AspireExport] public static YarpRoute WithTransformCopyResponseHeaders(this YarpRoute route, bool copy = true) { throw null; } - [AspireExport(Description = "Adds the transform which will enable or suppress copying response trailers to the client response.")] + [AspireExport] public static YarpRoute WithTransformCopyResponseTrailers(this YarpRoute route, bool copy = true) { throw null; } - [AspireExport(Description = "Adds the transform which will append or set the response header.")] + [AspireExport] public static YarpRoute WithTransformResponseHeader(this YarpRoute route, string headerName, string value, bool append = true, global::Yarp.ReverseProxy.Transforms.ResponseCondition condition = global::Yarp.ReverseProxy.Transforms.ResponseCondition.Success) { throw null; } - [AspireExport(Description = "Adds the transform which will remove the response header.")] + [AspireExport] public static YarpRoute WithTransformResponseHeaderRemove(this YarpRoute route, string headerName, global::Yarp.ReverseProxy.Transforms.ResponseCondition condition = global::Yarp.ReverseProxy.Transforms.ResponseCondition.Success) { throw null; } - [AspireExport(Description = "Adds the transform which will only copy the allowed response headers. Other transforms")] + [AspireExport] public static YarpRoute WithTransformResponseHeadersAllowed(this YarpRoute route, params string[] allowedHeaders) { throw null; } - [AspireExport(Description = "Adds the transform which will append or set the response trailer.")] + [AspireExport] public static YarpRoute WithTransformResponseTrailer(this YarpRoute route, string headerName, string value, bool append = true, global::Yarp.ReverseProxy.Transforms.ResponseCondition condition = global::Yarp.ReverseProxy.Transforms.ResponseCondition.Success) { throw null; } - [AspireExport(Description = "Adds the transform which will remove the response trailer.")] + [AspireExport] public static YarpRoute WithTransformResponseTrailerRemove(this YarpRoute route, string headerName, global::Yarp.ReverseProxy.Transforms.ResponseCondition condition = global::Yarp.ReverseProxy.Transforms.ResponseCondition.Success) { throw null; } - [AspireExport(Description = "Adds the transform which will only copy the allowed response trailers. Other transforms")] + [AspireExport] public static YarpRoute WithTransformResponseTrailersAllowed(this YarpRoute route, params string[] allowedHeaders) { throw null; } } } \ No newline at end of file diff --git a/src/Aspire.Hosting/api/Aspire.Hosting.cs b/src/Aspire.Hosting/api/Aspire.Hosting.cs index 60c26a7b764..0f37302c3c9 100644 --- a/src/Aspire.Hosting/api/Aspire.Hosting.cs +++ b/src/Aspire.Hosting/api/Aspire.Hosting.cs @@ -9,14 +9,12 @@ namespace Aspire.Hosting { [System.AttributeUsage(System.AttributeTargets.Class | System.AttributeTargets.Struct, Inherited = false, AllowMultiple = false)] - [System.Diagnostics.CodeAnalysis.Experimental("ASPIREATS001")] public sealed partial class AspireDtoAttribute : System.Attribute { public string? DtoTypeId { get { throw null; } set { } } } [System.AttributeUsage(System.AttributeTargets.Assembly | System.AttributeTargets.Class | System.AttributeTargets.Method | System.AttributeTargets.Property | System.AttributeTargets.Interface, Inherited = false, AllowMultiple = true)] - [System.Diagnostics.CodeAnalysis.Experimental("ASPIREATS001")] public sealed partial class AspireExportAttribute : System.Attribute { public AspireExportAttribute() { } @@ -41,14 +39,12 @@ public AspireExportAttribute(System.Type type) { } } [System.AttributeUsage(System.AttributeTargets.Class | System.AttributeTargets.Method | System.AttributeTargets.Property, Inherited = false, AllowMultiple = false)] - [System.Diagnostics.CodeAnalysis.Experimental("ASPIREATS001")] public sealed partial class AspireExportIgnoreAttribute : System.Attribute { public string? Reason { get { throw null; } set { } } } [System.AttributeUsage(System.AttributeTargets.Property | System.AttributeTargets.Parameter, AllowMultiple = false)] - [System.Diagnostics.CodeAnalysis.Experimental("ASPIREATS001")] public sealed partial class AspireUnionAttribute : System.Attribute { public AspireUnionAttribute(params System.Type[] types) { } @@ -57,7 +53,6 @@ public AspireUnionAttribute(params System.Type[] types) { } } [System.AttributeUsage(System.AttributeTargets.Property | System.AttributeTargets.Field, Inherited = false, AllowMultiple = false)] - [System.Diagnostics.CodeAnalysis.Experimental("ASPIREATS001")] public sealed partial class AspireValueAttribute : System.Attribute { public AspireValueAttribute(string catalogName) { } @@ -100,7 +95,7 @@ public static partial class ContainerRegistryResourceBuilderExtensions public static ApplicationModel.IResourceBuilder AddContainerRegistry(this IDistributedApplicationBuilder builder, string name, string endpoint, string? repository = null) { throw null; } [System.Diagnostics.CodeAnalysis.Experimental("ASPIRECOMPUTE003", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] - [AspireExport(Description = "Configures a resource to use a container registry")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithContainerRegistry(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder registry) where TDestination : ApplicationModel.IResource where TContainerRegistry : ApplicationModel.IResource, ApplicationModel.IContainerRegistry { throw null; } } @@ -113,28 +108,28 @@ public static partial class ContainerResourceBuilderExtensions [AspireExportIgnore(Reason = "Use the polyglot addContainer overload that accepts a string or AddContainerOptions value.")] public static ApplicationModel.IResourceBuilder AddContainer(this IDistributedApplicationBuilder builder, string name, string image) { throw null; } - [AspireExport(Description = "Adds a container resource built from a Dockerfile")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddDockerfile(this IDistributedApplicationBuilder builder, string name, string contextPath, string? dockerfilePath = null, string? stage = null) { throw null; } [AspireExportIgnore(Reason = "This synchronous overload is excluded from the polyglot surface; only the async callback overload is exported.")] [System.Diagnostics.CodeAnalysis.Experimental("ASPIREDOCKERFILEBUILDER001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] public static ApplicationModel.IResourceBuilder AddDockerfileBuilder(this IDistributedApplicationBuilder builder, string name, string contextPath, System.Action callback, string? stage = null) { throw null; } - [AspireExport(Description = "Adds a container resource built from a programmatically generated Dockerfile")] + [AspireExport] [System.Diagnostics.CodeAnalysis.Experimental("ASPIREDOCKERFILEBUILDER001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] public static ApplicationModel.IResourceBuilder AddDockerfileBuilder(this IDistributedApplicationBuilder builder, string name, string contextPath, System.Func callback, string? stage = null) { throw null; } - [AspireExportIgnore(Reason = "DockerfileFactoryContext exposes IServiceProvider and IResource — .NET runtime types not usable from polyglot hosts.")] + [AspireExportIgnore(Reason = "Polyglot app hosts use the async callback overload.")] public static ApplicationModel.IResourceBuilder AddDockerfileFactory(this IDistributedApplicationBuilder builder, string name, string contextPath, System.Func dockerfileFactory, string? stage = null) { throw null; } - [AspireExportIgnore(Reason = "DockerfileFactoryContext exposes IServiceProvider and IResource — .NET runtime types not usable from polyglot hosts.")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddDockerfileFactory(this IDistributedApplicationBuilder builder, string name, string contextPath, System.Func> dockerfileFactory, string? stage = null) { throw null; } - [AspireExport(Description = "Configures the resource to be published as a container")] + [AspireExport] public static ApplicationModel.IResourceBuilder PublishAsContainer(this ApplicationModel.IResourceBuilder builder) where T : ApplicationModel.ContainerResource { throw null; } - [AspireExport(Description = "Adds a bind mount")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithBindMount(this ApplicationModel.IResourceBuilder builder, string source, string target, bool isReadOnly = false) where T : ApplicationModel.ContainerResource { throw null; } @@ -146,7 +141,7 @@ public static ApplicationModel.IResourceBuilder WithBuildArg(this Applicat public static ApplicationModel.IResourceBuilder WithBuildArg(this ApplicationModel.IResourceBuilder builder, string name, object? value) where T : ApplicationModel.ContainerResource { throw null; } - [AspireExport("withParameterBuildSecret", MethodName = "withBuildSecret", Description = "Adds a build secret from a parameter resource")] + [AspireExport("withParameterBuildSecret", MethodName = "withBuildSecret")] public static ApplicationModel.IResourceBuilder WithBuildSecret(this ApplicationModel.IResourceBuilder builder, string name, ApplicationModel.IResourceBuilder value) where T : ApplicationModel.ContainerResource { throw null; } @@ -166,11 +161,11 @@ public static ApplicationModel.IResourceBuilder WithContainerFiles(this Ap public static ApplicationModel.IResourceBuilder WithContainerFiles(this ApplicationModel.IResourceBuilder builder, string destinationPath, string sourcePath, int? defaultOwner = null, int? defaultGroup = null, System.IO.UnixFileMode? umask = null) where T : ApplicationModel.ContainerResource { throw null; } - [AspireExport(Description = "Sets the container name")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithContainerName(this ApplicationModel.IResourceBuilder builder, string name) where T : ApplicationModel.ContainerResource { throw null; } - [AspireExport(Description = "Adds a network alias for the container")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithContainerNetworkAlias(this ApplicationModel.IResourceBuilder builder, string alias) where T : ApplicationModel.ContainerResource { throw null; } @@ -182,15 +177,15 @@ public static ApplicationModel.IResourceBuilder WithContainerRuntimeArgs(t public static ApplicationModel.IResourceBuilder WithContainerRuntimeArgs(this ApplicationModel.IResourceBuilder builder, System.Func callback) where T : ApplicationModel.ContainerResource { throw null; } - [AspireExport(Description = "Adds runtime arguments for the container")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithContainerRuntimeArgs(this ApplicationModel.IResourceBuilder builder, params string[] args) where T : ApplicationModel.ContainerResource { throw null; } - [AspireExport(Description = "Configures the resource to use a Dockerfile")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithDockerfile(this ApplicationModel.IResourceBuilder builder, string contextPath, string? dockerfilePath = null, string? stage = null) where T : ApplicationModel.ContainerResource { throw null; } - [AspireExport(Description = "Sets the base image for a Dockerfile build")] + [AspireExport] [System.Diagnostics.CodeAnalysis.Experimental("ASPIREDOCKERFILEBUILDER001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] public static ApplicationModel.IResourceBuilder WithDockerfileBaseImage(this ApplicationModel.IResourceBuilder builder, string? buildImage = null, string? runtimeImage = null) where T : ApplicationModel.IResource { throw null; } @@ -200,48 +195,48 @@ public static ApplicationModel.IResourceBuilder WithDockerfileBaseImage(th public static ApplicationModel.IResourceBuilder WithDockerfileBuilder(this ApplicationModel.IResourceBuilder builder, string contextPath, System.Action callback, string? stage = null) where T : ApplicationModel.ContainerResource { throw null; } - [AspireExport(Description = "Configures the resource to use a programmatically generated Dockerfile")] + [AspireExport] [System.Diagnostics.CodeAnalysis.Experimental("ASPIREDOCKERFILEBUILDER001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] public static ApplicationModel.IResourceBuilder WithDockerfileBuilder(this ApplicationModel.IResourceBuilder builder, string contextPath, System.Func callback, string? stage = null) where T : ApplicationModel.ContainerResource { throw null; } - [AspireExportIgnore(Reason = "DockerfileFactoryContext exposes IServiceProvider and IResource — .NET runtime types not usable from polyglot hosts.")] + [AspireExportIgnore(Reason = "Polyglot app hosts use the async callback overload.")] public static ApplicationModel.IResourceBuilder WithDockerfileFactory(this ApplicationModel.IResourceBuilder builder, string contextPath, System.Func dockerfileFactory, string? stage = null) where T : ApplicationModel.ContainerResource { throw null; } - [AspireExportIgnore(Reason = "DockerfileFactoryContext exposes IServiceProvider and IResource — .NET runtime types not usable from polyglot hosts.")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithDockerfileFactory(this ApplicationModel.IResourceBuilder builder, string contextPath, System.Func> dockerfileFactory, string? stage = null) where T : ApplicationModel.ContainerResource { throw null; } - [AspireExport(Description = "Configures endpoint proxy support")] + [AspireExportIgnore(Reason = "Binary compatibility shim for the resource-level WithEndpointProxySupport overload.")] public static ApplicationModel.IResourceBuilder WithEndpointProxySupport(this ApplicationModel.IResourceBuilder builder, bool proxyEnabled) where T : ApplicationModel.ContainerResource { throw null; } - [AspireExport(Description = "Sets the container entrypoint")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithEntrypoint(this ApplicationModel.IResourceBuilder builder, string entrypoint) where T : ApplicationModel.ContainerResource { throw null; } - [AspireExport(Description = "Sets the container image")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithImage(this ApplicationModel.IResourceBuilder builder, string image, string? tag = null) where T : ApplicationModel.ContainerResource { throw null; } - [AspireExport(Description = "Sets the container image pull policy")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithImagePullPolicy(this ApplicationModel.IResourceBuilder builder, ApplicationModel.ImagePullPolicy pullPolicy) where T : ApplicationModel.ContainerResource { throw null; } - [AspireExport(Description = "Sets the container image registry")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithImageRegistry(this ApplicationModel.IResourceBuilder builder, string? registry) where T : ApplicationModel.ContainerResource { throw null; } - [AspireExport(Description = "Sets the image SHA256 digest")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithImageSHA256(this ApplicationModel.IResourceBuilder builder, string sha256) where T : ApplicationModel.ContainerResource { throw null; } - [AspireExport(Description = "Sets the container image tag")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithImageTag(this ApplicationModel.IResourceBuilder builder, string tag) where T : ApplicationModel.ContainerResource { throw null; } - [AspireExport(Description = "Sets the lifetime behavior of the container resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithLifetime(this ApplicationModel.IResourceBuilder builder, ApplicationModel.ContainerLifetime lifetime) where T : ApplicationModel.ContainerResource { throw null; } @@ -299,7 +294,7 @@ public virtual void Dispose() { } public void Run() { } - [AspireExport("run", Description = "Runs the distributed application")] + [AspireExport("run", RunSyncOnBackgroundThread = true)] public virtual System.Threading.Tasks.Task RunAsync(System.Threading.CancellationToken cancellationToken = default) { throw null; } public virtual System.Threading.Tasks.Task StartAsync(System.Threading.CancellationToken cancellationToken = default) { throw null; } @@ -471,34 +466,34 @@ public sealed partial class DistributedApplicationOptions [System.Diagnostics.CodeAnalysis.Experimental("ASPIREDOTNETTOOL", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] public static partial class DotnetToolResourceExtensions { - [AspireExport(Description = "Adds a .NET tool resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddDotnetTool(this IDistributedApplicationBuilder builder, string name, string packageId) { throw null; } [AspireExportIgnore(Reason = "Open generic IResource constraint — not ATS-compatible.")] public static ApplicationModel.IResourceBuilder AddDotnetTool(this IDistributedApplicationBuilder builder, T resource) where T : ApplicationModel.DotnetToolResource { throw null; } - [AspireExport(Description = "Ignores existing NuGet feeds")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithToolIgnoreExistingFeeds(this ApplicationModel.IResourceBuilder builder) where T : ApplicationModel.DotnetToolResource { throw null; } - [AspireExport(Description = "Ignores failed NuGet sources")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithToolIgnoreFailedSources(this ApplicationModel.IResourceBuilder builder) where T : ApplicationModel.DotnetToolResource { throw null; } - [AspireExport(Description = "Sets the tool package ID")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithToolPackage(this ApplicationModel.IResourceBuilder builder, string packageId) where T : ApplicationModel.DotnetToolResource { throw null; } - [AspireExport(Description = "Allows prerelease tool versions")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithToolPrerelease(this ApplicationModel.IResourceBuilder builder) where T : ApplicationModel.DotnetToolResource { throw null; } - [AspireExport(Description = "Adds a NuGet source for the tool")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithToolSource(this ApplicationModel.IResourceBuilder builder, string source) where T : ApplicationModel.DotnetToolResource { throw null; } - [AspireExport(Description = "Sets the tool version")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithToolVersion(this ApplicationModel.IResourceBuilder builder, string version) where T : ApplicationModel.DotnetToolResource { throw null; } } @@ -514,10 +509,10 @@ public static partial class ExecutableResourceBuilderExtensions [AspireExportIgnore(Reason = "Uses object[] parameter which is not ATS-compatible. String[] overload is exported.")] public static ApplicationModel.IResourceBuilder AddExecutable(this IDistributedApplicationBuilder builder, string name, string command, string workingDirectory, params object[]? args) { throw null; } - [AspireExport(Description = "Adds an executable resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddExecutable(this IDistributedApplicationBuilder builder, string name, string command, string workingDirectory, params string[]? args) { throw null; } - [AspireExport(Description = "Publishes an executable as a Docker file", RunSyncOnBackgroundThread = true)] + [AspireExport(RunSyncOnBackgroundThread = true)] public static ApplicationModel.IResourceBuilder PublishAsDockerFile(this ApplicationModel.IResourceBuilder builder, System.Action>? configure) where T : ApplicationModel.ExecutableResource { throw null; } @@ -529,11 +524,11 @@ public static ApplicationModel.IResourceBuilder PublishAsDockerFile(this A public static ApplicationModel.IResourceBuilder PublishAsDockerFile(this ApplicationModel.IResourceBuilder builder) where T : ApplicationModel.ExecutableResource { throw null; } - [AspireExport("withExecutableCommand", Description = "Sets the executable command")] + [AspireExport("withExecutableCommand")] public static ApplicationModel.IResourceBuilder WithCommand(this ApplicationModel.IResourceBuilder builder, string command) where T : ApplicationModel.ExecutableResource { throw null; } - [AspireExport(Description = "Sets the executable working directory")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithWorkingDirectory(this ApplicationModel.IResourceBuilder builder, string workingDirectory) where T : ApplicationModel.ExecutableResource { throw null; } } @@ -615,7 +610,7 @@ public partial interface IDistributedApplicationBuilder ApplicationModel.IResourceBuilder AddResource(T resource) where T : ApplicationModel.IResource; - [AspireExport(Description = "Builds the distributed application")] + [AspireExport] DistributedApplication Build(); ApplicationModel.IResourceBuilder CreateResourceBuilder(T resource) where T : ApplicationModel.IResource; @@ -657,15 +652,20 @@ public partial class InputsDialogInteractionOptions : InteractionOptions } [System.Diagnostics.CodeAnalysis.Experimental("ASPIREINTERACTION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + [AspireExport(ExposeProperties = true)] public sealed partial class InputsDialogValidationContext { public required System.Threading.CancellationToken CancellationToken { get { throw null; } init { } } public required InteractionInputCollection Inputs { get { throw null; } init { } } + [AspireExportIgnore(Reason = "IServiceProvider is not part of the polyglot validation surface.")] public required System.IServiceProvider Services { get { throw null; } init { } } public void AddValidationError(InteractionInput input, string errorMessage) { } + + [AspireExport("InputsDialogValidationContext.addValidationError", MethodName = "addValidationError")] + public void AddValidationError(string inputName, string errorMessage) { } } [System.Diagnostics.CodeAnalysis.Experimental("ASPIREINTERACTION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] @@ -679,6 +679,7 @@ public enum InputType } [System.Diagnostics.CodeAnalysis.Experimental("ASPIREINTERACTION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + [AspireDto] [System.Diagnostics.DebuggerDisplay("Name = {Name}, InputType = {InputType}, Required = {Required}, Value = {Value}")] public sealed partial class InteractionInput { @@ -710,6 +711,7 @@ public sealed partial class InteractionInput } [System.Diagnostics.CodeAnalysis.Experimental("ASPIREINTERACTION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + [AspireExport] [System.Diagnostics.DebuggerDisplay("Count = {Count}")] public sealed partial class InteractionInputCollection : System.Collections.Generic.IReadOnlyList, System.Collections.Generic.IEnumerable, System.Collections.IEnumerable, System.Collections.Generic.IReadOnlyCollection { @@ -725,10 +727,21 @@ public InteractionInputCollection(System.Collections.Generic.IReadOnlyList GetEnumerator() { throw null; } + public int GetInt32(string name) { throw null; } + + public string? GetString(string name) { throw null; } + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() { throw null; } + [AspireExport("InteractionInputCollection.toArray", MethodName = "toArray")] + public InteractionInput[] ToArray() { throw null; } + public bool TryGetByName(string name, out InteractionInput? input) { throw null; } } @@ -802,9 +815,9 @@ public partial interface IUserSecretsManager void GetOrSetSecret(Microsoft.Extensions.Configuration.IConfigurationManager configuration, string name, System.Func valueGenerator); [AspireExportIgnore(Reason = "JsonObject is not ATS-compatible. Use the ATS helper overload that accepts a JSON string.")] System.Threading.Tasks.Task SaveStateAsync(System.Text.Json.Nodes.JsonObject state, System.Threading.CancellationToken cancellationToken = default); - [AspireExport(Description = "Attempts to delete a user secret value")] + [AspireExport] bool TryDeleteSecret(string name); - [AspireExport(Description = "Attempts to set a user secret value")] + [AspireExport] bool TrySetSecret(string name, string value); } @@ -856,7 +869,7 @@ public sealed partial class LoadInputContext public static partial class McpServerResourceBuilderExtensions { [System.Diagnostics.CodeAnalysis.Experimental("ASPIREMCP001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] - [AspireExport(Description = "Configures an MCP server endpoint on the resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithMcpServer(this ApplicationModel.IResourceBuilder builder, string? path = "/mcp", string? endpointName = null) where T : ApplicationModel.IResourceWithEndpoints { throw null; } } @@ -948,7 +961,7 @@ public static partial class ParameterResourceBuilderExtensions [AspireExportIgnore(Reason = "Polyglot app hosts use the internal addParameter dispatcher export.")] public static ApplicationModel.IResourceBuilder AddParameter(this IDistributedApplicationBuilder builder, string name, string value, bool publishValueAsDefault = false, bool secret = false) { throw null; } - [AspireExport(Description = "Adds a parameter sourced from configuration")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddParameterFromConfiguration(this IDistributedApplicationBuilder builder, string name, string configurationKey, bool secret = false) { throw null; } public static void ConfigureConnectionStringManifestPublisher(ApplicationModel.IResourceBuilder builder) { } @@ -959,7 +972,7 @@ public static void ConfigureConnectionStringManifestPublisher(ApplicationModel.I public static ApplicationModel.ParameterResource CreateParameter(IDistributedApplicationBuilder builder, string name, bool secret) { throw null; } - [AspireExport(Description = "Publishes the resource as a connection string")] + [AspireExport] public static ApplicationModel.IResourceBuilder PublishAsConnectionString(this ApplicationModel.IResourceBuilder builder) where T : ApplicationModel.ContainerResource, ApplicationModel.IResourceWithConnectionString { throw null; } @@ -967,7 +980,7 @@ public static ApplicationModel.IResourceBuilder PublishAsConnectionString( [AspireExportIgnore(Reason = "Complex Func delegate with InteractionInput — not ATS-compatible.")] public static ApplicationModel.IResourceBuilder WithCustomInput(this ApplicationModel.IResourceBuilder builder, System.Func createInput) { throw null; } - [AspireExport(Description = "Sets a parameter description")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithDescription(this ApplicationModel.IResourceBuilder builder, string description, bool enableMarkdown = false) { throw null; } } @@ -1002,17 +1015,17 @@ public static partial class ProjectResourceBuilderExtensions public static ApplicationModel.IResourceBuilder AddProject(this IDistributedApplicationBuilder builder, string name) where TProject : IProjectMetadata, new() { throw null; } - [AspireExport(Description = "Disables forwarded headers for the project")] + [AspireExport] public static ApplicationModel.IResourceBuilder DisableForwardedHeaders(this ApplicationModel.IResourceBuilder builder) { throw null; } - [AspireExport("publishProjectAsDockerFileWithConfigure", MethodName = "publishAsDockerFile", Description = "Publishes a project as a Docker file with optional container configuration", RunSyncOnBackgroundThread = true)] + [AspireExport("publishProjectAsDockerFileWithConfigure", MethodName = "publishAsDockerFile", RunSyncOnBackgroundThread = true)] public static ApplicationModel.IResourceBuilder PublishAsDockerFile(this ApplicationModel.IResourceBuilder builder, System.Action>? configure = null) where T : ApplicationModel.ProjectResource { throw null; } [AspireExportIgnore(Reason = "Uses Func which is not ATS-compatible.")] public static ApplicationModel.IResourceBuilder WithEndpointsInEnvironment(this ApplicationModel.IResourceBuilder builder, System.Func filter) { throw null; } - [AspireExport(Description = "Sets the number of replicas")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithReplicas(this ApplicationModel.IResourceBuilder builder, int replicas) { throw null; } } @@ -1033,41 +1046,41 @@ public static partial class RequiredCommandResourceExtensions public static ApplicationModel.IResourceBuilder WithRequiredCommand(this ApplicationModel.IResourceBuilder builder, string command, System.Func> validationCallback, string? helpLink = null) where T : ApplicationModel.IResource { throw null; } - [AspireExport(Description = "Adds a required command dependency")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithRequiredCommand(this ApplicationModel.IResourceBuilder builder, string command, string? helpLink = null) where T : ApplicationModel.IResource { throw null; } } public static partial class ResourceBuilderExtensions { - [AspireExport(Description = "Configures resource for HTTP/2")] + [AspireExport] public static ApplicationModel.IResourceBuilder AsHttp2Service(this ApplicationModel.IResourceBuilder builder) where T : ApplicationModel.IResourceWithEndpoints { throw null; } - [AspireExport(Description = "Clears all container file sources")] + [AspireExport] public static ApplicationModel.IResourceBuilder ClearContainerFilesSources(this ApplicationModel.IResourceBuilder builder) where T : IResourceWithContainerFiles { throw null; } - [AspireExport(Description = "Excludes the resource from the deployment manifest")] + [AspireExport] public static ApplicationModel.IResourceBuilder ExcludeFromManifest(this ApplicationModel.IResourceBuilder builder) where T : ApplicationModel.IResource { throw null; } - [AspireExport(Description = "Excludes the resource from MCP server exposure")] + [AspireExport] public static ApplicationModel.IResourceBuilder ExcludeFromMcp(this ApplicationModel.IResourceBuilder builder) where T : ApplicationModel.IResource { throw null; } - [AspireExport(Description = "Gets a connection property by key")] + [AspireExport] public static ApplicationModel.ReferenceExpression GetConnectionProperty(this ApplicationModel.IResourceWithConnectionString resource, string key) { throw null; } [AspireExportIgnore(Reason = "NetworkIdentifier is not ATS-compatible.")] - public static ApplicationModel.EndpointReference GetEndpoint(this ApplicationModel.IResourceBuilder builder, string name, ApplicationModel.NetworkIdentifier contextNetworkID) + public static ApplicationModel.EndpointReference GetEndpoint(this ApplicationModel.IResourceBuilder builder, string name, ApplicationModel.NetworkIdentifier contextNetworkId) where T : ApplicationModel.IResourceWithEndpoints { throw null; } - [AspireExport(Description = "Gets an endpoint reference")] + [AspireExport] public static ApplicationModel.EndpointReference GetEndpoint(this ApplicationModel.IResourceBuilder builder, string name) where T : ApplicationModel.IResourceWithEndpoints { throw null; } - [AspireExport("publishWithContainerFilesFromResource", MethodName = "publishWithContainerFiles", Description = "Configures the resource to copy container files from the specified source during publishing")] + [AspireExport("publishWithContainerFilesFromResource", MethodName = "publishWithContainerFiles")] public static ApplicationModel.IResourceBuilder PublishWithContainerFiles(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder source, string destinationPath) where T : ApplicationModel.IContainerFilesDestinationResource { throw null; } @@ -1084,7 +1097,7 @@ public static ApplicationModel.IResourceBuilder WaitFor(this ApplicationMo public static ApplicationModel.IResourceBuilder WaitFor(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder dependency) where T : ApplicationModel.IResourceWithWaitSupport { throw null; } - [AspireExport("waitForResourceCompletion", MethodName = "waitForCompletion", Description = "Waits for resource completion")] + [AspireExport("waitForResourceCompletion", MethodName = "waitForCompletion")] public static ApplicationModel.IResourceBuilder WaitForCompletion(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder dependency, int exitCode = 0) where T : ApplicationModel.IResourceWithWaitSupport { throw null; } @@ -1096,7 +1109,7 @@ public static ApplicationModel.IResourceBuilder WaitForStart(this Applicat public static ApplicationModel.IResourceBuilder WaitForStart(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder dependency) where T : ApplicationModel.IResourceWithWaitSupport { throw null; } - [AspireExport("withArgsCallback", Description = "Sets command-line arguments via callback")] + [AspireExport("withArgsCallback")] public static ApplicationModel.IResourceBuilder WithArgs(this ApplicationModel.IResourceBuilder builder, System.Action callback) where T : ApplicationModel.IResourceWithArgs { throw null; } @@ -1108,7 +1121,7 @@ public static ApplicationModel.IResourceBuilder WithArgs(this ApplicationM public static ApplicationModel.IResourceBuilder WithArgs(this ApplicationModel.IResourceBuilder builder, params object[] args) where T : ApplicationModel.IResourceWithArgs { throw null; } - [AspireExport(Description = "Adds arguments")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithArgs(this ApplicationModel.IResourceBuilder builder, params string[] args) where T : ApplicationModel.IResourceWithArgs { throw null; } @@ -1120,7 +1133,7 @@ public static ApplicationModel.IResourceBuilder WithCertificateAuthor public static ApplicationModel.IResourceBuilder WithCertificateTrustConfiguration(this ApplicationModel.IResourceBuilder builder, System.Func callback) where TResource : ApplicationModel.IResourceWithArgs, ApplicationModel.IResourceWithEnvironment { throw null; } - [AspireExport(Description = "Sets the certificate trust scope")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithCertificateTrustScope(this ApplicationModel.IResourceBuilder builder, ApplicationModel.CertificateTrustScope scope) where TResource : ApplicationModel.IResourceWithEnvironment, ApplicationModel.IResourceWithArgs { throw null; } @@ -1128,11 +1141,11 @@ public static ApplicationModel.IResourceBuilder WithCertificateTrustS public static ApplicationModel.IResourceBuilder WithChildRelationship(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResource child) where T : ApplicationModel.IResource { throw null; } - [AspireExport("withBuilderChildRelationship", MethodName = "withChildRelationship", Description = "Sets a child relationship")] + [AspireExport("withBuilderChildRelationship", MethodName = "withChildRelationship")] public static ApplicationModel.IResourceBuilder WithChildRelationship(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder child) where T : ApplicationModel.IResource { throw null; } - [AspireExport(Description = "Adds a resource command")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithCommand(this ApplicationModel.IResourceBuilder builder, string name, string displayName, System.Func> executeCommand, ApplicationModel.CommandOptions? commandOptions = null) where T : ApplicationModel.IResource { throw null; } @@ -1140,7 +1153,7 @@ public static ApplicationModel.IResourceBuilder WithCommand(this Applicati public static ApplicationModel.IResourceBuilder WithCommand(this ApplicationModel.IResourceBuilder builder, string name, string displayName, System.Func> executeCommand, System.Func? updateState = null, string? displayDescription = null, object? parameter = null, string? confirmationMessage = null, string? iconName = null, ApplicationModel.IconVariant? iconVariant = null, bool isHighlighted = false) where T : ApplicationModel.IResource { throw null; } - [AspireExportIgnore(Reason = "IComputeEnvironmentResource is a specialized interface — not ATS-compatible.")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithComputeEnvironment(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder computeEnvironmentResource) where T : ApplicationModel.IComputeResource { throw null; } @@ -1156,7 +1169,7 @@ public static ApplicationModel.IResourceBuilder WithConnectionProperty(thi public static ApplicationModel.IResourceBuilder WithConnectionStringRedirection(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceWithConnectionString resource) where T : ApplicationModel.IResourceWithConnectionString { throw null; } - [AspireExport(Description = "Sets the source directory for container files")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithContainerFilesSource(this ApplicationModel.IResourceBuilder builder, string sourcePath) where T : IResourceWithContainerFiles { throw null; } @@ -1165,27 +1178,38 @@ public static ApplicationModel.IResourceBuilder WithContainerFilesSource(t public static ApplicationModel.IResourceBuilder WithDebugSupport(this ApplicationModel.IResourceBuilder builder, System.Func launchConfigurationProducer, string launchConfigurationType, System.Action? argsCallback = null) where T : ApplicationModel.IResource { throw null; } - [AspireExport(Description = "Configures developer certificate trust")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithDeveloperCertificateTrust(this ApplicationModel.IResourceBuilder builder, bool trust) where TResource : ApplicationModel.IResourceWithEnvironment, ApplicationModel.IResourceWithArgs { throw null; } - [AspireExport(Description = "Adds a network endpoint")] - public static ApplicationModel.IResourceBuilder WithEndpoint(this ApplicationModel.IResourceBuilder builder, int? port = null, int? targetPort = null, string? scheme = null, string? name = null, string? env = null, bool isProxied = true, bool? isExternal = null, System.Net.Sockets.ProtocolType? protocol = null) + [AspireExportIgnore(Reason = "Binary compatibility shim for the nullable isProxied overload.")] + public static ApplicationModel.IResourceBuilder WithEndpoint(this ApplicationModel.IResourceBuilder builder, int? port, int? targetPort, string? scheme, string? name, string? env, bool isProxied, bool? isExternal, System.Net.Sockets.ProtocolType? protocol) where T : ApplicationModel.IResourceWithEndpoints { throw null; } - [AspireExportIgnore(Reason = "Subset of the full WithEndpoint overload which is already exported.")] + [AspireExportIgnore(Reason = "Binary compatibility shim for the nullable isProxied overload.")] public static ApplicationModel.IResourceBuilder WithEndpoint(this ApplicationModel.IResourceBuilder builder, int? port, int? targetPort, string? scheme, string? name, string? env, bool isProxied, bool? isExternal) where T : ApplicationModel.IResourceWithEndpoints { throw null; } + [AspireExport] + public static ApplicationModel.IResourceBuilder WithEndpoint(this ApplicationModel.IResourceBuilder builder, int? port = null, int? targetPort = null, string? scheme = null, string? name = null, string? env = null, bool? isProxied = null, bool? isExternal = null, System.Net.Sockets.ProtocolType? protocol = null) + where T : ApplicationModel.IResourceWithEndpoints { throw null; } + + [AspireExportIgnore(Reason = "Subset of the full WithEndpoint overload which is already exported.")] + public static ApplicationModel.IResourceBuilder WithEndpoint(this ApplicationModel.IResourceBuilder builder, int? port, int? targetPort, string? scheme, string? name, string? env, bool? isProxied, bool? isExternal) + where T : ApplicationModel.IResourceWithEndpoints { throw null; } + [AspireExportIgnore(Reason = "Polyglot app hosts use the internal withEndpointCallback export, which exposes EndpointUpdateContext instead of EndpointAnnotation.")] public static ApplicationModel.IResourceBuilder WithEndpoint(this ApplicationModel.IResourceBuilder builder, string endpointName, System.Action callback, bool createIfNotExists = true) where T : ApplicationModel.IResourceWithEndpoints { throw null; } + [AspireExport] + public static ApplicationModel.IResourceBuilder WithEndpointProxySupport(this ApplicationModel.IResourceBuilder builder, bool proxyEnabled) { throw null; } + [AspireExportIgnore(Reason = "Polyglot app hosts use the async callback overload.")] public static ApplicationModel.IResourceBuilder WithEnvironment(this ApplicationModel.IResourceBuilder builder, System.Action callback) where T : ApplicationModel.IResourceWithEnvironment { throw null; } - [AspireExport("withEnvironmentCallback", Description = "Sets environment variables via callback")] + [AspireExport("withEnvironmentCallback")] public static ApplicationModel.IResourceBuilder WithEnvironment(this ApplicationModel.IResourceBuilder builder, System.Func callback) where T : ApplicationModel.IResourceWithEnvironment { throw null; } @@ -1205,7 +1229,7 @@ public static ApplicationModel.IResourceBuilder WithEnvironment(this Appli public static ApplicationModel.IResourceBuilder WithEnvironment(this ApplicationModel.IResourceBuilder builder, string name, ApplicationModel.IResourceBuilder parameter) where T : ApplicationModel.IResourceWithEnvironment { throw null; } - [AspireExportIgnore(Reason = "Specialized overload — withReference covers this use case.")] + [AspireExportIgnore(Reason = "Polyglot app hosts use the internal withEnvironment dispatcher export.")] public static ApplicationModel.IResourceBuilder WithEnvironment(this ApplicationModel.IResourceBuilder builder, string name, ApplicationModel.IResourceBuilder externalService) where T : ApplicationModel.IResourceWithEnvironment { throw null; } @@ -1229,18 +1253,30 @@ public static ApplicationModel.IResourceBuilder WithEnvironment(this Appli public static ApplicationModel.IResourceBuilder WithEnvironment(this ApplicationModel.IResourceBuilder builder, string name, TValue value) where T : ApplicationModel.IResourceWithEnvironment where TValue : ApplicationModel.IValueProvider, ApplicationModel.IManifestExpressionProvider { throw null; } - [AspireExport(Description = "Prevents resource from starting automatically")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithExplicitStart(this ApplicationModel.IResourceBuilder builder) where T : ApplicationModel.IResource { throw null; } - [AspireExport(Description = "Makes HTTP endpoints externally accessible")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithExternalHttpEndpoints(this ApplicationModel.IResourceBuilder builder) where T : ApplicationModel.IResourceWithEndpoints { throw null; } - [AspireExport(Description = "Adds a health check by key")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithHealthCheck(this ApplicationModel.IResourceBuilder builder, string key) where T : ApplicationModel.IResource { throw null; } + [AspireExport] + public static ApplicationModel.IResourceBuilder WithHidden(this ApplicationModel.IResourceBuilder builder) + where T : ApplicationModel.IResource { throw null; } + + [AspireExportIgnore(Reason = "Use ATS-friendly overload that supports a single exit code or multiple exit codes.")] + public static ApplicationModel.IResourceBuilder WithHiddenOnCompletion(this ApplicationModel.IResourceBuilder builder, int exitCode) + where T : ApplicationModel.IResource { throw null; } + + [AspireExportIgnore(Reason = "Uses params array overload; use ATS-friendly overload for polyglot SDKs.")] + public static ApplicationModel.IResourceBuilder WithHiddenOnCompletion(this ApplicationModel.IResourceBuilder builder, params int[] exitCodes) + where T : ApplicationModel.IResource { throw null; } + [AspireExportIgnore(Reason = "Use the ATS-specific withHttpCommand export.")] public static ApplicationModel.IResourceBuilder WithHttpCommand(this ApplicationModel.IResourceBuilder builder, string path, string displayName, System.Func? endpointSelector, string? commandName = null, ApplicationModel.HttpCommandOptions? commandOptions = null) where TResource : ApplicationModel.IResourceWithEndpoints { throw null; } @@ -1249,15 +1285,19 @@ public static ApplicationModel.IResourceBuilder WithHttpCommand WithHttpCommand(this ApplicationModel.IResourceBuilder builder, string path, string displayName, string? endpointName = null, string? commandName = null, ApplicationModel.HttpCommandOptions? commandOptions = null) where TResource : ApplicationModel.IResourceWithEndpoints { throw null; } - [AspireExport(Description = "Adds an HTTP endpoint")] - public static ApplicationModel.IResourceBuilder WithHttpEndpoint(this ApplicationModel.IResourceBuilder builder, int? port = null, int? targetPort = null, string? name = null, string? env = null, bool isProxied = true) + [AspireExportIgnore(Reason = "Binary compatibility shim for the nullable isProxied overload.")] + public static ApplicationModel.IResourceBuilder WithHttpEndpoint(this ApplicationModel.IResourceBuilder builder, int? port, int? targetPort, string? name, string? env, bool isProxied) + where T : ApplicationModel.IResourceWithEndpoints { throw null; } + + [AspireExport] + public static ApplicationModel.IResourceBuilder WithHttpEndpoint(this ApplicationModel.IResourceBuilder builder, int? port = null, int? targetPort = null, string? name = null, string? env = null, bool? isProxied = null) where T : ApplicationModel.IResourceWithEndpoints { throw null; } [AspireExportIgnore(Reason = "Func delegate — not ATS-compatible.")] public static ApplicationModel.IResourceBuilder WithHttpHealthCheck(this ApplicationModel.IResourceBuilder builder, System.Func? endpointSelector, string? path = null, int? statusCode = null) where T : ApplicationModel.IResourceWithEndpoints { throw null; } - [AspireExport(Description = "Adds an HTTP health check")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithHttpHealthCheck(this ApplicationModel.IResourceBuilder builder, string? path = null, int? statusCode = null, string? endpointName = null) where T : ApplicationModel.IResourceWithEndpoints { throw null; } @@ -1282,19 +1322,23 @@ public static ApplicationModel.IResourceBuilder WithHttpsCertificateC where TResource : ApplicationModel.IResourceWithEnvironment, ApplicationModel.IResourceWithArgs { throw null; } [System.Diagnostics.CodeAnalysis.Experimental("ASPIRECERTIFICATES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] - [AspireExport("withParameterHttpsDeveloperCertificate", MethodName = "withHttpsDeveloperCertificate", Description = "Configures HTTPS with a developer certificate")] + [AspireExport("withParameterHttpsDeveloperCertificate", MethodName = "withHttpsDeveloperCertificate")] public static ApplicationModel.IResourceBuilder WithHttpsDeveloperCertificate(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder? password = null) where TResource : ApplicationModel.IResourceWithEnvironment, ApplicationModel.IResourceWithArgs { throw null; } - [AspireExport(Description = "Adds an HTTPS endpoint")] - public static ApplicationModel.IResourceBuilder WithHttpsEndpoint(this ApplicationModel.IResourceBuilder builder, int? port = null, int? targetPort = null, string? name = null, string? env = null, bool isProxied = true) + [AspireExportIgnore(Reason = "Binary compatibility shim for the nullable isProxied overload.")] + public static ApplicationModel.IResourceBuilder WithHttpsEndpoint(this ApplicationModel.IResourceBuilder builder, int? port, int? targetPort, string? name, string? env, bool isProxied) + where T : ApplicationModel.IResourceWithEndpoints { throw null; } + + [AspireExport] + public static ApplicationModel.IResourceBuilder WithHttpsEndpoint(this ApplicationModel.IResourceBuilder builder, int? port = null, int? targetPort = null, string? name = null, string? env = null, bool? isProxied = null) where T : ApplicationModel.IResourceWithEndpoints { throw null; } [System.Obsolete("This method is obsolete and will be removed in a future version. Use the WithHttpHealthCheck method instead.")] public static ApplicationModel.IResourceBuilder WithHttpsHealthCheck(this ApplicationModel.IResourceBuilder builder, string? path = null, int? statusCode = null, string? endpointName = null) where T : ApplicationModel.IResourceWithEndpoints { throw null; } - [AspireExport(Description = "Sets the icon for the resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithIconName(this ApplicationModel.IResourceBuilder builder, string iconName, ApplicationModel.IconVariant iconVariant = ApplicationModel.IconVariant.Filled) where T : ApplicationModel.IResource { throw null; } @@ -1304,10 +1348,15 @@ public static ApplicationModel.IResourceBuilder WithImagePushOptions(this where T : ApplicationModel.IComputeResource { throw null; } [System.Diagnostics.CodeAnalysis.Experimental("ASPIREPIPELINES003", UrlFormat = "https://aka.ms/aspire/diagnostics#{0}")] - [AspireExport(Description = "Sets image push options via callback")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithImagePushOptions(this ApplicationModel.IResourceBuilder builder, System.Func callback) where T : ApplicationModel.IComputeResource { throw null; } + [System.Diagnostics.CodeAnalysis.Experimental("ASPIREPERSISTENCE001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + [AspireExport] + public static ApplicationModel.IResourceBuilder WithLifetimeOf(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder sourceBuilder) + where T : ApplicationModel.IResource where TSource : ApplicationModel.IResource { throw null; } + [AspireExportIgnore(Reason = "ManifestPublishingContext exposes Utf8JsonWriter and DistributedApplicationExecutionContext — .NET runtime types not usable from polyglot hosts.")] public static ApplicationModel.IResourceBuilder WithManifestPublishingCallback(this ApplicationModel.IResourceBuilder builder, System.Action callback) where T : ApplicationModel.IResource { throw null; } @@ -1317,18 +1366,43 @@ public static ApplicationModel.IResourceBuilder WithManifestPublishingCallbac where T : ApplicationModel.IResource { throw null; } [System.Diagnostics.CodeAnalysis.Experimental("ASPIRECERTIFICATES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] - [AspireExport(Description = "Removes HTTPS certificate configuration")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithoutHttpsCertificate(this ApplicationModel.IResourceBuilder builder) where TResource : ApplicationModel.IResourceWithEnvironment, ApplicationModel.IResourceWithArgs { throw null; } + [System.Diagnostics.CodeAnalysis.Experimental("ASPIREPERSISTENCE001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + [AspireExport] + public static ApplicationModel.IResourceBuilder WithParentProcessLifetime(this ApplicationModel.IResourceBuilder builder, int parentProcessId) + where T : ApplicationModel.IResource { throw null; } + [AspireExportIgnore(Reason = "Raw IResource interface — not ATS-compatible.")] public static ApplicationModel.IResourceBuilder WithParentRelationship(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResource parent) where T : ApplicationModel.IResource { throw null; } - [AspireExport("withBuilderParentRelationship", MethodName = "withParentRelationship", Description = "Sets the parent relationship")] + [AspireExport("withBuilderParentRelationship", MethodName = "withParentRelationship")] public static ApplicationModel.IResourceBuilder WithParentRelationship(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder parent) where T : ApplicationModel.IResource { throw null; } + [System.Diagnostics.CodeAnalysis.Experimental("ASPIREPERSISTENCE001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + [AspireExport] + public static ApplicationModel.IResourceBuilder WithPersistentLifetime(this ApplicationModel.IResourceBuilder builder) + where T : ApplicationModel.IResource { throw null; } + + [System.Diagnostics.CodeAnalysis.Experimental("ASPIREPROCESSCOMMAND001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + [AspireExportIgnore(Reason = "Process command factories are C# callbacks and cannot be represented in polyglot app hosts.")] + public static ApplicationModel.IResourceBuilder WithProcessCommand(this ApplicationModel.IResourceBuilder builder, string commandName, string displayName, System.Func processSpecFactory, ApplicationModel.ProcessCommandOptions? commandOptions = null) + where TResource : ApplicationModel.IResource { throw null; } + + [System.Diagnostics.CodeAnalysis.Experimental("ASPIREPROCESSCOMMAND001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + [AspireExportIgnore(Reason = "Process command factories are C# callbacks and cannot be represented in polyglot app hosts.")] + public static ApplicationModel.IResourceBuilder WithProcessCommand(this ApplicationModel.IResourceBuilder builder, string commandName, string displayName, System.Func> processSpecFactory, ApplicationModel.ProcessCommandOptions? commandOptions = null) + where TResource : ApplicationModel.IResource { throw null; } + + [System.Diagnostics.CodeAnalysis.Experimental("ASPIREPROCESSCOMMAND001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + [AspireExportIgnore(Reason = "Process commands start local processes from AppHost callbacks and cannot be represented in polyglot app hosts.")] + public static ApplicationModel.IResourceBuilder WithProcessCommand(this ApplicationModel.IResourceBuilder builder, string commandName, string displayName, string executablePath, System.Collections.Generic.IReadOnlyList? arguments = null, ApplicationModel.ProcessCommandOptions? commandOptions = null) + where TResource : ApplicationModel.IResource { throw null; } + [AspireExportIgnore(Reason = "Polyglot app hosts use the generic withReference dispatcher export.")] public static ApplicationModel.IResourceBuilder WithReference(this ApplicationModel.IResourceBuilder builder, ApplicationModel.EndpointReference endpointReference) where TDestination : ApplicationModel.IResourceWithEnvironment { throw null; } @@ -1373,20 +1447,25 @@ public static ApplicationModel.IResourceBuilder WithReferenceRelationship( public static ApplicationModel.IResourceBuilder WithRelationship(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResource resource, string type) where T : ApplicationModel.IResource { throw null; } - [AspireExport("withBuilderRelationship", MethodName = "withRelationship", Description = "Adds a relationship to another resource")] + [AspireExport("withBuilderRelationship", MethodName = "withRelationship")] public static ApplicationModel.IResourceBuilder WithRelationship(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder resourceBuilder, string type) where T : ApplicationModel.IResource { throw null; } [System.Diagnostics.CodeAnalysis.Experimental("ASPIREPIPELINES003", UrlFormat = "https://aka.ms/aspire/diagnostics#{0}")] - [AspireExport(Description = "Sets the remote image name for publishing")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithRemoteImageName(this ApplicationModel.IResourceBuilder builder, string remoteImageName) where T : ApplicationModel.IComputeResource { throw null; } [System.Diagnostics.CodeAnalysis.Experimental("ASPIREPIPELINES003", UrlFormat = "https://aka.ms/aspire/diagnostics#{0}")] - [AspireExport(Description = "Sets the remote image tag for publishing")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithRemoteImageTag(this ApplicationModel.IResourceBuilder builder, string remoteImageTag) where T : ApplicationModel.IComputeResource { throw null; } + [System.Diagnostics.CodeAnalysis.Experimental("ASPIREPERSISTENCE001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + [AspireExport] + public static ApplicationModel.IResourceBuilder WithSessionLifetime(this ApplicationModel.IResourceBuilder builder) + where T : ApplicationModel.IResource { throw null; } + [AspireExportIgnore(Reason = "Polyglot app hosts use the internal withUrl dispatcher export.")] public static ApplicationModel.IResourceBuilder WithUrl(this ApplicationModel.IResourceBuilder builder, ApplicationModel.ReferenceExpression url, string? displayText = null) where T : ApplicationModel.IResource { throw null; } @@ -1399,7 +1478,7 @@ public static ApplicationModel.IResourceBuilder WithUrl(this ApplicationMo public static ApplicationModel.IResourceBuilder WithUrl(this ApplicationModel.IResourceBuilder builder, string url, string? displayText = null) where T : ApplicationModel.IResource { throw null; } - [AspireExport(Description = "Customizes the URL for a specific endpoint via callback")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithUrlForEndpoint(this ApplicationModel.IResourceBuilder builder, string endpointName, System.Action callback) where T : ApplicationModel.IResource { throw null; } @@ -1407,7 +1486,7 @@ public static ApplicationModel.IResourceBuilder WithUrlForEndpoint(this Ap public static ApplicationModel.IResourceBuilder WithUrlForEndpoint(this ApplicationModel.IResourceBuilder builder, string endpointName, System.Func callback) where T : ApplicationModel.IResourceWithEndpoints { throw null; } - [AspireExport(Description = "Customizes displayed URLs via callback")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithUrls(this ApplicationModel.IResourceBuilder builder, System.Action callback) where T : ApplicationModel.IResource { throw null; } @@ -1464,7 +1543,7 @@ public AfterResourcesCreatedEvent(System.IServiceProvider services, DistributedA [System.Diagnostics.DebuggerDisplay("Type = {GetType().Name,nq}, Name = {Endpoint.Name}, UriString = {UriString}")] public partial class AllocatedEndpoint { - public AllocatedEndpoint(EndpointAnnotation endpoint, string address, int port, EndpointBindingMode bindingMode, string? targetPortExpression = null, NetworkIdentifier? networkID = null) { } + public AllocatedEndpoint(EndpointAnnotation endpoint, string address, int port, EndpointBindingMode bindingMode, string? targetPortExpression = null, NetworkIdentifier? networkId = null) { } public AllocatedEndpoint(EndpointAnnotation endpoint, string address, int port, EndpointBindingMode bindingMode, string? targetPortExpression = null) { } @@ -1493,7 +1572,7 @@ public AllocatedEndpoint(EndpointAnnotation endpoint, string address, int port, public static partial class AspireStoreExtensions { - [AspireExport(Description = "Gets a deterministic file path for the specified file contents")] + [AspireExport] public static string GetFileNameWithContent(this IAspireStore aspireStore, string filenameTemplate, string sourceFilename) { throw null; } } @@ -1638,13 +1717,16 @@ public CommandLineArgsCallbackContext(System.Collections.Generic.IList a public Microsoft.Extensions.Logging.ILogger Logger { get { throw null; } init { } } - [AspireExport(Description = "Gets the resource associated with this callback")] + [AspireExport] public IResource Resource { get { throw null; } } } [AspireDto] public partial class CommandOptions { + [System.Diagnostics.CodeAnalysis.Experimental("ASPIREINTERACTION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + public System.Collections.Generic.IReadOnlyList Arguments { get { throw null; } set { } } + public string? ConfirmationMessage { get { throw null; } set { } } public string? Description { get { throw null; } set { } } @@ -1655,9 +1737,15 @@ public partial class CommandOptions public bool IsHighlighted { get { throw null; } set { } } + [System.Obsolete("Use Arguments to describe invocation arguments and ExecuteCommandContext.Arguments to read them.")] public object? Parameter { get { throw null; } set { } } public System.Func? UpdateState { get { throw null; } set { } } + + [System.Diagnostics.CodeAnalysis.Experimental("ASPIREINTERACTION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + public System.Func? ValidateArguments { get { throw null; } set { } } + + public ResourceCommandVisibility Visibility { get { throw null; } set { } } } [AspireDto] @@ -1693,7 +1781,11 @@ public static partial class CommandResults public static ExecuteCommandResult Success(string message, CommandResultData value) { throw null; } + public static ExecuteCommandResult Success(string message, string result, CommandResultFormat resultFormat, bool displayImmediately) { throw null; } + public static ExecuteCommandResult Success(string message, string result, CommandResultFormat resultFormat = CommandResultFormat.Text) { throw null; } + + public static ExecuteCommandResult Success(string message) { throw null; } } public sealed partial class ConnectionPropertyAnnotation : IResourceAnnotation @@ -1752,13 +1844,13 @@ public ContainerBuildOptionsCallbackAnnotation(System.Func BuildArguments { get { throw null; } } + public string? BuildContextIgnoreContent { get { throw null; } set { } } + public System.Collections.Generic.Dictionary BuildSecrets { get { throw null; } } public string ContextPath { get { throw null; } } @@ -2191,6 +2285,8 @@ public DockerfileBuildAnnotation(string contextPath, string dockerfilePath, stri public string? Stage { get { throw null; } } + public System.Threading.Tasks.Task EmitDockerfileArtifactsAsync(DockerfileFactoryContext context, string? dockerfilePath = null) { throw null; } + public System.Threading.Tasks.Task MaterializeDockerfileAsync(DockerfileFactoryContext context, System.Threading.CancellationToken cancellationToken) { throw null; } } @@ -2222,10 +2318,12 @@ public DockerfileBuilderCallbackContext(IResource resource, Docker.DockerfileBui public System.IServiceProvider Services { get { throw null; } } } + [AspireExport] public sealed partial class DockerfileFactoryContext { public System.Threading.CancellationToken CancellationToken { get { throw null; } init { } } + [AspireExport] public required IResource Resource { get { throw null; } init { } } public required System.IServiceProvider Services { get { throw null; } init { } } @@ -2261,9 +2359,13 @@ public sealed partial class EmulatorResourceAnnotation : IResourceAnnotation [System.Diagnostics.DebuggerDisplay("Type = {GetType().Name,nq}, Name = {Name}")] public sealed partial class EndpointAnnotation : IResourceAnnotation { - public EndpointAnnotation(System.Net.Sockets.ProtocolType protocol, NetworkIdentifier? networkID, string? uriScheme = null, string? transport = null, string? name = null, int? port = null, int? targetPort = null, bool? isExternal = null, bool isProxied = true) { } + public EndpointAnnotation(System.Net.Sockets.ProtocolType protocol, NetworkIdentifier? networkId, string? uriScheme, string? transport, string? name, int? port, int? targetPort, bool? isExternal, bool isProxied) { } - public EndpointAnnotation(System.Net.Sockets.ProtocolType protocol, string? uriScheme = null, string? transport = null, string? name = null, int? port = null, int? targetPort = null, bool? isExternal = null, bool isProxied = true) { } + public EndpointAnnotation(System.Net.Sockets.ProtocolType protocol, NetworkIdentifier? networkId, string? uriScheme = null, string? transport = null, string? name = null, int? port = null, int? targetPort = null, bool? isExternal = null, bool? isProxied = null) { } + + public EndpointAnnotation(System.Net.Sockets.ProtocolType protocol, string? uriScheme, string? transport, string? name, int? port, int? targetPort, bool? isExternal, bool isProxied) { } + + public EndpointAnnotation(System.Net.Sockets.ProtocolType protocol, string? uriScheme = null, string? transport = null, string? name = null, int? port = null, int? targetPort = null, bool? isExternal = null, bool? isProxied = null) { } public NetworkEndpointSnapshotList AllAllocatedEndpoints { get { throw null; } } @@ -2276,6 +2378,8 @@ public EndpointAnnotation(System.Net.Sockets.ProtocolType protocol, string? uriS public bool ExcludeReferenceEndpoint { get { throw null; } set { } } + public bool? IsExplicitlyProxied { get { throw null; } set { } } + public bool IsExternal { get { throw null; } set { } } public bool IsProxied { get { throw null; } set { } } @@ -2353,11 +2457,11 @@ public enum EndpointProperty [System.Diagnostics.DebuggerDisplay("Resource = {Resource.Name}, EndpointName = {EndpointName}, IsAllocated = {IsAllocated}")] public sealed partial class EndpointReference : IExpressionValue, IValueProvider, IManifestExpressionProvider, IValueWithReferences { - public EndpointReference(IResourceWithEndpoints owner, EndpointAnnotation endpoint, NetworkIdentifier? contextNetworkID) { } + public EndpointReference(IResourceWithEndpoints owner, EndpointAnnotation endpoint, NetworkIdentifier? contextNetworkId) { } public EndpointReference(IResourceWithEndpoints owner, EndpointAnnotation endpoint) { } - public EndpointReference(IResourceWithEndpoints owner, string endpointName, NetworkIdentifier? contextNetworkID = null) { } + public EndpointReference(IResourceWithEndpoints owner, string endpointName, NetworkIdentifier? contextNetworkId = null) { } public EndpointReference(IResourceWithEndpoints owner, string endpointName) { } @@ -2399,19 +2503,33 @@ public EndpointReference(IResourceWithEndpoints owner, string endpointName) { } public string Url { get { throw null; } } - [AspireExport(Description = "Gets a conditional expression that resolves to the enabledValue when TLS is enabled on the endpoint, or to the disabledValue otherwise.")] + [AspireExport] public ReferenceExpression GetTlsValue(ReferenceExpression enabledValue, ReferenceExpression disabledValue) { throw null; } [AspireExportIgnore] public System.Threading.Tasks.ValueTask GetValueAsync(ValueProviderContext context, System.Threading.CancellationToken cancellationToken = default) { throw null; } - [AspireExport(Description = "Gets the URL of the endpoint asynchronously")] + [AspireExport] public System.Threading.Tasks.ValueTask GetValueAsync(System.Threading.CancellationToken cancellationToken = default) { throw null; } - [AspireExport(Description = "Gets the specified property expression of the endpoint")] + [AspireExport] public EndpointReferenceExpression Property(EndpointProperty property) { throw null; } } + [System.Diagnostics.DebuggerDisplay("Type = {GetType().Name,nq}, Resource = {Resource.Name}, EndpointNames = {UseAllEndpoints ? \"(All)\" : string.Join(\", \", EndpointNames)}")] + public sealed partial class EndpointReferenceAnnotation : IResourceAnnotation + { + public EndpointReferenceAnnotation(IResourceWithEndpoints resource) { } + + public NetworkIdentifier ContextNetworkId { get { throw null; } set { } } + + public System.Collections.Generic.ISet EndpointNames { get { throw null; } } + + public IResourceWithEndpoints Resource { get { throw null; } } + + public bool UseAllEndpoints { get { throw null; } set { } } + } + [AspireExport(ExposeProperties = true)] [System.Diagnostics.DebuggerDisplay("EndpointExpression = {ValueExpression}, Property = {Property}, Endpoint = {Endpoint.EndpointName}")] public partial class EndpointReferenceExpression : IExpressionValue, IValueProvider, IManifestExpressionProvider, IValueWithReferences @@ -2457,12 +2575,12 @@ public EnvironmentCallbackContext(DistributedApplicationExecutionContext executi [AspireUnion(new[] { typeof(string), typeof(ReferenceExpression) })] public System.Collections.Generic.Dictionary EnvironmentVariables { get { throw null; } } - [AspireExport(Description = "Gets the execution context for this callback invocation")] + [AspireExport] public DistributedApplicationExecutionContext ExecutionContext { get { throw null; } } public Microsoft.Extensions.Logging.ILogger Logger { get { throw null; } set { } } - [AspireExport(Description = "Gets the resource associated with this callback")] + [AspireExport] public IResource Resource { get { throw null; } } } @@ -2497,12 +2615,15 @@ public ExecutableResource(string name, string command, string workingDirectory) [AspireExport(ExposeProperties = true)] public sealed partial class ExecuteCommandContext { + public required InteractionInputCollection Arguments { get { throw null; } init { } } + public required System.Threading.CancellationToken CancellationToken { get { throw null; } init { } } public required Microsoft.Extensions.Logging.ILogger Logger { get { throw null; } init { } } public required string ResourceName { get { throw null; } init { } } + [AspireExportIgnore(Reason = "IServiceProvider is not usable from polyglot command callbacks.")] public required System.IServiceProvider ServiceProvider { get { throw null; } init { } } } @@ -2534,14 +2655,14 @@ internal ExecutionConfigurationBuilder() { } public static partial class ExecutionConfigurationBuilderExtensions { - [AspireExport(Description = "Adds an arguments configuration gatherer")] + [AspireExport] public static IExecutionConfigurationBuilder WithArgumentsConfig(this IExecutionConfigurationBuilder builder) { throw null; } [System.Diagnostics.CodeAnalysis.Experimental("ASPIRECERTIFICATES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] - [AspireExport(Description = "Adds a certificate trust configuration gatherer")] + [AspireExport] public static IExecutionConfigurationBuilder WithCertificateTrustConfig(this IExecutionConfigurationBuilder builder, System.Func configContextFactory) { throw null; } - [AspireExport(Description = "Adds an environment variables configuration gatherer")] + [AspireExport] public static IExecutionConfigurationBuilder WithEnvironmentVariablesConfig(this IExecutionConfigurationBuilder builder) { throw null; } [System.Diagnostics.CodeAnalysis.Experimental("ASPIRECERTIFICATES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] @@ -2928,7 +3049,6 @@ public partial interface IResourceWithConnectionString : IResource, IExpressionV System.Threading.Tasks.ValueTask GetConnectionStringAsync(System.Threading.CancellationToken cancellationToken = default); } - [System.Diagnostics.CodeAnalysis.Experimental("ASPIREATS001")] public partial interface IResourceWithCustomWithReference : IResource where TSelf : IResource, IResourceWithCustomWithReference { IResourceBuilder? TryWithReference(IResourceBuilder builder, IResourceBuilder source, string? connectionName = null, bool optional = false, string? name = null) @@ -3044,6 +3164,12 @@ public LaunchProfileAnnotation(string launchProfileName) { } public string LaunchProfileName { get { throw null; } } } + public enum Lifetime + { + Session = 0, + Persistent = 1 + } + public readonly partial struct LogLine : System.IEquatable { private readonly object _dummy; @@ -3153,16 +3279,16 @@ public partial record NetworkEndpointSnapshot(ValueSnapshot S public partial class NetworkEndpointSnapshotList : System.Collections.Generic.IEnumerable, System.Collections.IEnumerable { - public void AddOrUpdateAllocatedEndpoint(NetworkIdentifier networkID, AllocatedEndpoint endpoint) { } + public void AddOrUpdateAllocatedEndpoint(NetworkIdentifier networkId, AllocatedEndpoint endpoint) { } - public System.Threading.Tasks.Task GetAllocatedEndpointAsync(NetworkIdentifier networkID, System.Threading.CancellationToken cancellationToken = default) { throw null; } + public System.Threading.Tasks.Task GetAllocatedEndpointAsync(NetworkIdentifier networkId, System.Threading.CancellationToken cancellationToken = default) { throw null; } public System.Collections.Generic.IEnumerator GetEnumerator() { throw null; } System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() { throw null; } [System.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) { throw null; } + public bool TryAdd(NetworkIdentifier networkId, ValueSnapshot snapshot) { throw null; } } public partial record NetworkIdentifier(string Value) @@ -3205,6 +3331,28 @@ public ParameterResource(string name, System.Func cal public System.Threading.Tasks.ValueTask GetValueAsync(System.Threading.CancellationToken cancellationToken) { throw null; } } + [System.Diagnostics.CodeAnalysis.Experimental("ASPIREPERSISTENCE001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + [System.Diagnostics.DebuggerDisplay("Type = {GetType().Name,nq}, Mode = {Mode}")] + public sealed partial class PersistenceAnnotation : IResourceAnnotation + { + public required PersistenceMode Mode { get { throw null; } set { } } + + public int? ParentProcessId { get { throw null; } set { } } + + public System.DateTime? ParentProcessTimestamp { get { throw null; } set { } } + + public IResource? SourceResource { get { throw null; } set { } } + } + + [System.Diagnostics.CodeAnalysis.Experimental("ASPIREPERSISTENCE001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + public enum PersistenceMode + { + Session = 0, + Persistent = 1, + Resource = 2, + ParentProcess = 3 + } + public sealed partial class PortAllocator : IPortAllocator { public PortAllocator(int startPort = 8000) { } @@ -3238,6 +3386,60 @@ public enum ProbeType Liveness = 2 } + [System.Diagnostics.CodeAnalysis.Experimental("ASPIREPROCESSCOMMAND001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + public partial class ProcessCommandOptions : CommandOptions + { + public bool DisplayImmediately { get { throw null; } set { } } + + public System.Func>? GetCommandResult { get { throw null; } set { } } + + public int MaxOutputLineCount { get { throw null; } set { } } + + public System.Collections.Generic.IReadOnlyList SuccessExitCodes { get { throw null; } set { } } + } + + [System.Diagnostics.CodeAnalysis.Experimental("ASPIREPROCESSCOMMAND001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + public sealed partial class ProcessCommandResultContext + { + public required System.Threading.CancellationToken CancellationToken { get { throw null; } init { } } + + public required int ExitCode { get { throw null; } init { } } + + public required Microsoft.Extensions.Logging.ILogger Logger { get { throw null; } init { } } + + public required System.Collections.Generic.IReadOnlyList Output { get { throw null; } init { } } + + public required ProcessCommandSpec ProcessCommandSpec { get { throw null; } init { } } + + public required string ResourceName { get { throw null; } init { } } + + public required System.IServiceProvider ServiceProvider { get { throw null; } init { } } + + public required int TotalOutputLineCount { get { throw null; } init { } } + + public string GetFormattedOutput(int maxLines = 50, string outputDescription = "Command output") { throw null; } + } + + [System.Diagnostics.CodeAnalysis.Experimental("ASPIREPROCESSCOMMAND001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + public sealed partial class ProcessCommandSpec + { + public ProcessCommandSpec(string executablePath) { } + + public System.Collections.Generic.IReadOnlyList Arguments { get { throw null; } init { } } + + public System.Collections.Generic.IDictionary EnvironmentVariables { get { throw null; } init { } } + + public string ExecutablePath { get { throw null; } } + + public bool InheritEnvironmentVariables { get { throw null; } init { } } + + public bool KillEntireProcessTree { get { throw null; } init { } } + + public string? StandardInputContent { get { throw null; } init { } } + + public string? WorkingDirectory { get { throw null; } init { } } + } + [System.Diagnostics.DebuggerDisplay("{DebuggerToString(),nq}")] public partial class ProjectResource : Resource, IResourceWithEnvironment, IResource, IResourceWithArgs, IResourceWithServiceDiscovery, IResourceWithEndpoints, IResourceWithWaitSupport, IResourceWithProbes, IComputeResource, IContainerFilesDestinationResource { @@ -3312,7 +3514,7 @@ internal ReferenceExpression() { } public System.Threading.Tasks.ValueTask GetValueAsync(ValueProviderContext context, System.Threading.CancellationToken cancellationToken) { throw null; } - [AspireExport(Description = "Gets the resolved string value of the reference expression asynchronously")] + [AspireExport] public System.Threading.Tasks.ValueTask GetValueAsync(System.Threading.CancellationToken cancellationToken) { throw null; } [System.Runtime.CompilerServices.InterpolatedStringHandler] @@ -3355,7 +3557,7 @@ public void Append(in ReferenceExpressionBuilderInterpolatedStringHandler handle [System.Obsolete("ReferenceExpression instances can't be used in interpolated string with a custom format. Duplicate the inner expression in-place.", true)] public void AppendFormatted(ReferenceExpression valueProvider, string format) { } - [AspireExport(Description = "Appends a formatted string value to the reference expression")] + [AspireExport] public void AppendFormatted(string? value, string? format = null) { } public void AppendFormatted(string? value) { } @@ -3366,13 +3568,13 @@ public void AppendFormatted(T valueProvider, string? format) public void AppendFormatted(T valueProvider) where T : IValueProvider, IManifestExpressionProvider { } - [AspireExport(Description = "Appends a literal string to the reference expression")] + [AspireExport] public void AppendLiteral(string value) { } - [AspireExport(Description = "Appends a value provider to the reference expression")] + [AspireExport] public void AppendValueProvider(object valueProvider, string? format = null) { } - [AspireExport(Description = "Builds the reference expression")] + [AspireExport] public ReferenceExpression Build() { throw null; } [System.Runtime.CompilerServices.InterpolatedStringHandler] @@ -3517,8 +3719,13 @@ public enum ResourceAnnotationMutationBehavior [System.Diagnostics.DebuggerDisplay("Type = {GetType().Name,nq}, Name = {Name}")] public sealed partial class ResourceCommandAnnotation : IResourceAnnotation { + public ResourceCommandAnnotation(string name, string displayName, System.Func updateState, System.Func> executeCommand, string? displayDescription, System.Collections.Generic.IReadOnlyList? arguments, string? confirmationMessage, string? iconName, IconVariant? iconVariant, bool isHighlighted, ResourceCommandVisibility visibility = ResourceCommandVisibility.UI | ResourceCommandVisibility.Api, System.Func? validateArguments = null) { } + public ResourceCommandAnnotation(string name, string displayName, System.Func updateState, System.Func> executeCommand, string? displayDescription, object? parameter, string? confirmationMessage, string? iconName, IconVariant? iconVariant, bool isHighlighted) { } + [System.Diagnostics.CodeAnalysis.Experimental("ASPIREINTERACTION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + public System.Collections.Generic.IReadOnlyList Arguments { get { throw null; } } + public string? ConfirmationMessage { get { throw null; } } public string? DisplayDescription { get { throw null; } } @@ -3535,23 +3742,36 @@ public ResourceCommandAnnotation(string name, string displayName, System.Func UpdateState { get { throw null; } } + + [System.Diagnostics.CodeAnalysis.Experimental("ASPIREINTERACTION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + public System.Func? ValidateArguments { get { throw null; } } + + public ResourceCommandVisibility Visibility { get { throw null; } } } public partial class ResourceCommandService { internal ResourceCommandService() { } + public System.Threading.Tasks.Task ExecuteCommandAsync(IResource resource, string commandName, InteractionInputCollection arguments, System.Threading.CancellationToken cancellationToken = default) { throw null; } + public System.Threading.Tasks.Task ExecuteCommandAsync(IResource resource, string commandName, System.Threading.CancellationToken cancellationToken = default) { throw null; } + public System.Threading.Tasks.Task ExecuteCommandAsync(string resourceId, string commandName, InteractionInputCollection arguments, System.Threading.CancellationToken cancellationToken = default) { throw null; } + public System.Threading.Tasks.Task ExecuteCommandAsync(string resourceId, string commandName, System.Threading.CancellationToken cancellationToken = default) { throw null; } } [System.Diagnostics.DebuggerDisplay(null, Name = "{Name}")] public sealed partial record ResourceCommandSnapshot(string Name, ResourceCommandState State, string DisplayName, string? DisplayDescription, object? Parameter, string? ConfirmationMessage, string? IconName, IconVariant? IconVariant, bool IsHighlighted) { + public System.Collections.Generic.IReadOnlyList Arguments { get { throw null; } init { } } + + public ResourceCommandVisibility Visibility { get { throw null; } init { } } } public enum ResourceCommandState @@ -3561,6 +3781,14 @@ public enum ResourceCommandState Hidden = 2 } + [System.Flags] + public enum ResourceCommandVisibility + { + None = 0, + UI = 1, + Api = 2 + } + public enum ResourceDependencyDiscoveryMode { Recursive = 0, @@ -3607,13 +3835,13 @@ public static partial class ResourceExtensions public static DeploymentTargetAnnotation? GetDeploymentTargetAnnotation(this IResource resource, IComputeEnvironmentResource? targetComputeEnvironment = null) { throw null; } [AspireExportIgnore(Reason = "Network-specific endpoint lookup is not part of the ATS surface.")] - public static EndpointReference GetEndpoint(this IResourceWithEndpoints resource, string endpointName, NetworkIdentifier contextNetworkID) { throw null; } + public static EndpointReference GetEndpoint(this IResourceWithEndpoints resource, string endpointName, NetworkIdentifier contextNetworkId) { throw null; } [AspireExportIgnore(Reason = "Resource handle endpoint lookup is not part of the ATS surface; use builder-based endpoint exports instead.")] public static EndpointReference GetEndpoint(this IResourceWithEndpoints resource, string endpointName) { throw null; } [AspireExportIgnore(Reason = "Network-specific endpoint enumeration is not part of the ATS surface.")] - public static System.Collections.Generic.IEnumerable GetEndpoints(this IResourceWithEndpoints resource, NetworkIdentifier contextNetworkID) { throw null; } + public static System.Collections.Generic.IEnumerable GetEndpoints(this IResourceWithEndpoints resource, NetworkIdentifier contextNetworkId) { throw null; } [AspireExportIgnore(Reason = "Resource handle endpoint enumeration is not part of the ATS surface; use builder-based endpoint exports instead.")] public static System.Collections.Generic.IEnumerable GetEndpoints(this IResourceWithEndpoints resource) { throw null; } @@ -3853,27 +4081,29 @@ public ResourceUrlsCallbackContext(DistributedApplicationExecutionContext execut public System.Threading.CancellationToken CancellationToken { get { throw null; } } - [AspireExport(Description = "Gets the execution context for this callback invocation")] + [AspireExport] public DistributedApplicationExecutionContext ExecutionContext { get { throw null; } } public Microsoft.Extensions.Logging.ILogger Logger { get { throw null; } set { } } - [AspireExport(Description = "Gets the resource associated with these URLs")] + [AspireExport] public IResource Resource { get { throw null; } } public System.Collections.Generic.List Urls { get { throw null; } } - public EndpointReference? GetEndpoint(string name, NetworkIdentifier contextNetworkID) { throw null; } + public EndpointReference? GetEndpoint(string name, NetworkIdentifier contextNetworkId) { throw null; } - [AspireExport(Description = "Gets an endpoint reference from the associated resource")] + [AspireExport] public EndpointReference? GetEndpoint(string name) { throw null; } } [AspireExport(ExposeProperties = true)] public sealed partial class UpdateCommandStateContext { + [AspireExportIgnore(Reason = "CustomResourceSnapshot contains object-valued properties that are not statically representable in polyglot SDKs. Use ResourceSnapshotData for the curated ATS projection.")] public required CustomResourceSnapshot ResourceSnapshot { get { throw null; } init { } } + [AspireExportIgnore(Reason = "IServiceProvider is not usable from polyglot command state callbacks.")] public required System.IServiceProvider ServiceProvider { get { throw null; } init { } } } @@ -4202,7 +4432,7 @@ public void SetValue(string value) { } [System.Diagnostics.CodeAnalysis.Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] public static partial class DistributedApplicationPipelineExtensions { - [AspireExport(Description = "Disables publish and deploy validation for unconsumed build-only containers.")] + [AspireExport] public static IDistributedApplicationPipeline DisableBuildOnlyContainerValidation(this IDistributedApplicationPipeline pipeline) { throw null; } } @@ -4314,7 +4544,7 @@ public partial class PipelineConfigurationContext [AspireExportIgnore(Reason = "IResource parameters on callback context methods are not ATS-compatible. Use pipeline helpers instead.")] public System.Collections.Generic.IEnumerable GetSteps(ApplicationModel.IResource resource) { throw null; } - [AspireExport(Description = "Gets pipeline steps with the specified tag")] + [AspireExport] public System.Collections.Generic.IEnumerable GetSteps(string tag) { throw null; } } @@ -4353,7 +4583,7 @@ public partial class PipelineOptions [System.Diagnostics.CodeAnalysis.Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] [System.Diagnostics.DebuggerDisplay("{DebuggerToString(),nq}")] - [AspireExport] + [AspireExport(ExposeProperties = true)] public partial class PipelineStep { public required System.Func Action { get { throw null; } init { } } @@ -4366,18 +4596,19 @@ public partial class PipelineStep public System.Collections.Generic.List RequiredBySteps { get { throw null; } init { } } + [AspireExportIgnore(Reason = "The associated resource is an internal runtime link and may be null for steps that are not tied to a resource.")] public ApplicationModel.IResource? Resource { get { throw null; } set { } } public System.Collections.Generic.List Tags { get { throw null; } init { } } public void DependsOn(PipelineStep step) { } - [AspireExport(Description = "Adds a dependency on another step by name")] + [AspireExport] public void DependsOn(string stepName) { } public void RequiredBy(PipelineStep step) { } - [AspireExport(Description = "Specifies that another step requires this step by name")] + [AspireExport] public void RequiredBy(string stepName) { } } @@ -4450,7 +4681,7 @@ public partial class PipelineStepFactoryContext [System.Diagnostics.CodeAnalysis.Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] public static partial class PipelineStepFactoryExtensions { - [AspireExport(Description = "Configures pipeline step dependencies via a callback")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithPipelineConfiguration(this ApplicationModel.IResourceBuilder builder, System.Action callback) where T : ApplicationModel.IResource { throw null; } @@ -4474,7 +4705,7 @@ public static ApplicationModel.IResourceBuilder WithPipelineStepFactory(th public static ApplicationModel.IResourceBuilder WithPipelineStepFactory(this ApplicationModel.IResourceBuilder builder, System.Func>> factory) where T : ApplicationModel.IResource { throw null; } - [AspireExport(Description = "Adds a pipeline step to the resource")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithPipelineStepFactory(this ApplicationModel.IResourceBuilder builder, string stepName, System.Func callback, string[]? dependsOn = null, string[]? requiredBy = null, string[]? tags = null, string? description = null) where T : ApplicationModel.IResource { throw null; } } @@ -4597,6 +4828,7 @@ public static partial class WellKnownPipelineTags namespace Aspire.Hosting.Publishing { + [AspireExport(ExposeProperties = true)] public sealed partial class AfterPublishEvent : Eventing.IDistributedApplicationEvent { public AfterPublishEvent(System.IServiceProvider services, ApplicationModel.DistributedApplicationModel model) { } @@ -4606,6 +4838,7 @@ public AfterPublishEvent(System.IServiceProvider services, ApplicationModel.Dist public System.IServiceProvider Services { get { throw null; } } } + [AspireExport(ExposeProperties = true)] public sealed partial class BeforePublishEvent : Eventing.IDistributedApplicationEvent { public BeforePublishEvent(System.IServiceProvider services, ApplicationModel.DistributedApplicationModel model) { } diff --git a/src/Aspire.TypeSystem/api/Aspire.TypeSystem.cs b/src/Aspire.TypeSystem/api/Aspire.TypeSystem.cs index 36306fbdd3f..83f42638aab 100644 --- a/src/Aspire.TypeSystem/api/Aspire.TypeSystem.cs +++ b/src/Aspire.TypeSystem/api/Aspire.TypeSystem.cs @@ -39,6 +39,8 @@ public sealed partial class AspireValueData public sealed partial class AtsCallbackParameterInfo { + public AtsDocumentationInfo? Documentation { get { throw null; } init { } } + public required string Name { get { throw null; } init { } } public required AtsTypeRef Type { get { throw null; } init { } } @@ -52,6 +54,8 @@ public sealed partial class AtsCapabilityInfo public string? Description { get { throw null; } init { } } + public AtsDocumentationInfo? Documentation { get { throw null; } init { } } + public System.Collections.Generic.IReadOnlyList ExpandedTargetTypes { get { throw null; } set { } } public bool IsObsolete { get { throw null; } init { } } @@ -185,10 +189,29 @@ public enum AtsDiagnosticSeverity Error = 2 } + public sealed partial class AtsDocumentationInfo + { + public System.Collections.Generic.IReadOnlyList Parameters { get { throw null; } init { } } + + public string? Remarks { get { throw null; } init { } } + + public string? Returns { get { throw null; } init { } } + + public string? Summary { get { throw null; } init { } } + } + public sealed partial class AtsDtoPropertyInfo { + public System.Collections.Generic.IReadOnlyList? CallbackParameters { get { throw null; } init { } } + + public AtsTypeRef? CallbackReturnType { get { throw null; } init { } } + public string? Description { get { throw null; } init { } } + public AtsDocumentationInfo? Documentation { get { throw null; } init { } } + + public bool IsCallback { get { throw null; } init { } } + public bool IsOptional { get { throw null; } init { } } public required string Name { get { throw null; } init { } } @@ -202,6 +225,8 @@ public sealed partial class AtsDtoTypeInfo public string? Description { get { throw null; } init { } } + public AtsDocumentationInfo? Documentation { get { throw null; } init { } } + public required string Name { get { throw null; } init { } } public required System.Collections.Generic.IReadOnlyList Properties { get { throw null; } init { } } @@ -213,17 +238,30 @@ public sealed partial class AtsEnumTypeInfo { public System.Type? ClrType { get { throw null; } init { } } + public AtsDocumentationInfo? Documentation { get { throw null; } init { } } + public required string Name { get { throw null; } init { } } public required string TypeId { get { throw null; } init { } } + public System.Collections.Generic.IReadOnlyList ValueInfos { get { throw null; } init { } } + public required System.Collections.Generic.IReadOnlyList Values { get { throw null; } init { } } } + public sealed partial class AtsEnumValueInfo + { + public AtsDocumentationInfo? Documentation { get { throw null; } init { } } + + public required string Name { get { throw null; } init { } } + } + public sealed partial class AtsExportedValueInfo { public string? Description { get { throw null; } init { } } + public AtsDocumentationInfo? Documentation { get { throw null; } init { } } + public required string OwningAssemblyName { get { throw null; } init { } } public required System.Collections.Generic.IReadOnlyList PathSegments { get { throw null; } init { } } @@ -233,6 +271,13 @@ public sealed partial class AtsExportedValueInfo public required System.Text.Json.Nodes.JsonNode? Value { get { throw null; } init { } } } + public sealed partial class AtsParameterDocumentationInfo + { + public required string Description { get { throw null; } init { } } + + public required string Name { get { throw null; } init { } } + } + public sealed partial class AtsParameterInfo { public System.Collections.Generic.IReadOnlyList? CallbackParameters { get { throw null; } init { } } @@ -241,6 +286,8 @@ public sealed partial class AtsParameterInfo public object? DefaultValue { get { throw null; } init { } } + public AtsDocumentationInfo? Documentation { get { throw null; } init { } } + public bool IsCallback { get { throw null; } init { } } public bool IsNullable { get { throw null; } init { } } @@ -275,6 +322,8 @@ public sealed partial class AtsTypeInfo [System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.Interfaces)] public System.Type? ClrType { get { throw null; } init { } } + public AtsDocumentationInfo? Documentation { get { throw null; } init { } } + public bool HasExposeMethods { get { throw null; } init { } } public bool HasExposeProperties { get { throw null; } init { } } @@ -312,6 +361,9 @@ public sealed partial class AtsTypeRef public bool IsInterface { get { throw null; } init { } } + [System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingDefault)] + public bool? IsNullable { get { throw null; } init { } } + public bool IsReadOnly { get { throw null; } init { } } public bool IsResourceBuilder { get { throw null; } }